Fixed sync handshake

This commit is contained in:
2026-10-23 10:19:30 +02:00
parent 8f58e15f0f
commit 3316fc0275
8 changed files with 593 additions and 226 deletions
@@ -9,6 +9,7 @@ import {
isUserSyncedInBrowser,
isActivationPending,
getActivationPendingReason,
setActivationPending,
clearUserSyncInBrowser,
UserSyncResult,
} from '../services/userSyncService';
@@ -16,7 +17,7 @@ import { AccountActivationModal } from '../components/auth/AccountActivationModa
import { NonPartnerAccessDeniedModal } from '../components/auth/NonPartnerAccessDeniedModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService';
import { verifyTenantLockout } from '../services/tenantLockoutService';
import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
import {
onSessionExpired,
triggerSessionExpired,
@@ -24,7 +25,7 @@ import {
cancelSessionExpiration,
dismissSubordinateModals,
} from '../services/sessionExpiration';
import { resolveUserRolesAndClaims } from '../services/roleClaimsCache';
import { resolveUserRolesAndClaims, clearCachedUserRbac } from '../services/roleClaimsCache';
export interface AuthUserProfile {
id: string;
@@ -431,9 +432,16 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token);
let tenantClaim =
(mergedClaims.tenant as string) ||
(oidcUser.profile?.tenant as string) ||
tenantClaimValues.tenant ||
undefined;
let parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId);
if (tenantClaim) {
setTenantName(tenantClaim);
}
const initialRoles = extractUserRoles(mergedClaims, null, token);
const initialPrimaryRole =
@@ -446,7 +454,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const profile: AuthUserProfile = {
id: userSub || 'user-1',
sub: userSub,
name: oidcUser.profile.name || oidcUser.profile.preferred_username || (mergedClaims.name as string) || 'Partner User',
name:
oidcUser.profile.name ||
oidcUser.profile.preferred_username ||
(mergedClaims.name as string) ||
'Partner User',
email: oidcUser.profile.email || (mergedClaims.email as string) || '',
role: initialPrimaryRole,
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
@@ -459,27 +471,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
window.history.replaceState({}, document.title, window.location.pathname);
const tenantLockoutResult = await verifyTenantLockout(
parsedTenantId ?? tenantClaimValues.tenantId,
tenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
profile.tenant = tenantLockoutResult.tenant.name;
setTenantName(tenantLockoutResult.tenant.name);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
} else if (tenantClaim) {
setTenantName(tenantClaim);
}
// Step 1: Sync user account FIRST (/v1/api/integration/sync) before calling any ActiveUserFilter-protected endpoints
try {
const syncResult = await syncUserAccount(userSub, token, profile, true);
if (syncResult.isUnauthorized) {
@@ -487,34 +479,73 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
window.location.replace('/');
return;
}
// Step 2: Resolve user roles & claims from /v1/api/users/{userSub}/roles and /claims
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
tenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
parsedTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...profile });
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
} else if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...profile });
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
setIsLoading(false);
return;
}
// Step 3: Verify tenant lockout using the HashId-encoded tenant ID
const effectiveTenantId =
parsedTenantId ??
syncResult.data?.tenantId ??
syncResult.data?.organisationId ??
tenantClaimValues.tenantId;
const tenantLockoutResult = await verifyTenantLockout(effectiveTenantId, tenantClaim);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
profile.tenant = tenantLockoutResult.tenant.name;
setTenantName(tenantLockoutResult.tenant.name);
}
if (tenantLockoutResult.tenant?.id !== undefined && !profile.tenant_id) {
const numericTid = Number(tenantLockoutResult.tenant.id);
if (!isNaN(numericTid) && numericTid > 0) {
profile.tenant_id = numericTid;
setTenantId(numericTid);
}
}
setUser({ ...profile });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
setActivationPending(false);
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
@@ -558,10 +589,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub);
}
const storedTenantId = extractTenantId(parsedClaims, storedToken);
let storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId);
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
let storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
let resolvedUser: AuthUserProfile | null = null;
if (storedUser) {
@@ -582,13 +613,73 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setTenantName(storedTenantClaim);
}
setIsLoading(false);
const hasInitialPartnerRole = hasPartnerRole(extractUserRoles(parsedClaims, resolvedUser, storedToken));
const hadPendingActivation = isActivationPending();
(async () => {
// If the user already has the Partner role and is not pending activation, unblock UI immediately and verify in background
if (hasInitialPartnerRole && !hadPendingActivation) {
setIsLoading(false);
}
const verifyPersistedSession = async () => {
try {
// Step 1: Sync user account FIRST (/v1/api/integration/sync)
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
hadPendingActivation
);
if (syncResult.isUnauthorized) {
await logout();
return;
}
// Step 2: Resolve user roles & claims
try {
const resolved = await resolveUserRolesAndClaims(
storedSub || '',
parsedClaims,
resolvedUser || {}
);
if (resolvedUser) {
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
if (resolved.tenant) {
resolvedUser.tenant = resolved.tenant;
storedTenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
resolvedUser.tenant_id = resolved.tenant_id;
storedTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
return;
}
// Step 3: Verify tenant lockout using HashId-encoded tenant ID
const effectiveTenantId =
storedTenantId ??
syncResult.data?.tenantId ??
syncResult.data?.organisationId ??
tenantClaimValues.tenantId;
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
effectiveTenantId,
storedTenantClaim,
hadPendingActivation
);
if (!tenantLockoutResult.isActive) {
@@ -601,61 +692,25 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (tenantLockoutResult.tenant?.name) {
if (resolvedUser) {
resolvedUser.tenant = tenantLockoutResult.tenant.name;
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setTenantName(tenantLockoutResult.tenant.name);
}
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
if (syncResult.isUnauthorized) {
await logout();
return;
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
return;
}
if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
const resolved = await resolveUserRolesAndClaims(
storedSub || '',
parsedClaims,
resolvedUser || {}
);
if (resolvedUser) {
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
if (resolved.tenant) {
resolvedUser.tenant = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
resolvedUser.tenant_id = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
if (tenantLockoutResult.tenant?.id !== undefined && resolvedUser && !resolvedUser.tenant_id) {
const numericTid = Number(tenantLockoutResult.tenant.id);
if (!isNaN(numericTid) && numericTid > 0) {
resolvedUser.tenant_id = numericTid;
setTenantId(numericTid);
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
}
if (resolvedUser) {
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setActivationPending(false);
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
@@ -664,8 +719,16 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
} finally {
setIsLoading(false);
}
})();
};
if (hasInitialPartnerRole && !hadPendingActivation) {
verifyPersistedSession();
} else {
await verifyPersistedSession();
}
return;
}
@@ -682,11 +745,68 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
try {
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
await loadRuntimeConfig();
clearTenantLockoutCache();
if (sub) {
clearCachedUserRbac(sub);
}
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim);
// Step 1: Sync user account FIRST (/v1/api/integration/sync)
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
return result;
}
if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
return result;
}
// Step 2: Refresh user roles & claims from backend
let currentTenantId: number | string | null = tenantId;
let currentTenantClaim: string | undefined =
(claims.tenant as string) || user?.tenant || undefined;
try {
const resolved = await resolveUserRolesAndClaims(sub, claims, user || {});
const updatedProfile = user ? { ...user } : null;
if (updatedProfile && resolved.primaryRole) {
updatedProfile.role = resolved.primaryRole;
}
if (resolved.tenant) {
currentTenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
if (updatedProfile) updatedProfile.tenant = resolved.tenant;
}
if (resolved.tenant_id !== undefined) {
currentTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
if (updatedProfile) updatedProfile.tenant_id = resolved.tenant_id;
}
if (updatedProfile) {
setUser(updatedProfile);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(updatedProfile));
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
const effectiveTenantId =
currentTenantId ??
result.data?.tenantId ??
result.data?.organisationId ??
tenantClaimValues.tenantId;
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
// Step 3: Verify tenant lockout using HashId-encoded tenant ID
const tenantLockoutResult = await verifyTenantLockout(
effectiveTenantId,
effectiveTenantClaim,
true
);
if (!tenantLockoutResult.isActive) {
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
setIsPendingActivation(true);
@@ -699,20 +819,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
};
}
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
return result;
if (tenantLockoutResult.tenant?.name) {
setTenantName(tenantLockoutResult.tenant.name);
}
if (result.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
} else if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
if (tenantLockoutResult.tenant?.id !== undefined && !tenantId) {
const numericTid = Number(tenantLockoutResult.tenant.id);
if (!isNaN(numericTid) && numericTid > 0) {
setTenantId(numericTid);
}
}
setActivationPending(false);
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
return result;
} catch (err: any) {
if (err?.response?.status === 401) {
@@ -753,6 +873,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
clearUserSyncInBrowser();
clearTenantLockoutCache();
setUser(null);
setSub('');
setClaims({});