Merge pull request 'Fixed sync handshake' (#161) from finishline into main

Reviewed-on: #161
This commit was merged in pull request #161.
This commit is contained in:
2026-10-23 10:20:48 +02:00
8 changed files with 593 additions and 226 deletions
+11 -10
View File
@@ -1,16 +1,17 @@
{ {
"ApiSettings": { "_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.",
"BaseUrl": "https://api.ifri.khongisa.co.za/"
},
"SecuritySettings": { "SecuritySettings": {
"Authority": "https://sts.security.khongisa.co.za", "Authority": "",
"Audience": "pfm-api-dev", "Audience": "",
"ClientId": "pfm-scalar-dev", "ClientId": "",
"ClientSecret": "secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27", "ClientSecret": "",
"Scope": "openid profile roles email address pfm-api-dev" "Scope": ""
},
"ApiSettings": {
"BaseUrl": ""
}, },
"HasherSettings": { "HasherSettings": {
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==", "Salt": "",
"MinHashLength": 11 "MinHashLength": ""
} }
} }
@@ -9,6 +9,7 @@ import {
isUserSyncedInBrowser, isUserSyncedInBrowser,
isActivationPending, isActivationPending,
getActivationPendingReason, getActivationPendingReason,
setActivationPending,
clearUserSyncInBrowser, clearUserSyncInBrowser,
UserSyncResult, UserSyncResult,
} from '../services/userSyncService'; } from '../services/userSyncService';
@@ -16,7 +17,7 @@ import { AccountActivationModal } from '../components/auth/AccountActivationModa
import { NonPartnerAccessDeniedModal } from '../components/auth/NonPartnerAccessDeniedModal'; import { NonPartnerAccessDeniedModal } from '../components/auth/NonPartnerAccessDeniedModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService'; import { encodeHashIdSync } from '../services/toolsService';
import { verifyTenantLockout } from '../services/tenantLockoutService'; import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
import { import {
onSessionExpired, onSessionExpired,
triggerSessionExpired, triggerSessionExpired,
@@ -24,7 +25,7 @@ import {
cancelSessionExpiration, cancelSessionExpiration,
dismissSubordinateModals, dismissSubordinateModals,
} from '../services/sessionExpiration'; } from '../services/sessionExpiration';
import { resolveUserRolesAndClaims } from '../services/roleClaimsCache'; import { resolveUserRolesAndClaims, clearCachedUserRbac } from '../services/roleClaimsCache';
export interface AuthUserProfile { export interface AuthUserProfile {
id: string; id: string;
@@ -431,9 +432,16 @@ 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 =
const parsedTenantId = extractTenantId(mergedClaims, token); (mergedClaims.tenant as string) ||
(oidcUser.profile?.tenant as string) ||
tenantClaimValues.tenant ||
undefined;
let parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId); setTenantId(parsedTenantId);
if (tenantClaim) {
setTenantName(tenantClaim);
}
const initialRoles = extractUserRoles(mergedClaims, null, token); const initialRoles = extractUserRoles(mergedClaims, null, token);
const initialPrimaryRole = const initialPrimaryRole =
@@ -446,7 +454,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const profile: AuthUserProfile = { const profile: AuthUserProfile = {
id: userSub || 'user-1', id: userSub || 'user-1',
sub: userSub, 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) || '', email: oidcUser.profile.email || (mergedClaims.email as string) || '',
role: initialPrimaryRole, role: initialPrimaryRole,
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined, organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
@@ -459,10 +471,53 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
const tenantLockoutResult = await verifyTenantLockout( // Step 1: Sync user account FIRST (/v1/api/integration/sync) before calling any ActiveUserFilter-protected endpoints
parsedTenantId ?? tenantClaimValues.tenantId, try {
tenantClaim const syncResult = await syncUserAccount(userSub, token, profile, true);
); if (syncResult.isUnauthorized) {
await logout();
window.location.replace('/');
return;
}
// Step 2: Resolve user roles & claims from /v1/api/users/{userSub}/roles and /claims
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
tenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
parsedTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...profile });
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
setIsLoading(false);
return;
}
// 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) { if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true); setIsPendingActivation(true);
@@ -475,46 +530,22 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (tenantLockoutResult.tenant?.name) { if (tenantLockoutResult.tenant?.name) {
profile.tenant = tenantLockoutResult.tenant.name; profile.tenant = tenantLockoutResult.tenant.name;
setTenantName(tenantLockoutResult.tenant.name); setTenantName(tenantLockoutResult.tenant.name);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); }
} else if (tenantClaim) { if (tenantLockoutResult.tenant?.id !== undefined && !profile.tenant_id) {
setTenantName(tenantClaim); const numericTid = Number(tenantLockoutResult.tenant.id);
if (!isNaN(numericTid) && numericTid > 0) {
profile.tenant_id = numericTid;
setTenantId(numericTid);
}
} }
try { setUser({ ...profile });
const syncResult = await syncUserAccount(userSub, token, profile, true); localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
if (syncResult.isUnauthorized) {
await logout(); setActivationPending(false);
window.location.replace('/');
return;
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
} else if (syncResult.success) {
setIsPendingActivation(false); setIsPendingActivation(false);
setIsSynced(true); setIsSynced(true);
setActivationError(null); 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 {}
}
} catch (syncErr: any) { } catch (syncErr: any) {
if (syncErr?.response?.status === 401) { if (syncErr?.response?.status === 401) {
await logout(); await logout();
@@ -558,10 +589,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) {
@@ -582,13 +613,73 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setTenantName(storedTenantClaim); 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 { 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( const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId, effectiveTenantId,
storedTenantClaim storedTenantClaim,
hadPendingActivation
); );
if (!tenantLockoutResult.isActive) { if (!tenantLockoutResult.isActive) {
@@ -601,61 +692,25 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (tenantLockoutResult.tenant?.name) { if (tenantLockoutResult.tenant?.name) {
if (resolvedUser) { if (resolvedUser) {
resolvedUser.tenant = tenantLockoutResult.tenant.name; resolvedUser.tenant = tenantLockoutResult.tenant.name;
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
} }
setTenantName(tenantLockoutResult.tenant.name); setTenantName(tenantLockoutResult.tenant.name);
} }
if (tenantLockoutResult.tenant?.id !== undefined && resolvedUser && !resolvedUser.tenant_id) {
const wasPending = isActivationPending(); const numericTid = Number(tenantLockoutResult.tenant.id);
setIsPendingActivation(wasPending); if (!isNaN(numericTid) && numericTid > 0) {
setActivationError(getActivationPendingReason()); resolvedUser.tenant_id = numericTid;
setTenantId(numericTid);
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 (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 }); setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
} }
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); setActivationPending(false);
} setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
} catch (syncErr: any) { } catch (syncErr: any) {
if (syncErr?.response?.status === 401) { if (syncErr?.response?.status === 401) {
await logout(); await logout();
@@ -664,8 +719,16 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setIsPendingActivation(true); setIsPendingActivation(true);
setIsSynced(false); setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending'); setActivationError(syncErr?.message || 'Account activation pending');
} finally {
setIsLoading(false);
}
};
if (hasInitialPartnerRole && !hadPendingActivation) {
verifyPersistedSession();
} else {
await verifyPersistedSession();
} }
})();
return; return;
} }
@@ -682,11 +745,68 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const syncUserSession = async (force = true): Promise<UserSyncResult> => { const syncUserSession = async (force = true): Promise<UserSyncResult> => {
try { try {
const tenantClaimValues = extractTenantClaimValues(claims, accessToken); await loadRuntimeConfig();
const currentTenantId = tenantId ?? tenantClaimValues.tenantId; clearTenantLockoutCache();
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined; 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) { 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);
@@ -699,20 +819,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}; };
} }
const result = await syncUserAccount(sub, accessToken, user || undefined, force); if (tenantLockoutResult.tenant?.name) {
if (result.isUnauthorized) { setTenantName(tenantLockoutResult.tenant.name);
await logout();
return result;
} }
if (result.success) { if (tenantLockoutResult.tenant?.id !== undefined && !tenantId) {
const numericTid = Number(tenantLockoutResult.tenant.id);
if (!isNaN(numericTid) && numericTid > 0) {
setTenantId(numericTid);
}
}
setActivationPending(false);
setIsPendingActivation(false); setIsPendingActivation(false);
setIsSynced(true); setIsSynced(true);
setActivationError(null); setActivationError(null);
} else if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
}
return result; return result;
} catch (err: any) { } catch (err: any) {
if (err?.response?.status === 401) { 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_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY); localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
clearUserSyncInBrowser(); clearUserSyncInBrowser();
clearTenantLockoutCache();
setUser(null); setUser(null);
setSub(''); setSub('');
setClaims({}); setClaims({});
+202 -35
View File
@@ -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 { export interface SecuritySettings {
Authority: string; Authority: string;
@@ -30,8 +31,8 @@ export interface AppConfig {
export const defaultSecuritySettings: SecuritySettings = { export const defaultSecuritySettings: SecuritySettings = {
Authority: 'https://sts.security.khongisa.co.za', Authority: 'https://sts.security.khongisa.co.za',
Audience: 'pfm-api-dev', Audience: 'pfm-api-dev',
ClientId: 'pfm-scalar-dev', ClientId: 'pfm-console-dev',
ClientSecret: 'secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27', ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
Scope: 'openid profile roles email address pfm-api-dev', Scope: 'openid profile roles email address pfm-api-dev',
}; };
@@ -44,11 +45,18 @@ export const defaultHasherSettings: HasherSettings = {
MinHashLength: 11, MinHashLength: 11,
}; };
function syncAllHasherSettings(salt: string, minHashLength: number): void {
updateToolsHasherSettings(salt, minHashLength);
updateUtilsHasherSettings(salt, minHashLength);
}
function pickFirstNonEmpty(...candidates: any[]): string | undefined { function pickFirstNonEmpty(...candidates: any[]): string | undefined {
for (const c of candidates) { for (const c of candidates) {
if (c !== undefined && c !== null) { if (c !== undefined && c !== null) {
const s = String(c).trim(); const s = String(c).trim();
if (s !== '' && s !== 'undefined' && s !== 'null') return s; if (s !== '' && s !== 'undefined' && s !== 'null') {
return s;
}
} }
} }
return undefined; return undefined;
@@ -56,13 +64,17 @@ function pickFirstNonEmpty(...candidates: any[]): string | undefined {
function normalizeBaseUrl(url: string): string { function normalizeBaseUrl(url: string): string {
let trimmed = url.trim(); 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}/`; return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
} }
function normalizeAuthorityUrl(url: string): string { function normalizeAuthorityUrl(url: string): string {
let trimmed = url.trim(); let trimmed = url.trim();
if (!/^https?:\/\//i.test(trimmed)) trimmed = `https://${trimmed}`; if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `https://${trimmed}`;
}
return trimmed.replace(/\/+$/, ''); return trimmed.replace(/\/+$/, '');
} }
@@ -72,44 +84,178 @@ function getWindowRuntimeConfig(): Record<string, any> {
return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {}; return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {};
} }
function extractSourceConfig(src: Record<string, any>) { function extractSourceConfig(src: Record<string, any>): {
authority?: string;
audience?: string;
clientId?: string;
clientSecret?: string;
scope?: string;
baseUrl?: string;
hasherSalt?: string;
hasherMinLength?: number;
} {
if (!src || typeof src !== 'object') return {}; 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 { return {
authority: pickFirstNonEmpty(src.VITE_OIDC_AUTHORITY, src.OIDC_AUTHORITY, src.SecuritySettings?.Authority, src.SecuritySettings__Authority), authority: pickFirstNonEmpty(
audience: pickFirstNonEmpty(src.VITE_OIDC_AUDIENCE, src.OIDC_AUDIENCE, src.SecuritySettings?.Audience, src.SecuritySettings__Audience), src.VITE_OIDC_AUTHORITY,
clientId: pickFirstNonEmpty(src.VITE_OIDC_CLIENT_ID, src.OIDC_CLIENT_ID, src.SecuritySettings?.ClientId, src.SecuritySettings__ClientId), src.OIDC_AUTHORITY,
clientSecret: pickFirstNonEmpty(src.VITE_OIDC_CLIENT_SECRET, src.OIDC_CLIENT_SECRET, src.SecuritySettings?.ClientSecret, src.SecuritySettings__ClientSecret), src.SecuritySettings__Authority,
scope: pickFirstNonEmpty(src.VITE_OIDC_SCOPE, src.OIDC_SCOPE, src.SecuritySettings?.Scope, src.SecuritySettings__Scope), sec.Authority,
baseUrl: pickFirstNonEmpty(src.VITE_API_BASE_URL, src.API_BASE_URL, src.ApiSettings?.BaseUrl, src.ApiSettings__BaseUrl), sec.authority,
hasherSalt: pickFirstNonEmpty(src.VITE_HASHER_SALT, src.HASHER_SALT, src.HasherSettings?.Salt, src.HasherSettings__Salt), src.Authority,
hasherMinLength: Number(pickFirstNonEmpty(src.VITE_HASHER_MIN_LENGTH, src.HASHER_MIN_LENGTH, src.HasherSettings?.MinHashLength)) || undefined, 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<string, any>): AppConfig { function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
const winCfg = extractSourceConfig(getWindowRuntimeConfig()); const winCfg = extractSourceConfig(getWindowRuntimeConfig());
const fetchCfg = fetchedJson ? extractSourceConfig(fetchedJson) : {}; const fetchCfg = fetchedJson ? extractSourceConfig(fetchedJson) : {};
const envCfg = extractSourceConfig((typeof import.meta !== 'undefined' && (import.meta as any).env ? (import.meta as any).env : {}) as Record<string, any>); const envCfg = extractSourceConfig(
(typeof import.meta !== 'undefined' && (import.meta as any).env
? (import.meta as any).env
: {}) as Record<string, any>
);
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'; let source: ConfigSource = 'fallback-defaults';
if (Object.values(winCfg).some((v) => v !== undefined)) source = 'runtime-injected'; if (hasWin) source = 'runtime-injected';
else if (Object.values(fetchCfg).some((v) => v !== undefined)) source = 'runtime-fetched'; else if (hasFetch) source = 'runtime-fetched';
else if (Object.values(envCfg).some((v) => v !== undefined)) source = 'build-env'; 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 { return {
SecuritySettings: { SecuritySettings: {
Authority: normalizeAuthorityUrl(pickFirstNonEmpty(winCfg.authority, fetchCfg.authority, envCfg.authority, defaultSecuritySettings.Authority)!), Authority: authority,
Audience: pickFirstNonEmpty(winCfg.audience, fetchCfg.audience, envCfg.audience, defaultSecuritySettings.Audience)!, Audience: audience,
ClientId: pickFirstNonEmpty(winCfg.clientId, fetchCfg.clientId, envCfg.clientId, defaultSecuritySettings.ClientId)!, ClientId: clientId,
ClientSecret: pickFirstNonEmpty(winCfg.clientSecret, fetchCfg.clientSecret, envCfg.clientSecret, defaultSecuritySettings.ClientSecret)!, ClientSecret: clientSecret,
Scope: pickFirstNonEmpty(winCfg.scope, fetchCfg.scope, envCfg.scope, defaultSecuritySettings.Scope), Scope: scope,
}, },
ApiSettings: { ApiSettings: {
BaseUrl: normalizeBaseUrl(pickFirstNonEmpty(winCfg.baseUrl, fetchCfg.baseUrl, envCfg.baseUrl, defaultApiSettings.BaseUrl)!), BaseUrl: baseUrl,
}, },
HasherSettings: { HasherSettings: {
Salt: pickFirstNonEmpty(winCfg.hasherSalt, fetchCfg.hasherSalt, envCfg.hasherSalt, defaultHasherSettings.Salt)!, Salt: hasherSalt,
MinHashLength: winCfg.hasherMinLength || fetchCfg.hasherMinLength || envCfg.hasherMinLength || defaultHasherSettings.MinHashLength, MinHashLength: hasherMinLength,
}, },
ConfigSource: source, ConfigSource: source,
LoadedAt: new Date().toISOString(), LoadedAt: new Date().toISOString(),
@@ -117,7 +263,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
} }
let runtimeConfig: AppConfig = resolveConfig(); let runtimeConfig: AppConfig = resolveConfig();
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> = [];
@@ -129,28 +275,49 @@ export function onConfigChanged(listener: (config: AppConfig) => void): () => vo
}; };
} }
export async function loadRuntimeConfig(): Promise<AppConfig> { let loadPromise: Promise<AppConfig> | null = null;
export async function loadRuntimeConfig(forceReload = false): Promise<AppConfig> {
if (loadPromise && !forceReload) {
return loadPromise;
}
loadPromise = (async () => {
let fetchedJson: Record<string, any> | undefined; let fetchedJson: Record<string, any> | undefined;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
const res = await fetch(`/config.json?_t=${Date.now()}`, { const res = await fetch(`/config.json?_t=${Date.now()}`, {
cache: 'no-store', cache: 'no-store',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}).catch(() => null); }).catch(() => null);
if (res && res.ok) { 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); fetchedJson = await res.json().catch(() => undefined);
} else {
const text = await res.text().catch(() => '');
if (text.trim().startsWith('{')) {
fetchedJson = JSON.parse(text);
}
}
} }
} catch { } catch {
// Safe fallback // Fallback if /config.json is unreachable
} }
} }
runtimeConfig = resolveConfig(fetchedJson); runtimeConfig = resolveConfig(fetchedJson);
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
configListeners.forEach((fn) => fn(runtimeConfig)); configListeners.forEach((fn) => fn(runtimeConfig));
return runtimeConfig; return runtimeConfig;
})();
return loadPromise;
} }
export const getAppConfig = () => runtimeConfig; export const getAppConfig = (): AppConfig => runtimeConfig;
export const getSecuritySettings = () => runtimeConfig.SecuritySettings; export const getSecuritySettings = (): SecuritySettings => runtimeConfig.SecuritySettings;
export const getApiBaseUrl = () => runtimeConfig.ApiSettings.BaseUrl; export const getApiBaseUrl = (): string => runtimeConfig.ApiSettings.BaseUrl;
export const getHasherSettings = () => runtimeConfig.HasherSettings; export const getHasherSettings = (): HasherSettings => runtimeConfig.HasherSettings;
+9 -4
View File
@@ -1,10 +1,15 @@
import {StrictMode} from 'react'; import { StrictMode } from 'react';
import {createRoot} from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.tsx'; import App from './App.tsx';
import './index.css'; 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(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
); );
});
@@ -343,12 +343,15 @@ export async function resolveUserRolesAndClaims(
} }
}); });
if (resolvedRoles.length > 0) { 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) { if (resolvedRoles.length === 0 && primaryRole) {
resolvedRoles.push(primaryRole); resolvedRoles.push(primaryRole);
mergedClaims.roles = resolvedRoles;
} }
const allRoleClaims: Claim[] = []; const allRoleClaims: Claim[] = [];
@@ -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 './toolsService';
export interface TenantLockoutResult { export interface TenantLockoutResult {
isActive: boolean; isActive: boolean;
@@ -58,30 +59,36 @@ export function getStatusLabel(status: any): string {
return 'Not Specified'; 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( 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 &&
@@ -94,19 +101,57 @@ export async function verifyTenantLockout(
let tenantRes: Tenant | null = null; let tenantRes: Tenant | null = null;
let lastError: any = null; let lastError: any = null;
// 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 { try {
tenantRes = await getTenant(primaryId); const hashedId = encodeHashIdSync(candidateId);
tenantRes = await getTenant(hashedId || candidateId);
} catch (err: any) { } catch (err: any) {
lastError = err; lastError = err;
tenantRes = null; tenantRes = null;
} }
}
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) { // 2. If tenant lookup by ID wasn't possible or didn't resolve, resolve via getTenants() (matching tenantClaim name if provided)
if (!tenantRes) {
try { try {
tenantRes = await getTenant(tenantClaim); 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; lastError = null;
} catch (err: any) { } catch (err: any) {
lastError = err; if (!lastError) lastError = err;
tenantRes = null; tenantRes = null;
} }
} }
@@ -140,7 +185,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 {
@@ -150,6 +195,8 @@ export async function verifyTenantLockout(
}; };
} }
setActivationPending(false);
const successResult: TenantLockoutResult = { const successResult: TenantLockoutResult = {
isActive: true, isActive: true,
tenant: tenantObj, tenant: tenantObj,
@@ -161,4 +208,3 @@ export async function verifyTenantLockout(
}; };
return successResult; return successResult;
} }
@@ -36,13 +36,20 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
} }
} }
function isAlreadyHashId(candidate: string): boolean { export function isAlreadyHashId(candidate: string): boolean {
if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) { if (
typeof candidate !== 'string' ||
candidate.length < HASHER_SETTINGS.minHashLength ||
/^\d+$/.test(candidate)
) {
return false; return false;
} }
try { try {
const decoded = hasher.decode(candidate); 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 { } catch {
return false; return false;
} }
@@ -35,14 +35,31 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
* Validates if candidate is already a HashID generated by this hasher. * Validates if candidate is already a HashID generated by this hasher.
*/ */
export function isHashId(candidate: string | null | undefined): boolean { 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; return false;
} }
try { try {
const decodedNumbers = hasher.decode(candidate); 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); const decodedHex = hasher.decodeHex(candidate);
if (decodedHex && decodedHex.length > 0) return true; if (
decodedHex &&
decodedHex.length > 0 &&
hasher.encodeHex(decodedHex) === candidate
) {
return true;
}
} catch { } catch {
return false; return false;
} }