AdminGuard.tsx
1 // Copyright (c) 2026 VPL Solutions. All rights reserved. 2 // Licensed under the MIT License. See LICENSE for details. 3 4 import { type ReactNode } from 'react'; 5 import { ShieldAlert, Loader2, RefreshCw } from 'lucide-react'; 6 import { useQuery } from '@tanstack/react-query'; 7 import { useAuth } from '../../auth/useAuth'; 8 import { Card } from '../ui/Card'; 9 import { adminApi } from '../../api/runtimes'; 10 import { ApiError } from '../../api/client'; 11 12 interface AdminGuardProps { 13 children: ReactNode; 14 } 15 16 /** 17 * Role guard for platform-admin pages. 18 * 19 * Calls GET /admin/whoami to check access server-side. 20 * No admin identities stored in the client bundle. 21 * 22 * When auth is disabled, all users are operators (backend returns is_admin: true). 23 * 24 * Retry logic handles the race where useIsAuthenticated() becomes true slightly 25 * before acquireTokenSilent() has the token ready, causing a token-less first 26 * request. retryDelay gives MSAL time to warm the cache before the next attempt. 27 */ 28 export function AdminGuard({ children }: AdminGuardProps) { 29 const { authEnabled, isAuthenticated } = useAuth(); 30 31 const { data, isLoading, error, refetch, isFetching } = useQuery({ 32 queryKey: ['admin', 'whoami'], 33 queryFn: adminApi.whoami, 34 enabled: authEnabled && isAuthenticated, 35 staleTime: 5 * 60 * 1000, 36 // Retry up to 2 times for transient auth failures (token not yet cached). 37 // Never retry on explicit 403 — that is a real permission denial. 38 retry: (failureCount, err) => { 39 if (err instanceof ApiError && err.status === 403) return false; 40 return failureCount < 2; 41 }, 42 retryDelay: 1500, 43 }); 44 45 // Auth disabled — no RBAC enforcement 46 if (!authEnabled) return <>{children}</>; 47 48 // Loading / retrying 49 if (isLoading || isFetching) { 50 return ( 51 <div className="flex items-center justify-center py-16"> 52 <Loader2 className="w-5 h-5 text-gray-400 animate-spin" /> 53 </div> 54 ); 55 } 56 57 // Auth/network error — distinct from permission denied. 58 // "Missing Bearer token" (401) means a transient token race; let the user retry 59 // rather than permanently blocking with "Access Restricted". 60 if (error) { 61 const isAuthError = 62 error instanceof ApiError && (error.status === 401 || error.status === 0); 63 64 if (isAuthError) { 65 return ( 66 <Card className="border-l-4 border-l-amber-400 max-w-lg mx-auto mt-12"> 67 <div className="flex items-start gap-3"> 68 <RefreshCw className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" /> 69 <div> 70 <p className="text-sm font-medium text-amber-700 dark:text-amber-400"> 71 Session not ready 72 </p> 73 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> 74 The authentication token was not available when the access check 75 ran. This can happen immediately after sign-in. 76 </p> 77 <button 78 onClick={() => refetch()} 79 className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors" 80 > 81 <RefreshCw className="w-3.5 h-3.5" /> 82 Retry 83 </button> 84 </div> 85 </div> 86 </Card> 87 ); 88 } 89 90 // 403 or other server error → genuine access denied 91 return ( 92 <Card className="border-l-4 border-l-red-400 max-w-lg mx-auto mt-12"> 93 <div className="flex items-center gap-3"> 94 <ShieldAlert className="w-5 h-5 text-red-500 shrink-0" /> 95 <div> 96 <p className="text-sm font-medium text-red-700 dark:text-red-400"> 97 Access Restricted 98 </p> 99 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> 100 Platform admin pages require the operator role. Contact your 101 administrator. 102 </p> 103 </div> 104 </div> 105 </Card> 106 ); 107 } 108 109 // Whoami returned is_admin: false 110 if (!data?.is_admin) { 111 return ( 112 <Card className="border-l-4 border-l-red-400 max-w-lg mx-auto mt-12"> 113 <div className="flex items-center gap-3"> 114 <ShieldAlert className="w-5 h-5 text-red-500 shrink-0" /> 115 <div> 116 <p className="text-sm font-medium text-red-700 dark:text-red-400"> 117 Access Restricted 118 </p> 119 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> 120 Platform admin pages require the operator role. Contact your 121 administrator. 122 </p> 123 </div> 124 </div> 125 </Card> 126 ); 127 } 128 129 return <>{children}</>; 130 }