feat(auth): implement role-based dashboard redirect
This commit is contained in:
240
apps/web/src/common/config/navigation.ts
Normal file
240
apps/web/src/common/config/navigation.ts
Normal 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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
8
apps/web/src/features/dashboard/AdminDashboard.tsx
Normal file
8
apps/web/src/features/dashboard/AdminDashboard.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
const AdminDashboard = () => {
|
||||
return (
|
||||
<div>Dashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminDashboard
|
||||
9
apps/web/src/features/dashboard/ClientDashboard.tsx
Normal file
9
apps/web/src/features/dashboard/ClientDashboard.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
const ClientDashboard = () => {
|
||||
return (
|
||||
<div>ClientDashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClientDashboard
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<div>Dashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
9
apps/web/src/features/dashboard/VendorDashboard.tsx
Normal file
9
apps/web/src/features/dashboard/VendorDashboard.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
const VendorDashboard = () => {
|
||||
return (
|
||||
<div>VendorDashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VendorDashboard
|
||||
99
apps/web/src/features/layouts/AppLayout.tsx
Normal file
99
apps/web/src/features/layouts/AppLayout.tsx
Normal 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;
|
||||
37
apps/web/src/features/layouts/ProtectedRoute.tsx
Normal file
37
apps/web/src/features/layouts/ProtectedRoute.tsx
Normal 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;
|
||||
122
apps/web/src/features/layouts/Sidebar.tsx
Normal file
122
apps/web/src/features/layouts/Sidebar.tsx
Normal 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;
|
||||
@@ -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 = () => {
|
||||
<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 />} />
|
||||
</Routes>
|
||||
</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;
|
||||
|
||||
55
apps/web/src/services/firestoreService.ts
Normal file
55
apps/web/src/services/firestoreService.ts
Normal 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";
|
||||
};
|
||||
Reference in New Issue
Block a user