Implemented session and account lockout management

Enabled account sync control and session expiration management
Applied some fixes to the govername page and project page
This commit is contained in:
2026-09-18 10:19:24 +02:00
parent 41bf9b63bf
commit ec777fabf8
24 changed files with 3888 additions and 500 deletions
+198 -28
View File
@@ -20,6 +20,7 @@ import {
UpdateProjectStatusRequest,
Activity,
CreateActivityRequest,
UpdateActivityRequest,
Milestone,
CreateMilestoneRequest,
UpdateMilestoneRequest,
@@ -51,26 +52,29 @@ import {
ActivityStatus,
} from '../types';
// Helper to extract ProblemDetails from Axios error
import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser';
import { registerUserEmail, registerUserEmails } from '../lib/formatters';
// Helper to extract ProblemDetails from Axios error or unknown error
export function parseApiError(error: unknown): ProblemDetails {
if (axios.isAxiosError(error)) {
const axiosErr = error as AxiosError<ProblemDetails>;
if (axiosErr.response?.data) {
if (axiosErr.response?.data && typeof axiosErr.response.data === 'object') {
return axiosErr.response.data;
}
return {
title: 'API Request Error',
detail: axiosErr.message || 'Unable to communicate with the remote PFM service.',
status: axiosErr.response?.status || 500,
};
}
const friendly = parseFriendlyError(error);
return {
title: 'Unexpected Application Error',
detail: String(error),
status: 500,
title: friendly.title,
detail: friendly.message,
status: friendly.statusCode || 500,
};
}
export { parseFriendlyError };
export type { FriendlyError };
// ==========================================
// 1. Executive Dashboard APIs
// ==========================================
@@ -82,17 +86,53 @@ export const getDashboard = async (): Promise<Dashboard> => {
// ==========================================
// 2. Governance & Structure APIs
// ==========================================
/**
* Normalizes an Organisation object from backend responses.
* Robustly maps status across variations (status, Status, activityStatus, approvalStatus, statusId, isActive).
*/
export const normalizeOrganisation = (raw: any): Organisation => {
if (!raw) return raw;
const status =
raw.status ??
raw.Status ??
raw.activityStatus ??
raw.ActivityStatus ??
raw.approvalStatus ??
raw.ApprovalStatus ??
raw.statusId ??
raw.StatusId ??
raw.state ??
raw.State ??
(raw.isActive !== undefined ? (raw.isActive ? 1 : 2) : undefined) ??
(raw.IsActive !== undefined ? (raw.IsActive ? 1 : 2) : undefined) ??
ActivityStatus.Active;
return {
...raw,
id: raw.id ?? raw.Id ?? raw.organisationId ?? raw.OrganisationId,
name: raw.name ?? raw.Name ?? null,
registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? null,
email: raw.email ?? raw.Email ?? null,
type: raw.type ?? raw.Type ?? raw.organisationType ?? raw.OrganisationType ?? 5,
status,
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
createdBy: raw.createdBy ?? raw.CreatedBy ?? '',
};
};
export const getOrganisations = async (page = 1, pageSize = 100): Promise<Organisation[]> => {
const res = await apiClient.get<Organisation[]>('/v1/api/governance/organisations', {
const res = await apiClient.get<any[]>('/v1/api/governance/organisations', {
params: { page, pageSize },
});
return res.data || [];
const list = res.data || [];
return list.map(normalizeOrganisation);
};
export const getOrganisation = async (organisationId: string | number): Promise<Organisation> => {
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId);
const res = await apiClient.get<Organisation>(`/v1/api/governance/organisations/${hashedOrgId}`);
return res.data;
const res = await apiClient.get<any>(`/v1/api/governance/organisations/${hashedOrgId}`);
return normalizeOrganisation(res.data);
};
export const createOrganisation = async (payload: CreateOrganisationRequest): Promise<Organisation> => {
@@ -275,11 +315,46 @@ export const getActivity = async (activityId: string | number): Promise<Activity
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/projects/activities', {
const body: any = {
...payload,
milestoneId:
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
? Number(payload.milestoneId)
: null,
createdBy: userSub || undefined,
});
return res.data;
};
try {
const res = await apiClient.post('/v1/api/projects/activities', body);
return res.data;
} catch (err: any) {
if (err?.response?.status === 405) {
const res = await apiClient.put('/v1/api/projects/activities', body);
return res.data;
}
throw err;
}
};
export const updateActivity = async (payload: UpdateActivityRequest): Promise<Activity> => {
const userSub = getCurrentUserSub();
const body: any = {
...payload,
milestoneId:
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
? Number(payload.milestoneId)
: null,
updatedBy: userSub || undefined,
};
try {
const res = await apiClient.put('/v1/api/projects/activities', body);
return res.data;
} catch (err: any) {
if (err?.response?.status === 404 || err?.response?.status === 405) {
const res = await apiClient.put('/v1/api/projects/activity', body);
return res.data;
}
throw err;
}
};
export const getMilestones = async (projectId: string | number): Promise<Milestone[]> => {
@@ -324,8 +399,9 @@ export const getProjectIndicators = async (projectId: string | number): Promise<
export const getProjectRisks = async (projectId: string | number): Promise<Risk[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Risk[]>(`/v1/api/projects/${hashedProjectId}/risks`);
return res.data || [];
const res = await apiClient.get<any[]>(`/v1/api/projects/${hashedProjectId}/risks`);
const list = res.data || [];
return list.map(normalizeRisk);
};
// ==========================================
@@ -470,32 +546,85 @@ export const approveDisbursement = async (disbursementId: string | number): Prom
});
};
/**
* Normalizes a Risk record from backend responses.
* Robustly parses Likelihood (1-5) and Impact (1-5), supporting strings, numbers, and case variations.
*/
export const normalizeRisk = (raw: any): Risk => {
if (!raw) return raw;
const rawLik = raw.likelihood ?? raw.Likelihood ?? raw.riskLikelihood ?? raw.RiskLikelihood;
const rawImp = raw.impact ?? raw.Impact ?? raw.riskImpact ?? raw.RiskImpact;
const parseScore = (val: any, defaultVal: number): number => {
if (typeof val === 'number' && !isNaN(val)) {
return Math.min(5, Math.max(1, Math.round(val)));
}
if (typeof val === 'string') {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed >= 1 && parsed <= 5) {
return parsed;
}
const lower = val.toLowerCase().trim();
if (lower.includes('rare') || lower.includes('insignificant') || lower.includes('trivial') || lower === '1') return 1;
if (lower.includes('unlikely') || lower.includes('minor') || lower === '2') return 2;
if (lower.includes('possible') || lower.includes('moderate') || lower.includes('medium') || lower === '3') return 3;
if (lower.includes('likely') || lower.includes('major') || lower.includes('high') || lower === '4') return 4;
if (lower.includes('almost') || lower.includes('certain') || lower.includes('severe') || lower.includes('critical') || lower.includes('catastrophic') || lower === '5') return 5;
}
return defaultVal;
};
const likelihood = parseScore(rawLik, 3);
const impact = parseScore(rawImp, 2);
return {
...raw,
id: raw.id ?? raw.Id ?? raw.riskId ?? raw.RiskId,
projectId: raw.projectId ?? raw.ProjectId,
name: raw.name ?? raw.Name ?? null,
description: raw.description ?? raw.Description ?? null,
likelihood,
impact,
createdBy: raw.createdBy ?? raw.CreatedBy ?? '',
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
owner: raw.owner ?? raw.Owner ?? raw.riskOwner ?? raw.RiskOwner ?? raw.createdBy ?? raw.CreatedBy ?? '',
mitigationAction: raw.mitigationAction ?? raw.MitigationAction ?? raw.mitigation ?? raw.Mitigation ?? '',
status: raw.status ?? raw.Status,
};
};
export const getAllRisks = async (page = 1, pageSize = 100): Promise<Risk[]> => {
const res = await apiClient.get<Risk[]>('/v1/api/execution/risks', {
const res = await apiClient.get<any[]>('/v1/api/execution/risks', {
params: { page, pageSize },
});
return res.data || [];
const list = res.data || [];
return list.map(normalizeRisk);
};
export const getRisk = async (riskId: string | number): Promise<Risk> => {
const hashedRiskId = await toolsService.encodeId(riskId);
const res = await apiClient.get<Risk>(`/v1/api/execution/risks/${hashedRiskId}`);
return res.data;
const res = await apiClient.get<any>(`/v1/api/execution/risks/${hashedRiskId}`);
return normalizeRisk(res.data);
};
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/execution/risks', {
...payload,
likelihood: Number(payload.likelihood),
impact: Number(payload.impact),
createdBy: userSub || undefined,
});
return res.data;
return normalizeRisk(res.data);
};
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/execution/risks', {
...payload,
likelihood: Number(payload.likelihood),
impact: Number(payload.impact),
updatedBy: userSub || undefined,
});
};
@@ -592,11 +721,16 @@ export const getUsers = async (
filter: UserFilterRequest = {}
): Promise<User[]> => {
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, filter);
return res.data || [];
const list = res.data || [];
registerUserEmails(list);
return list;
};
export const getUser = async (userId: string): Promise<User> => {
const res = await apiClient.get<User>(`/v1/api/users/${userId}`);
if (res.data?.id && res.data?.email) {
registerUserEmail(res.data.id, res.data.email);
}
return res.data;
};
@@ -604,7 +738,11 @@ export const getCurrentUser = async (): Promise<User> => {
const userSub = getCurrentUserSub();
if (userSub) {
try {
return await getUser(userSub);
const u = await getUser(userSub);
if (u?.id && u?.email) {
registerUserEmail(u.id, u.email);
}
return u;
} catch {
// Fallback
}
@@ -621,8 +759,40 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus |
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
};
export const syncIntegration = async (): Promise<void> => {
await apiClient.post('/v1/api/integration/sync');
export {
syncUserAccount,
ensureUserSynced,
isUserSyncedInBrowser,
saveUserSyncInBrowser,
clearUserSyncInBrowser,
getUserSyncRecord,
isActivationPending,
setActivationPending,
getActivationPendingReason,
} from './userSyncService';
/**
* Synchronizes the authenticated user with the backend to enable the user in the system.
* By default, sync is saved in the browser to prevent hotspotting the sync endpoint.
*/
export const syncUser = async (
userData?: {
id?: string;
sub?: string;
email?: string | null;
name?: string | null;
role?: string | null;
tenant?: string | null;
},
force = false
): Promise<boolean> => {
const { ensureUserSynced } = await import('./userSyncService');
return ensureUserSynced(userData?.sub || userData?.id, undefined, userData, force);
};
export const syncIntegration = async (force = true): Promise<void> => {
const { ensureUserSynced } = await import('./userSyncService');
await ensureUserSynced(undefined, undefined, undefined, force);
};
// ==========================================
@@ -1,5 +1,6 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint } from './sessionExpiration';
export const BASE_API_URL = getApiBaseUrl();
@@ -15,14 +16,69 @@ export const apiClient = axios.create({
// Request Interceptor to dynamically set baseURL and attach Bearer Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
async (config: InternalAxiosRequestConfig) => {
// Dynamically synchronize baseURL from runtime configuration
config.baseURL = getApiBaseUrl();
const token = localStorage.getItem('pfm_access_token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
// User Sync Guard:
// All API calls require an authenticated and enabled user in the backend system.
const isSyncRequest = isUserSyncEndpoint(config.url);
if (token && !isSyncRequest) {
const { isUserSyncedInBrowser, isActivationPending, syncUserAccount } = await import('./userSyncService');
// If user account is flagged as pending administrator activation, block downstream API calls
if (isActivationPending()) {
return Promise.reject(new Error('User account is pending administrator activation'));
}
const storedSub = localStorage.getItem('pfm_user_sub') || '';
if (!isUserSyncedInBrowser(storedSub, token)) {
const syncRes = await syncUserAccount(storedSub, token);
if (!syncRes.success) {
return Promise.reject(new Error(syncRes.errorMessage || 'User account sync pending activation'));
}
}
}
return config;
},
(error) => Promise.reject(error)
);
// Response Interceptor for global HTTP 401 detection
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
const requestUrl = error.config?.url;
const isSyncEndpoint = isUserSyncEndpoint(requestUrl);
// Disambiguation between Session Lockout and Session Expired:
// - Session Expired process monitors ALL OTHER endpoints for HTTP 401 other than the user sync endpoint.
// - User sync endpoint 401 is subordinated to the session lockout / auth initialization flow through call order and priority.
if (!isSyncEndpoint) {
// Active working endpoint returned HTTP 401: trigger modal with progress bar countdown and landing redirect
const detailMsg =
error?.response?.data?.detail ||
error?.response?.data?.message ||
'Authentication session expired or security token invalidated (HTTP 401).';
triggerSessionExpired(detailMsg, requestUrl);
} else {
// User sync endpoint returned HTTP 401: clean up session data silently for landing navigation
if (typeof window !== 'undefined') {
localStorage.removeItem('pfm_access_token');
localStorage.removeItem('pfm_access_user');
localStorage.removeItem('pfm_user_sync_record');
localStorage.removeItem('pfm_user_pending_activation');
localStorage.removeItem('pfm_user_pending_activation_reason');
}
}
}
return Promise.reject(error);
}
);
@@ -0,0 +1,159 @@
import { queryClient } from '../lib/queryClient';
export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token';
export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user';
export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub';
export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims';
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';
/**
* Determines whether a given URL or request path targets a user sync endpoint.
* This differentiates the user sync / lockout lifecycle from active session working endpoints.
*/
export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return (
clean.includes('/users/sync') ||
clean.includes('/integration/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
);
}
type SessionExpiredListener = (reason?: string) => void;
const listeners = new Set<SessionExpiredListener>();
let hasTriggeredSessionExpired = false;
/**
* Dismisses all subordinate UI modals throughout the application.
*/
export function dismissSubordinateModals(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:dismiss-subordinates'));
}
}
/**
* Cancels and avoids the session expiration process and resets its state.
*/
export function cancelSessionExpiration(): void {
hasTriggeredSessionExpired = false;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:session-expired-cancelled'));
}
}
/**
* Checks whether session expiration is currently active.
*/
export function isSessionExpiredState(): boolean {
return hasTriggeredSessionExpired;
}
/**
* Triggers global session expiration when an endpoint returns HTTP 401.
* Prevents multiple modal popups if multiple parallel requests fail with 401.
*
* NOTE: As established by system call priority and subordination, this process
* strictly monitors all other endpoints EXCEPT the user sync endpoint, which is
* reserved for the session lockout / activation process.
*/
export function triggerSessionExpired(reason?: string, requestUrl?: string): void {
// Disambiguation: Ignore HTTP 401 if originating from the user sync endpoint
if (requestUrl && isUserSyncEndpoint(requestUrl)) {
return;
}
// Priority Subordination: If user is in account activation lockout, session expiration
// is avoided and cancelled in favor of the higher-priority activation modal.
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) {
return;
}
if (hasTriggeredSessionExpired) {
return;
}
// Only trigger if a session was previously present in localStorage or state
const hasExistingToken = typeof window !== 'undefined' && !!localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (!hasExistingToken) {
return;
}
hasTriggeredSessionExpired = true;
// Session expiration modal dismisses all other subordinates when it is displayed
dismissSubordinateModals();
// Dispatch custom browser event
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('pfm:session-expired', {
detail: { reason: reason || 'HTTP 401 Unauthorized' },
})
);
}
// Notify registered callbacks
listeners.forEach((callback) => {
try {
callback(reason);
} catch (err) {
console.warn('[SessionExpiration] Callback execution error:', err);
}
});
}
/**
* Subscribes a callback to session expiration events.
* Returns an unsubscribe function.
*/
export function onSessionExpired(listener: SessionExpiredListener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* Resets the session expiration trigger guard.
*/
export function resetSessionExpiredState(): void {
hasTriggeredSessionExpired = false;
}
/**
* Clears all persistent tokens, user claims, synchronization metadata,
* and react-query caches, then securely redirects to the landing page.
*/
export function clearSessionAndRedirectToLanding(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
} catch (err) {
console.warn('[SessionExpiration] Storage cleanup warning:', err);
}
try {
queryClient.clear();
} catch (err) {
console.warn('[SessionExpiration] QueryClient clear warning:', err);
}
resetSessionExpiredState();
// Redirect to landing page
window.location.replace('/');
}
@@ -0,0 +1,337 @@
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;
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/users/sync',
'/v1/api/integration/sync',
'/v1/api/user/sync',
];
/**
* Determines whether a given URL or request path targets a user sync endpoint.
* This is used to differentiate user sync / lockout lifecycle calls from regular
* business working endpoints, ensuring established call priority and subordination.
*/
export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return (
clean.includes('/users/sync') ||
clean.includes('/integration/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
);
}
/**
* Executes the backend user sync request.
* Tries the primary '/v1/api/users/sync' endpoint, falling back to
* '/v1/api/integration/sync' or '/v1/api/user/sync' if needed.
*/
async function executeBackendSync(
userSub?: string,
profile?: UserSyncProfile
): Promise<{ endpoint: string; data?: any }> {
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,
};
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
let lastError: any = null;
for (const endpoint of candidateEndpoints) {
try {
const res = await apiClient.post(endpoint, payload);
return { endpoint, data: res.data };
} catch (err: any) {
lastError = err;
const status = err?.response?.status;
// 1. If HTTP 401 Unauthorized: token expired or invalid, fail fast
if (status === 401) {
throw err;
}
// 2. If HTTP 403 Forbidden, 423 Locked, or non-404 client error:
// The endpoint is valid, but the user is blocked/disabled/not activated by the administrator.
if (status === 403 || status === 423 || (status && status >= 400 && status !== 404 && status !== 405)) {
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;
}