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,494 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { ProblemDetails } from '../types';
|
||||
|
||||
export type ErrorCategory =
|
||||
| 'validation'
|
||||
| 'creation'
|
||||
| 'update'
|
||||
| 'security'
|
||||
| 'notFound'
|
||||
| 'conflict'
|
||||
| 'server'
|
||||
| 'general';
|
||||
|
||||
export interface FriendlyError {
|
||||
title: string;
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
isProblemDetails: boolean;
|
||||
category: ErrorCategory;
|
||||
categoryLabel?: string;
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
technicalDetails?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts camelCase or PascalCase field names to human-readable titles.
|
||||
* e.g. "HashId" -> "Resource Identifier", "startDate" -> "Start Date"
|
||||
*/
|
||||
function humanizeFieldName(field: string): string {
|
||||
if (!field) return 'Field';
|
||||
|
||||
const customMappings: Record<string, string> = {
|
||||
hashid: 'Resource Identifier (HashId)',
|
||||
id: 'Record ID',
|
||||
sub: 'User Identity',
|
||||
orgid: 'Organisation',
|
||||
organisationid: 'Organisation',
|
||||
portfolioid: 'Portfolio',
|
||||
programmeid: 'Programme',
|
||||
projectid: 'Project',
|
||||
grantid: 'Award',
|
||||
awardid: 'Award',
|
||||
contractid: 'Contract',
|
||||
budgetid: 'Budget',
|
||||
disbursementid: 'Disbursement',
|
||||
indicatorid: 'Indicator',
|
||||
riskid: 'Risk',
|
||||
email: 'Email Address',
|
||||
name: 'Name',
|
||||
title: 'Title',
|
||||
code: 'Reference Code',
|
||||
startdate: 'Start Date',
|
||||
enddate: 'End Date',
|
||||
targetdate: 'Target Date',
|
||||
actualdate: 'Actual Date',
|
||||
amount: 'Amount',
|
||||
budgetamount: 'Budget Amount',
|
||||
targetvalue: 'Target Value',
|
||||
actualvalue: 'Actual Value',
|
||||
baselinevalue: 'Baseline Value',
|
||||
status: 'Status',
|
||||
governancestatus: 'Governance Status',
|
||||
contractstatus: 'Contract Status',
|
||||
disbursementstatus: 'Disbursement Status',
|
||||
};
|
||||
|
||||
const lower = field.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (customMappings[lower]) {
|
||||
return customMappings[lower];
|
||||
}
|
||||
|
||||
// Insert space before uppercase letters
|
||||
const spaced = field.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
|
||||
*/
|
||||
function cleanTechnicalJargon(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
let cleaned = text
|
||||
.replace(/^AxiosError:\s*/i, '')
|
||||
.replace(/^Error:\s*/i, '')
|
||||
.replace(/Request failed with status code \d+/i, '')
|
||||
.replace(/https?:\/\/[^\s]+/g, '')
|
||||
.replace(/System\.[a-zA-Z0-9_.]+/g, '')
|
||||
.replace(/Microsoft\.[a-zA-Z0-9_.]+/g, '')
|
||||
.replace(/at\s+[a-zA-Z0-9_.]+\s*\(.*?\)/g, '')
|
||||
.trim();
|
||||
|
||||
// If after cleaning it's empty or trivial, return empty
|
||||
if (cleaned.length < 3 || cleaned === '[object Object]') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a title is a standard framework generic title.
|
||||
*/
|
||||
function isGenericFrameworkTitle(title?: string): boolean {
|
||||
if (!title) return true;
|
||||
const lower = title.toLowerCase().trim();
|
||||
return (
|
||||
lower === 'bad request' ||
|
||||
lower === 'one or more validation errors occurred.' ||
|
||||
lower === 'one or more validation errors occurred' ||
|
||||
lower === 'forbidden' ||
|
||||
lower === 'not found' ||
|
||||
lower === 'internal server error' ||
|
||||
lower === 'an error occurred while processing your request.' ||
|
||||
lower === 'api request error' ||
|
||||
lower.startsWith('request failed with')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a response object conforms to RFC 7807 / RFC 9457 ProblemDetails.
|
||||
*/
|
||||
export function isProblemDetailsObject(data: any): data is ProblemDetails {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
return (
|
||||
'title' in data ||
|
||||
'detail' in data ||
|
||||
'status' in data ||
|
||||
'type' in data ||
|
||||
'errors' in data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the user action context was an entity creation or an update.
|
||||
*/
|
||||
function detectOperationIntent(
|
||||
method?: string,
|
||||
actionTitle?: string,
|
||||
url?: string
|
||||
): 'creation' | 'update' | 'deletion' | 'general' {
|
||||
const normMethod = (method || '').toUpperCase();
|
||||
const text = `${actionTitle || ''} ${url || ''}`.toLowerCase();
|
||||
|
||||
if (
|
||||
normMethod === 'POST' ||
|
||||
text.includes('create') ||
|
||||
text.includes('registration') ||
|
||||
text.includes('register') ||
|
||||
text.includes('dispatch') ||
|
||||
text.includes('add ') ||
|
||||
text.includes('new ') ||
|
||||
text.includes('upload') ||
|
||||
text.includes('record actual') ||
|
||||
text.includes('log risk')
|
||||
) {
|
||||
return 'creation';
|
||||
}
|
||||
|
||||
if (
|
||||
normMethod === 'PUT' ||
|
||||
normMethod === 'PATCH' ||
|
||||
text.includes('update') ||
|
||||
text.includes('edit') ||
|
||||
text.includes('save') ||
|
||||
text.includes('status') ||
|
||||
text.includes('approve') ||
|
||||
text.includes('execute') ||
|
||||
text.includes('modify')
|
||||
) {
|
||||
return 'update';
|
||||
}
|
||||
|
||||
if (normMethod === 'DELETE' || text.includes('delete') || text.includes('remove')) {
|
||||
return 'deletion';
|
||||
}
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and formats human-friendly error guidance from any error,
|
||||
* prioritizing the standardised ProblemDetails model when present,
|
||||
* and handling BadRequest specifically for its multiple purposes:
|
||||
* - Input validation errors (field-level)
|
||||
* - Creation errors (prerequisites, business rules, duplicate identifiers)
|
||||
* - Update errors (lifecycle state restrictions, invalid transitions, permissions)
|
||||
*/
|
||||
export function parseFriendlyError(
|
||||
error: unknown,
|
||||
fallbackActionTitle = 'Action Failed'
|
||||
): FriendlyError {
|
||||
let statusCode: number | undefined;
|
||||
let rawTitle: string | undefined;
|
||||
let rawDetail: string | undefined;
|
||||
let rawErrors: Record<string, string[] | string> | undefined;
|
||||
let isProblemDetails = false;
|
||||
let rawResponseData: any = null;
|
||||
let technicalSummary = '';
|
||||
let httpMethod = '';
|
||||
let requestUrl = '';
|
||||
|
||||
// 1. Inspect Axios error structure
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosErr = error as AxiosError<any>;
|
||||
statusCode = axiosErr.response?.status;
|
||||
rawResponseData = axiosErr.response?.data;
|
||||
httpMethod = axiosErr.config?.method?.toUpperCase() || '';
|
||||
requestUrl = axiosErr.config?.url || '';
|
||||
technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`;
|
||||
|
||||
if (rawResponseData && typeof rawResponseData === 'object') {
|
||||
if (isProblemDetailsObject(rawResponseData)) {
|
||||
isProblemDetails = true;
|
||||
rawTitle = rawResponseData.title || undefined;
|
||||
rawDetail = rawResponseData.detail || undefined;
|
||||
statusCode = rawResponseData.status || statusCode;
|
||||
rawErrors = rawResponseData.errors;
|
||||
} else if (rawResponseData.message || rawResponseData.error) {
|
||||
rawDetail = rawResponseData.message || rawResponseData.error;
|
||||
}
|
||||
} else if (typeof rawResponseData === 'string') {
|
||||
// Check if response data is stringified JSON containing ProblemDetails
|
||||
try {
|
||||
const parsed = JSON.parse(rawResponseData);
|
||||
if (isProblemDetailsObject(parsed)) {
|
||||
isProblemDetails = true;
|
||||
rawTitle = parsed.title || undefined;
|
||||
rawDetail = parsed.detail || undefined;
|
||||
statusCode = parsed.status || statusCode;
|
||||
rawErrors = parsed.errors;
|
||||
} else {
|
||||
rawDetail = cleanTechnicalJargon(rawResponseData);
|
||||
}
|
||||
} catch {
|
||||
if (!rawResponseData.includes('<html')) {
|
||||
rawDetail = cleanTechnicalJargon(rawResponseData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Network disconnection or request timeout (no response received)
|
||||
if (!axiosErr.response) {
|
||||
if (axiosErr.code === 'ECONNABORTED' || axiosErr.message.includes('timeout')) {
|
||||
return {
|
||||
title: 'Request Timed Out',
|
||||
message: 'The server took too long to respond. Please check your network and try again.',
|
||||
statusCode: 408,
|
||||
isProblemDetails: false,
|
||||
category: 'server',
|
||||
categoryLabel: 'Timeout',
|
||||
technicalDetails: technicalSummary || axiosErr.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Connection Unavailable',
|
||||
message: 'Unable to reach the system service. Please verify your internet connection or try again in a few moments.',
|
||||
statusCode: 0,
|
||||
isProblemDetails: false,
|
||||
category: 'server',
|
||||
categoryLabel: 'Network',
|
||||
technicalDetails: technicalSummary || axiosErr.message,
|
||||
};
|
||||
}
|
||||
} else if (error && typeof error === 'object') {
|
||||
// 2. Direct ProblemDetails object or custom error object
|
||||
const obj = error as any;
|
||||
if (isProblemDetailsObject(obj)) {
|
||||
isProblemDetails = true;
|
||||
rawTitle = obj.title || undefined;
|
||||
rawDetail = obj.detail || undefined;
|
||||
statusCode = obj.status || undefined;
|
||||
rawErrors = obj.errors;
|
||||
} else if (obj.response?.data && isProblemDetailsObject(obj.response.data)) {
|
||||
isProblemDetails = true;
|
||||
rawTitle = obj.response.data.title || undefined;
|
||||
rawDetail = obj.response.data.detail || undefined;
|
||||
statusCode = obj.response.status || obj.response.data.status;
|
||||
rawErrors = obj.response.data.errors;
|
||||
} else if (obj.message) {
|
||||
rawDetail = obj.message;
|
||||
const statusMatch = obj.message.match(/status code (\d{3})/i);
|
||||
if (statusMatch) {
|
||||
statusCode = parseInt(statusMatch[1], 10);
|
||||
}
|
||||
}
|
||||
} else if (typeof error === 'string') {
|
||||
// 3. String error, e.g. String(err)
|
||||
const statusMatch = error.match(/status code (\d{3})/i);
|
||||
if (statusMatch) {
|
||||
statusCode = parseInt(statusMatch[1], 10);
|
||||
}
|
||||
rawDetail = cleanTechnicalJargon(error);
|
||||
}
|
||||
|
||||
// Parse field-level validation errors from ProblemDetails if present
|
||||
const fieldErrors: Array<{ field: string; message: string }> = [];
|
||||
if (rawErrors && typeof rawErrors === 'object') {
|
||||
for (const [key, val] of Object.entries(rawErrors)) {
|
||||
const fieldTitle = humanizeFieldName(key);
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach((msg) => {
|
||||
if (msg) fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(String(msg)) });
|
||||
});
|
||||
} else if (typeof val === 'string' && val) {
|
||||
fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(val) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect whether the operation is creation, update, deletion, or general
|
||||
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
|
||||
|
||||
// Build user-friendly title, message, and category based on standard HTTP codes and ProblemDetails
|
||||
let friendlyTitle = fallbackActionTitle;
|
||||
let friendlyMessage = '';
|
||||
let category: ErrorCategory = 'general';
|
||||
let categoryLabel: string | undefined = undefined;
|
||||
|
||||
switch (statusCode) {
|
||||
case 403:
|
||||
// Forbid: specifically occurs when the hashid used on a call is invalid or access denied
|
||||
category = 'security';
|
||||
categoryLabel = 'Invalid Identifier / Forbid';
|
||||
friendlyTitle = 'Access Not Permitted or Invalid Identifier';
|
||||
friendlyMessage =
|
||||
cleanTechnicalJargon(rawDetail || '') ||
|
||||
'The reference identifier (hash ID) is invalid, expired, or you do not have permission to access this item. Please verify your selection or contact your system administrator.';
|
||||
break;
|
||||
|
||||
case 404:
|
||||
// NotFound: item could not be located
|
||||
category = 'notFound';
|
||||
categoryLabel = 'Record Not Found';
|
||||
friendlyTitle = 'Record Not Found';
|
||||
friendlyMessage =
|
||||
cleanTechnicalJargon(rawDetail || '') ||
|
||||
'The requested record could not be found. It may have been archived, removed, or the link is outdated.';
|
||||
break;
|
||||
|
||||
case 400: {
|
||||
// BadRequest serves MULTIPLE purposes:
|
||||
// 1) Input validation errors (field-level errors dictionary)
|
||||
// 2) Creation errors (prerequisites, business rules, duplicate code, invalid entity state)
|
||||
// 3) Update errors (status transition restrictions, record locked, state mismatch)
|
||||
const hasSpecificTitle = rawTitle && !isGenericFrameworkTitle(rawTitle);
|
||||
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
|
||||
|
||||
if (fieldErrors.length > 0) {
|
||||
// Purpose 1: Input Validation Errors
|
||||
category = 'validation';
|
||||
categoryLabel =
|
||||
operationIntent === 'creation'
|
||||
? 'Creation Validation'
|
||||
: operationIntent === 'update'
|
||||
? 'Update Validation'
|
||||
: 'Input Validation';
|
||||
|
||||
if (hasSpecificTitle) {
|
||||
friendlyTitle = cleanTechnicalJargon(rawTitle!);
|
||||
} else if (operationIntent === 'creation') {
|
||||
friendlyTitle = 'Please Check New Record Details';
|
||||
} else if (operationIntent === 'update') {
|
||||
friendlyTitle = 'Please Check Updated Values';
|
||||
} else {
|
||||
friendlyTitle = 'Please Check Submitted Details';
|
||||
}
|
||||
|
||||
const errorList = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n');
|
||||
friendlyMessage = cleanDetail
|
||||
? `${cleanDetail}\n\n${errorList}`
|
||||
: `The submitted details could not be validated:\n${errorList}`;
|
||||
} else if (operationIntent === 'creation') {
|
||||
// Purpose 2: Creation Error (non-field-validation: business rule, duplicate, or prerequisite failure)
|
||||
category = 'creation';
|
||||
categoryLabel = 'Creation Error';
|
||||
|
||||
if (hasSpecificTitle) {
|
||||
friendlyTitle = cleanTechnicalJargon(rawTitle!);
|
||||
} else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') {
|
||||
friendlyTitle = fallbackActionTitle;
|
||||
} else {
|
||||
friendlyTitle = 'Unable to Create Record';
|
||||
}
|
||||
|
||||
friendlyMessage =
|
||||
cleanDetail ||
|
||||
'The new record could not be created. This may be due to business rule prerequisites, existing duplicate identifiers, or missing related records.';
|
||||
} else if (operationIntent === 'update') {
|
||||
// Purpose 3: Update Error (non-field-validation: status transition, record locked, lifecycle restriction)
|
||||
category = 'update';
|
||||
categoryLabel = 'Update Error';
|
||||
|
||||
if (hasSpecificTitle) {
|
||||
friendlyTitle = cleanTechnicalJargon(rawTitle!);
|
||||
} else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') {
|
||||
friendlyTitle = fallbackActionTitle;
|
||||
} else {
|
||||
friendlyTitle = 'Unable to Update Record';
|
||||
}
|
||||
|
||||
friendlyMessage =
|
||||
cleanDetail ||
|
||||
'The record could not be updated. It may be locked, in a status that does not allow modifications, or the requested status transition is not permitted.';
|
||||
} else {
|
||||
// General Bad Request
|
||||
category = 'general';
|
||||
categoryLabel = 'Request Error';
|
||||
|
||||
if (hasSpecificTitle) {
|
||||
friendlyTitle = cleanTechnicalJargon(rawTitle!);
|
||||
} else {
|
||||
friendlyTitle = fallbackActionTitle || 'Request Could Not Be Processed';
|
||||
}
|
||||
|
||||
friendlyMessage =
|
||||
cleanDetail ||
|
||||
'The request could not be processed due to invalid parameters or business rule requirements. Please review your input and try again.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 409:
|
||||
// Conflict
|
||||
category = 'conflict';
|
||||
categoryLabel = 'Conflict';
|
||||
friendlyTitle = 'Update Conflict';
|
||||
friendlyMessage =
|
||||
cleanTechnicalJargon(rawDetail || '') ||
|
||||
'This action conflicts with the current status of the record (for instance, another user may have updated it or a duplicate entry exists). Please refresh the page and try again.';
|
||||
break;
|
||||
|
||||
case 422:
|
||||
// Unprocessable Entity
|
||||
category = 'validation';
|
||||
categoryLabel = 'Incomplete Data';
|
||||
friendlyTitle = 'Information Incomplete';
|
||||
friendlyMessage =
|
||||
cleanTechnicalJargon(rawDetail || '') ||
|
||||
'The submitted information could not be processed by the system. Please verify that all required fields are complete.';
|
||||
break;
|
||||
|
||||
case 429:
|
||||
// Too Many Requests
|
||||
category = 'server';
|
||||
categoryLabel = 'Rate Limit';
|
||||
friendlyTitle = 'System Busy';
|
||||
friendlyMessage = 'The system received too many requests in a short time. Please pause for a moment before trying again.';
|
||||
break;
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
// Server error
|
||||
category = 'server';
|
||||
categoryLabel = 'Service Issue';
|
||||
friendlyTitle = 'Service Temporarily Unavailable';
|
||||
friendlyMessage =
|
||||
'The central service encountered an unexpected issue while processing your request. Please try again in a few moments.';
|
||||
break;
|
||||
|
||||
default:
|
||||
if (rawTitle && isProblemDetails && !isGenericFrameworkTitle(rawTitle)) {
|
||||
friendlyTitle = cleanTechnicalJargon(rawTitle) || fallbackActionTitle;
|
||||
}
|
||||
if (rawDetail) {
|
||||
friendlyMessage = cleanTechnicalJargon(rawDetail);
|
||||
} else if (fieldErrors.length > 0) {
|
||||
category = 'validation';
|
||||
categoryLabel = 'Validation';
|
||||
friendlyMessage = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n');
|
||||
} else {
|
||||
friendlyMessage = 'An unexpected issue occurred while processing this request. Please try again or contact support.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure title is friendly and not an uncaught technical string
|
||||
if (friendlyTitle.includes('AxiosError') || friendlyTitle.includes('status code')) {
|
||||
friendlyTitle = fallbackActionTitle;
|
||||
}
|
||||
|
||||
return {
|
||||
title: friendlyTitle,
|
||||
message: friendlyMessage,
|
||||
statusCode,
|
||||
isProblemDetails,
|
||||
category,
|
||||
categoryLabel,
|
||||
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
|
||||
technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user