/ src / components / admin / AdminGuard.tsx
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  }