diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index f9ac5e1..3d53410 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return { user: null, isAuthenticated: false, isLoading: true }; }); - // ── Bootstrap — restore session from stored token ────────────────────── + // Bootstrap — restore session from stored token useEffect(() => { const accessToken = tokenStorage.getAccess(); const refreshToken = tokenStorage.getRefresh(); @@ -93,7 +93,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); }, []); - // ── Listen for session expiry from axios interceptor ────────────────── + // Listen for session expiry from axios interceptor useEffect(() => { const onExpired = () => { setState({ user: null, isAuthenticated: false, isLoading: false }); @@ -102,7 +102,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => window.removeEventListener("gatelog:session-expired", onExpired); }, []); - // ── Login ────────────────────────────────────────────────────────────── + // Login const login = useCallback(async (email: string, password: string) => { const { data } = await authApi.login({ email, password }); tokenStorage.set(data.accessToken, data.refreshToken); @@ -113,7 +113,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); }, []); - // ── Logout ───────────────────────────────────────────────────────────── + // Logout const logout = useCallback(async () => { const refreshToken = tokenStorage.getRefresh(); try { @@ -126,7 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, []); - // ── Role helper ──────────────────────────────────────────────────────── + // Role helper const isRole = useCallback( (...roles: Role[]) => { return state.user ? roles.includes(state.user.role) : false; diff --git a/frontend/src/auth/ProtectedRoute.tsx b/frontend/src/auth/ProtectedRoute.tsx index e039006..b97d7ad 100644 --- a/frontend/src/auth/ProtectedRoute.tsx +++ b/frontend/src/auth/ProtectedRoute.tsx @@ -1,5 +1,7 @@ import { Navigate, Outlet, useLocation } from "react-router-dom"; import { useAuth, type Role } from "./AuthContext"; +import { AppLoader } from "../components/AppLoader"; +import { ROUTE_PATHS } from "../router/constants"; interface ProtectedRouteProps { allowedRoles?: Role[]; @@ -9,58 +11,20 @@ export default function ProtectedRoute({ allowedRoles }: ProtectedRouteProps) { const { isAuthenticated, isLoading, user } = useAuth(); const location = useLocation(); - // still bootstrapping from localStorage — render nothing - if (isLoading) return ; + // Show loader while auth is being restored + if (isLoading) { + return ; + } - // not authenticated — send to login, preserve intended destination + // Not authenticated — send to login, preserve intended destination if (!isAuthenticated) { - return ; + return ; } - // authenticated but wrong role + // Authenticated but wrong role if (allowedRoles && user && !allowedRoles.includes(user.role)) { - return ; + return ; } return ; -} - -function AppLoader() { - return ( -
- - -
- ); } \ No newline at end of file diff --git a/frontend/src/components/AppLoader.tsx b/frontend/src/components/AppLoader.tsx new file mode 100644 index 0000000..38fcd37 --- /dev/null +++ b/frontend/src/components/AppLoader.tsx @@ -0,0 +1,10 @@ +export function AppLoader() { + return ( +
+
+
+

Loading...

+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..ad6e6bf --- /dev/null +++ b/frontend/src/components/layout/AppShell.tsx @@ -0,0 +1,264 @@ +// src/components/layout/AppShell.tsx +import { useState, useEffect } from 'react' +import { NavLink, Outlet, useNavigate } from 'react-router-dom' +import { useAuth } from '../../auth/AuthContext' +import { ROUTE_PATHS } from '../../router/constants' +import { AppLoader } from '../AppLoader' +import { + LayoutDashboard, + UserPlus, + Users, + FileText, + Building2, + LogOut, + Menu, + X, + ChevronRight, + Shield, +} from 'lucide-react' + +interface NavItem { + label: string + path: string + icon: React.ReactNode + roles: Array<'SUPER_ADMIN' | 'MANAGER' | 'STAFF'> +} + +const NAV_ITEMS: NavItem[] = [ + { + label: 'Dashboard', + path: ROUTE_PATHS.DASHBOARD, + icon: , + roles: ['SUPER_ADMIN', 'MANAGER'], + }, + { + label: 'New Visitor', + path: ROUTE_PATHS.NEW_VISITOR, + icon: , + roles: ['SUPER_ADMIN', 'MANAGER', 'STAFF'], + }, + { + label: 'Visitors', + path: ROUTE_PATHS.VISITORS, + icon: , + roles: ['SUPER_ADMIN', 'MANAGER', 'STAFF'], + }, + { + label: 'Reports', + path: ROUTE_PATHS.REPORTS, + icon: , + roles: ['SUPER_ADMIN', 'MANAGER'], + }, + { + label: 'Users', + path: ROUTE_PATHS.USERS, + icon: , + roles: ['SUPER_ADMIN', 'MANAGER'], + }, + { + label: 'Sites', + path: ROUTE_PATHS.SITES, + icon: , + roles: ['SUPER_ADMIN'], + }, +] + +const ROLE_LABELS: Record = { + SUPER_ADMIN: 'Super Admin', + MANAGER: 'Manager', + STAFF: 'Staff', +} + +function getInitials(email: string): string { + // Add defensive check even here + if (!email) return '??' + const parts = email.split('@')[0].split(/[._-]/) + return parts + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? '') + .join('') +} + +export default function AppShell() { + const { user, logout, isRole, isLoading } = useAuth() + const navigate = useNavigate() + const [drawerOpen, setDrawerOpen] = useState(false) + + // Close drawer on Escape key + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setDrawerOpen(false) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, []) + + async function handleLogout() { + await logout() + navigate(ROUTE_PATHS.LOGIN, { replace: true }) + } + + // Check BOTH isLoading AND user + // The problem: isLoading can be false while user is still null + if (isLoading || !user) { + // If we're still loading or no user, show loader or redirect + if (!user && !isLoading) { + // Not loading but no user = not authenticated + navigate(ROUTE_PATHS.LOGIN, { replace: true }) + return null + } + return + } + + // Safe to use user object now + const visibleLinks = NAV_ITEMS.filter((item) => + item.roles.some((r) => isRole(r)) + ) + + const initials = getInitials(user.email) + const roleLabel = ROLE_LABELS[user.role] ?? user.role + + return ( +
+ {/* Sidebar overlay (mobile) */} + {drawerOpen && ( + + ) +} \ No newline at end of file diff --git a/frontend/src/router/Router.tsx b/frontend/src/router/Router.tsx index f39ec21..4b0699b 100644 --- a/frontend/src/router/Router.tsx +++ b/frontend/src/router/Router.tsx @@ -1,68 +1,70 @@ -import { createBrowserRouter } from "react-router-dom"; -import Landing from "../pages/Landing"; -import Login from "../pages/auth/Login"; -import Unauthorized from "../pages/auth/Unauthorized"; -import NotFound from "../pages/NotFound"; -import ProtectedRoute from "../auth/ProtectedRoute"; -import { ROUTE_PATHS } from "./constants"; +import { createBrowserRouter } from 'react-router-dom' +import Landing from '../pages/Landing' +import Login from '../pages/auth/Login' +import Unauthorized from '../pages/auth/Unauthorized' +import NotFound from '../pages/NotFound' +import ProtectedRoute from '../auth/ProtectedRoute' +import AppShell from '../components/layout/AppShell' +import { ROUTE_PATHS } from './constants' export const router = createBrowserRouter([ + // Public { path: ROUTE_PATHS.ROOT, element: }, { path: ROUTE_PATHS.LOGIN, element: }, { path: ROUTE_PATHS.UNAUTHORIZED, element: }, + // Authenticated: shell wraps all protected routes { element: , children: [ { - path: ROUTE_PATHS.NEW_VISITOR, - lazy: () => import("../pages/visitors/NewVisitor") - .then((m) => ({ Component: m.default })), - }, - { - path: ROUTE_PATHS.VISITORS, - lazy: () => import("../pages/visitors/VisitorList") - .then((m) => ({ Component: m.default })), - }, - ], - }, + element: , + children: [ + // Any role + { + path: ROUTE_PATHS.NEW_VISITOR, + lazy: () => import('../pages/visitors/NewVisitor') + .then((m) => ({ Component: m.default })), + }, + { + path: ROUTE_PATHS.VISITORS, + lazy: () => import('../pages/visitors/VisitorList') + .then((m) => ({ Component: m.default })), + }, - { - element: , - children: [ - { - path: ROUTE_PATHS.DASHBOARD, - lazy: () => import("../pages/Dashboard") - .then((m) => ({ Component: m.default })), - }, - { - path: ROUTE_PATHS.REPORTS, - lazy: () => import("../pages/Reports") - .then((m) => ({ Component: m.default })), - }, - { - path: ROUTE_PATHS.USERS, - lazy: () => import("../pages/users/Users") - .then((m) => ({ Component: m.default })), - }, - ], - }, + // Manager + Super Admin + { + path: ROUTE_PATHS.DASHBOARD, + lazy: () => import('../pages/Dashboard') + .then((m) => ({ Component: m.default })), + }, + { + path: ROUTE_PATHS.REPORTS, + lazy: () => import('../pages/Reports') + .then((m) => ({ Component: m.default })), + }, + { + path: ROUTE_PATHS.USERS, + lazy: () => import('../pages/users/Users') + .then((m) => ({ Component: m.default })), + }, - { - element: , - children: [ - { - path: ROUTE_PATHS.ADMIN, - lazy: () => import("../pages/users/Admin") - .then((m) => ({ Component: m.default })), - }, - { - path: ROUTE_PATHS.SITES, - lazy: () => import("../pages/sites/Sites") - .then((m) => ({ Component: m.default })), + // Super Admin only + { + path: ROUTE_PATHS.ADMIN, + lazy: () => import('../pages/users/Admin') + .then((m) => ({ Component: m.default })), + }, + { + path: ROUTE_PATHS.SITES, + lazy: () => import('../pages/sites/Sites') + .then((m) => ({ Component: m.default })), + }, + ], }, ], }, - { path: "*", element: }, -]); \ No newline at end of file + // Catch-all + { path: '*', element: }, +]) \ No newline at end of file