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
@@ -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),
};
}