Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | 1x 27x 27x 27x 1x 26x 3x 23x 22x 2x 20x 22x 6x 14x 1x 13x 13x 1x 1x 13x 1x 2x 1x 2x 1x 2x 1x 2x | /**
* ProtectedRoute Component
* Story 1.2: Route guards based on user roles and permissions
*/
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
// Tailwind, not MUI: ProtectedRoute is imported eagerly by App.tsx (it guards ~20 routes),
// so keeping it MUI-free is what stops @mui/material from leaking into the public entry chunk.
import { BATbernLoader } from '@components/shared/BATbernLoader';
import { useAuth } from '@hooks/useAuth';
import type { ProtectedRouteProps } from './types';
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
allowedRoles = ['organizer', 'speaker', 'partner', 'attendee'],
requiresAuth = true,
requiresVerification = false,
fallbackPath = '/login',
}) => {
const { isAuthenticated, isLoading, user, canAccess } = useAuth();
const location = useLocation();
// Show loading spinner while checking authentication
if (isLoading) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
<h1 className="mb-1 text-3xl font-light">Loading</h1>
<BATbernLoader size={80} />
<p className="text-sm">Checking authentication...</p>
</div>
);
}
// Redirect to login if authentication is required but user is not authenticated
if (requiresAuth && !isAuthenticated) {
return <Navigate to={fallbackPath} state={{ from: location }} replace />;
}
// Check if user exists and has required role
if (requiresAuth && user) {
// Story 12.11 (AC6): blocking onboarding gate. termsAcceptedAt === null means the
// hydrated /users/me response confirmed NO ToS/Privacy consent on record (fresh
// federated sign-ups, retro-gated federated rows) → force the user onto
// /profile?onboarding=1 until they consent. `undefined` (hydration failed /
// not yet hydrated) deliberately passes — fail-open, never lock users out on a
// transient /users/me failure. /profile itself (and /logout) stay reachable so
// the gate cannot loop.
if (
user.termsAcceptedAt === null &&
!location.pathname.startsWith('/profile') &&
!location.pathname.startsWith('/logout')
) {
return <Navigate to="/profile?onboarding=1" replace />;
}
// Story 11.E.3 (cherry-pick 73d94688): multi-role support — check if ANY of user's roles is allowed
const userRoles = user.roles ?? (user.role ? [user.role] : []);
if (!userRoles.some((r) => allowedRoles.includes(r))) {
// Redirect to dashboard instead of showing error
return <Navigate to="/dashboard" replace />;
}
// Check email verification if required
if (requiresVerification && !user.emailVerified) {
return (
<div className="p-6">
<div
role="alert"
className="rounded-md border border-amber-400/30 bg-amber-400/15 p-4 text-amber-200"
>
<h6 className="text-lg font-medium">Email Verification Required</h6>
<p className="text-sm">Please verify your email address to access this content.</p>
</div>
</div>
);
}
// Check path-based access control
const hasAccess = canAccess(location.pathname);
if (!hasAccess) {
console.warn('[ProtectedRoute] Access denied, redirecting to /dashboard');
// Redirect to dashboard instead of showing error
return <Navigate to="/dashboard" replace />;
}
}
// Render protected content
return <>{children}</>;
};
/**
* Role-specific route protection components
*/
export const OrganizerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ProtectedRoute allowedRoles={['organizer']}>{children}</ProtectedRoute>
);
// Code review 2026-05-18 (D3): tightened to SPEAKER only. The backend's
// @PreAuthorize("hasRole('SPEAKER')") on /api/v1/speaker-portal/** strict-rejects organizer-
// only tokens; admitting ORGANIZER at the frontend just mounts the page then errors with
// 403. Mirrors the backend contract; users with both ORGANIZER + SPEAKER roles still pass.
export const SpeakerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ProtectedRoute allowedRoles={['speaker']}>{children}</ProtectedRoute>
);
export const PartnerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ProtectedRoute allowedRoles={['organizer', 'partner']}>{children}</ProtectedRoute>
);
export const AttendeeRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ProtectedRoute allowedRoles={['attendee', 'organizer']}>{children}</ProtectedRoute>
);
|