Files
pfm/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx
T
2026-10-11 11:16:08 +02:00

914 lines
32 KiB
TypeScript

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 { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService';
import { verifyTenantLockout } from '../services/tenantLockoutService';
import {
onSessionExpired,
triggerSessionExpired,
clearSessionAndRedirectToLanding,
cancelSessionExpiration,
dismissSubordinateModals,
} from '../services/sessionExpiration';
import { resolveUserRolesAndClaims } from '../services/roleClaimsCache';
export interface AuthUserProfile {
id: string;
sub: string;
name: string;
email: string;
role: string;
organisationName?: string;
tenant?: string;
tenant_id?: number;
avatarUrl?: string;
}
export type UserClaims = Record<string, any>;
interface AuthContextType {
user: AuthUserProfile | null;
sub: string;
claims: UserClaims;
tenantId: number | null;
hashedTenantId: string | null;
tenantName: string | null;
accessToken: string | null;
roles: string[];
isPartner: boolean;
canManageTenants: boolean;
isAuthenticated: boolean;
isLoading: boolean;
isSynced: boolean;
isPendingActivation: boolean;
activationError: string | null;
isSessionExpired: boolean;
triggerSessionExpired: (reason?: string) => void;
login: () => Promise<void>;
logout: () => Promise<void>;
syncUserSession: (force?: boolean) => Promise<UserSyncResult>;
authError: string | null;
}
const AuthContext = createContext<AuthContextType | undefined>(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 extractUserRoles(
claims?: UserClaims | null,
user?: AuthUserProfile | null,
token?: string | null
): string[] {
const rolesSet = new Set<string>();
const processRoles = (raw: any) => {
if (!raw) return;
if (Array.isArray(raw)) {
raw.forEach((r) => {
if (typeof r === 'string' && r.trim()) rolesSet.add(r.trim());
});
} else if (typeof raw === 'string' && raw.trim()) {
if (raw.includes(',')) {
raw.split(',').forEach((r) => {
if (r.trim()) rolesSet.add(r.trim());
});
} else {
rolesSet.add(raw.trim());
}
}
};
if (claims) {
processRoles(claims.roles);
processRoles(claims.role);
processRoles(claims['roles']);
processRoles(claims['role']);
processRoles(claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']);
processRoles(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role']);
processRoles(claims['realm_access']?.roles);
processRoles(claims['resource_access']?.roles);
}
if (token) {
try {
const tokenClaims = parseJwtClaims(token);
processRoles(tokenClaims.roles);
processRoles(tokenClaims.role);
processRoles(tokenClaims['roles']);
processRoles(tokenClaims['role']);
processRoles(tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']);
processRoles(tokenClaims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role']);
} catch {}
}
if (user?.role && typeof user.role === 'string') {
processRoles(user.role);
}
if ((user as any)?.roles) {
processRoles((user as any).roles);
}
return Array.from(rolesSet);
}
export function hasPartnerRole(roles: string[]): boolean {
return roles.some((r) => r.toLowerCase() === 'partner');
}
export function canManageTenants(tenantId?: number | string | null, roles?: string[]): boolean {
const isTenantOne = Number(tenantId) === 1;
const hasAdmin = Array.isArray(roles) && roles.some((r) => r.toLowerCase() === 'admin');
return isTenantOne && hasAdmin;
}
export function extractTenantId(claims?: UserClaims | null, token?: string | null): number | null {
if (claims) {
const raw =
claims.tenant_id ??
(claims as any).tenantId ??
claims['tenant_id'] ??
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
if (token) {
try {
const tokenClaims = parseJwtClaims(token);
const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
} catch {}
}
if (typeof window !== 'undefined') {
try {
const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (storedClaimsRaw) {
const parsedClaims = JSON.parse(storedClaimsRaw);
const raw = parsedClaims.tenant_id ?? parsedClaims.tenantId ?? parsedClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (storedToken) {
const tokenClaims = parseJwtClaims(storedToken);
const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY);
if (storedUser) {
const parsedUser = JSON.parse(storedUser);
const raw = parsedUser.tenant_id ?? parsedUser.tenantId;
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
} catch {}
}
return null;
}
export function extractTenantClaimValues(
claims?: UserClaims | null,
token?: string | null
): {
tenantId: number | string | null;
tenant: string | null;
} {
let tenantId: number | string | null = null;
let tenant: string | null = null;
if (claims) {
const rawId =
claims.tenant_id ??
(claims as any).tenantId ??
claims['tenant_id'] ??
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid'];
if (rawId !== undefined && rawId !== null && rawId !== '') {
const parsed = Number(rawId);
tenantId = !isNaN(parsed) && isFinite(parsed) ? parsed : String(rawId).trim();
}
const rawTenant =
claims.tenant ??
claims['tenant'] ??
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenant'] ??
claims.tenant_name ??
(claims as any).tenantName ??
claims.org;
if (rawTenant !== undefined && rawTenant !== null && rawTenant !== '') {
if (typeof rawTenant === 'string') {
tenant = rawTenant.trim();
} else if (typeof rawTenant === 'object' && rawTenant.name) {
tenant = String(rawTenant.name).trim();
}
}
}
if (token && (!tenantId || !tenant)) {
try {
const tokenClaims = parseJwtClaims(token);
if (!tenantId) {
const rawId =
tokenClaims.tenant_id ??
tokenClaims.tenantId ??
tokenClaims['tenant_id'] ??
tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid'];
if (rawId !== undefined && rawId !== null && rawId !== '') {
const parsed = Number(rawId);
tenantId = !isNaN(parsed) && isFinite(parsed) ? parsed : String(rawId).trim();
}
}
if (!tenant) {
const rawTenant =
tokenClaims.tenant ??
tokenClaims['tenant'] ??
tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenant'] ??
tokenClaims.tenant_name ??
(tokenClaims as any).tenantName ??
tokenClaims.org;
if (rawTenant !== undefined && rawTenant !== null && rawTenant !== '') {
if (typeof rawTenant === 'string') {
tenant = rawTenant.trim();
} else if (typeof rawTenant === 'object' && rawTenant.name) {
tenant = String(rawTenant.name).trim();
}
}
}
} catch {}
}
return { tenantId, tenant };
}
export function getCurrentUserTenantId(): number | null {
return extractTenantId(
getCurrentUserClaims(),
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
);
}
export function getCurrentUserHashedTenantId(): string | null {
const tid = getCurrentUserTenantId();
if (tid === null || tid === undefined) return null;
return encodeHashIdSync(tid);
}
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<AuthUserProfile | null>(null);
const [sub, setSub] = useState<string>('');
const [claims, setClaims] = useState<UserClaims>({});
const [tenantId, setTenantId] = useState<number | null>(() => {
return extractTenantId(
getCurrentUserClaims(),
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
);
});
const [tenantName, setTenantName] = useState<string | null>(() => {
const claims = getCurrentUserClaims();
const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null;
const { tenant } = extractTenantClaimValues(claims, token);
return tenant;
});
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSynced, setIsSynced] = useState<boolean>(() => isUserSyncedInBrowser());
const [isPendingActivation, setIsPendingActivation] = useState<boolean>(() => isActivationPending());
const [activationError, setActivationError] = useState<string | null>(() => getActivationPendingReason());
const [authError, setAuthError] = useState<string | null>(null);
const [isSessionExpired, setIsSessionExpired] = useState<boolean>(false);
const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null);
const hashedTenantId = tenantId !== null && tenantId !== undefined ? encodeHashIdSync(tenantId) : null;
// Subscribe to global HTTP 401 session expiration events
useEffect(() => {
const unsubscribe = onSessionExpired((reason) => {
// If account activation is pending, suppress session expiration modal
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]);
// When account activation is pending, ensure secondary dialogs are closed
useEffect(() => {
if (isPendingActivation) {
setIsSessionExpired(false);
setSessionExpiredReason(null);
cancelSessionExpiration();
dismissSubordinateModals();
}
}, [isPendingActivation]);
// When session expiration modal is displayed, dismiss background modals
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 tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId);
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,
tenant_id: parsedTenantId ?? undefined,
};
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);
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
const tenantLockoutResult = await verifyTenantLockout(
parsedTenantId ?? tenantClaimValues.tenantId,
tenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
profile.tenant = tenantLockoutResult.tenant.name;
setTenantName(tenantLockoutResult.tenant.name);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
} else if (tenantClaim) {
setTenantName(tenantClaim);
}
// Synchronize authenticated user profile with backend directory
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);
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...profile });
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
}
} 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);
}
const storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId);
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
let resolvedUser: AuthUserProfile | null = null;
if (storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
if (!parsedUser.tenant && parsedClaims.tenant) {
parsedUser.tenant = parsedClaims.tenant;
}
if (parsedUser.tenant_id === undefined && storedTenantId !== null) {
parsedUser.tenant_id = storedTenantId;
}
setUser(parsedUser);
resolvedUser = parsedUser;
} catch {}
}
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
if (resolvedUser) {
resolvedUser.tenant = tenantLockoutResult.tenant.name;
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setTenantName(tenantLockoutResult.tenant.name);
} else if (storedTenantClaim) {
setTenantName(storedTenantClaim);
}
// 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);
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
try {
const resolved = await resolveUserRolesAndClaims(
storedSub || '',
parsedClaims,
resolvedUser || {}
);
if (resolvedUser) {
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
if (resolved.tenant) {
resolvedUser.tenant = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
resolvedUser.tenant_id = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
}
} 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<UserSyncResult> => {
try {
// Re-verify tenant status via GetTenant as part of the lockout resolution
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim);
if (!tenantLockoutResult.isActive) {
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(msg);
return {
success: false,
isActivationPending: true,
errorMessage: msg,
};
}
// Tenant is verified Active: proceed with user sync
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({});
setTenantId(null);
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);
}
};
const roles = React.useMemo(() => {
return extractUserRoles(claims, user, accessToken);
}, [claims, user, accessToken]);
const isPartner = React.useMemo(() => {
return hasPartnerRole(roles);
}, [roles]);
const canManageTenantsAccess = React.useMemo(() => {
return canManageTenants(tenantId, roles);
}, [tenantId, roles]);
return (
<AuthContext.Provider
value={{
user,
sub,
claims,
tenantId,
hashedTenantId,
tenantName,
accessToken,
roles,
isPartner,
canManageTenants: canManageTenantsAccess,
isAuthenticated: !!accessToken,
isLoading,
isSynced,
isPendingActivation,
activationError,
isSessionExpired,
triggerSessionExpired,
login,
logout,
syncUserSession,
authError,
}}
>
{children}
<SessionExpiredModal
isOpen={isSessionExpired && !isPendingActivation}
reason={sessionExpiredReason}
onDismiss={handleSessionExpiredDismiss}
countdownSeconds={8}
/>
</AuthContext.Provider>
);
};
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,
isPartner,
roles,
user,
claims,
sub,
logout,
syncUserSession,
activationError,
} = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-[#090d16]">
<div className="flex flex-col items-center gap-4">
<div className="relative flex items-center justify-center">
<BrandLogo size={52} showGlow animateOnHover={false} />
<div className="absolute -inset-2.5 rounded-full border-2 border-blue-500/20 border-t-blue-400 animate-spin" />
</div>
<p className="text-xs font-medium text-slate-400 tracking-wide">Verifying PFM Console Session...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/" state={{ from: location }} replace />;
}
// Security Access Control: Users assigned the Partner role are denied entry to the console
if (isPartner || hasPartnerRole(roles)) {
return (
<PartnerAccessDeniedModal
user={user}
claims={claims}
sub={sub}
onLogout={logout}
/>
);
}
// 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 (
<AccountActivationModal
user={user}
claims={claims}
sub={sub}
onSync={syncUserSession}
onLogout={logout}
initialError={activationError}
/>
);
}
return <>{children}</>;
};
// Guard for Tenant Management (tenant_id == 1 and role list has Admin)
export const TenantAdminProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => {
const { canManageTenants: allowed, isLoading } = useAuth();
if (isLoading) {
return null;
}
if (!allowed) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};