From a731c979bb47ea2a76f61dc339951421137b9d0f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 24 Oct 2026 17:29:48 +0200 Subject: [PATCH 1/2] Overhauled lockout process --- .../src/services/toolsService.ts | 247 ------------------ 1 file changed, 247 deletions(-) delete mode 100644 PostFundManagement.ConsoleUI/src/services/toolsService.ts diff --git a/PostFundManagement.ConsoleUI/src/services/toolsService.ts b/PostFundManagement.ConsoleUI/src/services/toolsService.ts deleted file mode 100644 index b0aa4a6..0000000 --- a/PostFundManagement.ConsoleUI/src/services/toolsService.ts +++ /dev/null @@ -1,247 +0,0 @@ -import Hashids from 'hashids'; -import { useQuery } from '@tanstack/react-query'; - -const env = (typeof import.meta !== 'undefined' && import.meta.env - ? import.meta.env - : {}) as Record; - -/** - * Deterministic Hashids configuration matching pfm-api HasherSettings. - * Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend. - */ -export const HASHER_SETTINGS = { - minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11, - salt: (env.VITE_HASHER_SALT as string) || '', -}; - -// Singleton Hashids instance initialized with salt & minHashLength -let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); - -/** - * Dynamically reconfigures the deterministic Hashids instance from runtime configuration. - */ -export function updateHasherSettings(salt?: string, minLength?: number | string): void { - let updated = false; - if (salt && typeof salt === 'string' && salt.trim() !== '') { - HASHER_SETTINGS.salt = salt.trim(); - updated = true; - } - if (minLength !== undefined && minLength !== null && minLength !== '') { - const parsed = Number(minLength); - if (!isNaN(parsed) && parsed > 0) { - HASHER_SETTINGS.minHashLength = parsed; - updated = true; - } - } - if (updated) { - hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); - } -} - -/** - * 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. - */ -function isAlreadyHashId(candidate: string): boolean { - if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) { - return false; - } - try { - const decoded = hasher.decode(candidate); - return Array.isArray(decoded) && decoded.length > 0; - } 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 | null | undefined): string { - if (id === undefined || id === null || id === '') { - return ''; - } - - // If already a number - if (typeof id === 'number') { - if (isNaN(id) || !isFinite(id) || id < 0) return ''; - return hasher.encode(id); - } - - const str = String(id).trim(); - if (!str) return ''; - - // Check if string is already a valid HashID - if (isAlreadyHashId(str)) { - return str; - } - - // Check if string is pure digits (e.g. "123") - if (/^\d+$/.test(str)) { - try { - const num = parseInt(str, 10); - if (!isNaN(num) && num >= 0) { - return hasher.encode(num); - } - } catch { - // Fall through - } - } - - // Non-numeric identifiers (UUIDs, codes) returned as-is - return str; -} - -/** - * 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 already pure integer string - 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 { - // If decoding fails, return raw string - } - - 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); -} - -export const toolsService = { - /** - * Synchronous encoding - */ - encodeIdSync(id: number | string | null | undefined): string { - return encodeHashIdSync(id); - }, - - /** - * Synchronous decoding - */ - decodeIdSync(hashId: string | number | null | undefined): number | string { - return decodeHashIdSync(hashId); - }, - - /** - * Synchronous decoding strictly to canonical integer - */ - decodeToNumberSync(hashId: number | string | null | undefined): number { - return decodeToNumberSync(hashId); - }, - - /** - * Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers) - */ - async encodeId(id: number | string | null | undefined, _context = 'general'): Promise { - return encodeHashIdSync(id); - }, - - /** - * Decodes a HashID string back to original identifier - */ - async decodeId(hashId: string | null | undefined, _context = 'general'): Promise { - return decodeHashIdSync(hashId); - }, - - /** - * Domain-specific convenience resolvers for entities - */ - async resolveProjectId(projectId: number | string): Promise { - return encodeHashIdSync(projectId); - }, - - async resolveOrganisationId(organisationId: number | string): Promise { - return encodeHashIdSync(organisationId); - }, - - async resolveProgrammeId(programmeId: number | string): Promise { - return encodeHashIdSync(programmeId); - }, - - async resolvePortfolioId(portfolioId: number | string): Promise { - return encodeHashIdSync(portfolioId); - }, - - async resolveAwardId(awardId: number | string): Promise { - return encodeHashIdSync(awardId); - }, - - async resolveContractId(contractId: number | string): Promise { - return encodeHashIdSync(contractId); - }, - - /** - * Resolves query parameters or path variables encoded as HashIDs before performing API calls - */ - async resolveHashedParams>( - params: T, - keysToHash: (keyof T)[] - ): Promise { - const resolved: any = { ...params }; - for (const key of keysToHash) { - const val = params[key]; - if (val !== undefined && val !== null) { - resolved[key] = encodeHashIdSync(val); - } - } - return resolved; - }, - - /** - * Compatibility cache primer (now a no-op as Hashids calculation is instantaneous client-side) - */ - async primeCache(_items: { id: number | string; context: string }[]): Promise { - // No-op - instant deterministic computation - }, -}; - -/** - * React Hook for consuming HashIDs with TanStack Query caching in components - */ -export function useHashId(id?: number | string | null, context = 'general') { - return useQuery({ - queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''], - queryFn: () => Promise.resolve(encodeHashIdSync(id)), - enabled: id !== undefined && id !== null && id !== '', - staleTime: Infinity, - }); -} -- 2.47.3 From 25e251aa79b2b7baa383de2820c74f4cd2f16b55 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 24 Oct 2026 17:36:30 +0200 Subject: [PATCH 2/2] Overhauled lockout process --- .../src/auth/AuthProvider.tsx | 285 +++++++++++------- .../auth/AccountActivationModal.tsx | 160 ++++------ .../auth/PartnerAccessDeniedModal.tsx | 115 ++----- .../components/auth/SessionExpiredModal.tsx | 73 ++--- .../monitoring/ContractsManagement.tsx | 2 +- .../components/users/UserSecurityModal.tsx | 52 ++-- PostFundManagement.ConsoleUI/src/config.ts | 10 +- .../src/pages/TenantManagementPage.tsx | 71 ++--- .../src/services/api.ts | 2 +- .../src/services/tenantLockoutService.ts | 137 +++++---- .../src/services/userSyncService.ts | 59 ++-- .../src/utils/hashids.ts | 135 +++++++++ 12 files changed, 574 insertions(+), 527 deletions(-) diff --git a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx index 5045b61..75e4c20 100644 --- a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx @@ -10,13 +10,14 @@ import { isActivationPending, getActivationPendingReason, clearUserSyncInBrowser, + setActivationPending, UserSyncResult, } from '../services/userSyncService'; import { AccountActivationModal } from '../components/auth/AccountActivationModal'; import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; -import { encodeHashIdSync } from '../services/toolsService'; -import { verifyTenantLockout } from '../services/tenantLockoutService'; +import { encodeHashIdSync } from '../utils/hashids'; +import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService'; import { onSessionExpired, triggerSessionExpired, @@ -307,11 +308,6 @@ export function resetUserManager(): void { userManagerInstance = null; } -// Automatically reset UserManager when runtime configuration is updated -onConfigChanged(() => { - resetUserManager(); -}); - export function getUserManager(): UserManager { if (!userManagerInstance && typeof window !== 'undefined') { const sec = getSecuritySettings(); @@ -437,8 +433,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims)); const tenantClaimValues = extractTenantClaimValues(mergedClaims, token); - const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined; - const parsedTenantId = extractTenantId(mergedClaims, token); + let tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined; + let parsedTenantId = extractTenantId(mergedClaims, token); setTenantId(parsedTenantId); const profile: AuthUserProfile = { @@ -458,29 +454,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => // Clean up code and state from URL window.history.replaceState({}, document.title, window.location.pathname); - // Verify tenant lockout at the instance tenant and tenant_id claims are retrieved - const tenantLockoutResult = await verifyTenantLockout( - parsedTenantId ?? tenantClaimValues.tenantId, - tenantClaim - ); - - if (!tenantLockoutResult.isActive) { - setIsPendingActivation(true); - setIsSynced(false); - setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found'); - setIsLoading(false); - return; - } - - if (tenantLockoutResult.tenant?.name) { - profile.tenant = tenantLockoutResult.tenant.name; - setTenantName(tenantLockoutResult.tenant.name); - localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); - } else if (tenantClaim) { - setTenantName(tenantClaim); - } - - // Synchronize authenticated user profile with backend directory + // Step 1: Synchronize authenticated user profile with backend directory FIRST (/v1/api/integration/sync) try { const syncResult = await syncUserAccount(userSub, token, profile, true); if (syncResult.isUnauthorized) { @@ -488,6 +462,51 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => window.location.replace('/'); 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) { setIsPendingActivation(true); setIsSynced(false); @@ -496,26 +515,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setIsPendingActivation(false); setIsSynced(true); setActivationError(null); - - // Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7) - try { - const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile); - if (resolved.primaryRole) { - profile.role = resolved.primaryRole; - } - if (resolved.tenant) { - profile.tenant = resolved.tenant; - setTenantName(resolved.tenant); - } - if (resolved.tenant_id !== undefined) { - profile.tenant_id = resolved.tenant_id; - setTenantId(resolved.tenant_id); - } - setUser({ ...profile }); - setClaims({ ...resolved.claims }); - localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); - localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); - } catch {} } } catch (syncErr: any) { if (syncErr?.response?.status === 401) { @@ -525,7 +524,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } setIsPendingActivation(true); 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); @@ -561,10 +562,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setSub(storedSub); } - const storedTenantId = extractTenantId(parsedClaims, storedToken); + let storedTenantId = extractTenantId(parsedClaims, storedToken); setTenantId(storedTenantId); 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; if (storedUser) { @@ -588,9 +589,53 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => // Initial fast unblock: valid persisted session found, unblock UI immediately setIsLoading(false); - // Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims) + // Asynchronous background revalidation (User Sync, Role Claims, Tenant Lockout) (async () => { 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( storedTenantId ?? tenantClaimValues.tenantId, storedTenantClaim @@ -612,22 +657,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => 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) { setIsPendingActivation(true); setIsSynced(false); @@ -639,27 +668,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setIsPendingActivation(false); setIsSynced(true); 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) { if (syncErr?.response?.status === 401) { @@ -668,7 +676,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } setIsPendingActivation(true); 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 => { try { - // Re-verify tenant status via GetTenant as part of the lockout resolution - const tenantClaimValues = extractTenantClaimValues(claims, accessToken); - const currentTenantId = tenantId ?? tenantClaimValues.tenantId; - const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined; + await loadRuntimeConfig(); + clearTenantLockoutCache(); - 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) { const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.'; setIsPendingActivation(true); @@ -705,28 +768,22 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => }; } - // Tenant is verified Active: proceed with user sync - const result = await syncUserAccount(sub, accessToken, user || undefined, force); - if (result.isUnauthorized) { - await logout(); - return result; - } - if (result.success) { - setIsPendingActivation(false); - setIsSynced(true); - setActivationError(null); - } else if (result.isActivationPending) { - setIsPendingActivation(true); - setIsSynced(false); - setActivationError(result.errorMessage || null); + if (tenantLockoutResult.tenant?.name) { + setTenantName(tenantLockoutResult.tenant.name); } + + 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'; + 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); setIsSynced(false); setActivationError(msg); diff --git a/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx b/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx index ec7dab9..afdc2fd 100644 --- a/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx @@ -115,141 +115,94 @@ export const AccountActivationModal: React.FC = ({ navigate('/'); }; + const isTenantIssue = !!errorMessage?.toLowerCase().includes('tenant'); + const modalContent = (
- {/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
e.stopPropagation()} > - {/* Amber accent top bar */} -
+ {/* Subtle accent top border */} +
- {/* Modal Header */} -
+ {/* Header */} +
-
-
- - - - - +
+
+
- - Access Restricted • Lockout Active - -

