All files / src/components/auth/ProtectedRoute ProtectedRoute.tsx

100% Statements 29/29
96.77% Branches 30/31
100% Functions 6/6
100% Lines 28/28

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>
);