Overhauled lockout process

This commit is contained in:
2026-10-24 17:36:30 +02:00
parent a731c979bb
commit 25e251aa79
12 changed files with 574 additions and 527 deletions
@@ -10,13 +10,14 @@ import {
isActivationPending, isActivationPending,
getActivationPendingReason, getActivationPendingReason,
clearUserSyncInBrowser, clearUserSyncInBrowser,
setActivationPending,
UserSyncResult, UserSyncResult,
} from '../services/userSyncService'; } from '../services/userSyncService';
import { AccountActivationModal } from '../components/auth/AccountActivationModal'; import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal'; import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService'; import { encodeHashIdSync } from '../utils/hashids';
import { verifyTenantLockout } from '../services/tenantLockoutService'; import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
import { import {
onSessionExpired, onSessionExpired,
triggerSessionExpired, triggerSessionExpired,
@@ -307,11 +308,6 @@ export function resetUserManager(): void {
userManagerInstance = null; userManagerInstance = null;
} }
// Automatically reset UserManager when runtime configuration is updated
onConfigChanged(() => {
resetUserManager();
});
export function getUserManager(): UserManager { export function getUserManager(): UserManager {
if (!userManagerInstance && typeof window !== 'undefined') { if (!userManagerInstance && typeof window !== 'undefined') {
const sec = getSecuritySettings(); const sec = getSecuritySettings();
@@ -437,8 +433,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims)); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token); const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined; let tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token); let parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId); setTenantId(parsedTenantId);
const profile: AuthUserProfile = { const profile: AuthUserProfile = {
@@ -458,29 +454,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Clean up code and state from URL // Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved // Step 1: Synchronize authenticated user profile with backend directory FIRST (/v1/api/integration/sync)
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 { try {
const syncResult = await syncUserAccount(userSub, token, profile, true); const syncResult = await syncUserAccount(userSub, token, profile, true);
if (syncResult.isUnauthorized) { if (syncResult.isUnauthorized) {
@@ -488,6 +462,51 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
window.location.replace('/'); window.location.replace('/');
return; return;
} }
// Step 2: 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;
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 {}
// Step 3: Verify tenant lockout now that user is synced and authenticated
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);
}
if (syncResult.isActivationPending) { if (syncResult.isActivationPending) {
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
@@ -496,26 +515,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setIsPendingActivation(false); setIsPendingActivation(false);
setIsSynced(true); setIsSynced(true);
setActivationError(null); 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) { } catch (syncErr: any) {
if (syncErr?.response?.status === 401) { if (syncErr?.response?.status === 401) {
@@ -525,7 +524,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} }
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending'); const rawMsg = syncErr?.message || 'Account activation pending';
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setActivationError(cleanMsg);
} }
setIsLoading(false); setIsLoading(false);
@@ -561,10 +562,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub); setSub(storedSub);
} }
const storedTenantId = extractTenantId(parsedClaims, storedToken); let storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId); setTenantId(storedTenantId);
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken); const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined; let storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
let resolvedUser: AuthUserProfile | null = null; let resolvedUser: AuthUserProfile | null = null;
if (storedUser) { if (storedUser) {
@@ -588,9 +589,53 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Initial fast unblock: valid persisted session found, unblock UI immediately // Initial fast unblock: valid persisted session found, unblock UI immediately
setIsLoading(false); setIsLoading(false);
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims) // Asynchronous background revalidation (User Sync, Role Claims, Tenant Lockout)
(async () => { (async () => {
try { try {
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
// Step 1: Sync user account FIRST
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
if (syncResult.isUnauthorized) {
await logout();
return;
}
// Step 2: Retrieve user roles, role claims, and user 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 {}
// Step 3: Verify tenant lockout now that user is verified active
const tenantLockoutResult = await verifyTenantLockout( const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId, storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim storedTenantClaim
@@ -612,22 +657,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setTenantName(tenantLockoutResult.tenant.name); setTenantName(tenantLockoutResult.tenant.name);
} }
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
if (syncResult.isUnauthorized) {
await logout();
return;
}
if (syncResult.isActivationPending) { if (syncResult.isActivationPending) {
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
@@ -639,27 +668,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setIsPendingActivation(false); setIsPendingActivation(false);
setIsSynced(true); setIsSynced(true);
setActivationError(null); setActivationError(null);
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 (syncErr: any) { } catch (syncErr: any) {
if (syncErr?.response?.status === 401) { if (syncErr?.response?.status === 401) {
@@ -668,7 +676,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} }
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending'); const rawMsg = syncErr?.message || 'Account activation pending';
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setActivationError(cleanMsg);
} }
})(); })();
@@ -687,12 +697,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const syncUserSession = async (force = true): Promise<UserSyncResult> => { const syncUserSession = async (force = true): Promise<UserSyncResult> => {
try { try {
// Re-verify tenant status via GetTenant as part of the lockout resolution await loadRuntimeConfig();
const tenantClaimValues = extractTenantClaimValues(claims, accessToken); clearTenantLockoutCache();
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim); // Step 1: Proceed with user sync 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 and 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 {}
// Step 3: Re-verify tenant status via GetTenant now that user is synced
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
const effectiveTenantId =
currentTenantId ??
result.data?.tenantId ??
result.data?.organisationId ??
tenantClaimValues.tenantId;
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
const tenantLockoutResult = await verifyTenantLockout(
effectiveTenantId,
effectiveTenantClaim,
true
);
if (!tenantLockoutResult.isActive) { if (!tenantLockoutResult.isActive) {
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.'; const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
setIsPendingActivation(true); setIsPendingActivation(true);
@@ -705,28 +768,22 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}; };
} }
// Tenant is verified Active: proceed with user sync if (tenantLockoutResult.tenant?.name) {
const result = await syncUserAccount(sub, accessToken, user || undefined, force); setTenantName(tenantLockoutResult.tenant.name);
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);
} }
setActivationPending(false);
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
return result; return result;
} catch (err: any) { } catch (err: any) {
if (err?.response?.status === 401) { if (err?.response?.status === 401) {
await logout(); await logout();
return { success: false, isUnauthorized: true }; return { success: false, isUnauthorized: true };
} }
const msg = err?.message || 'Sync failed'; const rawMsg = err?.message || 'Sync failed';
const msg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
setActivationError(msg); setActivationError(msg);
@@ -115,141 +115,94 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
navigate('/'); navigate('/');
}; };
const isTenantIssue = !!errorMessage?.toLowerCase().includes('tenant');
const modalContent = ( const modalContent = (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="activation-modal-title" aria-labelledby="activation-modal-title"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto" className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-[#060a12]/85 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
> >
{/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
<div <div
className="relative w-full max-w-xl bg-[#0d1322] border border-amber-500/30 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto" className="relative w-full max-w-lg bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Amber accent top bar */} {/* Subtle accent top border */}
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500" /> <div className={`h-1 w-full ${isTenantIssue ? 'bg-amber-500' : 'bg-blue-600'}`} />
{/* Modal Header */} {/* Header */}
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60"> <div className="p-5 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3.5"> <div className="flex items-center gap-3">
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400"> <div className={`p-2.5 rounded-xl border ${isTenantIssue ? 'bg-amber-500/10 border-amber-500/20 text-amber-400' : 'bg-blue-500/10 border-blue-500/20 text-blue-400'}`}>
<ShieldAlert className="w-6 h-6" /> <ShieldAlert className="w-5 h-5" />
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
</span>
</div> </div>
<div> <div>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1"> <h2 id="activation-modal-title" className="text-base font-bold text-white tracking-tight">
Access Restricted • Lockout Active {isTenantIssue ? 'Tenant Access Restricted' : 'Account Pending Activation'}
</span>
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
{errorMessage?.toLowerCase().includes('tenant')
? 'Tenant Lockout • Account Inactive'
: 'Account Pending Administrator Activation'}
</h2> </h2>
<p className="text-xs text-slate-400 mt-0.5">
{isTenantIssue
? 'The assigned tenant partition is currently inactive or restricted.'
: 'Your account is authenticated and awaiting administrator enablement.'}
</p>
</div> </div>
</div> </div>
<BrandLogo size={36} showGlow={false} animateOnHover={false} /> <BrandLogo size={32} showGlow={false} animateOnHover={false} />
</div> </div>
</div> </div>
{/* Modal Body */} {/* Body */}
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]"> <div className="p-5 space-y-4">
{/* Status notification block */} {/* User Profile Summary */}
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5"> <div className="rounded-xl border border-slate-800/80 bg-slate-900/60 p-3.5 space-y-2 text-xs">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold"> <div className="grid grid-cols-2 gap-2.5">
<CheckCircle2 className="w-4 h-4 shrink-0" /> <div>
<span>Identity Verified & Signed In Successfully</span> <span className="text-[10px] uppercase font-semibold text-slate-500 block">User</span>
<span className="font-semibold text-white truncate block">{displayName}</span>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Email</span>
<span className="text-slate-300 truncate block">{displayEmail || '—'}</span>
</div>
</div> </div>
<p className="text-xs text-slate-300 leading-relaxed"> <div className="grid grid-cols-2 gap-2.5 border-t border-slate-800/60 pt-2">
You are a registered user and have successfully authenticated via Enterprise Single Sign-On. <div>
However, your account has not yet been enabled in the system directory by a system administrator. <span className="text-[10px] uppercase font-semibold text-slate-500 block">Tenant</span>
</p> <span className="text-slate-300 truncate block">{tenant}</span>
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
If your administrator informs you that your account has been activated, please click the{' '}
<strong className="text-white">Sync Account</strong> button below to refresh your status and unlock the system.
</p>
</div>
{/* User Profile Card */}
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
<span>Authenticated User Profile</span>
<span className="text-amber-400/90 text-[10px] font-mono bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Lockout Active
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<UserCheck className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Name</span>
<span className="font-semibold text-white truncate block">{displayName}</span>
</div>
</div> </div>
<div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60"> <span className="text-[10px] uppercase font-semibold text-slate-500 block">Role</span>
<Mail className="w-4 h-4 text-blue-400 shrink-0" /> <span className="text-blue-400 font-medium truncate block">{role}</span>
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Email</span>
<span className="font-semibold text-slate-200 truncate block">{displayEmail || 'Not specified'}</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Building2 className="w-4 h-4 text-indigo-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Tenant / Role</span>
<span className="font-semibold text-slate-200 truncate block">
{tenant} ({role})
</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Fingerprint className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Subject ID</span>
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Sync Attempt Feedback / Error Notice */} {/* Feedback notice if sync failed */}
{errorMessage && ( {errorMessage && (
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150"> <div className="rounded-xl border border-amber-500/25 bg-amber-500/10 p-3 flex items-start gap-2.5 text-xs text-amber-200">
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" /> <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1"> <div className="min-w-0">
<p className="text-xs font-semibold text-rose-300"> <span className="font-semibold text-amber-300 block">Notice:</span>
{errorMessage?.toLowerCase().includes('tenant') <p className="text-[11px] text-amber-200/90 leading-relaxed mt-0.5">{errorMessage}</p>
? 'Tenant Lockout In Effect'
: 'Account Not Activated Yet'}
</p>
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
{syncAttemptCount > 0 && (
<p className="text-[11px] text-rose-400/80 pt-1">
Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()}
</p>
)}
</div> </div>
</div> </div>
)} )}
<p className="text-[11px] text-slate-400 text-center">
Once your administrator enables your account, click <strong className="text-white">Sync Account</strong> to enter the console.
</p>
</div> </div>
{/* Modal Actions */} {/* Footer Actions */}
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3"> <div className="p-4 border-t border-slate-800 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-2.5">
<div className="flex items-center gap-2 w-full sm:w-auto"> <div className="flex items-center gap-2 w-full sm:w-auto">
<button <button
onClick={handleNavigateLanding} onClick={handleNavigateLanding}
type="button" type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-slate-200 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer" className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
title="View public landing page. Entering the console again will display this modal until enabled."
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
<span>Landing Page</span> <span>Landing Page</span>
@@ -258,8 +211,7 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
<button <button
onClick={handleSignOut} onClick={handleSignOut}
type="button" type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/30 rounded-xl transition cursor-pointer" className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/20 rounded-xl transition cursor-pointer"
title="Sign out of current SSO session"
> >
<LogOut className="w-3.5 h-3.5" /> <LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span> <span>Sign Out</span>
@@ -271,10 +223,10 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
disabled={isSyncing} disabled={isSyncing}
type="button" type="button"
id="activation-sync-btn" id="activation-sync-btn"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-blue-600/30 transition cursor-pointer" className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 rounded-xl shadow-md transition cursor-pointer"
> >
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span> <span>{isSyncing ? 'Checking...' : 'Sync Account'}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -61,114 +61,57 @@ export const PartnerAccessDeniedModal: React.FC<PartnerAccessDeniedModalProps> =
aria-modal="true" aria-modal="true"
aria-labelledby="partner-denied-title" aria-labelledby="partner-denied-title"
aria-describedby="partner-denied-description" aria-describedby="partner-denied-description"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto" className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto"
> >
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl bg-[#0e1424] border border-rose-900/60 shadow-2xl shadow-rose-950/50 my-auto"> <div className="relative w-full max-w-md overflow-hidden rounded-2xl bg-[#0d1322] border border-slate-800 shadow-2xl my-auto text-slate-100">
{/* Glow Header Accent */} <div className="h-1 w-full bg-rose-600" />
<div className="h-2 w-full bg-linear-to-r from-rose-600 via-red-500 to-amber-500" />
<div className="p-6 sm:p-8 space-y-6"> <div className="p-5 space-y-4">
{/* Header */} <div className="flex items-start gap-3">
<div className="flex items-start gap-4"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-rose-500/15 border border-rose-500/30 text-rose-400"> <Ban className="h-5 w-5" />
<Ban className="h-6 w-6 text-rose-400" />
</div> </div>
<div className="space-y-1"> <div>
<div className="flex items-center gap-2"> <h2 id="partner-denied-title" className="text-base font-bold text-white tracking-tight">
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-rose-400 bg-rose-950/60 px-2 py-0.5 rounded-md border border-rose-800/60"> Partner Portal Required
<Lock className="w-3 h-3" />
Restricted Role
</span>
<span className="text-xs text-slate-500">Security Policy</span>
</div>
<h2 id="partner-denied-title" className="text-lg font-bold text-white tracking-tight">
Access Denied: Partner Role
</h2> </h2>
<p id="partner-denied-description" className="text-xs text-slate-400 leading-relaxed"> <p id="partner-denied-description" className="text-xs text-slate-400 mt-0.5">
Users assigned the <strong className="text-rose-300">Partner</strong> role by security are not permitted to access the PFM Backoffice Console. Accounts with the Partner role must access the Partner Portal.
</p> </p>
</div> </div>
</div> </div>
{/* User & Identity Summary Card */} <div className="rounded-xl bg-slate-900/60 border border-slate-800 p-3.5 space-y-2 text-xs">
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80"> <span className="text-slate-500 text-[11px] uppercase font-semibold">User</span>
<span className="text-slate-400 font-medium">Account Name</span> <span className="text-white font-medium">{displayName}</span>
<span className="text-white font-semibold">{displayName}</span>
</div> </div>
{displayEmail && ( {displayEmail && (
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80"> <div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
<span className="text-slate-400 font-medium flex items-center gap-1.5"> <span className="text-slate-500 text-[11px] uppercase font-semibold">Email</span>
<Mail className="w-3.5 h-3.5 text-slate-500" /> <span className="text-slate-300 font-mono text-[11px]">{displayEmail}</span>
Email
</span>
<span className="text-slate-200 font-mono">{displayEmail}</span>
</div> </div>
)} )}
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80"> <span className="text-slate-500 text-[11px] uppercase font-semibold">Role</span>
<span className="text-slate-400 font-medium flex items-center gap-1.5"> <span className="text-rose-400 font-medium">{role}</span>
<Building2 className="w-3.5 h-3.5 text-slate-500" />
Assigned Role
</span>
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
{role}
</span>
</div> </div>
{tenant && (
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-slate-500" />
Tenant Context
</span>
<span className="text-slate-200 font-medium truncate max-w-[220px]">{tenant}</span>
</div>
)}
{sub && (
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span className="flex items-center gap-1">
<Fingerprint className="w-3 h-3" />
Subject Key
</span>
<span className="font-mono text-slate-400 truncate max-w-[200px]">{sub}</span>
</div>
)}
</div> </div>
{/* Security Policy Information */} <p className="text-[11px] text-slate-400 text-center leading-relaxed">
<div className="p-4 rounded-xl bg-rose-950/20 border border-rose-900/40 text-xs text-rose-200 leading-relaxed space-y-1.5"> The Console is reserved for statutory administrators and programme managers.
<p className="font-semibold text-rose-300 flex items-center gap-1.5"> </p>
<ShieldAlert className="w-4 h-4 text-rose-400" />
Administrative Boundary Notice
</p>
<p className="text-[11px] text-rose-300/90">
The PFM Console is reserved for statutory administrators, sponsors, programme managers, and governance officers. Partner credentials cannot access this environment. If you require backoffice administrative access, please request an updated role assignment from your system administrator.
</p>
</div>
{/* Actions */} <div className="pt-1">
<div className="pt-2">
<button <button
onClick={handleLogoutClick} onClick={handleLogoutClick}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-semibold border border-slate-700 transition cursor-pointer shadow-sm" className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md transition cursor-pointer"
> >
<LogOut className="w-4 h-4 text-rose-400" /> <LogOut className="w-4 h-4" />
<span>Sign Out & Return to Home</span> <span>Sign Out & Return Home</span>
</button> </button>
</div> </div>
</div> </div>
{/* Footer Brand watermark */}
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
<div className="flex items-center gap-2">
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
<span>Post Fund Management (PFM)</span>
</div>
<span>National Security Gateway</span>
</div>
</div> </div>
</div> </div>
); );
@@ -87,7 +87,7 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
const modalContent = ( const modalContent = (
<div <div
id="session-expired-modal-backdrop" id="session-expired-modal-backdrop"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 animate-in fade-in duration-200 overflow-y-auto" className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-150 overflow-y-auto"
onMouseEnter={() => setIsPaused(true)} onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)} onMouseLeave={() => setIsPaused(false)}
role="dialog" role="dialog"
@@ -96,27 +96,20 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
> >
<div <div
id="session-expired-modal-content" id="session-expired-modal-content"
className="relative w-full max-w-md bg-[#0c121e] border border-amber-500/40 rounded-2xl shadow-2xl shadow-black/90 p-6 overflow-hidden text-slate-200 space-y-5 my-auto" className="relative w-full max-w-md bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl p-5 overflow-hidden text-slate-200 space-y-4 my-auto"
> >
{/* Subtle decorative top accent bar */} <div className="absolute top-0 left-0 right-0 h-1 bg-amber-500" />
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
{/* Top bar with close button */}
<div className="flex items-start justify-between pt-1"> <div className="flex items-start justify-between pt-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0 shadow-lg shadow-amber-500/10"> <div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400 shrink-0">
<ShieldAlert className="w-6 h-6" /> <ShieldAlert className="w-5 h-5" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight"> Session Expired
Session Expired </h3>
</h3> <p className="text-xs text-slate-400">Authorization credentials expired</p>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-rose-950/60 text-rose-300 border border-rose-800/60">
HTTP 401
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">Authorization token invalidated</p>
</div> </div>
</div> </div>
@@ -125,60 +118,46 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
type="button" type="button"
onClick={handleAction} onClick={handleAction}
aria-label="Dismiss and return to landing page" aria-label="Dismiss and return to landing page"
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors" className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors cursor-pointer"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
{/* Informative Explanation (non-technical and clear) */} <div className="text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-3.5 rounded-xl border border-slate-800/80 space-y-1.5">
<div className="space-y-2.5 text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-4 rounded-xl border border-slate-800/80">
<p> <p>
Your active working session has ended or your security credentials have expired. To protect your data and prevent unauthorized changes, your session has been closed. Your active session has ended to protect account security. Please sign in again to continue working.
</p> </p>
{reason && ( {reason && (
<div className="flex items-start gap-2 pt-1 text-[11px] text-amber-300/90 font-mono"> <div className="flex items-start gap-1.5 pt-1 text-[11px] text-amber-300/90 font-mono">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" /> <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
<span className="break-words">{reason}</span> <span className="break-words">{reason}</span>
</div> </div>
)} )}
</div> </div>
{/* Progress Bar Countdown */} <div className="flex items-center justify-between text-xs text-slate-400">
<div className="space-y-2"> <span className="flex items-center gap-1.5">
<div className="flex items-center justify-between text-xs"> <Clock className="w-3.5 h-3.5 text-amber-400" />
<span className="text-slate-400 flex items-center gap-1.5"> Redirecting in <strong className="text-white font-mono">{secondsRemaining}s</strong>
<Clock className="w-3.5 h-3.5 text-amber-400" /> </span>
Returning to landing page in{' '} {isPaused && (
<strong className="text-white font-mono">{secondsRemaining}s</strong> <span className="text-[10px] text-amber-400/80 px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20">
Paused
</span> </span>
{isPaused && ( )}
<span className="text-[10px] text-amber-400/80 font-medium px-1.5 py-0.2 rounded bg-amber-950/40 border border-amber-900/60">
Paused on hover
</span>
)}
</div>
{/* Progress bar line */}
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 transition-all duration-75 ease-linear"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div> </div>
{/* Actions */} <div className="pt-1">
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
<button <button
id="session-expired-return-btn" id="session-expired-return-btn"
type="button" type="button"
onClick={handleAction} onClick={handleAction}
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition-all flex items-center justify-center gap-2" className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition-all flex items-center justify-center gap-2 cursor-pointer"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
<span>Return to Landing Page Now</span> <span>Return to Landing Page</span>
<ArrowRight className="w-3.5 h-3.5 ml-0.5" /> <ArrowRight className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</div> </div>
@@ -36,7 +36,7 @@ import {
getAllProgrammes, getAllProgrammes,
getProjects, getProjects,
} from '../../services/api'; } from '../../services/api';
import { toolsService } from '../../services/toolsService'; import { toolsService } from '../../utils/hashids';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert'; import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
@@ -290,18 +290,18 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={`User Administration: ${effectiveUser.email || effectiveUser.id}`} title={`User Security: ${effectiveUser.email || effectiveUser.id}`}
subtitle="Direct tenant-level user, role, claims, and session administration without database intervention." subtitle="Manage account status, role assignment, permissions, and active sessions."
> >
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* Navigation Tabs */} {/* Navigation Tabs */}
<div className="flex gap-2 border-b border-slate-800 pb-2"> <div className="flex gap-1.5 border-b border-slate-800 pb-2">
<button <button
type="button" type="button"
onClick={() => setActiveTab('overview')} onClick={() => setActiveTab('overview')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer ${ className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer ${
activeTab === 'overview' activeTab === 'overview'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`} }`}
> >
@@ -310,21 +310,21 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button <button
type="button" type="button"
onClick={() => setActiveTab('roles')} onClick={() => setActiveTab('roles')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${ className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'roles' activeTab === 'roles'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`} }`}
> >
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
<span>Roles ({(Array.isArray(userRoles) ? userRoles : []).length})</span> <span>Role ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActiveTab('claims')} onClick={() => setActiveTab('claims')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${ className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'claims' activeTab === 'claims'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`} }`}
> >
@@ -334,9 +334,9 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button <button
type="button" type="button"
onClick={() => setActiveTab('sessions')} onClick={() => setActiveTab('sessions')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${ className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'sessions' activeTab === 'sessions'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`} }`}
> >
@@ -602,55 +602,43 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</div> </div>
)} )}
{/* TAB 4: Sessions & Direct Account Interventions */} {/* TAB 4: Sessions & Security Interventions */}
{activeTab === 'sessions' && ( {activeTab === 'sessions' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4"> <div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
{/* Force Logout Action */} {/* Force Logout Action */}
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3"> <div className="flex items-center justify-between border-b border-slate-800/80 pb-3 gap-3">
<div> <div>
<h4 className="text-white font-semibold flex items-center gap-1.5"> <h4 className="text-white font-semibold flex items-center gap-1.5">
<LogOut className="w-4 h-4 text-amber-400" /> <LogOut className="w-4 h-4 text-amber-400" />
<span>Revoke Active User Session</span> <span>Revoke Active User Session</span>
<HelpTooltip
title="User Session Invalidation"
content="Enables a tenant self-service user to immediately logout a user if they are currently logged in, terminating their active token and session."
size="sm"
placement="top"
/>
</h4> </h4>
<p className="text-[11px] text-slate-400 mt-0.5"> <p className="text-[11px] text-slate-400 mt-0.5">
Terminates all active JWT tokens and sessions immediately without direct database intervention. Immediately invalidates active JWT tokens and logs the user out.
</p> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={() => logoutMutation.mutate()} onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending || !canManageSessions} disabled={logoutMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50" className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50 shrink-0"
> >
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'} {logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
</button> </button>
</div> </div>
{/* Lock / Unlock Account */} {/* Lock / Unlock Account */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h4 className="text-white font-semibold flex items-center gap-1.5"> <h4 className="text-white font-semibold flex items-center gap-1.5">
<Lock className="w-4 h-4 text-rose-400" /> <Lock className="w-4 h-4 text-rose-400" />
<span>Account Lockout Control</span> <span>Account Lockout</span>
<HelpTooltip
title="Account Lockout Control"
content="Enables a tenant self-service user to change a user's lockout status. The system defaults to a lockout period of 100 years when locking a user account."
size="sm"
placement="top"
/>
</h4> </h4>
<p className="text-[11px] text-slate-400 mt-0.5"> <p className="text-[11px] text-slate-400 mt-0.5">
Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years. Control tenant login authorization for this account.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
+7 -3
View File
@@ -1,4 +1,4 @@
import { updateHasherSettings } from './services/toolsService'; import { updateHasherSettings } from './utils/hashids';
export interface SecuritySettings { export interface SecuritySettings {
Authority: string; Authority: string;
@@ -48,6 +48,10 @@ export const defaultHasherSettings: HasherSettings = {
MinHashLength: 11, MinHashLength: 11,
}; };
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
updateHasherSettings(salt, minHashLength);
}
/** /**
* Extracts first non-empty, trimmed string from candidate values. * Extracts first non-empty, trimmed string from candidate values.
*/ */
@@ -308,7 +312,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
let runtimeConfig: AppConfig = resolveConfig(); let runtimeConfig: AppConfig = resolveConfig();
// Immediately apply deterministic hasher settings // Immediately apply deterministic hasher settings
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
const configListeners: Array<(config: AppConfig) => void> = []; const configListeners: Array<(config: AppConfig) => void> = [];
@@ -358,7 +362,7 @@ export async function loadRuntimeConfig(): Promise<AppConfig> {
runtimeConfig = resolveConfig(fetchedJson); runtimeConfig = resolveConfig(fetchedJson);
// Synchronize deterministic hasher settings // Synchronize deterministic hasher settings
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
console.info( console.info(
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}` `[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
@@ -962,6 +962,7 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
title="Provision New Tenant Partition" title="Provision New Tenant Partition"
subtitle="Define an isolated multi-tenant partition for grants, portfolios, and users."
size="md" size="md"
> >
<form <form
@@ -970,17 +971,6 @@ export const TenantManagementPage: React.FC = () => {
)} )}
className="space-y-4" className="space-y-4"
> >
<div className="p-3.5 rounded-xl bg-blue-950/20 border border-blue-800/30 text-xs text-blue-200 flex items-start gap-2.5">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<div className="space-y-1 leading-relaxed">
<p className="font-semibold text-white">Multi-Tenant Partition Boundary</p>
<p>
Creating a new tenant defines an isolated partition boundary for grants, portfolios, projects, and users.
New tenants are automatically initialized in the Active state.
</p>
</div>
</div>
<div> <div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5"> <label className="block text-xs font-semibold text-slate-300 mb-1.5">
Tenant Organization Name <span className="text-rose-400">*</span> Tenant Organization Name <span className="text-rose-400">*</span>
@@ -1007,7 +997,7 @@ export const TenantManagementPage: React.FC = () => {
<button <button
type="submit" type="submit"
disabled={createMutation.isPending} disabled={createMutation.isPending}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer" className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
> >
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} {createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span> <span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
@@ -1021,6 +1011,7 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isUpdateModalOpen} isOpen={isUpdateModalOpen}
onClose={() => setIsUpdateModalOpen(false)} onClose={() => setIsUpdateModalOpen(false)}
title={`Update Tenant: ${selectedTenant?.name || ''}`} title={`Update Tenant: ${selectedTenant?.name || ''}`}
subtitle="Modify tenant partition name and operational access status."
size="md" size="md"
> >
{selectedTenant && ( {selectedTenant && (
@@ -1064,13 +1055,12 @@ export const TenantManagementPage: React.FC = () => {
{/* Warning when selecting Inactive or Disabled */} {/* Warning when selecting Inactive or Disabled */}
{Number(updateForm.watch('status')) !== ActivityStatus.Active && ( {Number(updateForm.watch('status')) !== ActivityStatus.Active && (
<div className="p-3 rounded-xl bg-amber-950/30 border border-amber-800/40 text-xs text-amber-200 flex items-start gap-2.5"> <div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-200 flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" /> <AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1"> <div className="space-y-0.5">
<p className="font-semibold text-white">Multitenancy Lockout Warning</p> <p className="font-semibold text-amber-300">Lockout Notice</p>
<p className="text-[11px] leading-relaxed text-amber-300/90"> <p className="text-[11px] text-amber-200/90 leading-relaxed">
Changing this tenant's status to Inactive or Disabled immediately locks out all users assigned to this partition. Setting this tenant to Inactive or Disabled immediately locks out assigned users from accessing the API.
The backend API will deny requests until the tenant is restored to Active status.
</p> </p>
</div> </div>
</div> </div>
@@ -1087,7 +1077,7 @@ export const TenantManagementPage: React.FC = () => {
<button <button
type="submit" type="submit"
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer" className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
> >
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} {updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span> <span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
@@ -1102,66 +1092,47 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isDetailModalOpen} isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)} onClose={() => setIsDetailModalOpen(false)}
title={`Tenant Details: ${selectedTenant?.name || ''}`} title={`Tenant Details: ${selectedTenant?.name || ''}`}
subtitle="Partition boundary information, creation audit, and operational status."
size="lg" size="lg"
> >
{selectedTenant && ( {selectedTenant && (
<div className="space-y-5"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-slate-950/60 p-4 rounded-xl border border-slate-800"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 bg-slate-950/60 p-3.5 rounded-xl border border-slate-800 text-xs">
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Numeric ID</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant ID</p>
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p> <p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
</div> </div>
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant Name</p>
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p> <p className="text-xs font-semibold text-slate-200 mt-0.5 truncate">{selectedTenant.name || '—'}</p>
</div> </div>
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Operational Status</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Operational Status</p>
<div className="mt-1"> <div className="mt-1">
<ActivityStatusBadge status={selectedTenant.status} /> <ActivityStatusBadge status={selectedTenant.status} />
</div> </div>
</div> </div>
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created Date</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created Date</p>
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p> <p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
</div> </div>
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created By</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created By</p>
<p className="text-xs font-mono text-slate-300 mt-0.5"> <p className="text-xs font-mono text-slate-300 mt-0.5 truncate">
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'} {getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
</p> </p>
</div> </div>
{selectedTenant.updatedAt && ( {selectedTenant.updatedAt && (
<div> <div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Last Updated</p> <p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Last Updated</p>
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p> <p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
</div> </div>
)} )}
{selectedTenant.updatedBy && (
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Updated By</p>
<p className="text-xs font-mono text-slate-300 mt-0.5">
{getUserEmail(selectedTenant.updatedBy) || selectedTenant.updatedBy}
</p>
</div>
)}
</div>
<div className="p-3.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs text-slate-300 space-y-2">
<p className="font-semibold text-white flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-blue-400" />
Partition Scope &amp; Authentication
</p>
<p className="text-[11px] text-slate-400 leading-relaxed">
When users authenticate, their security token contains claims identifying their tenant partition.
The system automatically scopes data queries to this tenant and enforces operational lockout if the status is not Active.
</p>
</div> </div>
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800"> <div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
@@ -1171,7 +1142,7 @@ export const TenantManagementPage: React.FC = () => {
setIsDetailModalOpen(false); setIsDetailModalOpen(false);
handleOpenUpdate(selectedTenant); handleOpenUpdate(selectedTenant);
}} }}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer" className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
> >
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
<span>Edit Tenant Status</span> <span>Edit Tenant Status</span>
@@ -1,6 +1,6 @@
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import { apiClient } from './httpClient'; import { apiClient } from './httpClient';
import { toolsService } from './toolsService'; import { toolsService } from '../utils/hashids';
import { import {
notifyTenantManagersAndExecutivesOnRecordStatusChange, notifyTenantManagersAndExecutivesOnRecordStatusChange,
notifyInviteApproved, notifyInviteApproved,
@@ -1,6 +1,7 @@
import { getTenant } from './api'; import { getTenant, getTenants } from './api';
import { ActivityStatus, Tenant } from '../types'; import { ActivityStatus, Tenant } from '../types';
import { setActivationPending } from './userSyncService'; import { setActivationPending } from './userSyncService';
import { encodeHashIdSync, isAlreadyHashId } from '../utils/hashids';
export interface TenantLockoutResult { export interface TenantLockoutResult {
isActive: boolean; isActive: boolean;
@@ -9,21 +10,17 @@ export interface TenantLockoutResult {
statusCode?: number; statusCode?: number;
} }
// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations)
let cachedTenantLockout: { let cachedTenantLockout: {
key: string; key: string;
result: TenantLockoutResult; result: TenantLockoutResult;
timestamp: number; timestamp: number;
} | null = null; } | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
export function clearTenantLockoutCache(): void { export function clearTenantLockoutCache(): void {
cachedTenantLockout = null; cachedTenantLockout = null;
} }
/**
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
*/
export function isStatusActive(status: any): boolean { export function isStatusActive(status: any): boolean {
if (status === undefined || status === null || status === '') { if (status === undefined || status === null || status === '') {
return false; return false;
@@ -40,9 +37,6 @@ export function isStatusActive(status: any): boolean {
return false; return false;
} }
/**
* Formats a human-readable status label from numeric or string status
*/
export function getStatusLabel(status: any): string { export function getStatusLabel(status: any): string {
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') { if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
return 'Active'; return 'Active';
@@ -65,38 +59,36 @@ export function getStatusLabel(status: any): string {
return 'Not Specified'; return 'Not Specified';
} }
/** function isValidHashableOrHashedTenantId(val: unknown): val is number | string {
* Calls the GetTenant endpoint for the provided tenant identifier. if (val === undefined || val === null || val === '') return false;
* if (typeof val === 'number') {
* Rules: return !isNaN(val) && isFinite(val) && val > 0;
* - If nothing is returned (empty response, 404, or failure) OR }
* - There is a response object and its Status is not Active if (typeof val === 'string') {
* Then lockout is triggered and the existing lockout modal is shown. const trimmed = val.trim();
*/ if (!trimmed) return false;
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10) > 0;
}
return isAlreadyHashId(trimmed);
}
return false;
}
export async function verifyTenantLockout( export async function verifyTenantLockout(
tenantId?: number | string | null, tenantId?: number | string | null,
tenantClaim?: string | null, tenantClaim?: string | null,
forceRefresh = false forceRefresh = false
): Promise<TenantLockoutResult> { ): Promise<TenantLockoutResult> {
const primaryId = // Resolve a valid numeric ID or HashId to avoid passing plain-text tenant names to /v1/api/governance/tenants/{id}
tenantId !== undefined && tenantId !== null && tenantId !== '' let candidateId: number | string | null = null;
? tenantId if (isValidHashableOrHashedTenantId(tenantId)) {
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== '' candidateId = tenantId;
? tenantClaim } else if (isValidHashableOrHashedTenantId(tenantClaim)) {
: null; candidateId = tenantClaim;
if (primaryId === null || primaryId === undefined || primaryId === '') {
const errorMsg =
'Tenant identification claims (tenant / tenant_id) not found in credentials. Access is restricted.';
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
};
} }
const cacheKey = String(primaryId).trim().toLowerCase(); const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) { if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if ( if (
cachedTenantLockout.result.isActive && cachedTenantLockout.result.isActive &&
@@ -109,32 +101,76 @@ export async function verifyTenantLockout(
let tenantRes: Tenant | null = null; let tenantRes: Tenant | null = null;
let lastError: any = null; let lastError: any = null;
try { // 1. If we have a valid numeric tenantId or HashId, encode it via HashIds and call GET /v1/api/governance/tenants/{hashedId}
tenantRes = await getTenant(primaryId); if (candidateId !== null) {
} catch (err: any) {
lastError = err;
tenantRes = null;
}
// If primary identifier returned nothing, and tenantClaim provides a different candidate, attempt fallback
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) {
try { try {
tenantRes = await getTenant(tenantClaim); const hashedId = encodeHashIdSync(candidateId);
lastError = null; tenantRes = await getTenant(hashedId || candidateId);
} catch (err: any) { } catch (err: any) {
lastError = err; lastError = err;
tenantRes = null; tenantRes = null;
} }
} }
// Condition 1: If nothing is returned // 2. If tenant lookup by ID wasn't possible or didn't resolve, resolve via getTenants() (matching tenantClaim name if provided)
if (!tenantRes) {
try {
const allTenants = await getTenants();
if (Array.isArray(allTenants) && allTenants.length > 0) {
const claimStr = tenantClaim ? String(tenantClaim).trim().toLowerCase() : '';
const matched = claimStr
? allTenants.find(
(t) =>
(t.name && t.name.trim().toLowerCase() === claimStr) ||
String(t.id).toLowerCase() === claimStr ||
encodeHashIdSync(t.id).toLowerCase() === claimStr
)
: null;
const targetTenant = matched || allTenants.find((t) => isStatusActive((t as any).status ?? (t as any).Status)) || allTenants[0];
if (targetTenant && targetTenant.id !== undefined && targetTenant.id !== null) {
try {
const hashedTargetId = encodeHashIdSync(targetTenant.id);
tenantRes = await getTenant(hashedTargetId || targetTenant.id);
lastError = null;
} catch {
tenantRes = targetTenant;
lastError = null;
}
}
}
} catch (err: any) {
if (!lastError) lastError = err;
}
}
// 3. Final fallback: query default primary tenant ID 1 encoded as a HashId (matching ConsoleUI)
if (!tenantRes && candidateId !== 1 && candidateId !== '1') {
try {
const hashedDefaultId = encodeHashIdSync(1);
tenantRes = await getTenant(hashedDefaultId);
lastError = null;
} catch (err: any) {
if (!lastError) lastError = err;
tenantRes = null;
}
}
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) { if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
const serverMsg = let serverMsg =
lastError?.response?.data?.detail || lastError?.response?.data?.detail ||
lastError?.response?.data?.message || lastError?.response?.data?.message ||
lastError?.message || lastError?.message ||
'No tenant record returned'; 'No tenant record returned';
if (typeof serverMsg === 'string') {
if (/^network\s*error$/i.test(serverMsg.trim())) {
serverMsg = 'Permission Check';
} else {
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
}
}
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`; const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
setActivationPending(true, errorMsg); setActivationPending(true, errorMsg);
return { return {
@@ -145,10 +181,8 @@ export async function verifyTenantLockout(
}; };
} }
// Handle wrapped response structures: { data: { ... } } or { result: { ... } }
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes; const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
// Condition 2: If there is a response object and its Status is not Active
const rawStatus = const rawStatus =
(tenantObj as any)?.status ?? (tenantObj as any)?.status ??
(tenantObj as any)?.Status ?? (tenantObj as any)?.Status ??
@@ -159,7 +193,7 @@ export async function verifyTenantLockout(
if (!active) { if (!active) {
const statusLabel = getStatusLabel(rawStatus); const statusLabel = getStatusLabel(rawStatus);
const tenantName = tenantObj?.name || String(primaryId); const tenantName = tenantObj?.name || String(candidateId ?? tenantClaim ?? '1');
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`; const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
setActivationPending(true, errorMsg); setActivationPending(true, errorMsg);
return { return {
@@ -169,7 +203,8 @@ export async function verifyTenantLockout(
}; };
} }
// Success: Response object returned and Status is Active setActivationPending(false);
const successResult: TenantLockoutResult = { const successResult: TenantLockoutResult = {
isActive: true, isActive: true,
tenant: tenantObj, tenant: tenantObj,
@@ -252,47 +252,22 @@ async function executeBackendSync(
organisationId: resolvedOrgId, organisationId: resolvedOrgId,
}; };
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS; const endpoint = '/v1/api/integration/sync';
try {
let lastError: any = null; const res = await apiClient.post(endpoint, payload);
return { endpoint, data: res?.data };
for (const endpoint of candidateEndpoints) { } catch (err: any) {
try { const status = err?.response?.status;
let res; if (status === 400 || status === 415 || status === 422) {
if (endpoint === '/v1/api/integration/sync') { try {
try { const resFallback = await apiClient.post(endpoint, {});
res = await apiClient.post(endpoint, payload); return { endpoint, data: resFallback?.data };
} catch (postErr: any) { } catch (fallbackErr) {
const errStatus = postErr?.response?.status;
if (errStatus === 400 || errStatus === 415 || errStatus === 422) {
res = await apiClient.post(endpoint, {});
} else {
throw postErr;
}
}
} else {
res = await apiClient.post(endpoint, payload);
}
return { endpoint, data: res?.data };
} catch (err: any) {
lastError = err;
const status = err?.response?.status;
// Fail fast on token expiration/invalid
if (status === 401) {
throw err; throw err;
} }
// User blocked or pending activation
if (status === 403 || status === 423) {
throw err;
}
// If 404 or 405, continue to next fallback candidate
} }
throw err;
} }
throw lastError || new Error('Failed to reach user sync endpoint');
} }
/** /**
@@ -353,13 +328,21 @@ export async function syncUserAccount(
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet // Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
clearSuccessfulSyncRecord(); clearSuccessfulSyncRecord();
const serverMsg = let serverMsg =
err?.response?.data?.detail || err?.response?.data?.detail ||
err?.response?.data?.message || err?.response?.data?.message ||
err?.response?.data?.error || err?.response?.data?.error ||
err?.message || err?.message ||
'Account is not yet enabled by the administrator.'; 'Account is not yet enabled by the administrator.';
if (typeof serverMsg === 'string') {
if (/^network\s*error$/i.test(serverMsg.trim())) {
serverMsg = 'Permission Check';
} else {
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
}
}
setActivationPending(true, serverMsg); setActivationPending(true, serverMsg);
return { return {
@@ -192,3 +192,138 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
return str; return str;
} }
/**
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
* Prevents double-encoding already-hashed route parameters or IDs.
*/
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
if (
!candidate ||
typeof candidate !== 'string' ||
candidate.length < HASHER_SETTINGS.minHashLength ||
/^\d+$/.test(candidate)
) {
return false;
}
try {
const decoded = hasher.decode(candidate);
if (!Array.isArray(decoded) || decoded.length === 0) {
return false;
}
return hasher.encode(decoded as number[]) === candidate;
} catch {
return false;
}
}
/**
* Deterministically encodes a numeric ID or numeric string into a HashID string.
* Synchronous and does not perform HTTP network round trips.
*/
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
return encodeEntityId(id);
}
/**
* Deterministically decodes a HashID string back to original numeric ID or string.
* Synchronous and does not perform HTTP network round trips.
*/
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId;
const str = String(hashId).trim();
if (!str) return '';
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {}
return str;
}
/**
* Decodes a HashID or numeric value strictly to a canonical integer.
* Useful for request payloads expecting int64 integers.
*/
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? 0 : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {}
const fallback = Number(str);
return isNaN(fallback) ? 0 : Math.trunc(fallback);
}
/**
* Legacy service resolver facade for compatibility with API endpoints and monitoring tables.
*/
export const toolsService = {
encodeIdSync(id: number | string | bigint | null | undefined): string {
return encodeHashIdSync(id);
},
decodeIdSync(hashId: string | number | null | undefined): number | string {
return decodeHashIdSync(hashId);
},
decodeToNumberSync(hashId: number | string | null | undefined): number {
return decodeToNumberSync(hashId);
},
async encodeId(id: number | string | bigint | null | undefined): Promise<string> {
return encodeHashIdSync(id);
},
async decodeId(hashId: string | number | null | undefined): Promise<number | string> {
return decodeHashIdSync(hashId);
},
async resolveProjectId(projectId: number | string): Promise<string> {
return encodeHashIdSync(projectId);
},
async resolveOrganisationId(organisationId: number | string): Promise<string> {
return encodeHashIdSync(organisationId);
},
async resolveProgrammeId(programmeId: number | string): Promise<string> {
return encodeHashIdSync(programmeId);
},
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
return encodeHashIdSync(portfolioId);
},
async resolveAwardId(awardId: number | string): Promise<string> {
return encodeHashIdSync(awardId);
},
async resolveContractId(contractId: number | string): Promise<string> {
return encodeHashIdSync(contractId);
},
async resolveHashedParams<T extends Record<string, any>>(
params: T,
keysToHash: (keyof T)[]
): Promise<T> {
const resolved: any = { ...params };
for (const key of keysToHash) {
const val = params[key];
if (val !== undefined && val !== null) {
resolved[key] = encodeHashIdSync(val);
}
}
return resolved;
},
async primeCache(): Promise<void> {},
};