Files
pfm/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx
T

920 lines
31 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,
setActivationPending,
clearUserSyncInBrowser,
UserSyncResult,
} from '../services/userSyncService';
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { NonPartnerAccessDeniedModal } from '../components/auth/NonPartnerAccessDeniedModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService';
import {
onSessionExpired,
triggerSessionExpired,
clearSessionAndRedirectToLanding,
cancelSessionExpiration,
dismissSubordinateModals,
} from '../services/sessionExpiration';
import { resolveUserRolesAndClaims, clearCachedUserRbac } 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;
}
export 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);
}
/**
* Checks whether the user has the 'Partner' role.
* On PartnerUI, ONLY users with the 'Partner' role are permitted.
*/
export function hasPartnerRole(roles: string[]): boolean {
return Array.isArray(roles) && roles.some((r) => r.toLowerCase().trim() === 'partner');
}
export function canManageTenants(_tenantId?: number | string | null, _roles?: string[]): boolean {
// PartnerUI does not permit tenant administration
return false;
}
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;
}
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;
useEffect(() => {
const unsubscribe = onSessionExpired((reason) => {
if (isPendingActivation || isActivationPending()) {
return;
}
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]);
useEffect(() => {
if (isPendingActivation) {
setIsSessionExpired(false);
setSessionExpiredReason(null);
cancelSessionExpiration();
dismissSubordinateModals();
}
}, [isPendingActivation]);
useEffect(() => {
if (isSessionExpired) {
dismissSubordinateModals();
}
}, [isSessionExpired]);
const handleSessionExpiredDismiss = () => {
setIsSessionExpired(false);
setSessionExpiredReason(null);
clearSessionAndRedirectToLanding();
};
useEffect(() => {
const initAuth = async () => {
try {
await loadRuntimeConfig();
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('code') && urlParams.has('state')) {
const mgr = getUserManager();
const oidcUser: OidcUser | undefined = await mgr.signinCallback();
if (oidcUser && oidcUser.access_token) {
const token = oidcUser.access_token;
setAccessToken(token);
localStorage.setItem(LOCAL_STORAGE_TOKEN_KEY, 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);
let tenantClaim =
(mergedClaims.tenant as string) ||
(oidcUser.profile?.tenant as string) ||
tenantClaimValues.tenant ||
undefined;
let parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId);
if (tenantClaim) {
setTenantName(tenantClaim);
}
const initialRoles = extractUserRoles(mergedClaims, null, token);
const initialPrimaryRole =
initialRoles.find((r) => r.toLowerCase().trim() === 'partner') ||
initialRoles[0] ||
(oidcUser.profile.role as string) ||
(mergedClaims.role as string) ||
'';
const profile: AuthUserProfile = {
id: userSub || 'user-1',
sub: userSub,
name:
oidcUser.profile.name ||
oidcUser.profile.preferred_username ||
(mergedClaims.name as string) ||
'Partner User',
email: oidcUser.profile.email || (mergedClaims.email as string) || '',
role: initialPrimaryRole,
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));
window.history.replaceState({}, document.title, window.location.pathname);
// Step 1: Sync user account FIRST (/v1/api/integration/sync) before calling any ActiveUserFilter-protected endpoints
try {
const syncResult = await syncUserAccount(userSub, token, profile, true);
if (syncResult.isUnauthorized) {
await logout();
window.location.replace('/');
return;
}
// Step 2: Resolve user roles & claims from /v1/api/users/{userSub}/roles and /claims
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
tenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
parsedTenantId = 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 {}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
setIsLoading(false);
return;
}
setUser({ ...profile });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
setActivationPending(false);
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;
}
}
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 storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId);
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
let 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 {}
}
if (storedTenantClaim) {
setTenantName(storedTenantClaim);
}
const hasInitialPartnerRole = hasPartnerRole(extractUserRoles(parsedClaims, resolvedUser, storedToken));
const hadPendingActivation = isActivationPending();
// If the user already has the Partner role and is not pending activation, unblock UI immediately and verify in background
if (hasInitialPartnerRole && !hadPendingActivation) {
setIsLoading(false);
}
const verifyPersistedSession = async () => {
try {
// Step 1: Sync user account FIRST (/v1/api/integration/sync)
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
hadPendingActivation
);
if (syncResult.isUnauthorized) {
await logout();
return;
}
// Step 2: Resolve user roles & claims
try {
const resolved = await resolveUserRolesAndClaims(
storedSub || '',
parsedClaims,
resolvedUser || {}
);
if (resolvedUser) {
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
if (resolved.tenant) {
resolvedUser.tenant = resolved.tenant;
storedTenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
resolvedUser.tenant_id = resolved.tenant_id;
storedTenantId = 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 {}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
return;
}
if (resolvedUser) {
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setActivationPending(false);
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');
} finally {
setIsLoading(false);
}
};
if (hasInitialPartnerRole && !hadPendingActivation) {
verifyPersistedSession();
} else {
await verifyPersistedSession();
}
return;
}
} 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 {
await loadRuntimeConfig();
if (sub) {
clearCachedUserRbac(sub);
}
// Step 1: Sync user account FIRST (/v1/api/integration/sync)
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
return result;
}
if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
return result;
}
// Step 2: Refresh user roles & claims from backend
let currentTenantId: number | string | null = tenantId;
let currentTenantClaim: string | undefined =
(claims.tenant as string) || user?.tenant || undefined;
try {
const resolved = await resolveUserRolesAndClaims(sub, claims, user || {});
const updatedProfile = user ? { ...user } : null;
if (updatedProfile && resolved.primaryRole) {
updatedProfile.role = resolved.primaryRole;
}
if (resolved.tenant) {
currentTenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
if (updatedProfile) updatedProfile.tenant = resolved.tenant;
}
if (resolved.tenant_id !== undefined) {
currentTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
if (updatedProfile) updatedProfile.tenant_id = resolved.tenant_id;
}
if (updatedProfile) {
setUser(updatedProfile);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(updatedProfile));
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
// Partner user verified successfully
setActivationPending(false);
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(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]);
return (
<AuthContext.Provider
value={{
user,
sub,
claims,
tenantId,
hashedTenantId,
tenantName,
accessToken,
roles,
isPartner,
canManageTenants: false,
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 for PartnerUI:
* 1. Redirects unauthenticated users to '/'
* 2. Strictly enforces that ONLY users with the 'Partner' role are permitted on PartnerUI.
* Any other role (Admin, Executive, Supervisor, Manager, Contributor, etc.) is blocked with NonPartnerAccessDeniedModal.
* 3. Renders AccountActivationModal if the partner account is pending administrator activation.
*/
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-emerald-500/20 border-t-emerald-400 animate-spin" />
</div>
<p className="text-xs font-medium text-slate-400 tracking-wide">Verifying Partner Portal Session...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/" state={{ from: location }} replace />;
}
// Requirement 2: RBAC on PartnerUI only functions for Partner roles, no other roles are permitted on this app.
if (!isPartner && !hasPartnerRole(roles)) {
return (
<NonPartnerAccessDeniedModal
user={user}
claims={claims}
sub={sub}
roles={roles}
onLogout={logout}
/>
);
}
if (isPendingActivation) {
return (
<AccountActivationModal
user={user}
claims={claims}
sub={sub}
onSync={syncUserSession}
onLogout={logout}
initialError={activationError}
/>
);
}
return <>{children}</>;
};