/ src / auth / AuthProvider.tsx
AuthProvider.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, useCallback, useEffect, useMemo, useState } from 'react';
  5  import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
  6  import { InteractionRequiredAuthError, type AccountInfo } from '@azure/msal-browser';
  7  import { AuthContext, type AuthContextValue } from './AuthContext';
  8  import { getMsalInstance, loginRequest } from './msalConfig';
  9  import { config } from '../config';
 10  
 11  // ── Disabled auth: everything is open ────────────────────────────────────────
 12  
 13  function DisabledAuthProvider({ children }: { children: ReactNode }) {
 14    const value = useMemo<AuthContextValue>(
 15      () => ({
 16        isAuthenticated: true,
 17        authEnabled: false,
 18        user: null,
 19        roles: ['operator'],
 20        getAccessToken: async () => null,
 21        login: async () => {},
 22        logout: async () => {},
 23      }),
 24      [],
 25    );
 26  
 27    return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
 28  }
 29  
 30  // ── Enabled auth: real MSAL integration ──────────────────────────────────────
 31  
 32  function MsalAuthInner({ children }: { children: ReactNode }) {
 33    const { instance, accounts } = useMsal();
 34    const isAuthenticated = useIsAuthenticated();
 35    const account: AccountInfo | null = accounts[0] ?? null;
 36  
 37    const roles = useMemo(() => {
 38      const claims = account?.idTokenClaims as Record<string, unknown> | undefined;
 39      const raw = claims?.roles;
 40      return Array.isArray(raw) ? (raw as string[]) : [];
 41    }, [account]);
 42  
 43    const getAccessToken = useCallback(async () => {
 44      if (!account) return null;
 45      try {
 46        const response = await instance.acquireTokenSilent({
 47          ...loginRequest,
 48          account,
 49        });
 50        return response.accessToken;
 51      } catch (error) {
 52        if (error instanceof InteractionRequiredAuthError) {
 53          try {
 54            const result = await instance.acquireTokenPopup(loginRequest);
 55            return result.accessToken;
 56          } catch {
 57            // Popup blocked or failed — fall back to redirect
 58            await instance.acquireTokenRedirect(loginRequest);
 59            return null;
 60          }
 61        }
 62        return null;
 63      }
 64    }, [instance, account]);
 65  
 66    const login = useCallback(async () => {
 67      await instance.loginRedirect(loginRequest);
 68    }, [instance]);
 69  
 70    const logout = useCallback(async () => {
 71      await instance.logoutRedirect({
 72        postLogoutRedirectUri: config.azure.redirectUri,
 73      });
 74    }, [instance]);
 75  
 76    const value = useMemo<AuthContextValue>(
 77      () => ({
 78        isAuthenticated,
 79        authEnabled: true,
 80        user: account,
 81        roles,
 82        getAccessToken,
 83        login,
 84        logout,
 85      }),
 86      [isAuthenticated, account, roles, getAccessToken, login, logout],
 87    );
 88  
 89    return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
 90  }
 91  
 92  function EnabledAuthProvider({ children }: { children: ReactNode }) {
 93    const [msalReady, setMsalReady] = useState(false);
 94    const msalInstance = getMsalInstance();
 95  
 96    useEffect(() => {
 97      msalInstance
 98        .initialize()
 99        .then(() => msalInstance.handleRedirectPromise())
100        .then((result) => {
101          // After redirect login, result.account is the signed-in account — set it active so
102          // getActiveAccount() returns a value before React Query fires any API calls.
103          if (result?.account) {
104            msalInstance.setActiveAccount(result.account);
105          } else {
106            // On non-redirect page loads (refresh / direct navigation), restore active account
107            // from MSAL cache if exactly one account exists.
108            const cached = msalInstance.getAllAccounts();
109            if (cached.length > 0) {
110              msalInstance.setActiveAccount(cached[0]);
111            }
112          }
113          setMsalReady(true);
114        })
115        .catch((err) => {
116          console.error('[MSAL] Redirect handling failed:', err);
117          setMsalReady(true); // still render so app isn't stuck
118        });
119    }, [msalInstance]);
120  
121    if (!msalReady) {
122      return (
123        <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
124          <div className="text-center space-y-3">
125            <div className="w-8 h-8 border-2 border-violet-400 border-t-transparent rounded-full animate-spin mx-auto" />
126            <p className="text-sm text-gray-500 dark:text-gray-400">Initializing authentication...</p>
127          </div>
128        </div>
129      );
130    }
131  
132    return (
133      <MsalProvider instance={msalInstance}>
134        <MsalAuthInner>{children}</MsalAuthInner>
135      </MsalProvider>
136    );
137  }
138  
139  // ── Public export: picks the right provider based on feature flag ─────────────
140  
141  export function AuthProvider({ children }: { children: ReactNode }) {
142    if (config.authEnabled) {
143      return <EnabledAuthProvider>{children}</EnabledAuthProvider>;
144    }
145    return <DisabledAuthProvider>{children}</DisabledAuthProvider>;
146  }