- {errorMessage?.toLowerCase().includes('tenant') - ? 'Tenant Lockout • Account Inactive' - : 'Account Pending Administrator Activation'} +

+ {isTenantIssue ? 'Tenant Access Restricted' : 'Account Pending Activation'}

+

+ {isTenantIssue + ? 'The assigned tenant partition is currently inactive or restricted.' + : 'Your account is authenticated and awaiting administrator enablement.'} +

- +
- {/* Modal Body */} -
- {/* Status notification block */} -
-
- - Identity Verified & Signed In Successfully + {/* Body */} +
+ {/* User Profile Summary */} +
+
+
+ User + {displayName} +
+
+ Email + {displayEmail || '—'} +
-

- You are a registered user and have successfully authenticated via Enterprise Single Sign-On. - However, your account has not yet been enabled in the system directory by a system administrator. -

-

- If your administrator informs you that your account has been activated, please click the{' '} - Sync Account button below to refresh your status and unlock the system. -

-
- - {/* User Profile Card */} -
-
- Authenticated User Profile - - Lockout Active - -
- -
-
- -
- Name - {displayName} -
+
+
+ Tenant + {tenant}
- -
- -
- Email - {displayEmail || 'Not specified'} -
-
- -
- -
- Tenant / Role - - {tenant} ({role}) - -
-
- -
- -
- Subject ID - {sub || 'SSO-Principal'} -
+
+ Role + {role}
- {/* Sync Attempt Feedback / Error Notice */} + {/* Feedback notice if sync failed */} {errorMessage && ( -
- -
-

- {errorMessage?.toLowerCase().includes('tenant') - ? 'Tenant Lockout In Effect' - : 'Account Not Activated Yet'} -

-

{errorMessage}

- {syncAttemptCount > 0 && ( -

- Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()} -

- )} +
+ +
+ Notice: +

{errorMessage}

)} + +

