feat(auth): implement email/password login form

This commit is contained in:
dhinesh-m24
2026-01-28 15:33:05 +05:30
parent 959a8c41e9
commit 6e81a062ab
18 changed files with 4287 additions and 8 deletions

View File

@@ -0,0 +1,329 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Loader2, AlertCircle } from "lucide-react";
import loginHero from "../../assets/login-hero.png";
import logo from "../../assets/logo.png";
import { Label } from "@radix-ui/react-label";
import { Input } from "../../common/components/ui/input";
import { Button } from "../../common/components/ui/button";
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
import type { User } from "firebase/auth";
import { app as firebaseApp } from "../../services/authService";
import { FirebaseError } from "firebase/app";
/**
* Login Page Component.
* Features a modern split-screen layout with an inspirational hero image.
* Handles user authentication via email/password with client-side validation.
*/
type LocationState = {
from?: {
pathname: string;
};
};
const Login: React.FC = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const [isFormValid, setIsFormValid] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [user, setUser] = useState<User | null>(null);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
setIsFormValid(validateForm(email, password));
}, [email, password]);
// Validate email format
const validateEmail = (value: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
};
// Validate password length
const validatePassword = (value: string): boolean => {
return value.length >= 8;
};
// Validate entire form
const validateForm = (emailValue: string, passwordValue: string): boolean => {
return validateEmail(emailValue) && validatePassword(passwordValue);
};
// Handle email input change
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
if (value.trim() && !validateEmail(value)) {
setEmailError("Please enter a valid email address");
} else {
setEmailError("");
}
};
// Handle password input change
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setPassword(value);
if (value && !validatePassword(value)) {
setPasswordError("Password must be at least 8 characters");
} else {
setPasswordError("");
}
};
// Navigate user after login (customize as needed)
useEffect(() => {
if (!user) return;
const state = location.state as LocationState | null;
const from = state?.from?.pathname;
navigate(from ?? "/dashboard", { replace: true });
}, [user, navigate, location.state]);
// Clear error message when component unmounts
useEffect(() => {
return () => {
setError("");
};
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// Validate before submission
if (!isFormValid) {
if (!validateEmail(email)) {
setEmailError("Please enter a valid email address");
}
if (!validatePassword(password)) {
setPasswordError("Password must be at least 8 characters");
}
return;
}
setIsLoading(true);
try {
const auth = getAuth(firebaseApp);
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password,
);
setUser(userCredential.user);
} catch (err: unknown) {
let message = "Failed to sign in. Please try again.";
if (err instanceof FirebaseError) {
switch (err.code) {
case "auth/user-not-found":
case "auth/wrong-password":
message = "Invalid email or password.";
break;
case "auth/too-many-requests":
message = "Too many failed attempts. Please try again later.";
break;
default:
message = err.message;
}
} else if (err instanceof Error) {
message = err.message;
}
setError(message);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen bg-slate-50/50">
{/* Left Side: Hero Image (Hidden on Mobile) */}
<div className="hidden lg:flex lg:w-3/5 xl:w-[65%] relative overflow-hidden">
<img
src={loginHero}
alt="Modern workspace"
className="absolute inset-0 w-full h-full object-cover transform scale-105 hover:scale-100 transition-transform duration-10000 ease-in-out"
/>
{/* Cinematic Overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-black/60 via-black/20 to-transparent z-10" />
<div className="absolute inset-0 bg-primary/10 backdrop-blur-[2px] z-0" />
{/* Top Left Logo */}
<div className="absolute top-12 left-12 z-20">
<img
src={logo}
alt="Krow Logo"
className="h-10 w-auto brightness-0 invert"
/>
</div>
<div className="absolute bottom-12 left-12 right-12 text-white z-10">
<h1 className="text-5xl font-bold mb-4 tracking-tight leading-tight">
Streamline your workforce <br /> with{" "}
<span className="text-secondary underline decoration-4 underline-offset-8">
KROW
</span>
</h1>
<p className="text-lg text-slate-100/90 max-w-lg leading-relaxed">
The all-in-one platform for managing staff, orders, and professional
relationships with precision and ease.
</p>
</div>
</div>
{/* Right Side: Login Form */}
<div className="w-full lg:w-2/5 xl:w-[35%] flex flex-col justify-center items-center p-8 sm:p-12 md:p-16 lg:p-20 bg-white border-l border-slate-200 z-10">
<div className="w-full max-w-md space-y-8">
{/* Logo / Branding */}
<div className="flex flex-col items-center lg:items-start space-y-4">
<img
src={logo}
alt="Krow Logo"
className="h-10 w-auto lg:hidden mb-2"
/>
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight text-primary-text">
Welcome Back
</h2>
<p className="text-secondary-text font-medium">
Please enter your details to sign in
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="flex items-center p-4 text-sm text-destructive-foreground bg-destructive/70 border border-destructive/20 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
<AlertCircle className="w-4 h-4 mr-2 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-semibold">
Work Email
</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={handleEmailChange}
disabled={isLoading}
required
aria-describedby={emailError ? "email-error" : undefined}
className={
emailError
? "border-destructive focus-visible:ring-destructive"
: ""
}
/>
{emailError && (
<p
id="email-error"
className="text-xs text-destructive font-medium"
>
{emailError}
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-1">
<Label htmlFor="password" className="text-sm font-semibold">
Password
</Label>
<button
type="button"
className="text-xs font-semibold text-primary hover:underline underline-offset-2"
onClick={() => console.log("TODO: Implement forgot password")}
>
Forgot password?
</button>
</div>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={handlePasswordChange}
disabled={isLoading}
required
aria-describedby={passwordError ? "password-error" : undefined}
className={
passwordError
? "border-destructive focus-visible:ring-destructive"
: ""
}
/>
{passwordError && (
<p
id="password-error"
className="text-xs text-destructive font-medium"
>
{passwordError}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !isFormValid}
size="default"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 mr-3 animate-spin" />
Authenticating...
</>
) : (
"Sign In to Workspace"
)}
</Button>
</form>
{/* Helper for MVP Testing / Demo purposes */}
<div className="mt-8 p-6 bg-slate-50/80 rounded-2xl border border-slate-100 backdrop-blur-sm">
<p className="text-[11px] font-bold text-muted-text uppercase tracking-widest mb-4">
Quick Login (Development)
</p>
<div className="flex flex-wrap gap-2">
{[
{ label: "Admin", email: "admin@krow.com" },
{ label: "Client", email: "client@krow.com" },
{ label: "Vendor", email: "vendor@krow.com" },
].map((cred) => (
<button
key={cred.label}
type="button"
onClick={() => {
setEmail(cred.email);
setPassword("password");
}}
className="px-4 py-2 bg-white border border-slate-200 text-[11px] font-bold rounded-xl hover:border-primary hover:text-primary transition-all"
>
{cred.label}
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,141 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import { loginWithEmail, logout, getCurrentUser } from "../../services/authService";
import type { User } from "firebase/auth";
export interface AuthUser {
uid: string;
email: string | null;
displayName: string | null;
photoURL: string | null;
role?: string;
}
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
status: "idle" | "loading" | "succeeded" | "failed";
error: string | null;
}
const initialState: AuthState = {
user: null,
isAuthenticated: false,
status: "idle",
error: null,
};
/**
* Async thunk for user login
*/
export const loginUser = createAsyncThunk(
"auth/loginUser",
async ({ email, password }: { email: string; password: string }, { rejectWithValue }) => {
const result = await loginWithEmail(email, password);
if (!result.success) {
return rejectWithValue(result.error);
}
const firebaseUser = result.user as User;
return {
uid: firebaseUser.uid,
email: firebaseUser.email,
displayName: firebaseUser.displayName,
photoURL: firebaseUser.photoURL,
};
}
);
/**
* Async thunk for user logout
*/
export const logoutUser = createAsyncThunk("auth/logoutUser", async (_, { rejectWithValue }) => {
const result = await logout();
if (!result.success) {
return rejectWithValue(result.error);
}
return null;
});
/**
* Async thunk to check if user is already logged in
*/
export const checkAuthStatus = createAsyncThunk("auth/checkAuthStatus", async () => {
const currentUser = getCurrentUser();
if (currentUser) {
return {
uid: currentUser.uid,
email: currentUser.email,
displayName: currentUser.displayName,
photoURL: currentUser.photoURL,
};
}
return null;
});
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
setRole: (state, action: PayloadAction<string>) => {
if (state.user) {
state.user.role = action.payload;
}
},
},
extraReducers: (builder) => {
// Login thunk
builder
.addCase(loginUser.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.status = "succeeded";
state.isAuthenticated = true;
state.user = action.payload;
state.error = null;
})
.addCase(loginUser.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload as string;
state.isAuthenticated = false;
});
// Logout thunk
builder
.addCase(logoutUser.fulfilled, (state) => {
state.user = null;
state.isAuthenticated = false;
state.status = "idle";
state.error = null;
})
.addCase(logoutUser.rejected, (state, action) => {
state.error = action.payload as string;
});
// Check auth status thunk
builder
.addCase(checkAuthStatus.fulfilled, (state, action) => {
if (action.payload) {
state.user = action.payload;
state.isAuthenticated = true;
} else {
state.user = null;
state.isAuthenticated = false;
}
state.status = "idle";
});
},
});
export const { clearError, setRole } = authSlice.actions;
export default authSlice.reducer;

View File

@@ -0,0 +1,23 @@
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
import { getAuth } from "firebase/auth";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
};
// Initialize Firebase
export const app = initializeApp(firebaseConfig);
export const analytics = getAnalytics(app);
export const auth = getAuth(app);

View File

@@ -0,0 +1,8 @@
const Dashboard = () => {
return (
<div>Dashboard</div>
)
}
export default Dashboard