364 lines
10 KiB
TypeScript
364 lines
10 KiB
TypeScript
import { apiClient } from './httpClient';
|
|
|
|
export const LOCAL_STORAGE_USER_SYNC_KEY = 'pfm_user_sync_record';
|
|
export const LOCAL_STORAGE_ACTIVATION_PENDING_KEY = 'pfm_user_pending_activation';
|
|
export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_reason';
|
|
|
|
export interface UserSyncRecord {
|
|
sub: string;
|
|
syncedAt: number;
|
|
syncedEndpoint: string;
|
|
tokenSnippet: string;
|
|
status: 'synced';
|
|
userData?: any;
|
|
}
|
|
|
|
export interface UserSyncProfile {
|
|
id?: string;
|
|
sub?: string;
|
|
email?: string | null;
|
|
name?: string | null;
|
|
role?: string | null;
|
|
tenant?: string | null;
|
|
tenant_id?: number | string | null;
|
|
organisationId?: number | string | null;
|
|
organisationName?: string | null;
|
|
}
|
|
|
|
export interface UserSyncResult {
|
|
success: boolean;
|
|
isUnauthorized?: boolean;
|
|
isActivationPending?: boolean;
|
|
endpoint?: string;
|
|
data?: any;
|
|
errorMessage?: string;
|
|
}
|
|
|
|
let inFlightSyncPromise: Promise<UserSyncResult> | null = null;
|
|
|
|
/**
|
|
* Checks if the user is currently flagged in the browser as pending administrator activation / lockout.
|
|
*/
|
|
export function isActivationPending(): boolean {
|
|
try {
|
|
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the reason/error message recorded when activation check was last pending.
|
|
*/
|
|
export function getActivationPendingReason(): string | null {
|
|
try {
|
|
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets or clears the pending activation flag in browser persistent storage.
|
|
*/
|
|
export function setActivationPending(pending: boolean, reason?: string): void {
|
|
try {
|
|
if (pending) {
|
|
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
|
|
if (reason) {
|
|
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason);
|
|
}
|
|
} else {
|
|
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
|
|
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
|
}
|
|
} catch (err) {
|
|
console.warn('[UserSync] Failed to update activation flag:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the current authenticated user has already been synchronized
|
|
* and stored in the browser's persistent storage.
|
|
*
|
|
* This prevents subsequent API calls, page refreshes, and navigations
|
|
* from creating a hotspot on the backend sync user endpoint.
|
|
*/
|
|
export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string | null): boolean {
|
|
try {
|
|
// If flagged as pending activation, they are NOT considered synced
|
|
if (isActivationPending()) {
|
|
return false;
|
|
}
|
|
|
|
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
|
if (!raw) return false;
|
|
|
|
const record: UserSyncRecord = JSON.parse(raw);
|
|
if (!record || record.status !== 'synced') return false;
|
|
|
|
// Check if the stored sync matches the current user sub (if specified)
|
|
if (currentSub && record.sub && record.sub !== currentSub) {
|
|
return false;
|
|
}
|
|
|
|
// Check if the token snippet matches (if token changed, e.g. new session login)
|
|
if (currentToken && record.tokenSnippet) {
|
|
const snippet = currentToken.slice(-16);
|
|
if (record.tokenSnippet !== snippet) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persists the user sync record into browser storage.
|
|
*/
|
|
export function saveUserSyncInBrowser(
|
|
sub: string,
|
|
token: string | null | undefined,
|
|
endpoint: string,
|
|
userData?: any
|
|
): void {
|
|
try {
|
|
const record: UserSyncRecord = {
|
|
sub: sub || 'authenticated_user',
|
|
syncedAt: Date.now(),
|
|
syncedEndpoint: endpoint,
|
|
tokenSnippet: token ? token.slice(-16) : '',
|
|
status: 'synced',
|
|
userData: userData || undefined,
|
|
};
|
|
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
|
|
} catch (err) {
|
|
console.warn('[UserSync] Failed to write sync record to localStorage:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears the user sync record from browser storage (used on logout or when activation fails).
|
|
*/
|
|
export function clearUserSyncInBrowser(): void {
|
|
try {
|
|
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
|
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
|
|
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Clears only the successful sync record, leaving activation pending intact.
|
|
*/
|
|
export function clearSuccessfulSyncRecord(): void {
|
|
try {
|
|
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Retrieves the current sync record from browser storage if present.
|
|
*/
|
|
export function getUserSyncRecord(): UserSyncRecord | null {
|
|
try {
|
|
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw) as UserSyncRecord;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const USER_SYNC_CANDIDATE_ENDPOINTS = [
|
|
'/v1/api/integration/sync',
|
|
'/v1/api/users/sync',
|
|
'/v1/api/user/sync',
|
|
];
|
|
|
|
/**
|
|
* Determines whether a given URL or request path targets a user sync endpoint.
|
|
*/
|
|
export function isUserSyncEndpoint(url?: string): boolean {
|
|
if (!url) return false;
|
|
const clean = url.toLowerCase().split('?')[0];
|
|
return (
|
|
clean.includes('/integration/sync') ||
|
|
clean.includes('/users/sync') ||
|
|
clean.includes('/user/sync') ||
|
|
clean.endsWith('/sync') ||
|
|
clean === '/sync'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Executes the backend user sync request across available candidate endpoints.
|
|
*/
|
|
async function executeBackendSync(
|
|
userSub?: string,
|
|
profile?: UserSyncProfile
|
|
): Promise<{ endpoint: string; data?: any }> {
|
|
const rawTenantId = profile?.tenant_id ?? (profile as any)?.tenantId;
|
|
const parsedTenantId =
|
|
rawTenantId !== undefined && rawTenantId !== null && rawTenantId !== ''
|
|
? Number(rawTenantId)
|
|
: undefined;
|
|
|
|
const resolvedOrgId =
|
|
parsedTenantId !== undefined
|
|
? parsedTenantId
|
|
: profile?.organisationId !== undefined && profile?.organisationId !== null && profile?.organisationId !== ''
|
|
? Number(profile.organisationId)
|
|
: undefined;
|
|
|
|
const payload = {
|
|
id: userSub || profile?.id || profile?.sub,
|
|
sub: userSub || profile?.sub,
|
|
email: profile?.email || undefined,
|
|
name: profile?.name || undefined,
|
|
role: profile?.role || undefined,
|
|
tenant: profile?.tenant || undefined,
|
|
tenant_id: parsedTenantId,
|
|
organisationId: resolvedOrgId,
|
|
};
|
|
|
|
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
|
|
|
|
let lastError: any = null;
|
|
|
|
for (const endpoint of candidateEndpoints) {
|
|
try {
|
|
let res;
|
|
if (endpoint === '/v1/api/integration/sync') {
|
|
try {
|
|
res = await apiClient.post(endpoint, payload);
|
|
} catch (postErr: any) {
|
|
const errStatus = postErr?.response?.status;
|
|
if (errStatus === 400 || errStatus === 415 || errStatus === 422) {
|
|
res = await apiClient.post(endpoint, {});
|
|
} else {
|
|
throw postErr;
|
|
}
|
|
}
|
|
} else {
|
|
res = await apiClient.post(endpoint, payload);
|
|
}
|
|
return { endpoint, data: res?.data };
|
|
} catch (err: any) {
|
|
lastError = err;
|
|
const status = err?.response?.status;
|
|
|
|
// Fail fast on token expiration/invalid
|
|
if (status === 401) {
|
|
throw err;
|
|
}
|
|
|
|
// User blocked or pending activation
|
|
if (status === 403 || status === 423) {
|
|
throw err;
|
|
}
|
|
|
|
// If 404 or 405, continue to next fallback candidate
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Failed to reach user sync endpoint');
|
|
}
|
|
|
|
/**
|
|
* Synchronizes the user account with the backend system.
|
|
* Handles:
|
|
* - HTTP 401 -> Returns isUnauthorized: true (triggering redirection to landing page)
|
|
* - HTTP 403 / failure -> Flags isActivationPending: true (triggering non-dismissable activation modal)
|
|
* - Success -> Caches sync record in browser, clears pending activation flag
|
|
*/
|
|
export async function syncUserAccount(
|
|
userSub?: string,
|
|
token?: string | null,
|
|
profile?: UserSyncProfile,
|
|
force = false
|
|
): Promise<UserSyncResult> {
|
|
const effectiveToken = token || (typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null);
|
|
const effectiveSub = userSub || (typeof window !== 'undefined' ? localStorage.getItem('pfm_user_sub') || '' : '');
|
|
|
|
// If already synced in the browser, no pending activation, and not forced, return immediately.
|
|
// Avoids hotspotting the sync endpoint.
|
|
if (!force && isUserSyncedInBrowser(effectiveSub, effectiveToken) && !isActivationPending()) {
|
|
return {
|
|
success: true,
|
|
endpoint: getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync',
|
|
data: getUserSyncRecord()?.userData,
|
|
};
|
|
}
|
|
|
|
// Deduplicate concurrent sync calls
|
|
if (inFlightSyncPromise) {
|
|
return inFlightSyncPromise;
|
|
}
|
|
|
|
inFlightSyncPromise = (async (): Promise<UserSyncResult> => {
|
|
try {
|
|
const result = await executeBackendSync(effectiveSub, profile);
|
|
// Success! Clear any activation lockout, save valid sync record to browser
|
|
setActivationPending(false);
|
|
saveUserSyncInBrowser(effectiveSub, effectiveToken, result.endpoint, result.data);
|
|
return {
|
|
success: true,
|
|
endpoint: result.endpoint,
|
|
data: result.data,
|
|
};
|
|
} catch (err: any) {
|
|
const status = err?.response?.status;
|
|
|
|
// Handle HTTP 401: Normal process of routing user to landing page
|
|
if (status === 401) {
|
|
clearUserSyncInBrowser();
|
|
setActivationPending(false);
|
|
return {
|
|
success: false,
|
|
isUnauthorized: true,
|
|
errorMessage: 'Authentication session expired or unauthorized (HTTP 401).',
|
|
};
|
|
}
|
|
|
|
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
|
|
clearSuccessfulSyncRecord();
|
|
const serverMsg =
|
|
err?.response?.data?.message ||
|
|
err?.response?.data?.detail ||
|
|
err?.response?.data?.error ||
|
|
err?.message ||
|
|
'Account is not yet enabled by the administrator.';
|
|
|
|
setActivationPending(true, serverMsg);
|
|
|
|
return {
|
|
success: false,
|
|
isUnauthorized: false,
|
|
isActivationPending: true,
|
|
errorMessage: serverMsg,
|
|
};
|
|
} finally {
|
|
inFlightSyncPromise = null;
|
|
}
|
|
})();
|
|
|
|
return inFlightSyncPromise;
|
|
}
|
|
|
|
/**
|
|
* Backward-compatible helper returning boolean success.
|
|
*/
|
|
export async function ensureUserSynced(
|
|
userSub?: string,
|
|
token?: string | null,
|
|
profile?: UserSyncProfile,
|
|
force = false
|
|
): Promise<boolean> {
|
|
const result = await syncUserAccount(userSub, token, profile, force);
|
|
return result.success;
|
|
}
|