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 | 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 { 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 => { 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 { const result = await syncUserAccount(userSub, token, profile, force); return result.success; }