From e214e32c17e5011beff63efe708667aede09b445 Mon Sep 17 00:00:00 2001 From: dhinesh-m24 Date: Thu, 29 Jan 2026 11:43:19 +0530 Subject: [PATCH] feat(auth): implement role-based dashboard redirect --- apps/web/src/common/config/navigation.ts | 240 ++++++++++++++++++ apps/web/src/features/auth/Login.tsx | 85 ++----- apps/web/src/features/auth/authSlice.ts | 40 ++- .../src/features/dashboard/AdminDashboard.tsx | 8 + .../features/dashboard/ClientDashboard.tsx | 9 + apps/web/src/features/dashboard/Dashboard.tsx | 8 - .../features/dashboard/VendorDashboard.tsx | 9 + apps/web/src/features/layouts/AppLayout.tsx | 99 ++++++++ .../src/features/layouts/ProtectedRoute.tsx | 37 +++ apps/web/src/features/layouts/Sidebar.tsx | 122 +++++++++ apps/web/src/routes.tsx | 79 +++++- apps/web/src/services/firestoreService.ts | 55 ++++ 12 files changed, 713 insertions(+), 78 deletions(-) create mode 100644 apps/web/src/common/config/navigation.ts create mode 100644 apps/web/src/features/dashboard/AdminDashboard.tsx create mode 100644 apps/web/src/features/dashboard/ClientDashboard.tsx delete mode 100644 apps/web/src/features/dashboard/Dashboard.tsx create mode 100644 apps/web/src/features/dashboard/VendorDashboard.tsx create mode 100644 apps/web/src/features/layouts/AppLayout.tsx create mode 100644 apps/web/src/features/layouts/ProtectedRoute.tsx create mode 100644 apps/web/src/features/layouts/Sidebar.tsx create mode 100644 apps/web/src/services/firestoreService.ts diff --git a/apps/web/src/common/config/navigation.ts b/apps/web/src/common/config/navigation.ts new file mode 100644 index 00000000..c74bd4c2 --- /dev/null +++ b/apps/web/src/common/config/navigation.ts @@ -0,0 +1,240 @@ +import { + LayoutDashboard, + Briefcase, + Calendar, + Users, + ShoppingBag, + FileText, + PiggyBank, + BarChart2, + Clock, + ClipboardList, + Scale, + UserPlus, + Users2, + ShieldCheck, + Receipt, + Building2, + DollarSign, + PieChart, + History, + MessageSquare, + BookOpen, + HelpCircle, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +export type Role = 'Admin' | 'Client' | 'Vendor'; + +export interface NavItem { + label: string; + path: string; + icon: LucideIcon; + allowedRoles: Role[]; +} + +export interface NavGroup { + title: string; + items: NavItem[]; +} + +export const ALL_ROLES: Role[] = ['Admin', 'Client', 'Vendor']; + +export const NAV_CONFIG: NavGroup[] = [ + { + title: 'Overview', + items: [ + { + label: 'Dashboard', + path: '/dashboard/admin', + icon: LayoutDashboard, + allowedRoles: ['Admin'], + }, + { + label: 'Dashboard', + path: '/dashboard/client', + icon: LayoutDashboard, + allowedRoles: ['Client'], + }, + { + label: 'Dashboard', + path: '/dashboard/vendor', + icon: LayoutDashboard, + allowedRoles: ['Vendor'], + }, + { + label: 'Savings Engine', + path: '/savings', + icon: PiggyBank, + allowedRoles: ALL_ROLES, + }, + { + label: 'Vendor Performance', + path: '/performance', + icon: BarChart2, + allowedRoles: ['Vendor', 'Admin'], + }, + ], + }, + { + title: 'Operations', + items: [ + { + label: 'Orders', + path: '/orders/client', + icon: Briefcase, + allowedRoles: ['Client'], + }, + { + label: 'Orders', + path: '/orders/vendor', + icon: Briefcase, + allowedRoles: ['Vendor'], + }, + { + label: 'Orders', + path: '/orders', + icon: Briefcase, + allowedRoles: ['Admin'], + }, + { + label: 'Schedule', + path: '/schedule', + icon: Calendar, + allowedRoles: ALL_ROLES, + }, + { + label: 'Staff Availability', + path: '/availability', + icon: Clock, + allowedRoles: ALL_ROLES, + }, + { + label: 'Task Board', + path: '/tasks', + icon: ClipboardList, + allowedRoles: ALL_ROLES, + }, + ], + }, + { + title: 'Marketplace', + items: [ + { + label: 'Discovery', + path: '/marketplace', + icon: ShoppingBag, + allowedRoles: ['Client', 'Admin'], + }, + { + label: 'Compare Rates', + path: '/marketplace/compare', + icon: Scale, + allowedRoles: ['Client', 'Admin'], + }, + ], + }, + { + title: 'Workforce', + items: [ + { + label: 'Staff Directory', + path: '/staff', + icon: Users, + allowedRoles: ALL_ROLES, + }, + { + label: 'Onboarding', + path: '/onboarding', + icon: UserPlus, + allowedRoles: ALL_ROLES, + }, + { + label: 'Teams', + path: '/teams', + icon: Users2, + allowedRoles: ALL_ROLES, + }, + { + label: 'Compliance', + path: '/compliance', + icon: ShieldCheck, + allowedRoles: ['Vendor', 'Admin'], + }, + { + label: 'Documents', + path: '/documents', + icon: FileText, + allowedRoles: ['Vendor', 'Admin'], + }, + ], + }, + { + title: 'Finance', + items: [ + { + label: 'Invoices', + path: '/invoices', + icon: Receipt, + allowedRoles: ALL_ROLES, + }, + ], + }, + { + title: 'Business', + items: [ + { + label: 'Clients', + path: '/clients', + icon: Building2, + allowedRoles: ['Vendor', 'Admin'], + }, + { + label: 'Service Rates', + path: '/rates', + icon: DollarSign, + allowedRoles: ['Vendor', 'Admin'], + }, + ], + }, + { + title: 'Analytics & Comm', + items: [ + { + label: 'Reports', + path: '/reports', + icon: PieChart, + allowedRoles: ALL_ROLES, + }, + { + label: 'Activity Log', + path: '/activity', + icon: History, + allowedRoles: ['Vendor', 'Admin'], + }, + { + label: 'Messages', + path: '/messages', + icon: MessageSquare, + allowedRoles: ALL_ROLES, + }, + { + label: 'Tutorials', + path: '/tutorials', + icon: BookOpen, + allowedRoles: ['Client', 'Admin'], + }, + ], + }, + { + title: 'Support', + items: [ + { + label: 'Help Center', + path: '/support', + icon: HelpCircle, + allowedRoles: ['Client', 'Admin'], + }, + ], + }, +]; diff --git a/apps/web/src/features/auth/Login.tsx b/apps/web/src/features/auth/Login.tsx index 7b0beeb5..4ceeae21 100644 --- a/apps/web/src/features/auth/Login.tsx +++ b/apps/web/src/features/auth/Login.tsx @@ -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(null); const navigate = useNavigate(); const location = useLocation(); - - useEffect(() => { - setIsFormValid(validateForm(email, password)); - }, [email, password]); + const dispatch = useDispatch(); + 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) => { 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 */} -
+
{/* Top Left Logo */} @@ -204,10 +169,10 @@ const Login: React.FC = () => {
- {error && ( + {reduxError && (
- - {error} + + {reduxError}
)} @@ -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 = () => { +
+ + + {/* Content Area */} +
+
+ +
+
+ +
+ ); +}; + +export default AppLayout; \ No newline at end of file diff --git a/apps/web/src/features/layouts/ProtectedRoute.tsx b/apps/web/src/features/layouts/ProtectedRoute.tsx new file mode 100644 index 00000000..59af7b31 --- /dev/null +++ b/apps/web/src/features/layouts/ProtectedRoute.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import type { RootState } from '../../store/store'; + +interface ProtectedRouteProps { + children: React.ReactNode; + allowedRoles: Array<'admin' | 'client' | 'vendor'>; + redirectTo?: string; +} + +/** + * ProtectedRoute Component + * Ensures only authenticated users with specific roles can access certain routes + * Redirects to appropriate dashboard if user role doesn't match allowed roles + */ +const ProtectedRoute: React.FC = ({ + children, + allowedRoles, + redirectTo = '/dashboard/client', +}) => { + const { isAuthenticated, user } = useSelector((state: RootState) => state.auth); + + // If user is not authenticated, redirect to login + if (!isAuthenticated) { + return ; + } + + // If user is authenticated but role is not allowed, redirect to specified path + if (user?.userRole && !allowedRoles.includes(user.userRole)) { + return ; + } + + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/apps/web/src/features/layouts/Sidebar.tsx b/apps/web/src/features/layouts/Sidebar.tsx new file mode 100644 index 00000000..d230f205 --- /dev/null +++ b/apps/web/src/features/layouts/Sidebar.tsx @@ -0,0 +1,122 @@ +import React, { useMemo } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { LogOut, Menu, X } from 'lucide-react'; +import { Button } from '../../common/components/ui/button'; +import { NAV_CONFIG } from "../../common/config/navigation"; +import type { Role } from '../../common/config/navigation'; + +interface SidebarProps { + sidebarOpen: boolean; + setSidebarOpen: (open: boolean) => void; + user: { + name?: string; + role?: string; + } | null; + onLogout: () => void; +} + +const Sidebar: React.FC = ({ + sidebarOpen, + setSidebarOpen, + user, + onLogout +}) => { + const location = useLocation(); + + // Filter navigation based on user role + const filteredNav = useMemo(() => { + const userRole = (user?.role || 'Client') as Role; + + return NAV_CONFIG.map(group => { + const visibleItems = group.items.filter(item => + item.allowedRoles.includes(userRole) + ); + return { + ...group, + items: visibleItems + }; + }).filter(group => group.items.length > 0); + }, [user?.role]); + + return ( + + ); +}; + +export default Sidebar; diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 25c60910..6b0a9a07 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -1,14 +1,21 @@ -import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; import Login from './features/auth/Login'; import ForgotPassword from './features/auth/ForgotPassword'; -import Dashboard from './features/dashboard/Dashboard'; - +import AppLayout from './features/layouts/AppLayout'; +import AdminDashboard from './features/dashboard/AdminDashboard'; +import ClientDashboard from './features/dashboard/ClientDashboard'; +import VendorDashboard from './features/dashboard/VendorDashboard'; +import ProtectedRoute from './features/layouts/ProtectedRoute'; +import { getDashboardPath } from './services/firestoreService'; +import type { RootState } from './store/store'; /** * AppRoutes Component * Defines the main routing structure of the application. * Groups routes by Layout (Public vs App). + * Implements role-based redirection after login. */ const AppRoutes: React.FC = () => { return ( @@ -27,11 +34,73 @@ const AppRoutes: React.FC = () => { } /> - } /> + {/* Authenticated Routes */} + }> + {/* Dashboard Redirect Logic - redirects to user's correct dashboard based on role */} + } /> + + {/* Protected Dashboard Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + } /> ); }; +/** + * RoleDashboardRedirect Component + * Dynamically redirects users to their appropriate dashboard based on their role + */ +const RoleDashboardRedirect: React.FC = () => { + const { isAuthenticated, user } = useSelector((state: RootState) => state.auth); + const navigate = useNavigate(); + + useEffect(() => { + if (!isAuthenticated) { + navigate('/login', { replace: true }); + return; + } + + if (user?.userRole) { + const dashboardPath = getDashboardPath(user.userRole); + navigate(dashboardPath, { replace: true }); + } + }, [isAuthenticated, user?.userRole, navigate]); + + return null; +}; + export default AppRoutes; diff --git a/apps/web/src/services/firestoreService.ts b/apps/web/src/services/firestoreService.ts new file mode 100644 index 00000000..07306ec6 --- /dev/null +++ b/apps/web/src/services/firestoreService.ts @@ -0,0 +1,55 @@ +import { getFirestore, doc, getDoc } from "firebase/firestore"; +import { app } from "../features/auth/firebase"; + +export interface UserData { + id: string; + email: string; + fullName?: string; + userRole: "admin" | "client" | "vendor"; + photoURL?: string; +} + +const db = getFirestore(app); + +/** + * Fetch user data from Firestore including their role + * @param uid - Firebase User UID + * @returns UserData object with role information + */ +export const fetchUserData = async (uid: string): Promise => { + try { + const userDocRef = doc(db, "users", uid); + const userDocSnap = await getDoc(userDocRef); + + if (userDocSnap.exists()) { + const data = userDocSnap.data(); + return { + id: uid, + email: data.email || "", + fullName: data.fullName, + userRole: data.userRole || "client", + photoURL: data.photoURL, + }; + } + + return null; + } catch (error) { + console.error("Error fetching user data from Firestore:", error); + throw error; + } +}; + +/** + * Get the dashboard path for a given user role + * @param userRole - The user's role + * @returns The appropriate dashboard path + */ +export const getDashboardPath = (userRole: string): string => { + const roleMap: Record = { + admin: "/dashboard/admin", + client: "/dashboard/client", + vendor: "/dashboard/vendor", + }; + + return roleMap[userRole.toLowerCase()] || "/dashboard/client"; +};