import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { UserManager, WebStorageStateStore, User as OidcUser } from 'oidc-client-ts'; import { Navigate, useLocation } from 'react-router-dom'; import { getSecuritySettings, loadRuntimeConfig, onConfigChanged } from '../config'; import { parseJwtClaims } from './jwt'; import { BrandLogo } from '../components/ui/BrandLogo'; import { syncUserAccount, isUserSyncedInBrowser, isActivationPending, getActivationPendingReason, clearUserSyncInBrowser, UserSyncResult, } from '../services/userSyncService'; import { AccountActivationModal } from '../components/auth/AccountActivationModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; import { onSessionExpired, triggerSessionExpired, clearSessionAndRedirectToLanding, cancelSessionExpiration, dismissSubordinateModals, } from '../services/sessionExpiration'; export interface AuthUserProfile { id: string; sub: string; name: string; email: string; role: string; organisationName?: string; tenant?: string; avatarUrl?: string; } export type UserClaims = Record; interface AuthContextType { user: AuthUserProfile | null; sub: string; claims: UserClaims; accessToken: string | null; isAuthenticated: boolean; isLoading: boolean; isSynced: boolean; isPendingActivation: boolean; activationError: string | null; isSessionExpired: boolean; triggerSessionExpired: (reason?: string) => void; login: () => Promise; logout: () => Promise; syncUserSession: (force?: boolean) => Promise; authError: string | null; } const AuthContext = createContext(undefined); export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token'; export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user'; export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub'; export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims'; export function getCurrentUserSub(): string { if (typeof window !== 'undefined') { return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || ''; } return ''; } export function getCurrentUserClaims(): UserClaims { if (typeof window !== 'undefined') { const raw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY); if (raw) { try { return JSON.parse(raw); } catch { return {}; } } } return {}; } let userManagerInstance: UserManager | null = null; export function resetUserManager(): void { userManagerInstance = null; } // Automatically reset UserManager when runtime configuration is updated onConfigChanged(() => { resetUserManager(); }); export function getUserManager(): UserManager { if (!userManagerInstance && typeof window !== 'undefined') { const sec = getSecuritySettings(); const origin = window.location.origin; userManagerInstance = new UserManager({ authority: sec.Authority, client_id: sec.ClientId, client_secret: sec.ClientSecret, redirect_uri: `${origin}/dashboard`, post_logout_redirect_uri: `${origin}/`, response_type: 'code', scope: sec.Scope || 'openid profile roles email address pfm-api-dev', extraQueryParams: { audience: sec.Audience, }, userStore: new WebStorageStateStore({ store: window.localStorage }), automaticSilentRenew: false, }); } return userManagerInstance!; } export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [sub, setSub] = useState(''); const [claims, setClaims] = useState({}); const [accessToken, setAccessToken] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSynced, setIsSynced] = useState(() => isUserSyncedInBrowser()); const [isPendingActivation, setIsPendingActivation] = useState(() => isActivationPending()); const [activationError, setActivationError] = useState(() => getActivationPendingReason()); const [authError, setAuthError] = useState(null); const [isSessionExpired, setIsSessionExpired] = useState(false); const [sessionExpiredReason, setSessionExpiredReason] = useState(null); // Subscribe to global HTTP 401 session expiration events useEffect(() => { const unsubscribe = onSessionExpired((reason) => { // Subordination: If account activation lockout is active, session expiration is completely avoided and bypassed if (isPendingActivation || isActivationPending()) { return; } // Only display modal if user currently has an active token or authenticated state const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null; if (token || accessToken) { setSessionExpiredReason(reason || 'Session expired or token invalidated (HTTP 401)'); setIsSessionExpired(true); } }); return () => { unsubscribe(); }; }, [accessToken, isPendingActivation]); // Priority Subordination: When activation process is active, dismiss all other modals, // cancel session expiration and avoid its processes so that there is no overlapping. useEffect(() => { if (isPendingActivation) { setIsSessionExpired(false); setSessionExpiredReason(null); cancelSessionExpiration(); dismissSubordinateModals(); } }, [isPendingActivation]); // When session expiration modal is displayed, dismiss all other subordinates useEffect(() => { if (isSessionExpired) { dismissSubordinateModals(); } }, [isSessionExpired]); const handleSessionExpiredDismiss = () => { setIsSessionExpired(false); setSessionExpiredReason(null); clearSessionAndRedirectToLanding(); }; // Initialize auth state, check for OIDC callback, and perform browser-cached user sync useEffect(() => { const initAuth = async () => { try { await loadRuntimeConfig(); // Check for URL query code indicating OIDC redirect callback const urlParams = new URLSearchParams(window.location.search); if (urlParams.has('code') && urlParams.has('state')) { const mgr = getUserManager(); const oidcUser: OidcUser = await mgr.signinCallback(); if (oidcUser && oidcUser.access_token) { const token = oidcUser.access_token; setAccessToken(token); localStorage.setItem(LOCAL_STORAGE_TOKEN_KEY, token); // Merge claims from profile and decoded JWT token const decodedTokenClaims = parseJwtClaims(token); const mergedClaims: UserClaims = { ...decodedTokenClaims, ...oidcUser.profile, }; const userSub = (oidcUser.profile?.sub || mergedClaims.sub || '') as string; setSub(userSub); setClaims(mergedClaims); localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims)); const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined; const profile: AuthUserProfile = { id: userSub || 'user-1', sub: userSub, name: oidcUser.profile.name || oidcUser.profile.preferred_username || (mergedClaims.name as string) || 'Authorized User', email: oidcUser.profile.email || (mergedClaims.email as string) || '', role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'), organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined, tenant: tenantClaim, }; setUser(profile); localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); // Clean up code and state from URL window.history.replaceState({}, document.title, window.location.pathname); // Execute user sync endpoint upon login. // If HTTP 401 -> routes to landing page // If non-401 failure -> displays non-dismissable activation modal // If success -> saved in browser to prevent endpoint hotspot try { const syncResult = await syncUserAccount(userSub, token, profile, true); if (syncResult.isUnauthorized) { await logout(); window.location.replace('/'); return; } if (syncResult.isActivationPending) { setIsPendingActivation(true); setIsSynced(false); setActivationError(syncResult.errorMessage || null); } else if (syncResult.success) { setIsPendingActivation(false); setIsSynced(true); setActivationError(null); } } catch (syncErr: any) { if (syncErr?.response?.status === 401) { await logout(); window.location.replace('/'); return; } setIsPendingActivation(true); setIsSynced(false); setActivationError(syncErr?.message || 'Account activation pending'); } setIsLoading(false); return; } } // Check local storage for persisted session const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY); const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY); let storedSub = localStorage.getItem(LOCAL_STORAGE_SUB_KEY); const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY); if (storedToken) { setAccessToken(storedToken); let parsedClaims: UserClaims = {}; if (storedClaimsRaw) { try { parsedClaims = JSON.parse(storedClaimsRaw); } catch {} } else { parsedClaims = parseJwtClaims(storedToken); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(parsedClaims)); } setClaims(parsedClaims); if (!storedSub && parsedClaims.sub) { storedSub = String(parsedClaims.sub); localStorage.setItem(LOCAL_STORAGE_SUB_KEY, storedSub); } if (storedSub) { setSub(storedSub); } let resolvedUser: AuthUserProfile | null = null; if (storedUser) { try { const parsedUser = JSON.parse(storedUser); if (!parsedUser.tenant && parsedClaims.tenant) { parsedUser.tenant = parsedClaims.tenant; } setUser(parsedUser); resolvedUser = parsedUser; } catch {} } // Check if session is already marked pending activation or needs sync check const wasPending = isActivationPending(); setIsPendingActivation(wasPending); setActivationError(getActivationPendingReason()); try { // Verify sync with browser cache priority (hotspot prevention) const syncResult = await syncUserAccount( storedSub || '', storedToken, resolvedUser || undefined, false ); if (syncResult.isUnauthorized) { // HTTP 401: Normal process of routing user to landing page await logout(); return; } if (syncResult.isActivationPending) { setIsPendingActivation(true); setIsSynced(false); setActivationError(syncResult.errorMessage || null); } else if (syncResult.success) { setIsPendingActivation(false); setIsSynced(true); setActivationError(null); } } catch (syncErr: any) { if (syncErr?.response?.status === 401) { await logout(); return; } setIsPendingActivation(true); setIsSynced(false); setActivationError(syncErr?.message || 'Account activation pending'); } } } catch (err: unknown) { console.warn('OIDC initialization notice:', err); setAuthError(err instanceof Error ? err.message : 'Auth initialization notice'); } finally { setIsLoading(false); } }; initAuth(); }, []); const syncUserSession = async (force = true): Promise => { try { const result = await syncUserAccount(sub, accessToken, user || undefined, force); if (result.isUnauthorized) { await logout(); return result; } if (result.success) { setIsPendingActivation(false); setIsSynced(true); setActivationError(null); } else if (result.isActivationPending) { setIsPendingActivation(true); setIsSynced(false); setActivationError(result.errorMessage || null); } return result; } catch (err: any) { if (err?.response?.status === 401) { await logout(); return { success: false, isUnauthorized: true }; } const msg = err?.message || 'Sync failed'; setIsPendingActivation(true); setIsSynced(false); setActivationError(msg); return { success: false, isActivationPending: true, errorMessage: msg }; } }; const login = async () => { try { setAuthError(null); await loadRuntimeConfig(); resetUserManager(); const mgr = getUserManager(); await mgr.signinRedirect(); } catch (err: unknown) { console.warn('OIDC Redirect encountered:', err); if (typeof window !== 'undefined' && window.self !== window.top) { const errorMsg = 'Identity Provider redirect was restricted inside this preview frame. To complete single sign-on authentication, open the application in a new browser tab.'; setAuthError(errorMsg); } else { setAuthError(err instanceof Error ? err.message : 'Identity Provider redirect error'); } } }; const logout = async () => { try { localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY); localStorage.removeItem(LOCAL_STORAGE_USER_KEY); localStorage.removeItem(LOCAL_STORAGE_SUB_KEY); localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY); clearUserSyncInBrowser(); setUser(null); setSub(''); setClaims({}); setAccessToken(null); setIsSynced(false); setIsPendingActivation(false); setActivationError(null); try { const mgr = getUserManager(); await mgr.signoutRedirect(); } catch { // Local state cleared } } catch (err) { console.error('Logout error:', err); } }; return ( {children} ); }; export const useAuth = () => { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }; // Route Guard export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => { const { isAuthenticated, isLoading, isPendingActivation, user, claims, sub, logout, syncUserSession, activationError, } = useAuth(); const location = useLocation(); if (isLoading) { return (

Verifying PFM Console Session...

); } if (!isAuthenticated) { return ; } // If user is authenticated but account has not yet been enabled by an administrator: // Render the non-dismissable activation modal, blocking all downstream layout and queries. if (isPendingActivation) { return ( ); } return <>{children}; };