Enabled account sync control and session expiration management Applied some fixes to the govername page and project page
514 lines
17 KiB
TypeScript
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}</>;
|
|
};
|