+ Once your administrator enables your account, click Sync Account to enter the console. +

- {/* Modal Actions */} -
+ {/* Footer Actions */} +
diff --git a/PostFundManagement.ConsoleUI/src/components/auth/PartnerAccessDeniedModal.tsx b/PostFundManagement.ConsoleUI/src/components/auth/PartnerAccessDeniedModal.tsx index ce7eb0a..02f283f 100644 --- a/PostFundManagement.ConsoleUI/src/components/auth/PartnerAccessDeniedModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/auth/PartnerAccessDeniedModal.tsx @@ -61,114 +61,57 @@ export const PartnerAccessDeniedModal: React.FC = aria-modal="true" aria-labelledby="partner-denied-title" 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" > -
- {/* Glow Header Accent */} -
+
+
-
- {/* Header */} -
-
- +
+
+
+
-
-
- - - Restricted Role - - Security Policy -
-

- Access Denied: Partner Role +
+

+ Partner Portal Required

-

- Users assigned the Partner role by security are not permitted to access the PFM Backoffice Console. +

+ Accounts with the Partner role must access the Partner Portal.

- {/* User & Identity Summary Card */} -
-
- Account Name - {displayName} +
+
+ User + {displayName}
- {displayEmail && ( -
- - - Email - - {displayEmail} +
+ Email + {displayEmail}
)} - -
- - - Assigned Role - - - {role} - +
+ Role + {role}
- - {tenant && ( -
- - - Tenant Context - - {tenant} -
- )} - - {sub && ( -
- - - Subject Key - - {sub} -
- )}
- {/* Security Policy Information */} -
-

