diff --git a/PostFundManagement.PartnerUI/public/config.json b/PostFundManagement.PartnerUI/public/config.json index eded912..e49fbd4 100644 --- a/PostFundManagement.PartnerUI/public/config.json +++ b/PostFundManagement.PartnerUI/public/config.json @@ -1,16 +1,17 @@ { - "ApiSettings": { - "BaseUrl": "https://api.ifri.khongisa.co.za/" - }, + "_comment": "Runtime configuration for Docker/Kubernetes deployments. Leave values empty in source control so local .env (VITE_*) variables are used during development. Container entrypoint scripts can populate these fields at container startup.", "SecuritySettings": { - "Authority": "https://sts.security.khongisa.co.za", - "Audience": "pfm-api-dev", - "ClientId": "pfm-scalar-dev", - "ClientSecret": "secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27", - "Scope": "openid profile roles email address pfm-api-dev" + "Authority": "", + "Audience": "", + "ClientId": "", + "ClientSecret": "", + "Scope": "" + }, + "ApiSettings": { + "BaseUrl": "" }, "HasherSettings": { - "Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==", - "MinHashLength": 11 + "Salt": "", + "MinHashLength": "" } } diff --git a/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx b/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx index 2a495e9..6eee598 100644 --- a/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.PartnerUI/src/auth/AuthProvider.tsx @@ -9,6 +9,7 @@ import { isUserSyncedInBrowser, isActivationPending, getActivationPendingReason, + setActivationPending, clearUserSyncInBrowser, UserSyncResult, } from '../services/userSyncService'; @@ -16,7 +17,7 @@ import { AccountActivationModal } from '../components/auth/AccountActivationModa import { NonPartnerAccessDeniedModal } from '../components/auth/NonPartnerAccessDeniedModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; import { encodeHashIdSync } from '../services/toolsService'; -import { verifyTenantLockout } from '../services/tenantLockoutService'; +import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService'; import { onSessionExpired, triggerSessionExpired, @@ -24,7 +25,7 @@ import { cancelSessionExpiration, dismissSubordinateModals, } from '../services/sessionExpiration'; -import { resolveUserRolesAndClaims } from '../services/roleClaimsCache'; +import { resolveUserRolesAndClaims, clearCachedUserRbac } from '../services/roleClaimsCache'; export interface AuthUserProfile { id: string; @@ -431,9 +432,16 @@ 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); + if (tenantClaim) { + setTenantName(tenantClaim); + } const initialRoles = extractUserRoles(mergedClaims, null, token); const initialPrimaryRole = @@ -446,7 +454,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const profile: AuthUserProfile = { id: userSub || 'user-1', sub: userSub, - name: oidcUser.profile.name || oidcUser.profile.preferred_username || (mergedClaims.name as string) || 'Partner User', + name: + oidcUser.profile.name || + oidcUser.profile.preferred_username || + (mergedClaims.name as string) || + 'Partner User', email: oidcUser.profile.email || (mergedClaims.email as string) || '', role: initialPrimaryRole, organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined, @@ -459,27 +471,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => window.history.replaceState({}, document.title, window.location.pathname); - 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); - } - + // Step 1: Sync user account FIRST (/v1/api/integration/sync) before calling any ActiveUserFilter-protected endpoints try { const syncResult = await syncUserAccount(userSub, token, profile, true); if (syncResult.isUnauthorized) { @@ -487,34 +479,73 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => window.location.replace('/'); return; } + + // Step 2: Resolve user roles & claims from /v1/api/users/{userSub}/roles and /claims + try { + const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile); + if (resolved.primaryRole) { + profile.role = resolved.primaryRole; + } + if (resolved.tenant) { + profile.tenant = resolved.tenant; + tenantClaim = resolved.tenant; + setTenantName(resolved.tenant); + } + if (resolved.tenant_id !== undefined) { + profile.tenant_id = resolved.tenant_id; + parsedTenantId = resolved.tenant_id; + setTenantId(resolved.tenant_id); + } + setUser({ ...profile }); + setClaims({ ...resolved.claims }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); + localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); + } catch {} + if (syncResult.isActivationPending) { setIsPendingActivation(true); setIsSynced(false); setActivationError(syncResult.errorMessage || null); - } else if (syncResult.success) { - setIsPendingActivation(false); - setIsSynced(true); - setActivationError(null); - - 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 {} + setIsLoading(false); + return; } + + // Step 3: Verify tenant lockout using the HashId-encoded tenant ID + const effectiveTenantId = + parsedTenantId ?? + syncResult.data?.tenantId ?? + syncResult.data?.organisationId ?? + tenantClaimValues.tenantId; + + const tenantLockoutResult = await verifyTenantLockout(effectiveTenantId, 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); + } + if (tenantLockoutResult.tenant?.id !== undefined && !profile.tenant_id) { + const numericTid = Number(tenantLockoutResult.tenant.id); + if (!isNaN(numericTid) && numericTid > 0) { + profile.tenant_id = numericTid; + setTenantId(numericTid); + } + } + + setUser({ ...profile }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); + + setActivationPending(false); + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); } catch (syncErr: any) { if (syncErr?.response?.status === 401) { await logout(); @@ -558,10 +589,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) { @@ -582,13 +613,73 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setTenantName(storedTenantClaim); } - setIsLoading(false); + const hasInitialPartnerRole = hasPartnerRole(extractUserRoles(parsedClaims, resolvedUser, storedToken)); + const hadPendingActivation = isActivationPending(); - (async () => { + // If the user already has the Partner role and is not pending activation, unblock UI immediately and verify in background + if (hasInitialPartnerRole && !hadPendingActivation) { + setIsLoading(false); + } + + const verifyPersistedSession = async () => { try { + // Step 1: Sync user account FIRST (/v1/api/integration/sync) + const syncResult = await syncUserAccount( + storedSub || '', + storedToken, + resolvedUser || undefined, + hadPendingActivation + ); + + if (syncResult.isUnauthorized) { + await logout(); + return; + } + + // Step 2: Resolve user roles & claims + try { + const resolved = await resolveUserRolesAndClaims( + storedSub || '', + parsedClaims, + resolvedUser || {} + ); + if (resolvedUser) { + if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole; + if (resolved.tenant) { + resolvedUser.tenant = resolved.tenant; + storedTenantClaim = resolved.tenant; + setTenantName(resolved.tenant); + } + if (resolved.tenant_id !== undefined) { + resolvedUser.tenant_id = resolved.tenant_id; + storedTenantId = resolved.tenant_id; + setTenantId(resolved.tenant_id); + } + setUser({ ...resolvedUser }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); + } + setClaims({ ...resolved.claims }); + localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); + } catch {} + + if (syncResult.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncResult.errorMessage || null); + return; + } + + // Step 3: Verify tenant lockout using HashId-encoded tenant ID + const effectiveTenantId = + storedTenantId ?? + syncResult.data?.tenantId ?? + syncResult.data?.organisationId ?? + tenantClaimValues.tenantId; + const tenantLockoutResult = await verifyTenantLockout( - storedTenantId ?? tenantClaimValues.tenantId, - storedTenantClaim + effectiveTenantId, + storedTenantClaim, + hadPendingActivation ); if (!tenantLockoutResult.isActive) { @@ -601,61 +692,25 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => if (tenantLockoutResult.tenant?.name) { if (resolvedUser) { resolvedUser.tenant = tenantLockoutResult.tenant.name; - setUser({ ...resolvedUser }); - localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); } setTenantName(tenantLockoutResult.tenant.name); } - - 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); - setActivationError(syncResult.errorMessage || null); - return; - } - - if (syncResult.success) { - 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)); + if (tenantLockoutResult.tenant?.id !== undefined && resolvedUser && !resolvedUser.tenant_id) { + const numericTid = Number(tenantLockoutResult.tenant.id); + if (!isNaN(numericTid) && numericTid > 0) { + resolvedUser.tenant_id = numericTid; + setTenantId(numericTid); } - setClaims({ ...resolved.claims }); - localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); } + if (resolvedUser) { + setUser({ ...resolvedUser }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); + } + + setActivationPending(false); + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); } catch (syncErr: any) { if (syncErr?.response?.status === 401) { await logout(); @@ -664,8 +719,16 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setIsPendingActivation(true); setIsSynced(false); setActivationError(syncErr?.message || 'Account activation pending'); + } finally { + setIsLoading(false); } - })(); + }; + + if (hasInitialPartnerRole && !hadPendingActivation) { + verifyPersistedSession(); + } else { + await verifyPersistedSession(); + } return; } @@ -682,11 +745,68 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const syncUserSession = async (force = true): Promise => { try { - const tenantClaimValues = extractTenantClaimValues(claims, accessToken); - const currentTenantId = tenantId ?? tenantClaimValues.tenantId; - const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined; + await loadRuntimeConfig(); + clearTenantLockoutCache(); + if (sub) { + clearCachedUserRbac(sub); + } - const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim); + // Step 1: Sync user account FIRST (/v1/api/integration/sync) + const result = await syncUserAccount(sub, accessToken, user || undefined, force); + if (result.isUnauthorized) { + await logout(); + return result; + } + if (result.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(result.errorMessage || null); + return result; + } + + // Step 2: Refresh user roles & claims from backend + let currentTenantId: number | string | null = tenantId; + let currentTenantClaim: string | undefined = + (claims.tenant as string) || user?.tenant || undefined; + + try { + const resolved = await resolveUserRolesAndClaims(sub, claims, user || {}); + const updatedProfile = user ? { ...user } : null; + if (updatedProfile && resolved.primaryRole) { + updatedProfile.role = resolved.primaryRole; + } + if (resolved.tenant) { + currentTenantClaim = resolved.tenant; + setTenantName(resolved.tenant); + if (updatedProfile) updatedProfile.tenant = resolved.tenant; + } + if (resolved.tenant_id !== undefined) { + currentTenantId = resolved.tenant_id; + setTenantId(resolved.tenant_id); + if (updatedProfile) updatedProfile.tenant_id = resolved.tenant_id; + } + if (updatedProfile) { + setUser(updatedProfile); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(updatedProfile)); + } + setClaims({ ...resolved.claims }); + localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); + } catch {} + + const tenantClaimValues = extractTenantClaimValues(claims, accessToken); + const effectiveTenantId = + currentTenantId ?? + result.data?.tenantId ?? + result.data?.organisationId ?? + tenantClaimValues.tenantId; + const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined; + + // Step 3: Verify tenant lockout using HashId-encoded tenant ID + const tenantLockoutResult = await verifyTenantLockout( + effectiveTenantId, + effectiveTenantClaim, + true + ); if (!tenantLockoutResult.isActive) { const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.'; setIsPendingActivation(true); @@ -699,20 +819,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => }; } - const result = await syncUserAccount(sub, accessToken, user || undefined, force); - if (result.isUnauthorized) { - await logout(); - return result; + if (tenantLockoutResult.tenant?.name) { + setTenantName(tenantLockoutResult.tenant.name); } - if (result.success) { - setIsPendingActivation(false); - setIsSynced(true); - setActivationError(null); - } else if (result.isActivationPending) { - setIsPendingActivation(true); - setIsSynced(false); - setActivationError(result.errorMessage || null); + if (tenantLockoutResult.tenant?.id !== undefined && !tenantId) { + const numericTid = Number(tenantLockoutResult.tenant.id); + if (!isNaN(numericTid) && numericTid > 0) { + setTenantId(numericTid); + } } + + setActivationPending(false); + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); return result; } catch (err: any) { if (err?.response?.status === 401) { @@ -753,6 +873,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => localStorage.removeItem(LOCAL_STORAGE_SUB_KEY); localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY); clearUserSyncInBrowser(); + clearTenantLockoutCache(); setUser(null); setSub(''); setClaims({}); diff --git a/PostFundManagement.PartnerUI/src/config.ts b/PostFundManagement.PartnerUI/src/config.ts index 4106179..6ee8257 100644 --- a/PostFundManagement.PartnerUI/src/config.ts +++ b/PostFundManagement.PartnerUI/src/config.ts @@ -1,4 +1,5 @@ -import { updateHasherSettings } from './services/toolsService'; +import { updateHasherSettings as updateToolsHasherSettings } from './services/toolsService'; +import { updateHasherSettings as updateUtilsHasherSettings } from './utils/hashids'; export interface SecuritySettings { Authority: string; @@ -30,8 +31,8 @@ export interface AppConfig { export const defaultSecuritySettings: SecuritySettings = { Authority: 'https://sts.security.khongisa.co.za', Audience: 'pfm-api-dev', - ClientId: 'pfm-scalar-dev', - ClientSecret: 'secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27', + ClientId: 'pfm-console-dev', + ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94', Scope: 'openid profile roles email address pfm-api-dev', }; @@ -44,11 +45,18 @@ export const defaultHasherSettings: HasherSettings = { MinHashLength: 11, }; +function syncAllHasherSettings(salt: string, minHashLength: number): void { + updateToolsHasherSettings(salt, minHashLength); + updateUtilsHasherSettings(salt, minHashLength); +} + function pickFirstNonEmpty(...candidates: any[]): string | undefined { for (const c of candidates) { if (c !== undefined && c !== null) { const s = String(c).trim(); - if (s !== '' && s !== 'undefined' && s !== 'null') return s; + if (s !== '' && s !== 'undefined' && s !== 'null') { + return s; + } } } return undefined; @@ -56,13 +64,17 @@ function pickFirstNonEmpty(...candidates: any[]): string | undefined { function normalizeBaseUrl(url: string): string { let trimmed = url.trim(); - if (!/^https?:\/\//i.test(trimmed)) trimmed = `https://${trimmed}`; + if (!/^https?:\/\//i.test(trimmed)) { + trimmed = `https://${trimmed}`; + } return trimmed.endsWith('/') ? trimmed : `${trimmed}/`; } function normalizeAuthorityUrl(url: string): string { let trimmed = url.trim(); - if (!/^https?:\/\//i.test(trimmed)) trimmed = `https://${trimmed}`; + if (!/^https?:\/\//i.test(trimmed)) { + trimmed = `https://${trimmed}`; + } return trimmed.replace(/\/+$/, ''); } @@ -72,44 +84,178 @@ function getWindowRuntimeConfig(): Record { return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {}; } -function extractSourceConfig(src: Record) { +function extractSourceConfig(src: Record): { + authority?: string; + audience?: string; + clientId?: string; + clientSecret?: string; + scope?: string; + baseUrl?: string; + hasherSalt?: string; + hasherMinLength?: number; +} { if (!src || typeof src !== 'object') return {}; + + const sec = src.SecuritySettings || src.securitySettings || {}; + const api = src.ApiSettings || src.apiSettings || {}; + const hasher = src.HasherSettings || src.hasherSettings || {}; + + const minLenRaw = pickFirstNonEmpty( + src.VITE_HASHER_MIN_LENGTH, + src.HASHER_MIN_LENGTH, + src.HasherSettings__MinHashLength, + hasher.MinHashLength, + hasher.minHashLength, + src.MinHashLength, + src.minHashLength + ); + const parsedMinLen = minLenRaw !== undefined ? Number(minLenRaw) : undefined; + return { - authority: pickFirstNonEmpty(src.VITE_OIDC_AUTHORITY, src.OIDC_AUTHORITY, src.SecuritySettings?.Authority, src.SecuritySettings__Authority), - audience: pickFirstNonEmpty(src.VITE_OIDC_AUDIENCE, src.OIDC_AUDIENCE, src.SecuritySettings?.Audience, src.SecuritySettings__Audience), - clientId: pickFirstNonEmpty(src.VITE_OIDC_CLIENT_ID, src.OIDC_CLIENT_ID, src.SecuritySettings?.ClientId, src.SecuritySettings__ClientId), - clientSecret: pickFirstNonEmpty(src.VITE_OIDC_CLIENT_SECRET, src.OIDC_CLIENT_SECRET, src.SecuritySettings?.ClientSecret, src.SecuritySettings__ClientSecret), - scope: pickFirstNonEmpty(src.VITE_OIDC_SCOPE, src.OIDC_SCOPE, src.SecuritySettings?.Scope, src.SecuritySettings__Scope), - baseUrl: pickFirstNonEmpty(src.VITE_API_BASE_URL, src.API_BASE_URL, src.ApiSettings?.BaseUrl, src.ApiSettings__BaseUrl), - hasherSalt: pickFirstNonEmpty(src.VITE_HASHER_SALT, src.HASHER_SALT, src.HasherSettings?.Salt, src.HasherSettings__Salt), - hasherMinLength: Number(pickFirstNonEmpty(src.VITE_HASHER_MIN_LENGTH, src.HASHER_MIN_LENGTH, src.HasherSettings?.MinHashLength)) || undefined, + authority: pickFirstNonEmpty( + src.VITE_OIDC_AUTHORITY, + src.OIDC_AUTHORITY, + src.SecuritySettings__Authority, + sec.Authority, + sec.authority, + src.Authority, + src.authority + ), + audience: pickFirstNonEmpty( + src.VITE_OIDC_AUDIENCE, + src.OIDC_AUDIENCE, + src.SecuritySettings__Audience, + sec.Audience, + sec.audience, + src.Audience, + src.audience + ), + clientId: pickFirstNonEmpty( + src.VITE_OIDC_CLIENT_ID, + src.OIDC_CLIENT_ID, + src.SecuritySettings__ClientId, + sec.ClientId, + sec.clientId, + src.ClientId, + src.clientId + ), + clientSecret: pickFirstNonEmpty( + src.VITE_OIDC_CLIENT_SECRET, + src.OIDC_CLIENT_SECRET, + src.SecuritySettings__ClientSecret, + sec.ClientSecret, + sec.clientSecret, + src.ClientSecret, + src.clientSecret + ), + scope: pickFirstNonEmpty( + src.VITE_OIDC_SCOPE, + src.OIDC_SCOPE, + src.SecuritySettings__Scope, + sec.Scope, + sec.scope, + src.Scope, + src.scope + ), + baseUrl: pickFirstNonEmpty( + src.VITE_API_BASE_URL, + src.API_BASE_URL, + src.ApiSettings__BaseUrl, + api.BaseUrl, + api.baseUrl, + src.BaseUrl, + src.baseUrl + ), + hasherSalt: pickFirstNonEmpty( + src.VITE_HASHER_SALT, + src.HASHER_SALT, + src.HasherSettings__Salt, + hasher.Salt, + hasher.salt, + src.Salt, + src.salt + ), + hasherMinLength: + parsedMinLen !== undefined && !isNaN(parsedMinLen) && parsedMinLen > 0 + ? parsedMinLen + : undefined, }; } function resolveConfig(fetchedJson?: Record): AppConfig { const winCfg = extractSourceConfig(getWindowRuntimeConfig()); const fetchCfg = fetchedJson ? extractSourceConfig(fetchedJson) : {}; - const envCfg = extractSourceConfig((typeof import.meta !== 'undefined' && (import.meta as any).env ? (import.meta as any).env : {}) as Record); + const envCfg = extractSourceConfig( + (typeof import.meta !== 'undefined' && (import.meta as any).env + ? (import.meta as any).env + : {}) as Record + ); + + const hasWin = Object.values(winCfg).some((v) => v !== undefined); + const hasFetch = Object.values(fetchCfg).some((v) => v !== undefined); + const hasEnv = Object.values(envCfg).some((v) => v !== undefined); let source: ConfigSource = 'fallback-defaults'; - if (Object.values(winCfg).some((v) => v !== undefined)) source = 'runtime-injected'; - else if (Object.values(fetchCfg).some((v) => v !== undefined)) source = 'runtime-fetched'; - else if (Object.values(envCfg).some((v) => v !== undefined)) source = 'build-env'; + if (hasWin) source = 'runtime-injected'; + else if (hasFetch) source = 'runtime-fetched'; + else if (hasEnv) source = 'build-env'; + + const authority = normalizeAuthorityUrl( + pickFirstNonEmpty(winCfg.authority, fetchCfg.authority, envCfg.authority, defaultSecuritySettings.Authority)! + ); + const audience = pickFirstNonEmpty( + winCfg.audience, + fetchCfg.audience, + envCfg.audience, + defaultSecuritySettings.Audience + )!; + const clientId = pickFirstNonEmpty( + winCfg.clientId, + fetchCfg.clientId, + envCfg.clientId, + defaultSecuritySettings.ClientId + )!; + const clientSecret = pickFirstNonEmpty( + winCfg.clientSecret, + fetchCfg.clientSecret, + envCfg.clientSecret, + defaultSecuritySettings.ClientSecret + )!; + const scope = pickFirstNonEmpty( + winCfg.scope, + fetchCfg.scope, + envCfg.scope, + defaultSecuritySettings.Scope + ); + const baseUrl = normalizeBaseUrl( + pickFirstNonEmpty(winCfg.baseUrl, fetchCfg.baseUrl, envCfg.baseUrl, defaultApiSettings.BaseUrl)! + ); + const hasherSalt = pickFirstNonEmpty( + winCfg.hasherSalt, + fetchCfg.hasherSalt, + envCfg.hasherSalt, + defaultHasherSettings.Salt + )!; + const hasherMinLength = + winCfg.hasherMinLength || + fetchCfg.hasherMinLength || + envCfg.hasherMinLength || + defaultHasherSettings.MinHashLength; return { SecuritySettings: { - Authority: normalizeAuthorityUrl(pickFirstNonEmpty(winCfg.authority, fetchCfg.authority, envCfg.authority, defaultSecuritySettings.Authority)!), - Audience: pickFirstNonEmpty(winCfg.audience, fetchCfg.audience, envCfg.audience, defaultSecuritySettings.Audience)!, - ClientId: pickFirstNonEmpty(winCfg.clientId, fetchCfg.clientId, envCfg.clientId, defaultSecuritySettings.ClientId)!, - ClientSecret: pickFirstNonEmpty(winCfg.clientSecret, fetchCfg.clientSecret, envCfg.clientSecret, defaultSecuritySettings.ClientSecret)!, - Scope: pickFirstNonEmpty(winCfg.scope, fetchCfg.scope, envCfg.scope, defaultSecuritySettings.Scope), + Authority: authority, + Audience: audience, + ClientId: clientId, + ClientSecret: clientSecret, + Scope: scope, }, ApiSettings: { - BaseUrl: normalizeBaseUrl(pickFirstNonEmpty(winCfg.baseUrl, fetchCfg.baseUrl, envCfg.baseUrl, defaultApiSettings.BaseUrl)!), + BaseUrl: baseUrl, }, HasherSettings: { - Salt: pickFirstNonEmpty(winCfg.hasherSalt, fetchCfg.hasherSalt, envCfg.hasherSalt, defaultHasherSettings.Salt)!, - MinHashLength: winCfg.hasherMinLength || fetchCfg.hasherMinLength || envCfg.hasherMinLength || defaultHasherSettings.MinHashLength, + Salt: hasherSalt, + MinHashLength: hasherMinLength, }, ConfigSource: source, LoadedAt: new Date().toISOString(), @@ -117,7 +263,7 @@ function resolveConfig(fetchedJson?: Record): AppConfig { } let runtimeConfig: AppConfig = resolveConfig(); -updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); +syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); const configListeners: Array<(config: AppConfig) => void> = []; @@ -129,28 +275,49 @@ export function onConfigChanged(listener: (config: AppConfig) => void): () => vo }; } -export async function loadRuntimeConfig(): Promise { - let fetchedJson: Record | undefined; - if (typeof window !== 'undefined') { - try { - const res = await fetch(`/config.json?_t=${Date.now()}`, { - cache: 'no-store', - headers: { Accept: 'application/json' }, - }).catch(() => null); - if (res && res.ok) { - fetchedJson = await res.json().catch(() => undefined); - } - } catch { - // Safe fallback - } +let loadPromise: Promise | null = null; + +export async function loadRuntimeConfig(forceReload = false): Promise { + if (loadPromise && !forceReload) { + return loadPromise; } - runtimeConfig = resolveConfig(fetchedJson); - updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); - configListeners.forEach((fn) => fn(runtimeConfig)); - return runtimeConfig; + + loadPromise = (async () => { + let fetchedJson: Record | undefined; + + if (typeof window !== 'undefined') { + try { + const res = await fetch(`/config.json?_t=${Date.now()}`, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }).catch(() => null); + + if (res && res.ok) { + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json') || contentType.includes('text/plain')) { + fetchedJson = await res.json().catch(() => undefined); + } else { + const text = await res.text().catch(() => ''); + if (text.trim().startsWith('{')) { + fetchedJson = JSON.parse(text); + } + } + } + } catch { + // Fallback if /config.json is unreachable + } + } + + runtimeConfig = resolveConfig(fetchedJson); + syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); + configListeners.forEach((fn) => fn(runtimeConfig)); + return runtimeConfig; + })(); + + return loadPromise; } -export const getAppConfig = () => runtimeConfig; -export const getSecuritySettings = () => runtimeConfig.SecuritySettings; -export const getApiBaseUrl = () => runtimeConfig.ApiSettings.BaseUrl; -export const getHasherSettings = () => runtimeConfig.HasherSettings; +export const getAppConfig = (): AppConfig => runtimeConfig; +export const getSecuritySettings = (): SecuritySettings => runtimeConfig.SecuritySettings; +export const getApiBaseUrl = (): string => runtimeConfig.ApiSettings.BaseUrl; +export const getHasherSettings = (): HasherSettings => runtimeConfig.HasherSettings; diff --git a/PostFundManagement.PartnerUI/src/main.tsx b/PostFundManagement.PartnerUI/src/main.tsx index 080dac3..85fa534 100644 --- a/PostFundManagement.PartnerUI/src/main.tsx +++ b/PostFundManagement.PartnerUI/src/main.tsx @@ -1,10 +1,15 @@ -import {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; import App from './App.tsx'; import './index.css'; +import { loadRuntimeConfig } from './config'; -createRoot(document.getElementById('root')!).render( - - - , -); +// Load external runtime configuration (/config.json or window.__RUNTIME_CONFIG__) +// before mounting the React application so OIDC, API BaseUrl, and HashIds use runtime values. +loadRuntimeConfig().finally(() => { + createRoot(document.getElementById('root')!).render( + + + , + ); +}); diff --git a/PostFundManagement.PartnerUI/src/services/roleClaimsCache.ts b/PostFundManagement.PartnerUI/src/services/roleClaimsCache.ts index bcf775f..fd73bfb 100644 --- a/PostFundManagement.PartnerUI/src/services/roleClaimsCache.ts +++ b/PostFundManagement.PartnerUI/src/services/roleClaimsCache.ts @@ -343,12 +343,15 @@ export async function resolveUserRolesAndClaims( } }); if (resolvedRoles.length > 0) { - primaryRole = resolvedRoles[0]; + const partnerMatch = resolvedRoles.find((r) => r.toLowerCase().trim() === 'partner'); + primaryRole = partnerMatch || resolvedRoles[0]; + mergedClaims.roles = resolvedRoles; } } if (resolvedRoles.length === 0 && primaryRole) { resolvedRoles.push(primaryRole); + mergedClaims.roles = resolvedRoles; } const allRoleClaims: Claim[] = []; diff --git a/PostFundManagement.PartnerUI/src/services/tenantLockoutService.ts b/PostFundManagement.PartnerUI/src/services/tenantLockoutService.ts index 9157c33..d4a08c9 100644 --- a/PostFundManagement.PartnerUI/src/services/tenantLockoutService.ts +++ b/PostFundManagement.PartnerUI/src/services/tenantLockoutService.ts @@ -1,6 +1,7 @@ -import { getTenant } from './api'; +import { getTenant, getTenants } from './api'; import { ActivityStatus, Tenant } from '../types'; import { setActivationPending } from './userSyncService'; +import { encodeHashIdSync, isAlreadyHashId } from './toolsService'; export interface TenantLockoutResult { isActive: boolean; @@ -58,30 +59,36 @@ export function getStatusLabel(status: any): string { return 'Not Specified'; } +function isValidHashableOrHashedTenantId(val: unknown): val is number | string { + if (val === undefined || val === null || val === '') return false; + if (typeof val === 'number') { + return !isNaN(val) && isFinite(val) && val > 0; + } + if (typeof val === 'string') { + 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( tenantId?: number | string | null, tenantClaim?: string | null, forceRefresh = false ): Promise { - const primaryId = - tenantId !== undefined && tenantId !== null && tenantId !== '' - ? tenantId - : tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== '' - ? tenantClaim - : null; - - 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, - }; + // Resolve a valid numeric ID or HashId to avoid passing plain-text tenant names to /v1/api/governance/tenants/{id} + let candidateId: number | string | null = null; + if (isValidHashableOrHashedTenantId(tenantId)) { + candidateId = tenantId; + } else if (isValidHashableOrHashedTenantId(tenantClaim)) { + candidateId = tenantClaim; } - const cacheKey = String(primaryId).trim().toLowerCase(); + const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase(); if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) { if ( cachedTenantLockout.result.isActive && @@ -94,23 +101,61 @@ export async function verifyTenantLockout( let tenantRes: Tenant | null = null; let lastError: any = null; - try { - tenantRes = await getTenant(primaryId); - } catch (err: any) { - lastError = err; - tenantRes = null; - } - - if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) { + // 1. If we have a valid numeric tenantId or HashId, encode it via HashIds and call GET /v1/api/governance/tenants/{hashedId} + if (candidateId !== null) { try { - tenantRes = await getTenant(tenantClaim); - lastError = null; + const hashedId = encodeHashIdSync(candidateId); + tenantRes = await getTenant(hashedId || candidateId); } catch (err: any) { lastError = err; tenantRes = null; } } + // 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)) { const serverMsg = lastError?.response?.data?.detail || @@ -140,7 +185,7 @@ export async function verifyTenantLockout( if (!active) { 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.`; setActivationPending(true, errorMsg); return { @@ -150,6 +195,8 @@ export async function verifyTenantLockout( }; } + setActivationPending(false); + const successResult: TenantLockoutResult = { isActive: true, tenant: tenantObj, @@ -161,4 +208,3 @@ export async function verifyTenantLockout( }; return successResult; } - diff --git a/PostFundManagement.PartnerUI/src/services/toolsService.ts b/PostFundManagement.PartnerUI/src/services/toolsService.ts index 3fcbc63..273b332 100644 --- a/PostFundManagement.PartnerUI/src/services/toolsService.ts +++ b/PostFundManagement.PartnerUI/src/services/toolsService.ts @@ -36,13 +36,20 @@ export function updateHasherSettings(salt?: string, minLength?: number | string) } } -function isAlreadyHashId(candidate: string): boolean { - if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) { +export function isAlreadyHashId(candidate: string): boolean { + if ( + typeof candidate !== 'string' || + candidate.length < HASHER_SETTINGS.minHashLength || + /^\d+$/.test(candidate) + ) { return false; } try { const decoded = hasher.decode(candidate); - return Array.isArray(decoded) && decoded.length > 0; + if (!Array.isArray(decoded) || decoded.length === 0) { + return false; + } + return hasher.encode(decoded as number[]) === candidate; } catch { return false; } diff --git a/PostFundManagement.PartnerUI/src/utils/hashids.ts b/PostFundManagement.PartnerUI/src/utils/hashids.ts index 2689f30..2535e0a 100644 --- a/PostFundManagement.PartnerUI/src/utils/hashids.ts +++ b/PostFundManagement.PartnerUI/src/utils/hashids.ts @@ -35,14 +35,31 @@ export function updateHasherSettings(salt?: string, minLength?: number | string) * Validates if candidate is already a HashID generated by this hasher. */ export function isHashId(candidate: string | null | undefined): boolean { - if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) { + if ( + !candidate || + typeof candidate !== 'string' || + candidate.length < HASHER_SETTINGS.minHashLength || + /^\d+$/.test(candidate) + ) { return false; } try { const decodedNumbers = hasher.decode(candidate); - if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true; + if ( + Array.isArray(decodedNumbers) && + decodedNumbers.length > 0 && + hasher.encode(decodedNumbers as number[]) === candidate + ) { + return true; + } const decodedHex = hasher.decodeHex(candidate); - if (decodedHex && decodedHex.length > 0) return true; + if ( + decodedHex && + decodedHex.length > 0 && + hasher.encodeHex(decodedHex) === candidate + ) { + return true; + } } catch { return false; }