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 }