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:
@@ -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