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 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...
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 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;
|
||||||
|
|||||||
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