feat(auth): implement role-based dashboard redirect

This commit is contained in:
dhinesh-m24
2026-01-29 11:43:19 +05:30
parent d07d42ad0b
commit e214e32c17
12 changed files with 713 additions and 78 deletions

View File

@@ -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'],
},
],
},
];

View File

@@ -1,20 +1,21 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { Loader2, AlertCircle } from "lucide-react"; import { Loader2, AlertCircle } from "lucide-react";
import loginHero from "../../assets/login-hero.png"; import loginHero from "../../assets/login-hero.png";
import logo from "../../assets/logo.png"; import logo from "../../assets/logo.png";
import { Label } from "@radix-ui/react-label"; import { Label } from "@radix-ui/react-label";
import { Input } from "../../common/components/ui/input"; import { Input } from "../../common/components/ui/input";
import { Button } from "../../common/components/ui/button"; import { Button } from "../../common/components/ui/button";
import { getAuth, signInWithEmailAndPassword } from "firebase/auth"; import { loginUser } from "../auth/authSlice";
import type { User } from "firebase/auth"; import { getDashboardPath } from "../../services/firestoreService";
import { app as firebaseApp } from "../../services/authService"; import type { RootState, AppDispatch } from "../../store/store";
import { FirebaseError } from "firebase/app";
/** /**
* Login Page Component. * Login Page Component.
* Features a modern split-screen layout with an inspirational hero image. * Features a modern split-screen layout with an inspirational hero image.
* Handles user authentication via email/password with client-side validation. * Handles user authentication via email/password with client-side validation.
* Uses Redux for state management and handles role-based redirection.
*/ */
type LocationState = { type LocationState = {
@@ -28,16 +29,10 @@ const Login: React.FC = () => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState(""); const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = 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 navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const dispatch = useDispatch<AppDispatch>();
useEffect(() => { const { status, error: reduxError, user, isAuthenticated } = useSelector((state: RootState) => state.auth);
setIsFormValid(validateForm(email, password));
}, [email, password]);
// Validate email format // Validate email format
const validateEmail = (value: string): boolean => { const validateEmail = (value: string): boolean => {
@@ -55,6 +50,8 @@ const Login: React.FC = () => {
return validateEmail(emailValue) && validatePassword(passwordValue); return validateEmail(emailValue) && validatePassword(passwordValue);
}; };
const isFormValid = validateForm(email, password);
// Handle email input change // Handle email input change
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; 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(() => { useEffect(() => {
if (!user) return; if (!isAuthenticated || !user?.userRole) return;
const state = location.state as LocationState | null; const state = location.state as LocationState | null;
const from = state?.from?.pathname; const from = state?.from?.pathname;
const dashboardPath = getDashboardPath(user.userRole);
navigate(from ?? "/dashboard", { replace: true }); navigate(from ?? dashboardPath, { replace: true });
}, [user, navigate, location.state]); }, [isAuthenticated, user?.userRole, navigate, location.state]);
// Clear error message when component unmounts // Clear error message when component unmounts
useEffect(() => { useEffect(() => {
return () => { return () => {
setError(""); // Error will be cleared from Redux state when user navigates away
}; };
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError("");
// Validate before submission // Validate before submission
if (!isFormValid) { if (!isFormValid) {
@@ -111,40 +108,8 @@ const Login: React.FC = () => {
return; return;
} }
setIsLoading(true); // Dispatch Redux action to handle login
try { dispatch(loginUser({ email, password }));
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 ( 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" 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 */} {/* 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" /> <div className="absolute inset-0 bg-primary/10 backdrop-blur-[2px] z-0" />
{/* Top Left Logo */} {/* Top Left Logo */}
@@ -204,10 +169,10 @@ const Login: React.FC = () => {
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <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"> <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" /> <AlertCircle className="w-4 h-4 mr-2 shrink-0" />
<span>{error}</span> <span>{reduxError}</span>
</div> </div>
)} )}
@@ -221,7 +186,7 @@ const Login: React.FC = () => {
placeholder="name@company.com" placeholder="name@company.com"
value={email} value={email}
onChange={handleEmailChange} onChange={handleEmailChange}
disabled={isLoading} disabled={status === "loading"}
required required
aria-describedby={emailError ? "email-error" : undefined} aria-describedby={emailError ? "email-error" : undefined}
className={ className={
@@ -259,7 +224,7 @@ const Login: React.FC = () => {
placeholder="••••••••" placeholder="••••••••"
value={password} value={password}
onChange={handlePasswordChange} onChange={handlePasswordChange}
disabled={isLoading} disabled={status === "loading"}
required required
aria-describedby={passwordError ? "password-error" : undefined} aria-describedby={passwordError ? "password-error" : undefined}
className={ className={
@@ -281,10 +246,10 @@ const Login: React.FC = () => {
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
disabled={isLoading || !isFormValid} disabled={status === "loading" || !isFormValid}
size="default" size="default"
> >
{isLoading ? ( {status === "loading" ? (
<> <>
<Loader2 className="w-5 h-5 mr-3 animate-spin" /> <Loader2 className="w-5 h-5 mr-3 animate-spin" />
Authenticating... Authenticating...

View File

@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit";
import { loginWithEmail, logout, getCurrentUser } from "../../services/authService"; import { loginWithEmail, logout, getCurrentUser } from "../../services/authService";
import { fetchUserData } from "../../services/firestoreService";
import type { User } from "firebase/auth"; import type { User } from "firebase/auth";
export interface AuthUser { export interface AuthUser {
@@ -8,7 +9,7 @@ export interface AuthUser {
email: string | null; email: string | null;
displayName: string | null; displayName: string | null;
photoURL: string | null; photoURL: string | null;
role?: string; userRole?: "admin" | "client" | "vendor";
} }
interface AuthState { interface AuthState {
@@ -27,6 +28,7 @@ const initialState: AuthState = {
/** /**
* Async thunk for user login * Async thunk for user login
* Fetches user data including role from Firestore after authentication
*/ */
export const loginUser = createAsyncThunk( export const loginUser = createAsyncThunk(
"auth/loginUser", "auth/loginUser",
@@ -38,11 +40,25 @@ export const loginUser = createAsyncThunk(
} }
const firebaseUser = result.user as User; 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 { return {
uid: firebaseUser.uid, uid: firebaseUser.uid,
email: firebaseUser.email, email: firebaseUser.email,
displayName: firebaseUser.displayName, displayName: firebaseUser.displayName,
photoURL: firebaseUser.photoURL, 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 * 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(); const currentUser = getCurrentUser();
if (currentUser) { 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 { return {
uid: currentUser.uid, uid: currentUser.uid,
email: currentUser.email, email: currentUser.email,
displayName: currentUser.displayName, displayName: currentUser.displayName,
photoURL: currentUser.photoURL, photoURL: currentUser.photoURL,
userRole: userRole,
}; };
} }
@@ -85,9 +115,9 @@ const authSlice = createSlice({
clearError: (state) => { clearError: (state) => {
state.error = null; state.error = null;
}, },
setRole: (state, action: PayloadAction<string>) => { setUserRole: (state, action: PayloadAction<"admin" | "client" | "vendor">) => {
if (state.user) { 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; export default authSlice.reducer;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { logoutUser } from '../auth/authSlice';
import { Button } from '../../common/components/ui/button';
import type { RootState, AppDispatch } from '../../store/store';
import {
Bell,
Search
} from 'lucide-react';
import Sidebar from './Sidebar';
import { getDashboardPath } from '../../services/firestoreService';
/**
* Main Application Layout for Authenticated Users.
* Includes Sidebar, Header, and Content Area.
* Handles role-based navigation rendering and validates user access to dashboard routes.
*/
const AppLayout: React.FC = () => {
// Typed selectors
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
const dispatch = useDispatch<AppDispatch>();
const location = useLocation();
const navigate = useNavigate();
const [sidebarOpen, setSidebarOpen] = React.useState(true);
// Validate dashboard access based on user role
useEffect(() => {
if (!user?.userRole) return;
const currentPath = location.pathname;
const correctDashboard = getDashboardPath(user.userRole);
// If user is trying to access a dashboard route
if (currentPath.startsWith('/dashboard/')) {
// Check if the current dashboard doesn't match their role
if (currentPath !== correctDashboard) {
// Redirect to their correct dashboard
navigate(correctDashboard, { replace: true });
}
}
}, [location.pathname, user?.userRole, navigate]);
// Auth Guard: Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
const handleLogout = () => {
dispatch(logoutUser());
};
return (
<div className="flex h-screen bg-slate-50 overflow-hidden">
<Sidebar
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
user={user ? { name: user.displayName || undefined, role: user.userRole } : null}
onLogout={handleLogout}
/>
{/* Main Content */}
<main className="flex-1 flex flex-col overflow-hidden relative">
{/* Top Header */}
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6 z-10 shrink-0">
<div className="flex items-center text-sm text-secondary-text">
<span className="font-medium text-primary-text">Workspace</span>
<span className="mx-2">/</span>
<span className="capitalize">{location.pathname.split('/')[1] || 'Dashboard'}</span>
</div>
<div className="flex items-center space-x-4">
<div className="relative hidden md:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-text" size={16} />
<input
type="text"
placeholder="Search..."
className="h-9 pl-9 pr-4 rounded-full bg-slate-100 border-none text-sm focus:ring-2 focus:ring-primary/20 w-64"
/>
</div>
<Button variant="ghost" size="icon" className="relative">
<Bell size={20} className="text-secondary-text" />
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border border-white"></span>
</Button>
</div>
</header>
{/* Content Area */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</main>
</div>
);
};
export default AppLayout;

View File

@@ -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<ProtectedRouteProps> = ({
children,
allowedRoles,
redirectTo = '/dashboard/client',
}) => {
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
// If user is not authenticated, redirect to login
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// If user is authenticated but role is not allowed, redirect to specified path
if (user?.userRole && !allowedRoles.includes(user.userRole)) {
return <Navigate to={redirectTo} replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -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<SidebarProps> = ({
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 (
<aside
className={`${sidebarOpen ? 'w-64' : 'w-20'
} bg-white border-r border-slate-200 transition-all duration-300 flex flex-col z-20`}
>
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-100 flex-shrink-0">
{sidebarOpen ? (
<span className="text-xl font-bold text-primary">KROW</span>
) : (
<span className="text-xl font-bold text-primary mx-auto">K</span>
)}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-1 rounded-md hover:bg-slate-100 text-secondary-text"
>
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
<nav className="flex-1 py-6 px-3 space-y-6 overflow-y-auto">
{filteredNav.map((group) => (
<div key={group.title}>
{sidebarOpen && (
<h3 className="px-3 mb-2 text-xs font-semibold text-muted-text uppercase tracking-wider">
{group.title}
</h3>
)}
<div className="space-y-1">
{group.items.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center px-3 py-2.5 rounded-lg transition-colors group ${location.pathname.startsWith(item.path)
? 'bg-primary/10 text-primary font-medium'
: 'text-secondary-text hover:bg-slate-50 hover:text-primary-text'
}`}
title={!sidebarOpen ? item.label : undefined}
>
<item.icon
size={20}
className={`flex-shrink-0 ${location.pathname.startsWith(item.path)
? 'text-primary'
: 'text-muted-text group-hover:text-secondary-text'
}`}
/>
{sidebarOpen && <span className="ml-3 truncate">{item.label}</span>}
</Link>
))}
</div>
</div>
))}
</nav>
<div className="p-4 border-t border-slate-100 flex-shrink-0">
<div className={`flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'}`}>
{sidebarOpen && (
<div className="flex items-center overflow-hidden">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs flex-shrink-0">
{user?.name?.charAt(0) || 'U'}
</div>
<div className="ml-3 overflow-hidden">
<p className="text-sm font-medium text-primary-text truncate w-32">{user?.name}</p>
<p className="text-xs text-secondary-text truncate">{user?.role}</p>
</div>
</div>
)}
<Button
variant="ghost"
size="icon"
onClick={onLogout}
title="Logout"
className="text-secondary-text hover:text-destructive hover:bg-destructive/10"
>
<LogOut size={18} />
</Button>
</div>
</div>
</aside>
);
};
export default Sidebar;

View File

@@ -1,14 +1,21 @@
import React from 'react'; import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import Login from './features/auth/Login'; import Login from './features/auth/Login';
import ForgotPassword from './features/auth/ForgotPassword'; 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 * AppRoutes Component
* Defines the main routing structure of the application. * Defines the main routing structure of the application.
* Groups routes by Layout (Public vs App). * Groups routes by Layout (Public vs App).
* Implements role-based redirection after login.
*/ */
const AppRoutes: React.FC = () => { const AppRoutes: React.FC = () => {
return ( return (
@@ -27,11 +34,73 @@ const AppRoutes: React.FC = () => {
<ForgotPassword /> <ForgotPassword />
} }
/> />
<Route path="/dashboard" element={<Dashboard />} /> {/* Authenticated Routes */}
<Route element={<AppLayout />}>
{/* Dashboard Redirect Logic - redirects to user's correct dashboard based on role */}
<Route path="/" element={<RoleDashboardRedirect />} />
{/* Protected Dashboard Routes */}
<Route
path="/dashboard/admin"
element={
<ProtectedRoute
allowedRoles={['admin']}
redirectTo="/dashboard/client"
>
<AdminDashboard />
</ProtectedRoute>
}
/>
<Route
path="/dashboard/client"
element={
<ProtectedRoute
allowedRoles={['client']}
redirectTo="/dashboard/client"
>
<ClientDashboard />
</ProtectedRoute>
}
/>
<Route
path="/dashboard/vendor"
element={
<ProtectedRoute
allowedRoles={['vendor']}
redirectTo="/dashboard/client"
>
<VendorDashboard />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>
</Router> </Router>
); );
}; };
/**
* 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; export default AppRoutes;

View File

@@ -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<UserData | null> => {
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<string, string> = {
admin: "/dashboard/admin",
client: "/dashboard/client",
vendor: "/dashboard/vendor",
};
return roleMap[userRole.toLowerCase()] || "/dashboard/client";
};