Files
pfm/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx
T
hermes ec777fabf8 Implemented session and account lockout management
Enabled account sync control and session expiration management
Applied some fixes to the govername page and project page
2026-09-18 10:19:24 +02:00

514 lines
17 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 { 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<string, any>;
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<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 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 [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);
// 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<UserSyncResult> => {
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 (
<AuthContext.Provider
value={{
user,
sub,
claims,
accessToken,
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,
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 />;
}
// 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}</>;
};