Fixed sync handshake #161
@@ -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({});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user