feat(auth): implement role-based dashboard redirect
This commit is contained in:
@@ -1,20 +1,21 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
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";
|
||||
import { loginUser } from "../auth/authSlice";
|
||||
import { getDashboardPath } from "../../services/firestoreService";
|
||||
import type { RootState, AppDispatch } from "../../store/store";
|
||||
|
||||
/**
|
||||
* Login Page Component.
|
||||
* Features a modern split-screen layout with an inspirational hero image.
|
||||
* Handles user authentication via email/password with client-side validation.
|
||||
* Uses Redux for state management and handles role-based redirection.
|
||||
*/
|
||||
|
||||
type LocationState = {
|
||||
@@ -28,16 +29,10 @@ const Login: React.FC = () => {
|
||||
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]);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const { status, error: reduxError, user, isAuthenticated } = useSelector((state: RootState) => state.auth);
|
||||
|
||||
// Validate email format
|
||||
const validateEmail = (value: string): boolean => {
|
||||
@@ -55,6 +50,8 @@ const Login: React.FC = () => {
|
||||
return validateEmail(emailValue) && validatePassword(passwordValue);
|
||||
};
|
||||
|
||||
const isFormValid = validateForm(email, password);
|
||||
|
||||
// Handle email input change
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
@@ -79,26 +76,26 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate user after login (customize as needed)
|
||||
// Navigate user after successful login
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!isAuthenticated || !user?.userRole) return;
|
||||
|
||||
const state = location.state as LocationState | null;
|
||||
const from = state?.from?.pathname;
|
||||
const dashboardPath = getDashboardPath(user.userRole);
|
||||
|
||||
navigate(from ?? "/dashboard", { replace: true });
|
||||
}, [user, navigate, location.state]);
|
||||
navigate(from ?? dashboardPath, { replace: true });
|
||||
}, [isAuthenticated, user?.userRole, navigate, location.state]);
|
||||
|
||||
// Clear error message when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setError("");
|
||||
// Error will be cleared from Redux state when user navigates away
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validate before submission
|
||||
if (!isFormValid) {
|
||||
@@ -111,40 +108,8 @@ const Login: React.FC = () => {
|
||||
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);
|
||||
}
|
||||
// Dispatch Redux action to handle login
|
||||
dispatch(loginUser({ email, password }));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -157,7 +122,7 @@ const Login: React.FC = () => {
|
||||
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-linear-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 */}
|
||||
@@ -204,10 +169,10 @@ const Login: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
{reduxError && (
|
||||
<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>
|
||||
<AlertCircle className="w-4 h-4 mr-2 shrink-0" />
|
||||
<span>{reduxError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -221,7 +186,7 @@ const Login: React.FC = () => {
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isLoading}
|
||||
disabled={status === "loading"}
|
||||
required
|
||||
aria-describedby={emailError ? "email-error" : undefined}
|
||||
className={
|
||||
@@ -259,7 +224,7 @@ const Login: React.FC = () => {
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
disabled={isLoading}
|
||||
disabled={status === "loading"}
|
||||
required
|
||||
aria-describedby={passwordError ? "password-error" : undefined}
|
||||
className={
|
||||
@@ -281,10 +246,10 @@ const Login: React.FC = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !isFormValid}
|
||||
disabled={status === "loading" || !isFormValid}
|
||||
size="default"
|
||||
>
|
||||
{isLoading ? (
|
||||
{status === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-3 animate-spin" />
|
||||
Authenticating...
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
import { loginWithEmail, logout, getCurrentUser } from "../../services/authService";
|
||||
import { fetchUserData } from "../../services/firestoreService";
|
||||
import type { User } from "firebase/auth";
|
||||
|
||||
export interface AuthUser {
|
||||
@@ -8,7 +9,7 @@ export interface AuthUser {
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
photoURL: string | null;
|
||||
role?: string;
|
||||
userRole?: "admin" | "client" | "vendor";
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
@@ -27,6 +28,7 @@ const initialState: AuthState = {
|
||||
|
||||
/**
|
||||
* Async thunk for user login
|
||||
* Fetches user data including role from Firestore after authentication
|
||||
*/
|
||||
export const loginUser = createAsyncThunk(
|
||||
"auth/loginUser",
|
||||
@@ -38,11 +40,25 @@ export const loginUser = createAsyncThunk(
|
||||
}
|
||||
|
||||
const firebaseUser = result.user as User;
|
||||
|
||||
// Fetch user role from Firestore
|
||||
let userRole: "admin" | "client" | "vendor" = "client";
|
||||
try {
|
||||
const userData = await fetchUserData(firebaseUser.uid);
|
||||
if (userData) {
|
||||
userRole = userData.userRole;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user role:", error);
|
||||
// Continue with default role on error
|
||||
}
|
||||
|
||||
return {
|
||||
uid: firebaseUser.uid,
|
||||
email: firebaseUser.email,
|
||||
displayName: firebaseUser.displayName,
|
||||
photoURL: firebaseUser.photoURL,
|
||||
userRole: userRole,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -62,16 +78,30 @@ export const logoutUser = createAsyncThunk("auth/logoutUser", async (_, { reject
|
||||
|
||||
/**
|
||||
* Async thunk to check if user is already logged in
|
||||
* Fetches user role from Firestore on app initialization
|
||||
*/
|
||||
export const checkAuthStatus = createAsyncThunk("auth/checkAuthStatus", async () => {
|
||||
export const checkAuthStatus = createAsyncThunk("auth/checkAuthStatus", async (_, { rejectWithValue }) => {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (currentUser) {
|
||||
// Fetch user role from Firestore
|
||||
let userRole: "admin" | "client" | "vendor" = "client";
|
||||
try {
|
||||
const userData = await fetchUserData(currentUser.uid);
|
||||
if (userData) {
|
||||
userRole = userData.userRole;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user role:", error);
|
||||
// Continue with default role on error
|
||||
}
|
||||
|
||||
return {
|
||||
uid: currentUser.uid,
|
||||
email: currentUser.email,
|
||||
displayName: currentUser.displayName,
|
||||
photoURL: currentUser.photoURL,
|
||||
userRole: userRole,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,9 +115,9 @@ const authSlice = createSlice({
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
setRole: (state, action: PayloadAction<string>) => {
|
||||
setUserRole: (state, action: PayloadAction<"admin" | "client" | "vendor">) => {
|
||||
if (state.user) {
|
||||
state.user.role = action.payload;
|
||||
state.user.userRole = action.payload;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -137,5 +167,5 @@ const authSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearError, setRole } = authSlice.actions;
|
||||
export const { clearError, setUserRole } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user