Refactored UI endpoint usage
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
Award,
|
||||
CreateAwardRequest,
|
||||
Contract,
|
||||
ContractStatus,
|
||||
CreateContractRequest,
|
||||
UpdateContractRequest,
|
||||
ExecuteContractRequest,
|
||||
@@ -429,6 +430,18 @@ export const createAward = async (payload: CreateAwardRequest): Promise<Award> =
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchHashId = async (id64: number | string): Promise<string> => {
|
||||
try {
|
||||
const res = await apiClient.get<string>(`/v1/api/tools/hashid/${id64}`);
|
||||
if (res.data && typeof res.data === 'string') {
|
||||
return res.data;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to client-side deterministic Hashids
|
||||
}
|
||||
return toolsService.encodeIdSync(id64);
|
||||
};
|
||||
|
||||
export const getContracts = async (page = 1, pageSize = 100): Promise<Contract[]> => {
|
||||
const res = await apiClient.get<Contract[]>('/v1/api/grants/contracts', {
|
||||
params: { page, pageSize },
|
||||
@@ -444,15 +457,94 @@ export const getContract = async (contractId: string | number): Promise<Contract
|
||||
|
||||
export const createContract = async (payload: CreateContractRequest): Promise<Contract> => {
|
||||
const userSub = getCurrentUserSub();
|
||||
const hashedAwardId = await toolsService.resolveAwardId(payload.awardId);
|
||||
const res = await apiClient.post('/v1/api/grants/contracts', {
|
||||
...payload,
|
||||
awardId: hashedAwardId,
|
||||
createdBy: userSub || undefined,
|
||||
});
|
||||
return res.data;
|
||||
|
||||
// Extract ID from headers (e.g. Location or Content-Location) if present
|
||||
const locationHeader = res.headers?.['location'] || res.headers?.['Location'];
|
||||
let headerId: string | undefined;
|
||||
if (locationHeader && typeof locationHeader === 'string') {
|
||||
const parts = locationHeader.split('/');
|
||||
headerId = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
// Case 1: Backend returns string contractId/hash directly
|
||||
if (typeof res.data === 'string' && res.data.trim().length > 0) {
|
||||
const rawStr = res.data.trim();
|
||||
return {
|
||||
id: rawStr,
|
||||
contractId: rawStr,
|
||||
awardId: hashedAwardId,
|
||||
totalValue: payload.totalValue,
|
||||
effectiveAt: payload.effectiveAt,
|
||||
expiresAt: payload.expiresAt,
|
||||
status: ContractStatus.Draft,
|
||||
} as unknown as Contract;
|
||||
}
|
||||
|
||||
// Case 2: Backend returns object
|
||||
if (res.data && typeof res.data === 'object') {
|
||||
const candidateId =
|
||||
res.data.id ??
|
||||
res.data.contractId ??
|
||||
res.data.Id ??
|
||||
res.data.ContractId ??
|
||||
headerId;
|
||||
if (candidateId) {
|
||||
return {
|
||||
...res.data,
|
||||
id: candidateId,
|
||||
contractId: candidateId,
|
||||
awardId: res.data.awardId || hashedAwardId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: ID extracted from Location header
|
||||
if (headerId) {
|
||||
return {
|
||||
id: headerId,
|
||||
contractId: headerId,
|
||||
awardId: hashedAwardId,
|
||||
totalValue: payload.totalValue,
|
||||
effectiveAt: payload.effectiveAt,
|
||||
expiresAt: payload.expiresAt,
|
||||
status: ContractStatus.Draft,
|
||||
} as unknown as Contract;
|
||||
}
|
||||
|
||||
// Case 4: If backend returns empty 200 OK without body, locate newly created contract by awardId
|
||||
try {
|
||||
const existing = await getContracts(1, 10);
|
||||
const matched = existing.find(
|
||||
(c) =>
|
||||
String(c.awardId) === String(hashedAwardId) ||
|
||||
String(c.awardId) === String(payload.awardId)
|
||||
);
|
||||
if (matched) return matched;
|
||||
} catch {}
|
||||
|
||||
return (res.data || {
|
||||
id: hashedAwardId,
|
||||
contractId: hashedAwardId,
|
||||
awardId: hashedAwardId,
|
||||
totalValue: payload.totalValue,
|
||||
effectiveAt: payload.effectiveAt,
|
||||
expiresAt: payload.expiresAt,
|
||||
status: ContractStatus.Draft,
|
||||
}) as unknown as Contract;
|
||||
};
|
||||
|
||||
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
|
||||
await apiClient.put('/v1/api/grants/contracts', payload);
|
||||
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
|
||||
await apiClient.put('/v1/api/grants/contracts', {
|
||||
...payload,
|
||||
contractId: hashedContractId,
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadContractFile = async (
|
||||
@@ -471,14 +563,19 @@ export const uploadContractFile = async (
|
||||
|
||||
export const getContractSecureLink = async (contractId: string | number): Promise<string> => {
|
||||
const hashedContractId = await toolsService.resolveContractId(contractId);
|
||||
const res = await apiClient.get<string>(`/v1/api/grants/contracts/${hashedContractId}/securelink`);
|
||||
return res.data;
|
||||
const res = await apiClient.get<string | { url?: string; link?: string }>(
|
||||
`/v1/api/grants/contracts/${hashedContractId}/securelink`
|
||||
);
|
||||
if (typeof res.data === 'string') return res.data;
|
||||
return (res.data as any)?.url || (res.data as any)?.link || String(res.data);
|
||||
};
|
||||
|
||||
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
|
||||
const userSub = getCurrentUserSub();
|
||||
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
|
||||
await apiClient.post('/v1/api/grants/contracts/execute', {
|
||||
...payload,
|
||||
contractId: hashedContractId,
|
||||
executedBy: userSub || undefined,
|
||||
});
|
||||
};
|
||||
@@ -759,7 +856,7 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus |
|
||||
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
|
||||
};
|
||||
|
||||
export {
|
||||
import {
|
||||
syncUserAccount,
|
||||
ensureUserSynced,
|
||||
isUserSyncedInBrowser,
|
||||
@@ -771,6 +868,18 @@ export {
|
||||
getActivationPendingReason,
|
||||
} from './userSyncService';
|
||||
|
||||
export {
|
||||
syncUserAccount,
|
||||
ensureUserSynced,
|
||||
isUserSyncedInBrowser,
|
||||
saveUserSyncInBrowser,
|
||||
clearUserSyncInBrowser,
|
||||
getUserSyncRecord,
|
||||
isActivationPending,
|
||||
setActivationPending,
|
||||
getActivationPendingReason,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -786,13 +895,15 @@ export const syncUser = async (
|
||||
},
|
||||
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);
|
||||
try {
|
||||
await apiClient.post('/v1/api/integration/sync', {});
|
||||
} catch {
|
||||
await ensureUserSynced(undefined, undefined, undefined, force);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
@@ -821,26 +932,3 @@ export const getApplicantInvites = async (applicantUserId: string): Promise<Invi
|
||||
const res = await apiClient.get<Invite[]>(`/v1/api/communications/invites/${applicantUserId}`);
|
||||
return res.data || [];
|
||||
};
|
||||
|
||||
// Legacy compatibility helpers
|
||||
export const inviteUser = async (payload: InviteUserRequest): Promise<User> => {
|
||||
const userSub = getCurrentUserSub();
|
||||
const res = await apiClient.post('/v1/api/governance/users/invite', {
|
||||
...payload,
|
||||
invitedBy: userSub || undefined,
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const revokeUser = async (userId: string): Promise<void> => {
|
||||
await apiClient.delete(`/v1/api/governance/users/${userId}`);
|
||||
};
|
||||
|
||||
export const getAuditLogs = async (): Promise<AuditLog[]> => {
|
||||
try {
|
||||
const res = await apiClient.get<AuditLog[]>('/v1/api/governance/audit');
|
||||
return res.data || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { InternalAxiosRequestConfig } from 'axios';
|
||||
import { getApiBaseUrl } from '../config';
|
||||
import { triggerSessionExpired, isUserSyncEndpoint } from './sessionExpiration';
|
||||
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
|
||||
|
||||
export const BASE_API_URL = getApiBaseUrl();
|
||||
|
||||
@@ -28,8 +29,6 @@ apiClient.interceptors.request.use(
|
||||
// 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'));
|
||||
@@ -57,11 +56,9 @@ apiClient.interceptors.response.use(
|
||||
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.
|
||||
// Handle 401 Unauthorized errors
|
||||
if (!isSyncEndpoint) {
|
||||
// Active working endpoint returned HTTP 401: trigger modal with progress bar countdown and landing redirect
|
||||
// Working endpoint returned HTTP 401: trigger session expiration prompt
|
||||
const detailMsg =
|
||||
error?.response?.data?.detail ||
|
||||
error?.response?.data?.message ||
|
||||
@@ -69,7 +66,7 @@ apiClient.interceptors.response.use(
|
||||
|
||||
triggerSessionExpired(detailMsg, requestUrl);
|
||||
} else {
|
||||
// User sync endpoint returned HTTP 401: clean up session data silently for landing navigation
|
||||
// User sync endpoint returned HTTP 401: clear cached auth state
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('pfm_access_token');
|
||||
localStorage.removeItem('pfm_access_user');
|
||||
|
||||
@@ -58,19 +58,14 @@ export function isSessionExpiredState(): boolean {
|
||||
/**
|
||||
* 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
|
||||
// Ignore HTTP 401 if originating from 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 user is pending account activation, ignore general session expiration
|
||||
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) {
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +82,7 @@ export function triggerSessionExpired(reason?: string, requestUrl?: string): voi
|
||||
|
||||
hasTriggeredSessionExpired = true;
|
||||
|
||||
// Session expiration modal dismisses all other subordinates when it is displayed
|
||||
// Dismiss open secondary modals
|
||||
dismissSubordinateModals();
|
||||
|
||||
// Dispatch custom browser event
|
||||
|
||||
@@ -172,22 +172,20 @@ export function getUserSyncRecord(): UserSyncRecord | null {
|
||||
}
|
||||
|
||||
export const USER_SYNC_CANDIDATE_ENDPOINTS = [
|
||||
'/v1/api/users/sync',
|
||||
'/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.
|
||||
* 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('/users/sync') ||
|
||||
clean.includes('/user/sync') ||
|
||||
clean.endsWith('/sync') ||
|
||||
clean === '/sync'
|
||||
@@ -195,9 +193,7 @@ export function isUserSyncEndpoint(url?: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Executes the backend user sync request across available candidate endpoints.
|
||||
*/
|
||||
async function executeBackendSync(
|
||||
userSub?: string,
|
||||
@@ -218,20 +214,33 @@ async function executeBackendSync(
|
||||
|
||||
for (const endpoint of candidateEndpoints) {
|
||||
try {
|
||||
const res = await apiClient.post(endpoint, payload);
|
||||
return { endpoint, data: res.data };
|
||||
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;
|
||||
|
||||
// 1. If HTTP 401 Unauthorized: token expired or invalid, fail fast
|
||||
// Fail fast on token expiration/invalid
|
||||
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)) {
|
||||
// User blocked or pending activation
|
||||
if (status === 403 || status === 423) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user