- - Administrative Boundary Notice -

-

- 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. -

-
+

+ The Console is reserved for statutory administrators and programme managers. +

- {/* Actions */} -
+
- - {/* Footer Brand watermark */} -
-
- - Post Fund Management (PFM) -
- National Security Gateway -
); diff --git a/PostFundManagement.ConsoleUI/src/components/auth/SessionExpiredModal.tsx b/PostFundManagement.ConsoleUI/src/components/auth/SessionExpiredModal.tsx index 7681a5a..b5dc830 100644 --- a/PostFundManagement.ConsoleUI/src/components/auth/SessionExpiredModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/auth/SessionExpiredModal.tsx @@ -87,7 +87,7 @@ export const SessionExpiredModal: React.FC = ({ const modalContent = (
setIsPaused(true)} onMouseLeave={() => setIsPaused(false)} role="dialog" @@ -96,27 +96,20 @@ export const SessionExpiredModal: React.FC = ({ >
- {/* Subtle decorative top accent bar */} -
+
- {/* Top bar with close button */}
-
- +
+
-
-

- Session Expired -

- - HTTP 401 - -
-

Authorization token invalidated

+

+ Session Expired +

+

Authorization credentials expired

@@ -125,60 +118,46 @@ export const SessionExpiredModal: React.FC = ({ type="button" onClick={handleAction} 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" >
- {/* Informative Explanation (non-technical and clear) */} -
+

- 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.

{reason && ( -
+
{reason}
)}
- {/* Progress Bar Countdown */} -
-
- - - Returning to landing page in{' '} - {secondsRemaining}s +
+ + + Redirecting in {secondsRemaining}s + + {isPaused && ( + + Paused - {isPaused && ( - - Paused on hover - - )} -
- - {/* Progress bar line */} -
-
-
+ )}
- {/* Actions */} -
+
diff --git a/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx b/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx index f9d299d..cc0b558 100644 --- a/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx +++ b/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx @@ -36,7 +36,7 @@ import { getAllProgrammes, getProjects, } from '../../services/api'; -import { toolsService } from '../../services/toolsService'; +import { toolsService } from '../../utils/hashids'; import { Modal } from '../ui/Modal'; import { useToast } from '../ui/Toast'; import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert'; diff --git a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx index 577cf4f..a919e50 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx @@ -290,18 +290,18 @@ export const UserSecurityModal: React.FC = ({
{/* Navigation Tabs */} -
+
)} - {/* TAB 4: Sessions & Direct Account Interventions */} + {/* TAB 4: Sessions & Security Interventions */} {activeTab === 'sessions' && (
{/* Force Logout Action */} -
+

Revoke Active User Session -

- Terminates all active JWT tokens and sessions immediately without direct database intervention. + Immediately invalidates active JWT tokens and logs the user out.

{/* Lock / Unlock Account */} -
+

- Account Lockout Control - + Account Lockout

- 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.

-
+