914 lines
32 KiB
TypeScript
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}</>;
|
|
};
|