feat(auth): implement email/password login form
This commit is contained in:
329
apps/web/src/features/auth/Login.tsx
Normal file
329
apps/web/src/features/auth/Login.tsx
Normal 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;
|
||||
141
apps/web/src/features/auth/authSlice.ts
Normal file
141
apps/web/src/features/auth/authSlice.ts
Normal 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;
|
||||
23
apps/web/src/features/auth/firebase.ts
Normal file
23
apps/web/src/features/auth/firebase.ts
Normal 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);
|
||||
8
apps/web/src/features/dashboard/Dashboard.tsx
Normal file
8
apps/web/src/features/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<div>Dashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
Reference in New Issue
Block a user