Implemented partnerui

This commit is contained in:
2026-10-23 00:58:47 +02:00
parent 9ed210613e
commit 8f58e15f0f
27 changed files with 11416 additions and 983 deletions
@@ -0,0 +1,565 @@
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;
hasReturnObject: boolean;
isProblemDetails: boolean;
category: ErrorCategory;
categoryLabel?: string;
errorsList?: string[];
fieldErrors?: Array<{ field: string; message: string }>;
technicalDetails?: string;
}
export interface ProblemDetailsResponse {
type?: string | null;
title?: string | null;
status?: number | null;
detail?: string | null;
details?: string | null;
Details?: string | null;
instance?: string | null;
errors?: Record<string, string[] | string> | any;
[key: string]: any;
}
/**
* Checks if a string contains raw HTTP request/response headers or dump.
*/
export function isRawHttpOutput(text: string): boolean {
if (!text || typeof text !== 'string') return false;
const headerPatterns = [
/^(?:accept|authorization|content-type|content-length|origin|referer|user-agent|sec-ch-ua|sec-fetch|x-forwarded|x-real-ip|host):/im,
/(?:authorization:\s*bearer|content-type:\s*application\/)/i,
/HTTP\/\d(?:\.\d)?\s+\d{3}/i,
];
let matches = 0;
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (/^[A-Za-z0-9-]+:\s*.+$/.test(trimmed)) {
if (headerPatterns.some((p) => p.test(trimmed))) {
matches++;
}
}
}
return matches >= 2 || (matches >= 1 && /(?:authorization:\s*bearer|content-type:\s*application)/i.test(text));
}
/**
* Redacts sensitive tokens and credentials from technical traces.
*/
export function sanitizeTechnicalDetails(text: string): string {
if (!text) return '';
return text
.replace(/(authorization:\s*bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
.replace(/(bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
.replace(/("?(?:token|access_token|password|secret|apiKey)"?\s*[:=]\s*)"[^"]+"/gi, '$1"[REDACTED]"');
}
/**
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
*/
export function cleanTechnicalJargon(text: string): string {
if (!text) return '';
if (isRawHttpOutput(text)) {
return '';
}
const 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 (cleaned.length < 2 || cleaned === '[object Object]' || cleaned.toLowerCase() === 'error') {
return '';
}
return cleaned;
}
/**
* Safely inspects whether an error contains a valid response body object.
*/
export function inspectErrorResponseObject(error: unknown): ProblemDetailsResponse | null {
if (!error) return null;
const anyErr = error as any;
const data = anyErr?.response?.data;
if (data && typeof data === 'object') {
return data as ProblemDetailsResponse;
}
if (typeof data === 'string' && data.trim().startsWith('{')) {
try {
const parsed = JSON.parse(data.trim());
if (parsed && typeof parsed === 'object') {
return parsed as ProblemDetailsResponse;
}
} catch {
// Not a JSON object
}
}
if (typeof error === 'object' && error !== null) {
if (
'details' in anyErr ||
'Details' in anyErr ||
'detail' in anyErr ||
'Detail' in anyErr ||
'status' in anyErr ||
'title' in anyErr ||
'type' in anyErr ||
'errors' in anyErr ||
'Errors' in anyErr
) {
return anyErr as ProblemDetailsResponse;
}
}
return null;
}
/**
* Checks if a response object conforms to RFC 7807 / RFC 9110 ProblemDetails.
*/
export function isProblemDetailsObject(data: any): data is ProblemDetails {
if (!data || typeof data !== 'object') return false;
return (
'details' in data ||
'Details' in data ||
'title' in data ||
'detail' in data ||
'Detail' in data ||
'status' in data ||
'type' in data ||
'errors' in data ||
'Errors' in data
);
}
/**
* Parses a comma-delimited error list from the `detail` property.
*/
export function parseCommaDelimitedErrors(detail?: string | null): string[] {
if (!detail || typeof detail !== 'string') return [];
const trimmed = detail.trim();
if (!trimmed) return [];
const rawParts = trimmed.split(',');
const errors: string[] = [];
for (const part of rawParts) {
const pTrim = part.trim();
if (!pTrim) continue;
const cleaned = cleanTechnicalJargon(pTrim);
if (cleaned && cleaned.length > 0) {
errors.push(cleaned);
} else if (pTrim.length > 0 && pTrim.toLowerCase() !== 'error') {
errors.push(pTrim);
}
}
return errors.length > 0 ? errors : [trimmed];
}
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';
}
/**
* Comprehensive common error handling function across the application.
* Handles RFC 9110 / RFC 7807 ProblemDetails and non-technical language.
*/
export function parseFriendlyError(
error: unknown,
fallbackActionTitle = 'Action Failed'
): FriendlyError {
let statusCode: number | undefined;
let technicalSummary = '';
let httpMethod = '';
let requestUrl = '';
const anyErr = error as any;
if (axios.isAxiosError(error)) {
const axiosErr = error as AxiosError<any>;
statusCode = axiosErr.response?.status;
httpMethod = axiosErr.config?.method?.toUpperCase() || '';
requestUrl = axiosErr.config?.url || '';
technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`;
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 connection and try again.',
statusCode: 408,
hasReturnObject: false,
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,
hasReturnObject: false,
isProblemDetails: false,
category: 'server',
categoryLabel: 'Network',
technicalDetails: technicalSummary || axiosErr.message,
};
}
} else if (anyErr && typeof anyErr === 'object') {
statusCode = anyErr.response?.status ?? anyErr.status;
httpMethod = anyErr.config?.method?.toUpperCase() || '';
requestUrl = anyErr.config?.url || '';
if (statusCode) {
technicalSummary = `HTTP ${statusCode}`;
}
}
const returnObject = inspectErrorResponseObject(error);
const hasReturnObject = returnObject !== null;
if (returnObject) {
if (returnObject.status && typeof returnObject.status === 'number') {
statusCode = returnObject.status;
}
}
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
if (statusCode === 401) {
const rawDetail =
returnObject?.details ??
returnObject?.Details ??
returnObject?.detail ??
returnObject?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.response?.data?.detail ??
anyErr?.details ??
anyErr?.message;
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
return {
title: 'Authentication Required',
message: cleanDetail || 'Your authentication session has expired or is unauthorized (HTTP 401). Please sign in again.',
statusCode: 401,
hasReturnObject,
isProblemDetails: hasReturnObject,
category: 'security',
categoryLabel: 'Unauthorized',
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : 'HTTP 401 Unauthorized',
};
}
if (statusCode === 403) {
const rawDetail =
returnObject?.details ??
returnObject?.Details ??
returnObject?.detail ??
returnObject?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.response?.data?.detail ??
anyErr?.details ??
anyErr?.message;
const detailErrors = parseCommaDelimitedErrors(rawDetail);
const friendlyMessage =
detailErrors.length > 0
? detailErrors.length === 1
? detailErrors[0]
: detailErrors.map((err) => `• ${err}`).join('\n')
: 'You do not have permission to access this resource or perform this action (HTTP 403).';
return {
title: fallbackActionTitle !== 'Action Failed' ? fallbackActionTitle : 'Access Forbidden',
message: friendlyMessage,
statusCode: 403,
hasReturnObject,
isProblemDetails: hasReturnObject,
category: 'security',
categoryLabel: 'Forbidden',
errorsList: detailErrors.length > 0 ? detailErrors : undefined,
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : 'HTTP 403 Forbidden',
};
}
if (hasReturnObject && returnObject) {
const rawDetail =
returnObject.detail ??
returnObject.Detail ??
returnObject.details ??
returnObject.Details ??
anyErr?.response?.data?.detail ??
anyErr?.response?.data?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.detail ??
anyErr?.Detail ??
anyErr?.details ??
anyErr?.Details ??
returnObject.message ??
returnObject.Message ??
returnObject.error ??
returnObject.Error;
const errorsList: string[] = [];
if (Array.isArray(rawDetail)) {
rawDetail.forEach((e: any) => {
const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e));
if (cleaned) errorsList.push(cleaned);
});
} else if (typeof rawDetail === 'string' && rawDetail.trim()) {
errorsList.push(...parseCommaDelimitedErrors(rawDetail));
}
if (errorsList.length === 0 && returnObject.errors) {
if (Array.isArray(returnObject.errors)) {
returnObject.errors.forEach((e: any) => {
const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e));
if (cleaned) errorsList.push(cleaned);
});
} else if (typeof returnObject.errors === 'object') {
for (const [key, val] of Object.entries(returnObject.errors)) {
if (Array.isArray(val)) {
val.forEach((m) => {
const cleaned = cleanTechnicalJargon(String(m));
if (cleaned) errorsList.push(`${key}: ${cleaned}`);
});
} else if (typeof val === 'string' && val.trim()) {
const cleaned = cleanTechnicalJargon(val);
if (cleaned) errorsList.push(`${key}: ${cleaned}`);
}
}
}
}
let category: ErrorCategory = 'general';
let categoryLabel: string = 'Error';
if (statusCode === 400) {
if (operationIntent === 'creation') {
category = 'creation';
categoryLabel = 'Creation Error';
} else if (operationIntent === 'update') {
category = 'update';
categoryLabel = 'Update Error';
} else {
category = 'validation';
categoryLabel = 'Validation Error';
}
} else if (statusCode === 404) {
category = 'notFound';
categoryLabel = 'Not Found';
} else if (statusCode === 409) {
category = 'conflict';
categoryLabel = 'Conflict';
} else if (statusCode === 422) {
category = 'validation';
categoryLabel = 'Validation Error';
} else if (statusCode && statusCode >= 500) {
category = 'server';
categoryLabel = 'Server Error';
}
let friendlyMessage = '';
if (errorsList.length > 1) {
friendlyMessage = errorsList.map((err) => `• ${err}`).join('\n');
} else if (errorsList.length === 1) {
friendlyMessage = errorsList[0];
} else {
friendlyMessage =
operationIntent === 'creation'
? 'Failed to create record. Please verify the submitted parameters.'
: operationIntent === 'update'
? 'Failed to update record. Please verify the submitted parameters.'
: 'Request could not be processed due to invalid parameters.';
}
let displayTitle = fallbackActionTitle;
if (displayTitle === 'Action Failed' && returnObject.title && returnObject.title.toLowerCase() !== 'error') {
displayTitle = returnObject.title;
}
return {
title: displayTitle,
message: friendlyMessage,
statusCode,
hasReturnObject: true,
isProblemDetails: true,
category,
categoryLabel,
errorsList: errorsList.length > 0 ? errorsList : undefined,
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
};
}
let title = fallbackActionTitle || 'Action Failed';
let message = '';
let category: ErrorCategory = 'general';
let categoryLabel: string = 'Error';
const directDetail =
anyErr?.response?.data?.detail ??
anyErr?.response?.data?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.detail ??
anyErr?.Detail ??
anyErr?.details ??
anyErr?.Details;
const fallbackErrorsList: string[] = [];
if (directDetail) {
if (typeof directDetail === 'string' && directDetail.trim()) {
fallbackErrorsList.push(...parseCommaDelimitedErrors(directDetail.trim()));
message = fallbackErrorsList.join('\n');
} else if (Array.isArray(directDetail)) {
fallbackErrorsList.push(...directDetail.map(String));
message = fallbackErrorsList.join('\n');
}
}
if (!message) {
switch (statusCode) {
case 400:
category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation';
categoryLabel = 'Bad Request';
message =
operationIntent === 'creation'
? 'The new record could not be created. Please review the submitted parameters.'
: operationIntent === 'update'
? 'The record could not be updated. Please verify the submitted parameters.'
: 'The request could not be processed due to invalid parameters.';
break;
case 404:
title = 'Record Not Found';
category = 'notFound';
categoryLabel = 'Not Found';
message = 'The requested resource could not be found (HTTP 404). It may have been archived, removed, or the link is outdated.';
break;
case 409:
title = 'Resource Conflict';
category = 'conflict';
categoryLabel = 'Conflict';
message = 'This operation conflicts with the current status of the resource (HTTP 409). Please refresh the page and try again.';
break;
case 422:
category = 'validation';
categoryLabel = 'Incomplete Data';
message = 'The submitted information could not be processed by the server (HTTP 422). Please verify all required fields.';
break;
case 429:
title = 'Rate Limit Exceeded';
category = 'server';
categoryLabel = 'Rate Limit';
message = 'The system received too many requests in a short time (HTTP 429). Please pause for a moment before trying again.';
break;
case 500:
case 502:
case 503:
case 504:
title = 'Service Temporarily Unavailable';
category = 'server';
categoryLabel = 'Service Issue';
message = `The server encountered an error while processing your request (HTTP ${statusCode}). Please try again in a few moments.`;
break;
default:
if (typeof error === 'string' && error.trim()) {
message = cleanTechnicalJargon(error);
} else if (error instanceof Error && error.message) {
message = cleanTechnicalJargon(error.message);
}
if (!message) {
message = statusCode
? `An unexpected server issue occurred (HTTP ${statusCode}). Please try again or contact support.`
: 'An unexpected issue occurred while processing this request. Please try again.';
}
break;
}
}
return {
title,
message,
statusCode,
hasReturnObject: fallbackErrorsList.length > 0,
isProblemDetails: false,
category,
categoryLabel,
errorsList: fallbackErrorsList.length > 0 ? fallbackErrorsList : undefined,
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
};
}
@@ -0,0 +1,191 @@
import Hashids from 'hashids';
const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt:
(env.VITE_HASHER_SALT as string) ||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
};
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
let updated = false;
if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim();
updated = true;
}
if (minLength !== undefined && minLength !== null && minLength !== '') {
const parsed = Number(minLength);
if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed;
updated = true;
}
}
if (updated) {
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
}
}
/**
* Validates if candidate is already a HashID generated by this hasher.
*/
export function isHashId(candidate: string | null | undefined): boolean {
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
return false;
}
try {
const decodedNumbers = hasher.decode(candidate);
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
const decodedHex = hasher.decodeHex(candidate);
if (decodedHex && decodedHex.length > 0) return true;
} catch {
return false;
}
return false;
}
/**
* Encodes a user idNumber using Hashids Hex encoding.
* Supports South African national IDs (13 digits), passports, and alphanumeric keys.
*/
export function encodeIdNumberHex(idNumber: string | number | null | undefined): string {
if (idNumber === undefined || idNumber === null) return '';
const str = String(idNumber).trim();
if (!str) return '';
if (isHashId(str)) {
return str;
}
if (/^[0-9a-fA-F]+$/.test(str)) {
try {
const encoded = hasher.encodeHex(str.toLowerCase());
if (encoded) return encoded;
} catch {
// Fallback below
}
}
try {
const hex = Array.from(str)
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
.join('');
const encoded = hasher.encodeHex(hex);
return encoded || str;
} catch {
return str;
}
}
/**
* Decodes a Hashids Hex encoded string back to original idNumber.
*/
export function decodeIdNumberHex(hash: string | null | undefined): string {
if (!hash || typeof hash !== 'string') return '';
const trimmed = hash.trim();
if (!trimmed) return '';
try {
const decodedHex = hasher.decodeHex(trimmed);
if (!decodedHex) return trimmed;
if (/^[0-9a-fA-F]{4,}$/.test(decodedHex) && decodedHex.length % 2 === 0) {
let isText = true;
let text = '';
for (let i = 0; i < decodedHex.length; i += 2) {
const code = parseInt(decodedHex.slice(i, i + 2), 16);
if (code < 32 || code > 126) {
isText = false;
break;
}
text += String.fromCharCode(code);
}
if (isText && text.length > 0) return text;
}
return decodedHex;
} catch {
return trimmed;
}
}
/**
* Converts any numeric or string ID to a safe 64-bit integer string.
* Preserves precision beyond 2^53 - 1 by avoiding standard float coercion.
*/
export function toSafeInt64String(val: string | number | bigint | null | undefined): string {
if (val === undefined || val === null || val === '') return '';
if (typeof val === 'bigint') return val.toString();
if (typeof val === 'number') {
if (isNaN(val) || !isFinite(val)) return '';
return Math.trunc(val).toString();
}
const str = String(val).trim();
if (/^-?\d+$/.test(str)) {
return str;
}
return str;
}
/**
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
*/
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
if (id === undefined || id === null || id === '') return '';
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
if (!str) return '';
if (isHashId(str)) return str;
if (/^\d+$/.test(str)) {
try {
const num = Number(str);
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
return hasher.encode(num);
}
const bigHex = BigInt(str).toString(16);
return hasher.encodeHex(bigHex);
} catch {
// Fall through
}
}
return str;
}
/**
* Deterministically decodes a standard HashID string to its canonical ID representation as string.
*/
export function decodeEntityId(hashId: string | number | null | undefined): string {
if (hashId === undefined || hashId === null || hashId === '') return '';
const str = String(hashId).trim();
if (!str) return '';
if (/^-?\d+$/.test(str)) return str;
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return decoded[0].toString();
}
const decodedHex = hasher.decodeHex(str);
if (decodedHex) {
try {
return BigInt('0x' + decodedHex).toString();
} catch {
return decodedHex;
}
}
} catch {
// Return raw
}
return str;
}