feat(auth): implement role-based dashboard redirect
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user