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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user