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
+13 -32
View File
@@ -2,29 +2,10 @@ import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient'; import { queryClient } from './lib/queryClient';
import { AuthProvider, useAuth } from './auth/AuthProvider'; import { AuthProvider, ProtectedRoute, useAuth } from './auth/AuthProvider';
import { ToastProvider } from './components/ui/Toast';
import { LandingPage } from './components/landing/LandingPage'; import { LandingPage } from './components/landing/LandingPage';
import { DashboardPage } from './components/dashboard/DashboardPage'; import { DashboardPage } from './components/dashboard/DashboardPage';
import { LaserDivider } from './components/layout/LaserDivider';
function AuthenticatedLayout() {
return (
<div className="h-screen w-screen overflow-hidden bg-slate-950 text-slate-100 flex flex-row select-none">
{/* Fixed-width vertical navigation pane on the left */}
<aside className="w-64 sm:w-72 md:w-80 shrink-0 h-full relative flex flex-col">
{/* Navigation placeholder / empty clean pane */}
</aside>
{/* Laser beam divider with central light source */}
<LaserDivider />
{/* Content pane occupying the rest of the screen */}
<main className="flex-1 h-full min-w-0 relative flex flex-col overflow-y-auto">
<DashboardPage />
</main>
</div>
);
}
function AppRoutes() { function AppRoutes() {
const { isAuthenticated, isLoading, login } = useAuth(); const { isAuthenticated, isLoading, login } = useAuth();
@@ -55,16 +36,14 @@ function AppRoutes() {
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
isAuthenticated ? ( <ProtectedRoute>
<AuthenticatedLayout /> <DashboardPage />
) : ( </ProtectedRoute>
<LandingPage onLogin={login} isLoading={isLoading} />
)
} }
/> />
<Route <Route
path="*" path="*"
element={<Navigate to={isAuthenticated ? "/dashboard" : "/"} replace />} element={<Navigate to={isAuthenticated ? '/dashboard' : '/'} replace />}
/> />
</Routes> </Routes>
); );
@@ -73,11 +52,13 @@ function AppRoutes() {
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <ToastProvider>
<BrowserRouter> <AuthProvider>
<AppRoutes /> <BrowserRouter>
</BrowserRouter> <AppRoutes />
</AuthProvider> </BrowserRouter>
</AuthProvider>
</ToastProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,323 @@
import React, { useMemo } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthProvider';
export type RoleClaim =
| 'dashboard'
| 'governance'
| 'projects_milestones'
| 'grant_financials'
| 'evidence_risks'
| 'beneficiaries'
| 'change_management'
| 'site_visits'
| 'compliance'
| 'settings_invites'
| 'settings_users'
| 'settings_auditlog'
| 'settings_notifications'
| 'tenant_self_service'
| 'tenant_management'
| 'unrestricted';
export type ClaimAction = 'view' | 'add' | 'update' | 'export' | 'all';
export interface RoleClaimDefinition {
claim: RoleClaim;
label: string;
description: string;
}
export const ROLE_CLAIM_DEFINITIONS: RoleClaimDefinition[] = [
{ claim: 'dashboard', label: 'Partner Dashboard', description: 'Overview of ongoing projects, funding awards, and inbox' },
{ claim: 'governance', label: 'Governance & Structure', description: 'Holding entities, investment portfolios, and statutory programmes' },
{ claim: 'projects_milestones', label: 'Projects & Milestones', description: 'Work breakdown structure, scheduled milestones, and progress' },
{ claim: 'grant_financials', label: 'Grant & Financials', description: 'Funding awards, budget line allocations, and disbursements' },
{ claim: 'evidence_risks', label: 'M&E, Evidence & Risks', description: 'Evidence submission and risk registers' },
{ claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' },
{ claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' },
{ claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' },
{ claim: 'compliance', label: 'Compliance', description: 'Statutory compliance, outcome fulfillment, and risk monitoring' },
{ claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' },
{ claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' },
{ claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' },
{ claim: 'settings_notifications', label: 'Notifications & Messages', description: 'In-app and email communications with the funding organisation' },
{ claim: 'tenant_self_service', label: 'Tenant Self-Service', description: 'Tenant configuration parameters and self-managed sub-users' },
{ claim: 'tenant_management', label: 'Tenant Management', description: 'Multi-tenant partition directory and cross-tenant isolation' },
{ claim: 'unrestricted', label: 'Unrestricted Access', description: 'Full system-wide control' },
];
export const CLAIM_ACTION_OPTIONS: { action: ClaimAction; label: string; description: string }[] = [
{ action: 'view', label: 'view', description: 'Can view records based on role' },
{ action: 'add', label: 'add', description: 'Can create records or give feedback; change non-approve statuses' },
{ action: 'update', label: 'update', description: 'Can use approve status change in dropdowns' },
{ action: 'export', label: 'export', description: 'Can click export buttons (Spreadsheet/PDF)' },
{ action: 'all', label: 'all', description: 'Can do everything' },
];
/**
* RBAC on PartnerUI ONLY functions for Partner roles.
* No other roles (Admin, Executive, Supervisor, Manager, Contributor) are permitted on this app.
*/
export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
partner: [
'dashboard',
'projects_milestones',
'grant_financials',
'evidence_risks',
'beneficiaries',
'change_management',
'site_visits',
'compliance',
'settings_notifications',
],
};
/**
* Checks whether the user's roles include the mandatory 'Partner' role.
* On PartnerUI, ONLY users with the 'Partner' role are permitted.
*/
export function isPartnerRoleOnlyPermitted(roles: string[] = []): boolean {
return Array.isArray(roles) && roles.some((r) => r.toLowerCase().trim() === 'partner');
}
/**
* Resolves action capability set given a claim value string for a Partner user.
*/
export function resolveClaimActionSet(
claimValue: string | null | undefined,
roleName?: string
): Set<ClaimAction> {
const actions = new Set<ClaimAction>();
if (!claimValue && !roleName) return actions;
const rawVal = (claimValue || '').toLowerCase().trim();
const role = (roleName || '').toLowerCase().trim();
// Only Partner role is recognized in PartnerUI
if (role && role !== 'partner') {
return actions;
}
if (rawVal) {
if (rawVal === 'all' || rawVal === 'unrestricted' || rawVal === 'true' || rawVal === '1') {
actions.add('all');
actions.add('view');
actions.add('add');
actions.add('update');
actions.add('export');
return actions;
}
if (rawVal === 'none' || rawVal === 'restricted' || rawVal === 'false' || rawVal === '0') {
return actions;
}
const tokens = rawVal.split(',').map((t) => t.trim());
for (const val of tokens) {
if (val === 'all') {
actions.add('all');
actions.add('view');
actions.add('add');
actions.add('update');
actions.add('export');
} else if (val === 'view' || val === 'read') {
actions.add('view');
} else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') {
actions.add('view');
actions.add('add');
} else if (val === 'update' || val === 'approve' || val === 'verify') {
actions.add('view');
actions.add('add');
actions.add('update');
} else if (val === 'export') {
actions.add('view');
actions.add('export');
}
}
return actions;
}
// Default fallback for Partner role: view, add (submit evidence, log activities, send messages, execute contract), export
if (role === 'partner') {
actions.add('view');
actions.add('add');
actions.add('export');
}
return actions;
}
/**
* Helper to extract explicit claim value from varied dictionary structures
*/
export function extractExplicitClaimValue(
claims: Record<string, any> = {},
claim: RoleClaim
): string | null {
if (!claims || typeof claims !== 'object') return null;
if (claims[claim] !== undefined && claims[claim] !== null) {
return String(claims[claim]).trim();
}
if (claims[`role_claim:${claim}`] !== undefined) {
return String(claims[`role_claim:${claim}`]).trim();
}
if (claims[`claim:${claim}`] !== undefined) {
return String(claims[`claim:${claim}`]).trim();
}
const noUnderscore = claim.replace(/_/g, '');
if (claims[noUnderscore] !== undefined) {
return String(claims[noUnderscore]).trim();
}
const arrayCandidates = [
claims.claims,
claims.UserClaims,
claims.user_claims,
claims.role_claims,
];
for (const arr of arrayCandidates) {
if (Array.isArray(arr)) {
for (const item of arr) {
if (typeof item === 'object' && item !== null) {
const type = (item.type || item.claimType || item.name || '').toLowerCase().trim();
if (type === claim || type === noUnderscore) {
return String(item.value || item.claimValue || 'all').trim();
}
} else if (typeof item === 'string') {
const [type, val] = item.split(':').map((s) => s.trim().toLowerCase());
if (type === claim || type === noUnderscore) {
return val || 'all';
}
}
}
}
}
return null;
}
/**
* Evaluates whether a user with given roles and token claims has permission for a specific claim and action.
*
* CRITICAL RULE FOR PARTNER UI:
* RBAC on PartnerUI ONLY functions for Partner roles. No other roles are permitted on this app.
*/
export function evaluateUserPermission(
roles: string[] = [],
claims: Record<string, any> = {},
claim: RoleClaim,
action: ClaimAction = 'view'
): boolean {
// Security Gate: Only users with the 'Partner' role are permitted on PartnerUI.
const isPartner = isPartnerRoleOnlyPermitted(roles);
if (!isPartner) return false;
// 1. Check if Partner user has explicit claim in claims dictionary
const rawClaimValue = extractExplicitClaimValue(claims, claim);
if (rawClaimValue !== null && rawClaimValue !== '') {
const actionSet = resolveClaimActionSet(rawClaimValue, 'partner');
return actionSet.has('all') || actionSet.has(action);
}
// 2. Check Partner base role mappings
const allowedClaims = ROLE_CLAIM_MAP['partner'];
if (allowedClaims && allowedClaims.includes(claim)) {
const actionSet = resolveClaimActionSet(undefined, 'partner');
if (actionSet.has('all') || actionSet.has(action)) {
return true;
}
}
return false;
}
/**
* Evaluates Maker-Checker separation of duties constraint.
*/
export function isRecordMaker(
createdBy?: string | null,
currentUserId?: string | null,
currentUserSub?: string | null,
currentUserEmail?: string | null
): boolean {
if (!createdBy) return false;
const creator = createdBy.trim().toLowerCase();
if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true;
if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true;
if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true;
return false;
}
/**
* React hook exposing RBAC evaluation helpers for PartnerUI components.
* Enforces that only the Partner role has permissions in this application.
*/
export function useRbac() {
const { roles, claims, user, sub } = useAuth();
const permissions = useMemo(() => {
const isPartner = isPartnerRoleOnlyPermitted(roles);
const canAccess = (claim: RoleClaim, action: ClaimAction = 'view'): boolean => {
if (!isPartner) return false;
return evaluateUserPermission(roles, claims, claim, action);
};
const canView = (claim: RoleClaim): boolean => canAccess(claim, 'view');
const canAdd = (claim: RoleClaim): boolean => canAccess(claim, 'add');
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
const isMaker = (createdBy?: string | null): boolean => {
return isRecordMaker(createdBy, user?.id, sub, user?.email);
};
const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => {
if (!canApprove(claim)) return false;
return !isMaker(createdBy);
};
const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => {
if (!isPartner) return 'Partner role required';
if (!canApprove(claim)) return 'Requires approval permission from the funding organisation';
if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records';
return null;
};
return {
isPartner,
isUnrestricted: false,
canAccess,
canView,
canAdd,
canApprove,
canExport,
isMaker,
canApproveRecord,
getApprovalRestrictionReason,
};
}, [roles, claims, user, sub]);
return permissions;
}
/**
* Route protection component for claim-restricted screens in PartnerUI.
*/
export const ClaimProtectedRoute: React.FC<{
claim: RoleClaim;
action?: ClaimAction;
children: React.ReactNode;
}> = ({ claim, action = 'view', children }) => {
const { canAccess } = useRbac();
const allowed = canAccess(claim, action);
if (!allowed) {
return React.createElement(Navigate, { to: '/dashboard', replace: true });
}
return React.createElement(React.Fragment, null, children);
};
@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import {
ShieldAlert,
RefreshCw,
LogOut,
UserCheck,
CheckCircle2,
AlertTriangle,
ArrowLeft,
Mail,
Building2,
Fingerprint,
} from 'lucide-react';
import { BrandLogo } from '../ui/BrandLogo';
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
interface AccountActivationModalProps {
user: AuthUserProfile | null;
claims: UserClaims;
sub: string;
onSync: (force?: boolean) => Promise<{ success: boolean; isUnauthorized?: boolean; errorMessage?: string }>;
onLogout: () => Promise<void>;
initialError?: string | null;
}
export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
user,
claims,
sub,
onSync,
onLogout,
initialError,
}) => {
const navigate = useNavigate();
const [isSyncing, setIsSyncing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(initialError || null);
const [syncAttemptCount, setSyncAttemptCount] = useState(0);
useEffect(() => {
if (initialError !== undefined) {
setErrorMessage(initialError);
}
}, [initialError]);
useEffect(() => {
cancelSessionExpiration();
dismissSubordinateModals();
}, []);
const displayName =
user?.name ||
(claims.name as string) ||
(claims.preferred_username as string) ||
'Registered Partner';
const displayEmail =
user?.email ||
(claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
'';
const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Partner Organisation';
const role = user?.role || (claims.role as string) || 'Partner';
const handleSyncClick = async () => {
if (isSyncing) return;
setIsSyncing(true);
setErrorMessage(null);
try {
const result = await onSync(true);
if (result.isUnauthorized) {
await onLogout();
navigate('/', { replace: true });
return;
}
if (!result.success) {
setSyncAttemptCount((prev) => prev + 1);
setErrorMessage(
result.errorMessage ||
'Your partner account is not yet enabled. Please ask your funding administrator to activate your account.'
);
}
} catch (err: unknown) {
const errObj = err as { response?: { status?: number }; message?: string };
if (errObj?.response?.status === 401) {
await onLogout();
navigate('/', { replace: true });
return;
}
setErrorMessage(
errObj?.message ||
'Account activation check encountered an error. Please confirm with your administrator and try again.'
);
} finally {
setIsSyncing(false);
}
};
const handleSignOut = async () => {
await onLogout();
navigate('/', { replace: true });
};
const handleNavigateLanding = () => {
navigate('/');
};
const modalContent = (
<div
role="dialog"
aria-modal="true"
aria-labelledby="activation-modal-title"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
>
<div
className="relative w-full max-w-xl bg-[#0d1322] border border-amber-500/30 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-emerald-500" />
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3.5">
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400">
<ShieldAlert className="w-6 h-6" />
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
</span>
</div>
<div>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1">
Access Restricted • Activation Pending
</span>
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
{errorMessage?.toLowerCase().includes('tenant')
? 'Organisation Account Inactive'
: 'Partner Account Pending Activation'}
</h2>
</div>
</div>
<BrandLogo size={36} showGlow={false} animateOnHover={false} />
</div>
</div>
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]">
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>Signed In Successfully</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
You have signed in successfully, but your partner account is waiting to be activated by the funding administrator.
</p>
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
Once your administrator confirms your account is active, click{' '}
<strong className="text-white">Check Account Status</strong> below to open your dashboard.
</p>
</div>
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
<span>Your Partner Profile</span>
<span className="text-amber-400/90 text-[10px] font-mono bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Pending Activation
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<UserCheck className="w-4 h-4 text-emerald-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Name</span>
<span className="font-semibold text-white truncate block">{displayName}</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Mail className="w-4 h-4 text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Email</span>
<span className="font-semibold text-slate-200 truncate block">{displayEmail || 'Not specified'}</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Building2 className="w-4 h-4 text-cyan-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Organisation / Role</span>
<span className="font-semibold text-slate-200 truncate block">
{tenant} ({role})
</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Fingerprint className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Account ID</span>
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
</div>
</div>
</div>
</div>
{errorMessage && (
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150">
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs font-semibold text-rose-300">Account Not Activated Yet</p>
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
{syncAttemptCount > 0 && (
<p className="text-[11px] text-rose-400/80 pt-1">
Checked #{syncAttemptCount} at {new Date().toLocaleTimeString()}
</p>
)}
</div>
</div>
)}
</div>
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2 w-full sm:w-auto">
<button
onClick={handleNavigateLanding}
type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-slate-200 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Home Page</span>
</button>
<button
onClick={handleSignOut}
type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/30 rounded-xl transition cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
</button>
</div>
<button
onClick={handleSyncClick}
disabled={isSyncing}
type="button"
id="activation-sync-btn"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 text-xs font-semibold text-white bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-emerald-600/30 transition cursor-pointer"
>
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
<span>{isSyncing ? 'Checking Status...' : 'Check Account Status'}</span>
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
};
@@ -0,0 +1,179 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import {
ShieldAlert,
LogOut,
Ban,
Mail,
Building2,
Fingerprint,
Lock,
} from 'lucide-react';
import { BrandLogo } from '../ui/BrandLogo';
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
interface NonPartnerAccessDeniedModalProps {
user: AuthUserProfile | null;
claims: UserClaims;
sub: string;
roles: string[];
onLogout: () => Promise<void>;
}
/**
* Blocks any authenticated user who does NOT hold the 'Partner' role.
* RBAC on PartnerUI strictly permits only Partner roles; no other roles are allowed.
*/
export const NonPartnerAccessDeniedModal: React.FC<NonPartnerAccessDeniedModalProps> = ({
user,
claims,
sub,
roles,
onLogout,
}) => {
const navigate = useNavigate();
useEffect(() => {
cancelSessionExpiration();
dismissSubordinateModals();
}, []);
const displayName =
user?.name ||
(claims.name as string) ||
(claims.preferred_username as string) ||
'Authenticated User';
const displayEmail =
user?.email ||
(claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
'';
const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Statutory Enterprise';
const assignedRolesDisplay = roles.length > 0 ? roles.join(', ') : user?.role || (claims.role as string) || 'Unassigned';
const handleLogoutClick = async () => {
await onLogout();
navigate('/', { replace: true });
};
const modalContent = (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="non-partner-denied-title"
aria-describedby="non-partner-denied-description"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto"
>
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl bg-[#0e1424] border border-rose-900/60 shadow-2xl shadow-rose-950/50 my-auto">
<div className="h-2 w-full bg-gradient-to-r from-rose-600 via-red-500 to-amber-500" />
<div className="p-6 sm:p-8 space-y-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-rose-500/15 border border-rose-500/30 text-rose-400">
<Ban className="h-6 w-6 text-rose-400" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-rose-400 bg-rose-950/60 px-2 py-0.5 rounded-md border border-rose-800/60">
<Lock className="w-3 h-3" />
Partner Role Required
</span>
<span className="text-xs text-slate-500">Security Policy</span>
</div>
<h2 id="non-partner-denied-title" className="text-lg font-bold text-white tracking-tight">
Access Denied: Partner Accounts Only
</h2>
<p id="non-partner-denied-description" className="text-xs text-slate-400 leading-relaxed">
This application is exclusively reserved for users assigned the <strong className="text-emerald-300">Partner</strong> role. Your account role (<strong className="text-rose-300">{assignedRolesDisplay}</strong>) is not permitted on the Partner Portal.
</p>
</div>
</div>
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs">
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium">Account Name</span>
<span className="text-white font-semibold">{displayName}</span>
</div>
{displayEmail && (
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5 text-slate-500" />
Email
</span>
<span className="text-slate-200 font-mono">{displayEmail}</span>
</div>
)}
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-slate-500" />
Assigned Role(s)
</span>
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
{assignedRolesDisplay}
</span>
</div>
{tenant && (
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-slate-500" />
Organisation / Tenant
</span>
<span className="text-slate-200 font-medium truncate max-w-[220px]">{tenant}</span>
</div>
)}
{sub && (
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span className="flex items-center gap-1">
<Fingerprint className="w-3 h-3" />
Subject Key
</span>
<span className="font-mono text-slate-400 truncate max-w-[200px]">{sub}</span>
</div>
)}
</div>
<div className="p-4 rounded-xl bg-rose-950/20 border border-rose-900/40 text-xs text-rose-200 leading-relaxed space-y-1.5">
<p className="font-semibold text-rose-300 flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4 text-rose-400" />
Portal Separation Notice
</p>
<p className="text-[11px] text-rose-300/90">
Internal staff, executives, programme managers, and administrators must sign in through the PFM Management Console. Only external grant recipients and project partners with the <strong>Partner</strong> role are permitted to use this portal.
</p>
</div>
<div className="pt-2">
<button
onClick={handleLogoutClick}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-semibold border border-slate-700 transition cursor-pointer shadow-sm"
>
<LogOut className="w-4 h-4 text-rose-400" />
<span>Sign Out & Return to Home</span>
</button>
</div>
</div>
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
<div className="flex items-center gap-2">
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
<span>PFM Partner Portal</span>
</div>
<span>Role Security Gate</span>
</div>
</div>
</div>
);
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
};
@@ -0,0 +1,182 @@
import React, { useEffect, useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react';
import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration';
interface SessionExpiredModalProps {
isOpen: boolean;
onDismiss?: () => void;
reason?: string | null;
countdownSeconds?: number;
}
export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
isOpen,
onDismiss,
reason,
countdownSeconds = 8,
}) => {
const [timeLeftMs, setTimeLeftMs] = useState(countdownSeconds * 1000);
const [isPaused, setIsPaused] = useState(false);
const totalDurationMs = countdownSeconds * 1000;
const isClearingRef = useRef(false);
useEffect(() => {
if (isOpen) {
dismissSubordinateModals();
}
}, [isOpen]);
useEffect(() => {
const handleCancelled = () => {
setTimeLeftMs(countdownSeconds * 1000);
setIsPaused(false);
isClearingRef.current = false;
};
window.addEventListener('pfm:session-expired-cancelled', handleCancelled);
return () => {
window.removeEventListener('pfm:session-expired-cancelled', handleCancelled);
};
}, [countdownSeconds]);
useEffect(() => {
if (!isOpen) {
setTimeLeftMs(countdownSeconds * 1000);
setIsPaused(false);
isClearingRef.current = false;
return;
}
const intervalMs = 50;
const interval = setInterval(() => {
setTimeLeftMs((prev) => {
if (isPaused) return prev;
const next = prev - intervalMs;
if (next <= 0) {
clearInterval(interval);
handleAction();
return 0;
}
return next;
});
}, intervalMs);
return () => clearInterval(interval);
}, [isOpen, isPaused, countdownSeconds]);
const handleAction = () => {
if (isClearingRef.current) return;
isClearingRef.current = true;
if (onDismiss) {
onDismiss();
} else {
clearSessionAndRedirectToLanding();
}
};
if (!isOpen) return null;
const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000));
const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100));
const modalContent = (
<div
id="session-expired-modal-backdrop"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 animate-in fade-in duration-200 overflow-y-auto"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
role="dialog"
aria-modal="true"
aria-labelledby="session-expired-title"
>
<div
id="session-expired-modal-content"
className="relative w-full max-w-md bg-[#0c121e] border border-amber-500/40 rounded-2xl shadow-2xl shadow-black/90 p-6 overflow-hidden text-slate-200 space-y-5 my-auto"
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
<div className="flex items-start justify-between pt-1">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0 shadow-lg shadow-amber-500/10">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
Session Expired
</h3>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-rose-950/60 text-rose-300 border border-rose-800/60">
HTTP 401
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">Please sign in again to continue</p>
</div>
</div>
<button
id="session-expired-close-btn"
type="button"
onClick={handleAction}
aria-label="Dismiss and return to landing page"
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-2.5 text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-4 rounded-xl border border-slate-800/80">
<p>
Your active working session has ended for security. To protect your information, please return to the home page and sign in again.
</p>
{reason && (
<div className="flex items-start gap-2 pt-1 text-[11px] text-amber-300/90 font-mono">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
<span className="break-words">{reason}</span>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-400 flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-amber-400" />
Returning to home page in{' '}
<strong className="text-white font-mono">{secondsRemaining}s</strong>
</span>
{isPaused && (
<span className="text-[10px] text-amber-400/80 font-medium px-1.5 py-0.2 rounded bg-amber-950/40 border border-amber-900/60">
Paused on hover
</span>
)}
</div>
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 transition-all duration-75 ease-linear"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
<button
id="session-expired-return-btn"
type="button"
onClick={handleAction}
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-500/20 transition-all flex items-center justify-center gap-2 cursor-pointer"
>
<LogOut className="w-4 h-4" />
<span>Return to Home Page Now</span>
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
};
File diff suppressed because it is too large Load Diff
@@ -138,9 +138,9 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
#{hashedProjectId} #{hashedProjectId}
</span> </span>
)} )}
{project.code && ( {(project as any).code && (
<span className="font-mono text-xs text-slate-300 bg-white/5 border border-white/10 px-2 py-0.5 rounded-md"> <span className="font-mono text-xs text-slate-300 bg-white/5 border border-white/10 px-2 py-0.5 rounded-md">
{project.code} {(project as any).code}
</span> </span>
)} )}
{renderStatusBadge(project.status, 'In Progress')} {renderStatusBadge(project.status, 'In Progress')}
@@ -162,7 +162,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="bg-slate-950/60 border border-white/10 px-4 py-2 rounded-2xl"> <div className="bg-slate-950/60 border border-white/10 px-4 py-2 rounded-2xl">
<div className="text-[10px] text-slate-400 uppercase tracking-wider font-semibold">Total Budget</div> <div className="text-[10px] text-slate-400 uppercase tracking-wider font-semibold">Total Budget</div>
<div className="text-sm font-bold text-emerald-300 font-mono"> <div className="text-sm font-bold text-emerald-300 font-mono">
{formatCurrency(project.budget || totalAwardsAmount)} {formatCurrency((project as any).budget || totalAwardsAmount)}
</div> </div>
</div> </div>
@@ -390,7 +390,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="flex items-center gap-2 truncate"> <div className="flex items-center gap-2 truncate">
<FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0" /> <FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span className="text-slate-200 font-medium truncate"> <span className="text-slate-200 font-medium truncate">
{ev.title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`} {(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</span> </span>
</div> </div>
{ev.documentUrl && ( {ev.documentUrl && (
@@ -0,0 +1,33 @@
import React from 'react';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
interface BrandLogoProps {
size?: number;
className?: string;
showGlow?: boolean;
animateOnHover?: boolean;
}
export const BrandLogo: React.FC<BrandLogoProps> = ({
size = 32,
className = '',
showGlow = false,
animateOnHover = true,
}) => {
return (
<div
className={`inline-flex items-center justify-center relative ${
animateOnHover ? 'transition-transform duration-300 hover:scale-105' : ''
} ${className}`}
>
{showGlow && (
<div
className="absolute inset-0 rounded-full bg-emerald-500/25 blur-md pointer-events-none"
style={{ width: size, height: size }}
/>
)}
<PfmPartnerLogo size={size} className="relative z-10" />
</div>
);
};
@@ -0,0 +1,378 @@
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import {
CheckCircle2,
AlertCircle,
Info,
X,
Copy,
Check,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { parseFriendlyError, ErrorCategory } from '../../utils/apiErrorParser';
export type ToastType = 'success' | 'error' | 'info';
export interface ToastItem {
id: string;
type: ToastType;
title: string;
message?: string;
duration: number; // in milliseconds
createdAt: number;
statusCode?: number;
category?: ErrorCategory;
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
}
export interface ToastOptions {
duration?: number;
statusCode?: number;
category?: ErrorCategory;
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
}
interface ToastContextType {
toast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => void;
success: (title: string, message?: string, options?: ToastOptions) => void;
error: (titleOrError: unknown, messageOrError?: unknown, options?: ToastOptions) => void;
info: (title: string, message?: string, options?: ToastOptions) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
// Default durations (in ms):
// Errors linger significantly longer (14s) so users have sufficient time to read and note the failure.
const DEFAULT_DURATIONS: Record<ToastType, number> = {
success: 4500,
info: 6000,
error: 14000,
};
const ToastCard: React.FC<{
toast: ToastItem;
onRemove: (id: string) => void;
}> = ({ toast, onRemove }) => {
const [isPaused, setIsPaused] = useState(false);
const [remainingTime, setRemainingTime] = useState(toast.duration);
const [copied, setCopied] = useState(false);
const [showTechnical, setShowTechnical] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (isPaused) {
if (intervalRef.current) clearInterval(intervalRef.current);
return;
}
const step = 100;
intervalRef.current = setInterval(() => {
setRemainingTime((prev) => {
if (prev <= step) {
if (intervalRef.current) clearInterval(intervalRef.current);
onRemove(toast.id);
return 0;
}
return prev - step;
});
}, step);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPaused, toast.id, onRemove]);
const handleCopy = () => {
const textToCopy = [
`PFM Partner Portal Notice: ${toast.title}`,
toast.message ? `Details: ${toast.message}` : '',
toast.statusCode ? `Status Code: ${toast.statusCode}` : '',
toast.technicalDetails ? `Technical Info: ${toast.technicalDetails}` : '',
`Timestamp: ${new Date(toast.createdAt).toISOString()}`,
]
.filter(Boolean)
.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const progressPercent = Math.max(0, Math.min(100, (remainingTime / toast.duration) * 100));
return (
<div
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
className={`pointer-events-auto relative flex flex-col rounded-2xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#0d1322] backdrop-blur-none ${
toast.type === 'success'
? 'border-emerald-500/60 text-gray-100 shadow-emerald-950/40 ring-1 ring-emerald-500/20'
: toast.type === 'error'
? 'border-rose-500/70 text-gray-100 shadow-rose-950/60 ring-1 ring-rose-500/30'
: 'border-teal-500/60 text-gray-100 shadow-teal-950/40 ring-1 ring-teal-500/20'
}`}
>
<div className="flex items-start gap-3 p-4">
{toast.type === 'success' && <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />}
{toast.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />}
{toast.type === 'info' && <Info className="w-5 h-5 text-teal-400 shrink-0 mt-0.5" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<h4 className="text-sm font-semibold text-white tracking-tight leading-snug">{toast.title}</h4>
{toast.categoryLabel && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${
toast.category === 'validation'
? 'bg-amber-950/60 text-amber-300 border-amber-800/60'
: toast.category === 'creation'
? 'bg-emerald-950/60 text-emerald-300 border-emerald-800/60'
: toast.category === 'update'
? 'bg-teal-950/60 text-teal-300 border-teal-800/60'
: toast.category === 'security'
? 'bg-rose-950/60 text-rose-300 border-rose-800/60'
: toast.category === 'notFound'
? 'bg-slate-800 text-slate-300 border-slate-700'
: toast.category === 'conflict'
? 'bg-purple-950/60 text-purple-300 border-purple-800/60'
: 'bg-slate-800 text-slate-300 border-slate-700'
}`}
>
{toast.categoryLabel}
</span>
)}
{toast.statusCode && (
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
HTTP {toast.statusCode}
</span>
)}
</div>
{toast.errorsList && toast.errorsList.length > 0 ? (
<div className="mt-2 space-y-1.5">
<ul className="space-y-1.5">
{toast.errorsList.map((err, idx) => (
<li
key={idx}
className="text-xs text-rose-200/95 flex items-start gap-2 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
>
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
<span className="leading-snug break-words font-medium">{err}</span>
</li>
))}
</ul>
</div>
) : toast.message ? (
<div className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message.includes('\n') ? (
<ul className="space-y-1.5 mt-1">
{toast.message
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((line, idx) => (
<li
key={idx}
className="flex items-start gap-2 text-rose-200/95 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
>
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
<span className="leading-snug break-words font-medium">{line.replace(/^[•\-*]\s*/, '')}</span>
</li>
))}
</ul>
) : (
<p>{toast.message}</p>
)}
</div>
) : null}
{toast.technicalDetails && (
<div className="mt-2.5 pt-2 border-t border-slate-800/80">
<button
type="button"
onClick={() => setShowTechnical(!showTechnical)}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
>
<span>{showTechnical ? 'Hide Technical Reference' : 'Show Technical Reference'}</span>
{showTechnical ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{showTechnical && (
<div className="mt-1.5 p-2 rounded bg-[#090d16] border border-slate-800 font-mono text-[10px] text-slate-400 break-all select-all">
{toast.technicalDetails}
</div>
)}
</div>
)}
{toast.type === 'error' && (
<div className="mt-3 flex items-center justify-between gap-2 pt-2 border-t border-slate-800/60 text-[11px] text-slate-400">
<span className="text-[10px] text-slate-500">
{isPaused ? 'Timer paused (hovering)' : 'Notification lingers for review'}
</span>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 px-2 py-1 rounded bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors cursor-pointer"
title="Copy details to clipboard for support or administrator"
>
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
<span>{copied ? 'Copied' : 'Copy Details'}</span>
</button>
</div>
)}
</div>
<button
onClick={() => onRemove(toast.id)}
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
title="Dismiss notification"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="w-full bg-slate-800/40 h-1 overflow-hidden">
<div
className={`h-full transition-all duration-100 ease-linear ${
toast.type === 'success'
? 'bg-emerald-500'
: toast.type === 'error'
? 'bg-rose-500'
: 'bg-teal-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
);
};
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
const addToast = (
type: ToastType,
title: string,
message?: string,
options?: ToastOptions
) => {
const id = Math.random().toString(36).substring(2, 9);
const duration = options?.duration || DEFAULT_DURATIONS[type];
setToasts((prev) => [
...prev,
{
id,
type,
title,
message,
duration,
createdAt: Date.now(),
statusCode: options?.statusCode,
category: options?.category,
categoryLabel: options?.categoryLabel,
technicalDetails: options?.technicalDetails,
fieldErrors: options?.fieldErrors,
errorsList: options?.errorsList,
},
]);
};
const success = (title: string, message?: string, options?: ToastOptions) =>
addToast('success', title, message, options);
const info = (title: string, message?: string, options?: ToastOptions) =>
addToast('info', title, message, options);
const error = (
titleOrError: unknown,
messageOrError?: unknown,
options?: ToastOptions
) => {
let rawError: unknown = null;
let actionTitle = 'Action Failed';
if (typeof titleOrError === 'string') {
actionTitle = titleOrError;
rawError = messageOrError;
} else {
rawError = titleOrError;
if (typeof messageOrError === 'string') {
actionTitle = messageOrError;
}
}
const parsed = parseFriendlyError(rawError, actionTitle);
let finalMessage = parsed.message;
if (
typeof messageOrError === 'string' &&
messageOrError.trim() &&
!messageOrError.includes('Request failed with status code') &&
(!parsed.hasReturnObject || !parsed.errorsList?.length)
) {
finalMessage = messageOrError.trim();
}
let finalErrorsList = parsed.errorsList || options?.errorsList;
if (!finalErrorsList && typeof finalMessage === 'string' && finalMessage.includes(',')) {
const parts = finalMessage.split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length > 1) {
finalErrorsList = parts;
}
}
addToast('error', parsed.title, finalMessage, {
duration: options?.duration || DEFAULT_DURATIONS.error,
statusCode: parsed.statusCode || options?.statusCode,
category: options?.category || parsed.category,
categoryLabel: options?.categoryLabel || parsed.categoryLabel,
technicalDetails: parsed.technicalDetails || options?.technicalDetails,
fieldErrors: parsed.fieldErrors || options?.fieldErrors,
errorsList: finalErrorsList,
});
};
const toastRegion = (
<div
role="region"
aria-label="System Notifications"
className="fixed bottom-5 right-5 z-[999999] flex flex-col gap-3 max-w-md sm:max-w-lg w-full pointer-events-none p-2"
style={{ zIndex: 999999 }}
>
{toasts.map((t) => (
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
))}
</div>
);
return (
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
{children}
{typeof document !== 'undefined' ? createPortal(toastRegion, document.body) : null}
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
@@ -0,0 +1,357 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useAuth } from '../auth/AuthProvider';
import {
getUserNotifications,
getNotifications,
createNotification,
markNotificationAsRead,
getCurrentUserTenantId,
} from '../services/api';
import {
Notification,
NotificationPlatform,
NotificationStatus,
CreateNotificationRequest,
} from '../types';
export interface DraftMessage {
id: string;
recipientId: string;
recipientEmail: string;
recipientName?: string;
subject: string;
message: string;
platform?: NotificationPlatform;
updatedAt: string;
}
export function useInAppNotifications() {
const { user, claims } = useAuth();
const queryClient = useQueryClient();
const userId = (user?.id || claims?.sub || '') as string;
const userEmail = (user?.email || claims?.email || '') as string;
const draftsStorageKey = `pfm_partner_mailbox_drafts_${userId || 'default'}`;
const starredStorageKey = `pfm_partner_mailbox_starred_${userId || 'default'}`;
const trashStorageKey = `pfm_partner_mailbox_trash_${userId || 'default'}`;
const sentStorageKey = `pfm_partner_mailbox_sent_${userId || 'default'}`;
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
try {
const stored = localStorage.getItem(key);
const parsed: T[] = stored ? JSON.parse(stored) : [];
if (fallbackKey && fallbackKey !== key) {
const fallbackStored = localStorage.getItem(fallbackKey);
const fallbackParsed: T[] = fallbackStored ? JSON.parse(fallbackStored) : [];
if (fallbackParsed.length > 0) {
const merged = [...parsed];
for (const item of fallbackParsed) {
const itemId = (item as any)?.id ?? item;
if (!merged.some(m => ((m as any)?.id ?? m) === itemId)) {
merged.push(item);
}
}
localStorage.setItem(key, JSON.stringify(merged));
localStorage.removeItem(fallbackKey);
return merged;
}
}
return parsed;
} catch {
return [];
}
}, []);
const [starredIds, setStarredIds] = useState<string[]>(() =>
readStorageList<string>(starredStorageKey, 'pfm_partner_mailbox_starred_default')
);
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
readStorageList<string>(trashStorageKey, 'pfm_partner_mailbox_trash_default')
);
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_partner_mailbox_drafts_default')
);
const [localSent, setLocalSent] = useState<Notification[]>(() =>
readStorageList<Notification>(sentStorageKey, 'pfm_partner_mailbox_sent_default')
);
const syncFromStorage = useCallback(() => {
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_partner_mailbox_starred_default'));
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_partner_mailbox_trash_default'));
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_partner_mailbox_drafts_default'));
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_partner_mailbox_sent_default'));
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
useEffect(() => {
syncFromStorage();
}, [syncFromStorage]);
useEffect(() => {
const handleSync = () => syncFromStorage();
window.addEventListener('pfm-mailbox-sync', handleSync);
window.addEventListener('storage', handleSync);
return () => {
window.removeEventListener('pfm-mailbox-sync', handleSync);
window.removeEventListener('storage', handleSync);
};
}, [syncFromStorage]);
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
queryKey: ['inapp-notifications', userId],
queryFn: async () => {
let list = await getUserNotifications(userId || undefined);
if (!list || list.length === 0) {
const orgList = await getNotifications();
if (orgList && orgList.length > 0) {
list = orgList;
}
}
return list || [];
},
refetchInterval: 30000,
staleTime: 10000,
});
const inAppNotifications = useMemo(() => {
return rawNotifications.filter(
n => Number(n.platform) === NotificationPlatform.InApp || n.platform === (NotificationPlatform.InApp as any)
);
}, [rawNotifications]);
const inboxNotifications = useMemo(() => {
return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
const allMailboxNotifications = inboxNotifications;
const unreadNotifications = useMemo(() => {
return inboxNotifications.filter(
n =>
Number(n.status) !== NotificationStatus.Read &&
Number(n.status) !== NotificationStatus.Cancelled &&
Number(n.status) !== NotificationStatus.Failed
);
}, [inboxNotifications]);
const unreadCount = unreadNotifications.length;
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
const starredNotifications = useMemo(() => {
return inAppNotifications.filter(
n =>
starredIds.includes(String(n.id)) &&
!trashedIds.includes(String(n.id))
);
}, [inAppNotifications, starredIds, trashedIds]);
const trashedNotifications = useMemo(() => {
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
}, [inAppNotifications, trashedIds]);
const markAsReadMutation = useMutation({
mutationFn: async (id: number | string) => {
await markNotificationAsRead(id);
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['inapp-notifications', userId] });
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === String(id) ? { ...n, status: NotificationStatus.Read } : n))
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
},
});
const markAllAsRead = useCallback(async () => {
const unread = unreadNotifications;
for (const item of unread) {
try {
await markNotificationAsRead(item.id);
} catch {}
}
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}, [unreadNotifications, queryClient]);
const toggleStar = useCallback((id: string | number) => {
const strId = String(id);
setStarredIds(prev => {
const next = prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId];
try {
localStorage.setItem(starredStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [starredStorageKey]);
const deleteMessage = useCallback((id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.includes(strId) ? prev : [...prev, strId];
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [trashStorageKey]);
const restoreMessage = useCallback((id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [trashStorageKey]);
const permanentDelete = useCallback((id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
} catch {}
return next;
});
setLocalSent(prev => {
const next = prev.filter(n => String(n.id) !== strId);
try {
localStorage.setItem(sentStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [trashStorageKey, sentStorageKey]);
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
const draftId = draft.id || `draft_${Date.now()}`;
const newDraft: DraftMessage = {
...draft,
id: draftId,
updatedAt: new Date().toISOString(),
};
setDrafts(prev => {
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_partner_mailbox_drafts_default');
const base = current.length >= prev.length ? current : prev;
const filtered = base.filter(d => d.id !== draftId);
const next = [newDraft, ...filtered];
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
return draftId;
}, [draftsStorageKey, readStorageList]);
const deleteDraft = useCallback((id: string) => {
setDrafts(prev => {
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_partner_mailbox_drafts_default');
const base = current.length >= prev.length ? current : prev;
const next = base.filter(d => d.id !== id);
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [draftsStorageKey, readStorageList]);
const sendMessageMutation = useMutation({
mutationFn: async (payload: {
recipientId: string;
recipientEmail: string;
recipientName?: string;
subject: string;
message: string;
platform?: NotificationPlatform;
organisationId?: number | string;
draftId?: string;
}) => {
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
const targetPlatform = payload.platform || NotificationPlatform.InApp;
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'Partner User'}`;
const req: CreateNotificationRequest = {
organisationId: orgId,
recipient: payload.recipientId,
platform: targetPlatform,
subject: payload.subject,
destination: formattedDestination,
message: payload.message,
status: targetPlatform === NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued,
};
await createNotification(req);
const sentItem: Notification = {
id: `sent_${Date.now()}`,
organisationId: orgId,
recipient: payload.recipientId,
platform: targetPlatform,
status: NotificationStatus.Sent,
subject: payload.subject,
message: payload.message,
destination: formattedDestination,
createdAt: new Date().toISOString(),
sentAt: new Date().toISOString(),
};
setLocalSent(prev => {
const next = [sentItem, ...prev];
try {
localStorage.setItem(sentStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
if (payload.draftId) {
deleteDraft(payload.draftId);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
},
});
return {
rawNotifications,
inAppNotifications,
allMailboxNotifications,
inboxNotifications,
unreadNotifications,
unreadCount,
top5Unread,
starredNotifications,
trashedNotifications,
sentNotifications: localSent,
drafts,
starredIds,
trashedIds,
isLoading,
refetch,
markAsRead: (id: string | number) => markAsReadMutation.mutate(id),
markAllAsRead,
toggleStar,
deleteMessage,
restoreMessage,
permanentDelete,
saveDraft,
deleteDraft,
sendMessage: sendMessageMutation.mutateAsync,
isSending: sendMessageMutation.isPending,
};
}
@@ -0,0 +1,683 @@
import {
ApprovalStatus,
ComplianceStatus,
ContractStatus,
ActivityStatus,
Priority,
OrganisationType,
IndustrySector,
ProjectType,
UnitOfMeasure,
EvidenceType,
RiskLikelihood,
RiskImpact,
NotificationPlatform,
NotificationStatus,
EmailStatuses,
UserRole,
} from '../types';
export const formatZAR = (amount: number): string => {
return new Intl.NumberFormat('en-ZA', {
style: 'currency',
currency: 'ZAR',
maximumFractionDigits: 0,
}).format(amount || 0);
};
export const formatCompactZAR = (amount: number): string => {
const num = Number(amount) || 0;
if (num >= 1_000_000_000) {
return `R ${(num / 1_000_000_000).toFixed(2)}bn`;
}
if (num >= 1_000_000) {
return `R ${(num / 1_000_000).toFixed(1)}m`;
}
if (num >= 1_000) {
return `R ${(num / 1_000).toFixed(0)}k`;
}
return formatZAR(num);
};
export const formatDate = (dateStr?: string | null): string => {
if (!dateStr) return '—';
try {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
} catch {
return dateStr;
}
};
export const formatShortDate = (dateStr?: string | null): string => {
if (!dateStr) return '—';
try {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return dateStr;
}
};
export const EMAIL_STATUS_OPTIONS = [
{ value: EmailStatuses.GeneralError, label: 'General Error' },
{ value: EmailStatuses.AuthenticationError, label: 'Authentication Error' },
{ value: EmailStatuses.ProtocolError, label: 'Protocol Error' },
{ value: EmailStatuses.Connected, label: 'Connected' },
{ value: EmailStatuses.Disconnected, label: 'Disconnected' },
{ value: EmailStatuses.TooManyConnections, label: 'Too Many Connections' },
{ value: EmailStatuses.ConnectionAborted, label: 'Connection Aborted' },
{ value: EmailStatuses.Success, label: 'Success' },
];
export const EMAIL_STATUSES_OPTIONS = EMAIL_STATUS_OPTIONS;
export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Pending, label: 'Pending' },
{ value: NotificationStatus.Queued, label: 'Queued' },
{ value: NotificationStatus.Processing, label: 'Processing' },
{ value: NotificationStatus.Sent, label: 'Sent' },
{ value: NotificationStatus.Delivered, label: 'Delivered' },
{ value: NotificationStatus.Read, label: 'Read' },
{ value: NotificationStatus.Failed, label: 'Failed' },
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' },
{ value: NotificationStatus.Draft, label: 'Draft' },
];
export const NOTIFICATION_PLATFORM_OPTIONS = [
{ value: NotificationPlatform.Email, label: 'Email' },
{ value: NotificationPlatform.SMS, label: 'SMS' },
{ value: NotificationPlatform.WhatsApp, label: 'WhatsApp' },
{ value: NotificationPlatform.PushNotification, label: 'Push Notification' },
{ value: NotificationPlatform.InApp, label: 'In App' },
{ value: NotificationPlatform.Teams, label: 'Teams' },
{ value: NotificationPlatform.Slack, label: 'Slack' },
{ value: NotificationPlatform.Webhook, label: 'Webhook' },
{ value: NotificationPlatform.Other, label: 'Other' },
];
export const EVIDENCE_TYPE_OPTIONS = [
{ value: EvidenceType.AttendanceRegister, label: 'Attendance Register' },
{ value: EvidenceType.BankStatement, label: 'Bank Statement' },
{ value: EvidenceType.CompletionCertificate, label: 'Completion Certificate' },
{ value: EvidenceType.FinancialInvoiceOrReceipt, label: 'Financial Invoice Or Receipt' },
{ value: EvidenceType.GeotaggedPhoto, label: 'Geotagged Photo' },
{ value: EvidenceType.InspectionReport, label: 'Inspection Report' },
{ value: EvidenceType.MeetingMinutes, label: 'Meeting Minutes' },
{ value: EvidenceType.PayrollOrStipendRegister, label: 'Payroll Or Stipend Register' },
{ value: EvidenceType.ProcurementDocument, label: 'Procurement Document' },
{ value: EvidenceType.TechnicalSpecificationOrDesign, label: 'Technical Specification Or Design' },
{ value: EvidenceType.VideoRecording, label: 'Video Recording' },
{ value: EvidenceType.Other, label: 'Other' },
];
export const PROJECT_TYPE_OPTIONS = [
{ value: ProjectType.CapacityBuildingAndTraining, label: 'Capacity Building And Training' },
{ value: ProjectType.CapitalExpenditureAndInfrastructure, label: 'Capital Expenditure And Infrastructure' },
{ value: ProjectType.CommercializationAndScaling, label: 'Commercialization And Scaling' },
{ value: ProjectType.FeasibilityAndTechnicalAssistance, label: 'Feasibility And Technical Assistance' },
{ value: ProjectType.OperationalGrantAndSubsidy, label: 'Operational Grant And Subsidy' },
{ value: ProjectType.ProductDevelopmentAndPrototyping, label: 'Product Development And Prototyping' },
{ value: ProjectType.ResearchAndDevelopment, label: 'Research And Development' },
{ value: ProjectType.SocialImpactAndCommunityDevelopment, label: 'Social Impact And Community Development' },
{ value: ProjectType.TechnologyTransferAndAdoption, label: 'Technology Transfer And Adoption' },
{ value: ProjectType.Innovation, label: 'Innovation' },
{ value: ProjectType.Other, label: 'Other' },
];
export const INDUSTRY_SECTOR_OPTIONS = [
{ value: IndustrySector.AgricultureAndAgroProcessing, label: 'Agriculture And Agro Processing' },
{ value: IndustrySector.AutomotiveAndTransportEquipment, label: 'Automotive And Transport Equipment' },
{ value: IndustrySector.ChemicalsAndPharmaceuticals, label: 'Chemicals And Pharmaceuticals' },
{ value: IndustrySector.ConstructionAndInfrastructure, label: 'Construction And Infrastructure' },
{ value: IndustrySector.CreativeIndustriesAndMedia, label: 'Creative Industries And Media' },
{ value: IndustrySector.DefenseAndAerospace, label: 'Defense And Aerospace' },
{ value: IndustrySector.EducationAndSkillsDevelopment, label: 'Education And Skills Development' },
{ value: IndustrySector.EnergyAndUtilities, label: 'Energy And Utilities' },
{ value: IndustrySector.FinancialServices, label: 'Financial Services' },
{ value: IndustrySector.HealthcareAndLifeSciences, label: 'Healthcare And Life Sciences' },
{ value: IndustrySector.InformationCommunicationTechnology, label: 'Information Communication Technology' },
{ value: IndustrySector.ManufacturingAndIndustrial, label: 'Manufacturing And Industrial' },
{ value: IndustrySector.MiningAndMetals, label: 'Mining And Metals' },
{ value: IndustrySector.RealEstateAndHousing, label: 'Real Estate And Housing' },
{ value: IndustrySector.RetailAndConsumerGoods, label: 'Retail And Consumer Goods' },
{ value: IndustrySector.TourismAndHospitality, label: 'Tourism And Hospitality' },
{ value: IndustrySector.WaterAndSanitation, label: 'Water And Sanitation' },
{ value: IndustrySector.Other, label: 'Other' },
];
export const ORGANISATION_TYPE_OPTIONS = [
{ value: OrganisationType.NonGovernmentalOrganisation, label: 'Non-Governmental Organisation' },
{ value: OrganisationType.CivilSocietyOrganisation, label: 'Civil Society Organisation' },
{ value: OrganisationType.Trust, label: 'Trust' },
{ value: OrganisationType.FaithBasedOrganisation, label: 'Faith-Based Organisation' },
{ value: OrganisationType.PrivateLimitedCompany, label: 'Private Limited Company' },
{ value: OrganisationType.PublicLimitedCompany, label: 'Public Limited Company' },
{ value: OrganisationType.SoleProprietorship, label: 'Sole Proprietorship' },
{ value: OrganisationType.Partnership, label: 'Partnership' },
{ value: OrganisationType.SocialEnterprise, label: 'Social Enterprise' },
{ value: OrganisationType.NationalGovernmentMinistry, label: 'National Government Ministry' },
{ value: OrganisationType.ProvincialGovernmentEntity, label: 'Provincial Government Entity' },
{ value: OrganisationType.LocalGovernmentMunicipality, label: 'Local Government Municipality' },
{ value: OrganisationType.StateOwnedEnterprise, label: 'State-Owned Enterprise' },
{ value: OrganisationType.PublicAgency, label: 'Public Agency' },
{ value: OrganisationType.PublicUniversity, label: 'Public University' },
{ value: OrganisationType.PrivateUniversity, label: 'Private University' },
{ value: OrganisationType.ResearchInstitute, label: 'Research Institute' },
{ value: OrganisationType.TechnicalVocationalCollege, label: 'Technical Vocational College' },
{ value: OrganisationType.Cooperative, label: 'Cooperative' },
{ value: OrganisationType.MicrofinanceInstitution, label: 'Microfinance Institution' },
{ value: OrganisationType.CommercialBank, label: 'Commercial Bank' },
{ value: OrganisationType.DevelopmentFinanceInstitution, label: 'Development Finance Institution' },
{ value: OrganisationType.UnitedNationsAgency, label: 'United Nations Agency' },
{ value: OrganisationType.IntergovernmentalOrganisation, label: 'Intergovernmental Organisation' },
{ value: OrganisationType.InternationalNGO, label: 'International NGO' },
{ value: OrganisationType.Consortium, label: 'Consortium' },
{ value: OrganisationType.SpecialPurposeVehicle, label: 'Special Purpose Vehicle' },
];
export const PRIORITY_OPTIONS = [
{ value: Priority.Low, label: 'Low' },
{ value: Priority.Medium, label: 'Medium' },
{ value: Priority.High, label: 'High' },
{ value: Priority.Critical, label: 'Critical' },
];
export const UNIT_OF_MEASURE_OPTIONS = [
{ value: UnitOfMeasure.Count, label: 'Count' },
{ value: UnitOfMeasure.Percentage, label: 'Percentage' },
{ value: UnitOfMeasure.Currency, label: 'Currency' },
{ value: UnitOfMeasure.Area, label: 'Area' },
{ value: UnitOfMeasure.Weight, label: 'Weight' },
{ value: UnitOfMeasure.Volume, label: 'Volume' },
{ value: UnitOfMeasure.DurationDays, label: 'Duration (Days)' },
{ value: UnitOfMeasure.DurationMonths, label: 'Duration (Months)' },
{ value: UnitOfMeasure.Ratio, label: 'Ratio' },
];
export const CONTRACT_STATUS_OPTIONS = [
{ value: ContractStatus.Draft, label: 'Draft' },
{ value: ContractStatus.PendingApproval, label: 'Pending Approval' },
{ value: ContractStatus.Active, label: 'Active' },
{ value: ContractStatus.Expired, label: 'Expired' },
{ value: ContractStatus.Terminated, label: 'Terminated' },
{ value: ContractStatus.Amended, label: 'Amended' },
];
export const APPROVAL_STATUS_OPTIONS = [
{ value: ApprovalStatus.Pending, label: 'Pending' },
{ value: ApprovalStatus.Review, label: 'Review' },
{ value: ApprovalStatus.Approved, label: 'Approved' },
{ value: ApprovalStatus.Rejected, label: 'Rejected' },
{ value: ApprovalStatus.Removed, label: 'Removed' },
];
export const ACTIVITY_STATUS_OPTIONS = [
{ value: ActivityStatus.Active, label: 'Active' },
{ value: ActivityStatus.Inactive, label: 'Inactive' },
{ value: ActivityStatus.Disabled, label: 'Disabled' },
];
export const RISK_LIKELIHOOD_OPTIONS = [
{ value: RiskLikelihood.Rare, label: 'Rare' },
{ value: RiskLikelihood.Unlikely, label: 'Unlikely' },
{ value: RiskLikelihood.Possible, label: 'Possible' },
{ value: RiskLikelihood.Likely, label: 'Likely' },
{ value: RiskLikelihood.AlmostCertain, label: 'Almost Certain' },
];
export const RISK_IMPACT_OPTIONS = [
{ value: RiskImpact.Minor, label: 'Minor' },
{ value: RiskImpact.Moderate, label: 'Moderate' },
{ value: RiskImpact.Major, label: 'Major' },
{ value: RiskImpact.Critical, label: 'Critical' },
{ value: RiskImpact.Severe, label: 'Severe' },
];
export const getApprovalStatusBadge = (status: ApprovalStatus | number) => {
const s = Number(status);
switch (s) {
case ApprovalStatus.Approved:
return {
label: 'Approved',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
dot: 'bg-emerald-500',
};
case ApprovalStatus.Review:
return {
label: 'In Review',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
dot: 'bg-amber-500',
};
case ApprovalStatus.Rejected:
return {
label: 'Needs Changes',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
dot: 'bg-rose-500',
};
case ApprovalStatus.Removed:
return {
label: 'Removed',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
dot: 'bg-slate-500',
};
case ApprovalStatus.Pending:
default:
return {
label: 'Pending',
className: 'bg-teal-500/10 text-teal-300 border-teal-500/30 font-semibold',
dot: 'bg-teal-400',
};
}
};
export const getComplianceStatusBadge = (status: ComplianceStatus | number) => {
const s = Number(status);
switch (s) {
case ComplianceStatus.Exceeded:
return {
label: 'Exceeded Expectation',
className: 'bg-purple-500/15 text-purple-300 border-purple-500/30 font-semibold',
dot: 'bg-purple-400',
};
case ComplianceStatus.Met:
return {
label: 'Met',
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 font-semibold',
dot: 'bg-emerald-400',
};
case ComplianceStatus.NotMet:
default:
return {
label: 'Not Met',
className: 'bg-rose-500/15 text-rose-300 border-rose-500/30 font-semibold',
dot: 'bg-rose-400',
};
}
};
export const COMPLIANCE_STATUS_OPTIONS = [
{ value: ComplianceStatus.NotMet, label: 'Not Met' },
{ value: ComplianceStatus.Met, label: 'Met' },
{ value: ComplianceStatus.Exceeded, label: 'Exceeded Expectation' },
];
export const getContractStatusBadge = (status: ContractStatus | number) => {
const s = Number(status);
switch (s) {
case ContractStatus.Active:
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
case ContractStatus.PendingApproval:
return {
label: 'Pending Approval',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
};
case ContractStatus.Amended:
return {
label: 'Amended',
className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30 font-semibold',
};
case ContractStatus.Expired:
return {
label: 'Expired',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
};
case ContractStatus.Terminated:
return {
label: 'Terminated',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
};
case ContractStatus.Draft:
default:
return {
label: 'Draft',
className: 'bg-slate-800/80 text-slate-300 border-slate-700/80 font-semibold',
};
}
};
export const getActivityStatusBadge = (status: ActivityStatus | number | string | boolean | null | undefined) => {
if (status === null || status === undefined) {
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
}
if (typeof status === 'boolean') {
return status
? { label: 'Active', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold' }
: { label: 'Inactive', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold' };
}
const str = String(status).trim().toLowerCase();
if (str === '1' || str === 'active' || str === 'enabled') {
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
}
if (str === '2' || str === 'inactive') {
return {
label: 'Inactive',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
};
}
if (str === '3' || str === 'disabled' || str === 'suspended' || str === 'terminated') {
return {
label: str === 'suspended' ? 'Suspended' : 'Disabled',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
};
}
if (str === 'approved') {
return {
label: 'Approved',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
}
if (str === 'pending' || str === 'review') {
return {
label: 'Pending',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
};
}
const s = Number(status);
switch (s) {
case ActivityStatus.Active:
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
case ActivityStatus.Inactive:
return {
label: 'Inactive',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
};
case ActivityStatus.Disabled:
return {
label: 'Disabled',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
};
default:
return {
label: String(status),
className: 'bg-slate-800/80 text-slate-300 border-slate-700/80 font-semibold',
};
}
};
export const getOrganisationStatusBadge = (statusOrOrg: any) => {
let val: any = statusOrOrg;
if (statusOrOrg !== null && typeof statusOrOrg === 'object') {
val =
statusOrOrg.status ??
statusOrOrg.Status ??
statusOrOrg.activityStatus ??
statusOrOrg.ActivityStatus ??
statusOrOrg.approvalStatus ??
statusOrOrg.ApprovalStatus ??
statusOrOrg.statusId ??
statusOrOrg.StatusId ??
statusOrOrg.state ??
statusOrOrg.State ??
(statusOrOrg.isActive !== undefined ? (statusOrOrg.isActive ? 1 : 2) : undefined) ??
(statusOrOrg.IsActive !== undefined ? (statusOrOrg.IsActive ? 1 : 2) : undefined);
}
return getActivityStatusBadge(val);
};
export const getPriorityBadge = (priority: Priority | number) => {
const p = Number(priority);
switch (p) {
case Priority.Critical:
return { label: 'Critical', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold' };
case Priority.High:
return { label: 'High', className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold' };
case Priority.Medium:
return { label: 'Medium', className: 'bg-teal-500/10 text-teal-300 border-teal-500/30 font-semibold' };
case Priority.Low:
default:
return { label: 'Low', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold' };
}
};
export const getOrganisationTypeLabel = (type: OrganisationType | number): string => {
const opt = ORGANISATION_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Organisation';
};
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => {
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector));
return opt ? opt.label : 'Industry Sector';
};
export const getProjectTypeLabel = (type: ProjectType | number): string => {
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Project Type';
};
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
const opt = EVIDENCE_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Evidence Document';
};
export const getUnitOfMeasureLabel = (uom?: UnitOfMeasure | number | null): string => {
if (uom === null || uom === undefined) return 'Qty';
const opt = UNIT_OF_MEASURE_OPTIONS.find((o) => o.value === Number(uom));
return opt ? opt.label : 'Qty';
};
export const getNotificationPlatformLabel = (platform?: NotificationPlatform | number | null): string => {
const opt = NOTIFICATION_PLATFORM_OPTIONS.find((o) => o.value === Number(platform));
return opt ? opt.label : 'Platform';
};
export const getNotificationStatusLabel = (status?: NotificationStatus | number | null): string => {
const opt = NOTIFICATION_STATUS_OPTIONS.find((o) => o.value === Number(status));
return opt ? opt.label : 'Status';
};
export const getEmailStatusLabel = (status?: EmailStatuses | number | null): string => {
const opt = EMAIL_STATUS_OPTIONS.find((o) => o.value === Number(status));
return opt ? opt.label : 'Status';
};
export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
const s = Number(status);
switch (s) {
case EmailStatuses.Connected:
case EmailStatuses.Success:
return { label: getEmailStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
case EmailStatuses.Disconnected:
return { label: getEmailStatusLabel(s), className: 'bg-slate-100 text-slate-700 border-slate-300 font-semibold' };
default:
return { label: getEmailStatusLabel(s), className: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
}
};
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status);
switch (s) {
case NotificationStatus.Draft:
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
case NotificationStatus.Delivered:
case NotificationStatus.Read:
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
case NotificationStatus.Sent:
case NotificationStatus.Queued:
case NotificationStatus.Processing:
return { label: getNotificationStatusLabel(s), className: 'bg-sky-50 text-sky-800 border-sky-300 font-semibold' };
case NotificationStatus.Pending:
return { label: getNotificationStatusLabel(s), className: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' };
case NotificationStatus.Failed:
case NotificationStatus.Cancelled:
case NotificationStatus.Bounced:
default:
return { label: getNotificationStatusLabel(s), className: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
}
};
export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: RiskImpact | number) => {
const score = (Number(likelihood) || 1) * (Number(impact) || 1);
if (score >= 15) {
return { level: 'Critical', color: 'bg-rose-600 text-white', badge: 'bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold' };
}
if (score >= 10) {
return { level: 'High', color: 'bg-orange-500 text-white', badge: 'bg-orange-500/20 text-orange-300 border border-orange-500/40 font-bold' };
}
if (score >= 5) {
return { level: 'Moderate', color: 'bg-amber-500 text-white', badge: 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold' };
}
return { level: 'Low', color: 'bg-emerald-600 text-white', badge: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-semibold' };
};
const USER_EMAIL_CACHE: Record<string, string> = {};
const EMAIL_TO_USER_ID_CACHE: Record<string, string> = {};
export const registerUserEmail = (userId: string | number, email: string) => {
if (userId && email && typeof email === 'string' && email.trim()) {
const cleanId = String(userId).trim();
const cleanEmail = email.trim();
USER_EMAIL_CACHE[cleanId] = cleanEmail;
USER_EMAIL_CACHE[cleanId.toLowerCase()] = cleanEmail;
EMAIL_TO_USER_ID_CACHE[cleanEmail.toLowerCase()] = cleanId;
}
};
export const registerUserEmails = (users: Array<{ id: string | number; email?: string | null }>) => {
if (!Array.isArray(users)) return;
for (const u of users) {
if (u && u.id && u.email) {
registerUserEmail(u.id, u.email);
}
}
};
export const getUserIdByEmail = (email?: string | null): string | undefined => {
if (!email || typeof email !== 'string') return undefined;
return EMAIL_TO_USER_ID_CACHE[email.trim().toLowerCase()];
};
export const getUserEmail = (userIdOrEmail?: string | number | null): string => {
if (!userIdOrEmail) return '—';
const str = String(userIdOrEmail).trim();
if (!str) return '—';
if (str.includes('@')) return str;
if (USER_EMAIL_CACHE[str]) return USER_EMAIL_CACHE[str];
if (USER_EMAIL_CACHE[str.toLowerCase()]) return USER_EMAIL_CACHE[str.toLowerCase()];
try {
const storedUserJson = localStorage.getItem('pfm_auth_user');
const storedSub = localStorage.getItem('pfm_user_sub');
if (storedUserJson) {
const parsed = JSON.parse(storedUserJson);
if (parsed?.email) {
const email = String(parsed.email).trim();
if (
(parsed.id && String(parsed.id).toLowerCase() === str.toLowerCase()) ||
(parsed.sub && String(parsed.sub).toLowerCase() === str.toLowerCase()) ||
(storedSub && String(storedSub).toLowerCase() === str.toLowerCase())
) {
registerUserEmail(str, email);
return email;
}
}
}
} catch {}
return str;
};
export const USER_ROLE_OPTIONS = [
{ value: 'Partner', label: 'Partner' },
{ value: 'Admin', label: 'Admin' },
{ value: 'Executive', label: 'Executive' },
{ value: 'Supervisor', label: 'Supervisor' },
{ value: 'Manager', label: 'Manager' },
{ value: 'Contributor', label: 'Contributor' },
{ value: 'Unassigned', label: 'Unassigned' },
];
export const getUserRoleBadge = (role?: string | number | UserRole | null) => {
if (role === null || role === undefined || role === '' || String(role).trim().toLowerCase() === 'null' || String(role).trim().toLowerCase() === 'unassigned') {
return {
label: 'Unassigned',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 shadow-xs',
dot: 'bg-slate-500',
};
}
const roleStr = String(role).trim().toLowerCase();
if (roleStr === 'partner') {
return {
label: 'Partner',
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 shadow-xs shadow-emerald-500/10 font-semibold',
dot: 'bg-emerald-400',
};
}
if (roleStr === 'admin' || roleStr === 'superadmin' || roleStr === '1') {
return {
label: 'Admin',
className: 'bg-rose-500/15 text-rose-300 border-rose-500/30 shadow-xs shadow-rose-500/10 font-semibold',
dot: 'bg-rose-400',
};
}
if (roleStr === 'executive' || roleStr === '2') {
return {
label: 'Executive',
className: 'bg-amber-500/15 text-amber-300 border-amber-500/30 shadow-xs shadow-amber-500/10 font-semibold',
dot: 'bg-amber-400',
};
}
if (roleStr === 'supervisor' || roleStr === '3') {
return {
label: 'Supervisor',
className: 'bg-purple-500/15 text-purple-300 border-purple-500/30 shadow-xs shadow-purple-500/10 font-semibold',
dot: 'bg-purple-400',
};
}
if (roleStr === 'manager' || roleStr === 'programmemanager' || roleStr === 'projectmanager' || roleStr === '4') {
return {
label: 'Manager',
className: 'bg-blue-500/15 text-blue-300 border-blue-500/30 shadow-xs shadow-blue-500/10 font-semibold',
dot: 'bg-blue-400',
};
}
if (roleStr === 'contributor' || roleStr === 'viewer' || roleStr === 'recipientviewer' || roleStr === '5') {
return {
label: 'Contributor',
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 shadow-xs shadow-emerald-500/10 font-semibold',
dot: 'bg-emerald-400',
};
}
const formatted = roleStr.charAt(0).toUpperCase() + roleStr.slice(1);
return {
label: formatted,
className: 'bg-cyan-500/15 text-cyan-300 border-cyan-500/30',
dot: 'bg-cyan-400',
};
};
@@ -0,0 +1,100 @@
/**
* Markdown & Branded HTML Formatting Utilities for PostFundManagement (PFM)
*/
export function isHtmlContent(content: string): boolean {
if (!content) return false;
const trimmed = content.trim();
return (
trimmed.startsWith('<!DOCTYPE') ||
trimmed.startsWith('<html') ||
trimmed.startsWith('<div') ||
trimmed.startsWith('<table') ||
trimmed.startsWith('<p') ||
trimmed.startsWith('<h') ||
trimmed.startsWith('<ul') ||
trimmed.startsWith('<ol') ||
trimmed.startsWith('<blockquote') ||
(trimmed.includes('</') && trimmed.includes('>')) ||
/<(strong|b|em|i|u|h[1-6]|ul|ol|li|table|blockquote|div|p|img|hr|br)\b/i.test(trimmed)
);
}
export function stripMarkdown(text: string): string {
if (!text) return '';
return text
.replace(/<[^>]*>/g, ' ')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/#{1,6}\s+/g, '')
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2')
.replace(/~~(.*?)~~/g, '$1')
.replace(/`{1,3}.*?`{1,3}/gs, '')
.replace(/^>\s+/gm, '')
.replace(/^[-*+]\s+/gm, '')
.replace(/^\d+\.\s+/gm, '')
.replace(/\|/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function parseMarkdownToHtml(markdown: string): string {
if (!markdown) return '';
let html = markdown
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/\r\n/g, '\n');
html = html.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<pre style="background:#0b1120;border:1px solid #1e293b;border-radius:12px;padding:14px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;color:#e2e8f0;margin:12px 0;"><code>${escaped}</code></pre>`;
});
html = html.replace(/`([^`]+)`/g, (_match, code) => {
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<code style="background:#1e293b;color:#34d399;padding:2px 6px;border-radius:6px;font-family:monospace;font-size:12px;">${escaped}</code>`;
});
html = html.replace(/^#### (.*?)$/gm, '<h4 style="font-size:14px;font-weight:700;color:#f8fafc;margin:12px 0 6px 0;">$1</h4>');
html = html.replace(/^### (.*?)$/gm, '<h3 style="font-size:16px;font-weight:700;color:#f8fafc;margin:14px 0 8px 0;">$1</h3>');
html = html.replace(/^## (.*?)$/gm, '<h2 style="font-size:18px;font-weight:800;color:#ffffff;margin:16px 0 8px 0;border-bottom:1px solid #1e293b;padding-bottom:6px;">$1</h2>');
html = html.replace(/^# (.*?)$/gm, '<h1 style="font-size:22px;font-weight:800;color:#ffffff;margin:18px 0 10px 0;border-bottom:1px solid #334155;padding-bottom:8px;">$1</h1>');
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;">$1</strong>');
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;">$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em style="color:#cbd5e1;">$1</em>');
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#34d399;text-decoration:underline;">$1</a>');
html = html.replace(/^[-*+]\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:disc;color:#cbd5e1;padding:2px 0;">$1</li>');
html = html.replace(/^\d+\.\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:decimal;color:#cbd5e1;padding:2px 0;">$1</li>');
const paragraphs = html.split(/\n{2,}/);
html = paragraphs
.map((p) => {
const trimmed = p.trim();
if (!trimmed) return '';
if (
trimmed.startsWith('<h') ||
trimmed.startsWith('<div') ||
trimmed.startsWith('<table') ||
trimmed.startsWith('<pre') ||
trimmed.startsWith('<blockquote') ||
trimmed.startsWith('<hr') ||
trimmed.startsWith('<li')
) {
return trimmed;
}
return `<p style="margin:8px 0;line-height:1.6;color:#cbd5e1;">${trimmed.replace(/\n/g, '<br />')}</p>`;
})
.join('\n');
return html;
}
@@ -1,11 +1,25 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient, InvalidateQueryFilters } from '@tanstack/react-query';
import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 1, retry: 1,
staleTime: 1000 * 60 * 5, // 5 minutes
}, },
}, },
}); });
/**
* Invalidates queries while simultaneously bumping the mutation epoch.
*/
export async function invalidateAndBustCache(
client: QueryClient,
filters?: InvalidateQueryFilters
): Promise<void> {
bumpMutationEpoch();
await client.invalidateQueries(filters);
}
@@ -14,8 +14,8 @@ export function hashParam(id: number | string | null | undefined): string {
// Users & Integration Endpoints // Users & Integration Endpoints
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export const usersApi = { export const usersApi = {
getUser: (userId: string | number) => getUser: (userId: string) =>
apiClient.get<Models.User>(`/v1/api/users/${hashParam(userId)}`), apiClient.get<Models.User>(`/v1/api/users/${userId}`),
getUsersPaged: (page: number, pageSize: number, filter?: Models.UserFilterRequest) => getUsersPaged: (page: number, pageSize: number, filter?: Models.UserFilterRequest) =>
apiClient.post<Models.User[]>(`/v1/api/users/${page}/${pageSize}`, filter || {}), apiClient.post<Models.User[]>(`/v1/api/users/${page}/${pageSize}`, filter || {}),
@@ -23,8 +23,8 @@ export const usersApi = {
syncIntegration: (payload: any) => syncIntegration: (payload: any) =>
apiClient.post<any>('/v1/api/integration/sync', payload), apiClient.post<any>('/v1/api/integration/sync', payload),
updateUserStatus: (userId: string | number, status: string | number) => updateUserStatus: (userId: string, status: string | number) =>
apiClient.get<any>(`/v1/api/users/${hashParam(userId)}/status/${status}`), apiClient.get<any>(`/v1/api/users/${userId}/status/${status}`),
}; };
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@@ -72,7 +72,7 @@ export const projectsApi = {
// Outcomes // Outcomes
createOutcome: (payload: Models.CreateOutcomeRequest) => createOutcome: (payload: Models.CreateOutcomeRequest) =>
apiClient.post<Models.Outcome>('/v1/api/projects/beneficiaris/outcomes', payload), apiClient.post<Models.Outcome>('/v1/api/projects/beneficiaries/outcomes', payload),
updateOutcome: (payload: Models.UpdateOutcomeRequest) => updateOutcome: (payload: Models.UpdateOutcomeRequest) =>
apiClient.put<Models.Outcome>('/v1/api/projects/outcomes', payload), apiClient.put<Models.Outcome>('/v1/api/projects/outcomes', payload),
@@ -403,14 +403,17 @@ export const communicationsApi = {
getInvitesPaged: (page: number, pageSize: number) => getInvitesPaged: (page: number, pageSize: number) =>
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${page}/${pageSize}`), apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${page}/${pageSize}`),
getApplicantInvites: (applicantUserId: string | number) => getApplicantData: (applicantUserId: string) =>
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${hashParam(applicantUserId)}`), apiClient.get<Models.ApplicantData[]>(`/v1/api/communications/applicants/${applicantUserId}/all`),
getApplicantInvite: (applicantUserId: string | number) => getApplicantInvites: (applicantUserId: string) =>
apiClient.get<Models.Invite>(`/v1/api/communications/invites/applicants/${hashParam(applicantUserId)}`), apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${applicantUserId}`),
getApplicantInvite: (applicantUserId: string) =>
apiClient.get<Models.Invite>(`/v1/api/communications/invites/applicants/${applicantUserId}`),
getNotifications: (organisationId: string | number) => getNotifications: (organisationId: string | number) =>
apiClient.get<Models.Notification[]>(`/v1/api/communications/norifications/${hashParam(organisationId)}`), apiClient.get<Models.Notification[]>(`/v1/api/communications/notifications/${hashParam(organisationId)}`),
}; };
// Consolidated export // Consolidated export
File diff suppressed because it is too large Load Diff
@@ -1,694 +1 @@
/** export * from '../../types';
* OpenAPI TypeScript Models for PostFundManagement.Api v1
* Synchronized from https://api.ifri.khongisa.co.za/openapi/v1/
*/
export interface ProblemDetails {
type?: string | null;
title?: string | null;
status?: number | string | null;
detail?: string | null;
instance?: string | null;
[key: string]: any;
}
export type ActivityStatus = 0 | 1 | 2 | 3 | 4 | 'Planned' | 'InProgress' | 'Completed' | 'Delayed' | 'Cancelled' | number | string;
export type ApprovalStatus = 0 | 1 | 2 | 3 | 'Draft' | 'PendingApproval' | 'Approved' | 'Rejected' | number | string;
export type ContractStatus = 0 | 1 | 2 | 3 | 4 | 'Draft' | 'Active' | 'Executed' | 'Terminated' | 'Expired' | number | string;
export type EvidenceType = 0 | 1 | 2 | 3 | 4 | 5 | 'Document' | 'Invoice' | 'Receipt' | 'SitePhoto' | 'Report' | 'Other' | number | string;
export type IndustrySector = number | string;
export type NotificationPlatform = 0 | 1 | 2 | 'Email' | 'SMS' | 'InApp' | number | string;
export type NotificationStatus = 0 | 1 | 2 | 3 | 'Queued' | 'Sent' | 'Failed' | 'Delivered' | number | string;
export type OrganisationType = 0 | 1 | 2 | 3 | 'Grantee' | 'Funder' | 'Partner' | 'Auditor' | number | string;
export type Priority = 0 | 1 | 2 | 3 | 'Low' | 'Medium' | 'High' | 'Critical' | number | string;
export type ProjectType = 0 | 1 | 2 | 3 | 'Grant' | 'Loan' | 'Equity' | 'Hybrid' | number | string;
export type RiskImpact = 0 | 1 | 2 | 3 | 'Low' | 'Medium' | 'High' | 'Critical' | number | string;
export type RiskLikelihood = 0 | 1 | 2 | 3 | 'Rare' | 'Unlikely' | 'Possible' | 'Likely' | number | string;
export type UnitOfMeasure = number | string;
export interface Pagination {
page?: number;
pageSize?: number;
totalRecords?: number;
totalPages?: number;
}
export interface User {
id?: string;
sub?: string;
email?: string;
name?: string;
role?: string;
tenant?: string;
tenant_id?: number | string;
status?: string | number;
createdAt?: string;
updatedAt?: string;
[key: string]: any;
}
export interface UserFilterRequest {
email?: string;
name?: string;
role?: string;
status?: string;
}
export interface Activity {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
status?: ActivityStatus;
startDate?: string;
endDate?: string;
progressPercentage?: number;
[key: string]: any;
}
export interface CreateActivityRequest {
projectId: string | number;
title: string;
description?: string;
startDate?: string;
endDate?: string;
status?: ActivityStatus;
}
export interface Beneficiary {
id?: string | number;
projectId?: string | number;
name?: string;
identificationNumber?: string;
contactNumber?: string;
email?: string;
location?: string;
gender?: string;
age?: number;
[key: string]: any;
}
export interface CreateBeneficiaryRequest {
projectId: string | number;
name: string;
identificationNumber?: string;
contactNumber?: string;
email?: string;
location?: string;
gender?: string;
age?: number;
}
export interface UpdateBeneficiaryRequest {
id: string | number;
projectId?: string | number;
name?: string;
identificationNumber?: string;
contactNumber?: string;
email?: string;
location?: string;
gender?: string;
age?: number;
}
export interface Outcome {
id?: string | number;
beneficiaryId?: string | number;
projectId?: string | number;
title?: string;
description?: string;
targetMetric?: string;
achievedMetric?: string;
dateRecorded?: string;
[key: string]: any;
}
export interface CreateOutcomeRequest {
beneficiaryId?: string | number;
projectId?: string | number;
title: string;
description?: string;
targetMetric?: string;
achievedMetric?: string;
dateRecorded?: string;
}
export interface UpdateOutcomeRequest {
id: string | number;
title?: string;
description?: string;
targetMetric?: string;
achievedMetric?: string;
}
export interface ChangeRequest {
id?: string | number;
projectId?: string | number;
reason?: string;
proposedChanges?: string;
requestedBy?: string;
status?: ApprovalStatus;
requestDate?: string;
approvalDate?: string;
[key: string]: any;
}
export interface CreateChangeRequest {
projectId: string | number;
reason: string;
proposedChanges: string;
}
export interface UpdateChangeRequest {
id: string | number;
reason?: string;
proposedChanges?: string;
status?: ApprovalStatus;
}
export interface ComplianceItem {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
isCompliant?: boolean;
dueDate?: string;
verifiedAt?: string;
verifiedBy?: string;
[key: string]: any;
}
export interface CreateComplianceItemRequest {
projectId: string | number;
title: string;
description?: string;
dueDate?: string;
}
export interface UpdateComplianceItemRequest {
id: string | number;
title?: string;
description?: string;
isCompliant?: boolean;
dueDate?: string;
}
export interface Issue {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
priority?: Priority;
status?: string;
reportedBy?: string;
resolvedAt?: string;
[key: string]: any;
}
export interface CreateIssueRequest {
projectId: string | number;
title: string;
description?: string;
priority?: Priority;
}
export interface UpdateIssueRequest {
id: string | number;
title?: string;
description?: string;
priority?: Priority;
status?: string;
}
export interface Milestone {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
dueDate?: string;
amount?: number;
status?: ApprovalStatus;
completionPercentage?: number;
[key: string]: any;
}
export interface CreateMilestoneRequest {
projectId: string | number;
title: string;
description?: string;
dueDate?: string;
amount?: number;
}
export interface UpdateMilestoneRequest {
id: string | number;
title?: string;
description?: string;
dueDate?: string;
amount?: number;
status?: ApprovalStatus;
completionPercentage?: number;
}
export interface Project {
id?: string | number;
programmeId?: string | number;
organisationId?: string | number;
name?: string;
code?: string;
description?: string;
type?: ProjectType;
startDate?: string;
endDate?: string;
budget?: number;
status?: string;
[key: string]: any;
}
export interface CreateProjectRequest {
programmeId: string | number;
organisationId: string | number;
name: string;
code?: string;
description?: string;
type?: ProjectType;
startDate?: string;
endDate?: string;
budget?: number;
}
export interface UpdateProjectRequest {
id: string | number;
name?: string;
description?: string;
budget?: number;
startDate?: string;
endDate?: string;
}
export interface UpdateProjectStatusRequest {
projectId: string | number;
status: string | number;
}
export interface SiteVisit {
id?: string | number;
projectId?: string | number;
visitDate?: string;
visitorName?: string;
findings?: string;
recommendations?: string;
[key: string]: any;
}
export interface CreateSiteVisitRequest {
projectId: string | number;
visitDate: string;
visitorName: string;
findings?: string;
recommendations?: string;
}
export interface UpdateSiteVisitRequest {
id: string | number;
visitDate?: string;
visitorName?: string;
findings?: string;
recommendations?: string;
}
export interface Award {
id?: string | number;
projectId?: string | number;
amount?: number;
awardDate?: string;
conditions?: string;
[key: string]: any;
}
export interface CreateAwardRequest {
projectId: string | number;
amount: number;
awardDate?: string;
conditions?: string;
}
export interface Budget {
id?: string | number;
projectId?: string | number;
category?: string;
allocatedAmount?: number;
spentAmount?: number;
notes?: string;
[key: string]: any;
}
export interface CreateBudgetRequest {
projectId: string | number;
category: string;
allocatedAmount: number;
notes?: string;
}
export interface UpdateBudgetRequest {
id: string | number;
category?: string;
allocatedAmount?: number;
spentAmount?: number;
notes?: string;
}
export interface Contract {
id?: string | number;
projectId?: string | number;
title?: string;
contractNumber?: string;
status?: ContractStatus;
signedDate?: string;
expiryDate?: string;
value?: number;
[key: string]: any;
}
export interface CreateContractRequest {
projectId: string | number;
title: string;
contractNumber?: string;
signedDate?: string;
expiryDate?: string;
value?: number;
}
export interface UpdateContractRequest {
id: string | number;
title?: string;
status?: ContractStatus;
expiryDate?: string;
value?: number;
}
export interface ExecuteContractRequest {
contractId: string | number;
executionDate: string;
notes?: string;
}
export interface Instrument {
id?: string | number;
programmeId?: string | number;
name?: string;
type?: string;
description?: string;
terms?: string;
[key: string]: any;
}
export interface CreateInstrumentRequest {
programmeId: string | number;
name: string;
type?: string;
description?: string;
terms?: string;
}
export interface UpdateInstrumentRequest {
id: string | number;
name?: string;
type?: string;
description?: string;
terms?: string;
}
export interface Organisation {
id?: string | number;
name?: string;
registrationNumber?: string;
type?: OrganisationType;
email?: string;
phone?: string;
address?: string;
[key: string]: any;
}
export interface CreateOrganisationRequest {
name: string;
registrationNumber?: string;
type?: OrganisationType;
email?: string;
phone?: string;
address?: string;
}
export interface UpdateOrganisationRequest {
id: string | number;
name?: string;
registrationNumber?: string;
type?: OrganisationType;
email?: string;
phone?: string;
address?: string;
}
export interface Portfolio {
id?: string | number;
organisationId?: string | number;
name?: string;
description?: string;
totalValue?: number;
[key: string]: any;
}
export interface CreatePortfolioRequest {
organisationId: string | number;
name: string;
description?: string;
}
export interface UpdatePortfolioRequest {
id: string | number;
name?: string;
description?: string;
}
export interface Programme {
id?: string | number;
portfolioId?: string | number;
name?: string;
description?: string;
budget?: number;
startDate?: string;
endDate?: string;
[key: string]: any;
}
export interface CreateProgrammeRequest {
portfolioId: string | number;
name: string;
description?: string;
budget?: number;
startDate?: string;
endDate?: string;
}
export interface UpdateProgrammeRequest {
id: string | number;
name?: string;
description?: string;
budget?: number;
}
export interface Tenant {
id?: string | number;
name?: string;
code?: string;
isActive?: boolean;
createdDate?: string;
[key: string]: any;
}
export interface CreateTenantRequest {
name: string;
code?: string;
isActive?: boolean;
}
export interface UpdateTenantRequest {
id: string | number;
name?: string;
code?: string;
isActive?: boolean;
}
export interface Dashboard {
totalProjects?: number;
totalCommittedCapital?: number;
totalDisbursedCapital?: number;
activeMilestonesCount?: number;
complianceRate?: number;
recentActivities?: any[];
[key: string]: any;
}
export interface AuditLog {
id?: string | number;
entityName?: string;
entityId?: string | number;
action?: string;
timestamp?: string;
performedBy?: string;
details?: string;
error?: string | null;
isError?: boolean;
[key: string]: any;
}
export interface Disbursement {
id?: string | number;
projectId?: string | number;
milestoneId?: string | number;
amount?: number;
disbursementDate?: string;
status?: ApprovalStatus;
referenceNumber?: string;
[key: string]: any;
}
export interface CreateDisbursementRequest {
projectId: string | number;
milestoneId?: string | number;
amount: number;
referenceNumber?: string;
}
export interface UpdateDisbursementRequest {
id: string | number;
amount?: number;
status?: ApprovalStatus;
referenceNumber?: string;
}
export interface Risk {
id?: string | number;
projectId?: string | number;
title?: string;
description?: string;
likelihood?: RiskLikelihood;
impact?: RiskImpact;
mitigationPlan?: string;
status?: string;
[key: string]: any;
}
export interface CreateRiskRequest {
projectId: string | number;
title: string;
description?: string;
likelihood?: RiskLikelihood;
impact?: RiskImpact;
mitigationPlan?: string;
}
export interface UpdateRiskRequest {
id: string | number;
title?: string;
description?: string;
likelihood?: RiskLikelihood;
impact?: RiskImpact;
mitigationPlan?: string;
status?: string;
}
export interface Rule {
id?: string | number;
programmeId?: string | number;
name?: string;
expression?: string;
description?: string;
isActive?: boolean;
[key: string]: any;
}
export interface CreateRuleRequest {
programmeId: string | number;
name: string;
expression: string;
description?: string;
isActive?: boolean;
}
export interface UpdateRuleRequest {
id: string | number;
name?: string;
expression?: string;
description?: string;
isActive?: boolean;
}
export interface Evidence {
id?: string | number;
projectId?: string | number;
milestoneId?: string | number;
title?: string;
description?: string;
type?: EvidenceType;
fileUrl?: string;
fileName?: string;
fileSize?: number;
uploadedAt?: string;
verified?: boolean;
[key: string]: any;
}
export interface UpdateEvidenceRequest {
id: string | number;
title?: string;
description?: string;
type?: EvidenceType;
verified?: boolean;
}
export interface Indicator {
id?: string | number;
projectId?: string | number;
name?: string;
description?: string;
targetValue?: number;
actualValue?: number;
unit?: UnitOfMeasure;
[key: string]: any;
}
export interface CreateIndicatorRequest {
projectId: string | number;
name: string;
description?: string;
targetValue: number;
unit?: UnitOfMeasure;
}
export interface Invite {
id?: string | number;
email?: string;
role?: string;
organisationId?: string | number;
status?: string;
expiresAt?: string;
[key: string]: any;
}
export interface InviteRequest {
email: string;
role?: string;
organisationId?: string | number;
}
export interface Notification {
id?: string | number;
organisationId?: string | number;
platform?: NotificationPlatform;
status?: NotificationStatus;
message?: string;
recipient?: string;
sentAt?: string;
[key: string]: any;
}
@@ -3,29 +3,95 @@ import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint, isTenantEndpoint } from './sessionExpiration'; import { triggerSessionExpired, isUserSyncEndpoint, isTenantEndpoint } from './sessionExpiration';
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService'; import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
export const BASE_API_URL = getApiBaseUrl();
const MUTATION_EPOCH_STORAGE_KEY = 'pfm_mutation_epoch';
let activeMutationEpoch: number = (() => {
if (typeof window !== 'undefined') {
try {
const stored = sessionStorage.getItem(MUTATION_EPOCH_STORAGE_KEY);
if (stored) {
const parsed = Number(stored);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
} catch {}
}
return Date.now();
})();
export function getMutationEpoch(): number {
return activeMutationEpoch;
}
export function bumpMutationEpoch(): number {
activeMutationEpoch = Date.now();
if (typeof window !== 'undefined') {
try {
sessionStorage.setItem(MUTATION_EPOCH_STORAGE_KEY, String(activeMutationEpoch));
} catch {}
}
return activeMutationEpoch;
}
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: getApiBaseUrl(), baseURL: BASE_API_URL,
headers: { headers: {
'Content-Type': 'application/json; version=1', 'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1', Accept: 'application/json; version=1',
}, },
timeout: 20000, timeout: 15000,
}); });
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => { async (config: InternalAxiosRequestConfig) => {
config.baseURL = getApiBaseUrl(); config.baseURL = getApiBaseUrl();
const token = typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null; const token = localStorage.getItem('pfm_access_token');
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url); const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
const isExternal =
config.url?.startsWith('http://') ||
config.url?.startsWith('https://') ||
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
} else {
config.params = config.params || {};
config.params._t = Date.now();
}
} else {
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch();
}
}
}
}
if (token && !isLifecycleRequest) { if (token && !isLifecycleRequest) {
if (isActivationPending()) { if (isActivationPending()) {
return Promise.reject(new Error('User account is pending administrator activation')); return Promise.reject(new Error('User account is pending administrator activation'));
} }
const storedSub = typeof window !== 'undefined' ? (localStorage.getItem('pfm_user_sub') || '') : '';
const storedSub = localStorage.getItem('pfm_user_sub') || '';
if (!isUserSyncedInBrowser(storedSub, token)) { if (!isUserSyncedInBrowser(storedSub, token)) {
const syncRes = await syncUserAccount(storedSub, token); const syncRes = await syncUserAccount(storedSub, token);
if (!syncRes.success) { if (!syncRes.success) {
@@ -33,20 +99,40 @@ apiClient.interceptors.request.use(
} }
} }
} }
return config; return config;
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)
); );
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => {
const method = response.config?.method?.toLowerCase();
if (method && ['post', 'put', 'patch', 'delete'].includes(method)) {
bumpMutationEpoch();
}
return response;
},
(error) => { (error) => {
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
const requestUrl = error.config?.url; const requestUrl = error.config?.url;
const isLifecycleEndpoint = isUserSyncEndpoint(requestUrl) || isTenantEndpoint(requestUrl); const isLifecycleEndpoint = isUserSyncEndpoint(requestUrl) || isTenantEndpoint(requestUrl);
if (!isLifecycleEndpoint) { if (!isLifecycleEndpoint) {
const detailMsg = error?.response?.data?.detail || error?.response?.data?.message || 'Session expired (HTTP 401).'; const detailMsg =
error?.response?.data?.detail ||
error?.response?.data?.message ||
'Authentication session expired or security token invalidated (HTTP 401).';
triggerSessionExpired(detailMsg, requestUrl); triggerSessionExpired(detailMsg, requestUrl);
} else {
if (typeof window !== 'undefined') {
localStorage.removeItem('pfm_access_token');
localStorage.removeItem('pfm_access_user');
localStorage.removeItem('pfm_user_sync_record');
localStorage.removeItem('pfm_user_pending_activation');
localStorage.removeItem('pfm_user_pending_activation_reason');
}
} }
} }
return Promise.reject(error); return Promise.reject(error);
@@ -0,0 +1,281 @@
import {
NotificationPlatform,
NotificationStatus,
ApprovalStatus,
ActivityStatus,
ContractStatus,
ComplianceStatus,
User,
Invite,
CreateNotificationRequest,
} from '../types';
import { apiClient } from './httpClient';
import {
getCurrentUserSub,
getCurrentUserClaims,
getCurrentUserTenantId,
} from '../auth/AuthProvider';
export const CONSOLE_PORTAL_URL = 'https://console.ifri.khongisa.co.za/';
export const PARTNER_PORTAL_URL = 'https://partner.ifri.khongisa.co.za/';
export async function dispatchNotification(payload: CreateNotificationRequest): Promise<void> {
const tenantId = getCurrentUserTenantId();
const orgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? Number(payload.organisationId)
: (tenantId !== null ? tenantId : 1);
await apiClient.post('/v1/api/communications/notifications', {
organisationId: orgId,
recipient: payload.recipient,
platform: payload.platform,
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined ? payload.status : undefined,
});
}
export async function fetchDirectoryUsers(page = 1, pageSize = 200): Promise<User[]> {
try {
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, {});
return res.data || [];
} catch (err) {
console.warn('[NotificationRules] Failed to fetch users directory:', err);
return [];
}
}
export function isApprovedOrActiveStatus(status: number | string | undefined | null): boolean {
if (status === undefined || status === null) return false;
const numStatus = Number(status);
if (
numStatus === ApprovalStatus.Approved ||
numStatus === ActivityStatus.Active ||
numStatus === ContractStatus.Active ||
numStatus === ContractStatus.Executed ||
numStatus === ComplianceStatus.Met ||
numStatus === ComplianceStatus.Exceeded
) {
return true;
}
const strStatus = String(status).toLowerCase().trim();
return (
strStatus === 'approved' ||
strStatus === 'active' ||
strStatus === 'executed' ||
strStatus === 'met' ||
strStatus === 'exceeded' ||
strStatus === 'verified'
);
}
export function getApprovedOrActiveStatusLabel(status: number | string | undefined | null): string {
if (status === undefined || status === null) return 'Active';
const numStatus = Number(status);
if (numStatus === ApprovalStatus.Approved) return 'Approved';
if (numStatus === ActivityStatus.Active) return 'Active';
if (numStatus === ContractStatus.Active) return 'Active';
if (numStatus === ComplianceStatus.Met) return 'Compliant (Met)';
const str = String(status);
return str.charAt(0).toUpperCase() + str.slice(1);
}
export async function getTenantManagersAndExecutives(): Promise<User[]> {
try {
const tenantId = getCurrentUserTenantId();
const users = await fetchDirectoryUsers(1, 200);
const managersAndExecutives = users.filter((u) => {
const roleStr = String(u.role || '').toLowerCase();
const isManagerOrExec =
roleStr.includes('manager') ||
roleStr.includes('executive') ||
roleStr.includes('director') ||
roleStr === '2' ||
roleStr === '4';
const matchesTenant =
!tenantId || !u.tenant_id || Number(u.tenant_id) === Number(tenantId);
return isManagerOrExec && matchesTenant;
});
if (managersAndExecutives.length > 0) {
return managersAndExecutives;
}
const elevatedUsers = users.filter((u) => {
const roleStr = String(u.role || '').toLowerCase();
return (
roleStr.includes('admin') ||
roleStr.includes('supervisor') ||
roleStr === '1' ||
roleStr === '3'
);
});
if (elevatedUsers.length > 0) {
return elevatedUsers;
}
} catch (err) {
console.warn('[NotificationRules] Directory lookup failed, using authenticated identity fallback:', err);
}
const currentSub = getCurrentUserSub();
const currentClaims = getCurrentUserClaims();
if (currentSub) {
return [
{
id: currentSub,
email: (currentClaims.email as string) || (currentClaims.preferred_username as string) || 'manager@pfm.org.za',
status: ActivityStatus.Active,
lastLoginAt: null,
role: 'Manager',
name: (currentClaims.name as string) || 'Tenant Manager',
},
];
}
return [];
}
export interface RecordStatusChangeNotificationParams {
recordType: string;
recordId: string | number;
newStatus: number | string;
title?: string;
previousStatus?: number | string;
}
export async function notifyTenantManagersAndExecutivesOnRecordStatusChange(
params: RecordStatusChangeNotificationParams
): Promise<void> {
const { recordType, recordId, newStatus, title } = params;
if (recordType.toLowerCase() === 'user' || recordType.toLowerCase() === 'users') {
return;
}
if (!isApprovedOrActiveStatus(newStatus)) {
return;
}
const statusLabel = getApprovedOrActiveStatusLabel(newStatus);
const orgId = getCurrentUserTenantId() || 1;
const managersAndExecutives = await getTenantManagersAndExecutives();
if (managersAndExecutives.length === 0) {
return;
}
const recordDescriptor = title
? `${recordType} "${title}" (#${recordId})`
: `${recordType} #${recordId}`;
const inAppSubject = `[${statusLabel}] ${recordType} #${recordId} is now ${statusLabel}`;
const inAppMessage = `### Statutory Record Update\n\nThe record **${recordDescriptor}** has officially transitioned to **${statusLabel}** status.\n\n- **Record Category:** ${recordType}\n- **Identifier:** #${recordId}\n- **Updated Status:** ${statusLabel}\n- **Timestamp:** ${new Date().toLocaleString()}`;
const emailSubject = `[PFM Governance Alert] ${recordType} #${recordId} Status Transition: ${statusLabel}`;
const emailMessage = `### Formal Statutory Notification\n\nThis is an automated notification to Tenant Managers and Executives regarding an approved record status update.\n\n**Record Details:**\n- **Category:** ${recordType}\n- **Reference Identifier:** #${recordId}\n${title ? `- **Title:** ${title}\n` : ''}- **New Lifecycle Status:** **${statusLabel}**\n- **Effective Timestamp:** ${new Date().toUTCString()}`;
for (const recipient of managersAndExecutives) {
if (!recipient.id) continue;
const recipientEmail = recipient.email || 'manager@pfm.org.za';
try {
await dispatchNotification({
organisationId: orgId,
recipient: recipient.id,
platform: NotificationPlatform.InApp,
subject: inAppSubject,
destination: recipientEmail,
message: inAppMessage,
status: NotificationStatus.Delivered,
});
} catch {}
try {
await dispatchNotification({
organisationId: orgId,
recipient: recipient.id,
platform: NotificationPlatform.Email,
subject: emailSubject,
destination: `To: ${recipientEmail} | From: PFM Automated Governance System`,
message: emailMessage,
status: NotificationStatus.Queued,
});
} catch {}
}
}
export interface InviteApprovedNotificationParams {
invite: Partial<Invite> & {
id: number | string;
recipient: string;
invitedOrganisationId?: number | string;
organisationId?: number | string;
awardId?: number | string;
contractId?: number | string;
message?: string;
};
partnerUser?: User | null;
partnerEmail?: string;
}
export async function notifyInviteApproved(
params: InviteApprovedNotificationParams
): Promise<void> {
const { invite, partnerUser, partnerEmail } = params;
if (!invite.recipient) return;
const orgId = invite.invitedOrganisationId || invite.organisationId || getCurrentUserTenantId() || 1;
const targetEmail = partnerEmail || partnerUser?.email || invite.recipient;
try {
await dispatchNotification({
organisationId: orgId,
recipient: invite.recipient,
platform: NotificationPlatform.InApp,
subject: 'Onboarding Invitation Approved: Access to Partner Portal Granted',
destination: targetEmail,
message: `Your statutory onboarding invitation has been formally Approved. Access the Partner Portal at ${PARTNER_PORTAL_URL}.`,
status: NotificationStatus.Delivered,
});
} catch {}
}
export interface NewUserCreatedNotificationParams {
userId: string;
email: string;
isPartner: boolean;
roleName?: string;
tenantId?: number | string | null;
}
export async function notifyNewUserCreated(
params: NewUserCreatedNotificationParams
): Promise<void> {
const { userId, email, isPartner, tenantId } = params;
if (!userId || !email) return;
const orgId = tenantId ? Number(tenantId) : (getCurrentUserTenantId() || 1);
const targetUrl = isPartner ? PARTNER_PORTAL_URL : CONSOLE_PORTAL_URL;
try {
await dispatchNotification({
organisationId: orgId,
recipient: userId,
platform: NotificationPlatform.InApp,
subject: 'Welcome to PFM - Your Account Has Been Created',
destination: email,
message: `Your user account has been created. Please sign in at ${targetUrl}.`,
status: NotificationStatus.Delivered,
});
} catch {}
}
@@ -0,0 +1,440 @@
import { apiClient } from './httpClient';
import { Role, Claim } from '../types';
import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac';
export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_partner_cache_system_roles';
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_partner_cache_role_claims_';
export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_partner_cache_system_claims';
export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_partner_rbac_cache_version';
export const CURRENT_RBAC_CACHE_VERSION = 'v2.2-partner';
export function ensureFreshRbacCache(): void {
if (typeof window === 'undefined') return;
try {
const version = localStorage.getItem(LOCAL_STORAGE_RBAC_VERSION_KEY);
if (version !== CURRENT_RBAC_CACHE_VERSION) {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith(LOCAL_STORAGE_ROLE_CLAIMS_PREFIX) || key === 'pfm_user_claims')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
inMemoryRoleClaims.clear();
localStorage.setItem(LOCAL_STORAGE_RBAC_VERSION_KEY, CURRENT_RBAC_CACHE_VERSION);
}
} catch {}
}
let inMemorySystemRoles: Role[] | null = null;
const inMemoryRoleClaims = new Map<string, Claim[]>();
let inMemorySystemClaims: Claim[] | null = null;
const inMemoryUserRoles = new Map<string, { roles: Role[]; timestamp: number }>();
const inMemoryUserClaims = new Map<string, { claims: Claim[]; timestamp: number }>();
const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000;
ensureFreshRbacCache();
// PartnerUI RBAC only functions for Partner roles
export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
partner: [
{ claimId: 50, claimType: 'dashboard', claimValue: 'view, export' },
{ claimId: 51, claimType: 'projects_milestones', claimValue: 'view, add, export' },
{ claimId: 52, claimType: 'grant_financials', claimValue: 'view, add, export' },
{ claimId: 53, claimType: 'evidence_risks', claimValue: 'view, add, export' },
{ claimId: 54, claimType: 'beneficiaries', claimValue: 'view, add, export' },
{ claimId: 55, claimType: 'change_management', claimValue: 'view, add, export' },
{ claimId: 56, claimType: 'site_visits', claimValue: 'view, export' },
{ claimId: 57, claimType: 'compliance', claimValue: 'view, add, export' },
{ claimId: 58, claimType: 'settings_notifications', claimValue: 'view, add' },
],
};
export function normalizeClaim(c: any): Claim {
const claimType = c.claimType || c.type || c.name || '';
const claimValue = c.claimValue || c.value || 'all';
const claimId = c.claimId ?? c.id ?? undefined;
return {
...c,
type: claimType,
value: claimValue,
claimType,
claimValue,
claimId,
};
}
export function getCachedSystemRoles(): Role[] | null {
if (inMemorySystemRoles && inMemorySystemRoles.length > 0) {
return inMemorySystemRoles;
}
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
inMemorySystemRoles = parsed;
return parsed;
}
}
} catch {}
}
return null;
}
export function setCachedSystemRoles(roles: Role[]): void {
inMemorySystemRoles = roles;
if (typeof window !== 'undefined') {
try {
localStorage.setItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY, JSON.stringify(roles));
} catch {}
}
}
export function getCachedRoleClaims(roleKey: string): Claim[] | null {
const normalizedKey = roleKey.toLowerCase().trim();
if (inMemoryRoleClaims.has(normalizedKey)) {
return inMemoryRoleClaims.get(normalizedKey)!;
}
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(`${LOCAL_STORAGE_ROLE_CLAIMS_PREFIX}${normalizedKey}`);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const normalized = parsed.map(normalizeClaim);
inMemoryRoleClaims.set(normalizedKey, normalized);
return normalized;
}
}
} catch {}
}
return null;
}
export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void {
const normalizedKey = roleKey.toLowerCase().trim();
const normalized = claims.map(normalizeClaim);
inMemoryRoleClaims.set(normalizedKey, normalized);
if (typeof window !== 'undefined') {
try {
localStorage.setItem(
`${LOCAL_STORAGE_ROLE_CLAIMS_PREFIX}${normalizedKey}`,
JSON.stringify(normalized)
);
} catch {}
}
}
export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise<Role[]> {
if (!forceRefresh) {
const cached = getCachedSystemRoles();
if (cached && cached.length > 0) return cached;
}
try {
const res = await apiClient.get<Role[]>('/v1/api/users/system/roles');
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
setCachedSystemRoles(res.data);
return res.data;
}
} catch {}
const cached = getCachedSystemRoles();
if (cached && cached.length > 0) return cached;
const defaults: Role[] = [{ id: '6', name: 'Partner' }];
setCachedSystemRoles(defaults);
return defaults;
}
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
export async function fetchAndCacheRoleClaims(
roleId: string,
roleName?: string,
forceRefresh = false
): Promise<Claim[]> {
const roleIdKey = roleId.toLowerCase().trim();
const roleNameKey = (roleName || '').toLowerCase().trim();
if (!forceRefresh) {
const cachedById = getCachedRoleClaims(roleIdKey);
if (cachedById && cachedById.length > 0) return cachedById;
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
}
}
let effectiveGuid = GUID_REGEX.test(roleId.trim()) ? roleId.trim() : null;
if (!effectiveGuid) {
const cachedRoles = getCachedSystemRoles();
if (cachedRoles && cachedRoles.length > 0) {
const match = cachedRoles.find(
(r) =>
(r.name && r.name.toLowerCase() === roleNameKey) ||
(r.name && r.name.toLowerCase() === roleIdKey) ||
String(r.id).toLowerCase() === roleIdKey
);
if (match && match.id && GUID_REGEX.test(String(match.id).trim())) {
effectiveGuid = String(match.id).trim();
}
}
}
if (effectiveGuid) {
try {
const res = await apiClient.get<Claim[]>(`/v1/api/users/system/roles/${effectiveGuid}/claims`);
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
const normalized = res.data.map(normalizeClaim);
setCachedRoleClaims(roleIdKey, normalized);
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
if (effectiveGuid.toLowerCase() !== roleIdKey) {
setCachedRoleClaims(effectiveGuid.toLowerCase(), normalized);
}
return normalized;
}
} catch {}
}
const cachedById = getCachedRoleClaims(roleIdKey);
if (cachedById && cachedById.length > 0) return cachedById;
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
}
const fallback = (roleNameKey && DEFAULT_ROLE_CLAIMS[roleNameKey]) || DEFAULT_ROLE_CLAIMS[roleIdKey] || [];
const normalized = fallback.map(normalizeClaim);
setCachedRoleClaims(roleIdKey, normalized);
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
return normalized;
}
export function clearCachedUserRbac(userId?: string): void {
if (userId) {
const key = userId.toLowerCase().trim();
inMemoryUserRoles.delete(key);
inMemoryUserClaims.delete(key);
if (typeof window !== 'undefined') {
try {
localStorage.removeItem(`pfm_partner_cache_user_roles_${key}`);
localStorage.removeItem(`pfm_partner_cache_user_claims_${key}`);
} catch {}
}
} else {
inMemoryUserRoles.clear();
inMemoryUserClaims.clear();
}
}
export async function getUserRolesFromApi(userId: string, forceRefresh = false): Promise<Role[]> {
const key = userId.toLowerCase().trim();
if (!forceRefresh) {
const cached = inMemoryUserRoles.get(key);
if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
return cached.roles;
}
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(`pfm_partner_cache_user_roles_${key}`);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
inMemoryUserRoles.set(key, { roles: parsed, timestamp: Date.now() });
return parsed;
}
}
} catch {}
}
}
try {
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
const roles = res.data || [];
if (roles.length > 0) {
inMemoryUserRoles.set(key, { roles, timestamp: Date.now() });
if (typeof window !== 'undefined') {
try {
localStorage.setItem(`pfm_partner_cache_user_roles_${key}`, JSON.stringify(roles));
} catch {}
}
}
return roles;
} catch {
return inMemoryUserRoles.get(key)?.roles || [];
}
}
export async function getUserClaimsFromApi(userId: string, forceRefresh = false): Promise<Claim[]> {
const key = userId.toLowerCase().trim();
if (!forceRefresh) {
const cached = inMemoryUserClaims.get(key);
if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
return cached.claims;
}
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(`pfm_partner_cache_user_claims_${key}`);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
const normalized = parsed.map(normalizeClaim);
inMemoryUserClaims.set(key, { claims: normalized, timestamp: Date.now() });
return normalized;
}
}
} catch {}
}
}
try {
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
const claims = (res.data || []).map(normalizeClaim);
if (claims.length > 0) {
inMemoryUserClaims.set(key, { claims, timestamp: Date.now() });
if (typeof window !== 'undefined') {
try {
localStorage.setItem(`pfm_partner_cache_user_claims_${key}`, JSON.stringify(claims));
} catch {}
}
}
return claims;
} catch {
return inMemoryUserClaims.get(key)?.claims || [];
}
}
export async function resolveUserRolesAndClaims(
userSub: string,
initialClaims: Record<string, any> = {},
currentProfile: { role?: string; tenant?: string; tenant_id?: number } = {}
): Promise<{
roles: string[];
claims: Record<string, any>;
primaryRole: string;
tenant?: string;
tenant_id?: number;
}> {
ensureFreshRbacCache();
const mergedClaims: Record<string, any> = { ...initialClaims };
const resolvedRoles: string[] = [];
let userRoleObjects: Role[] = [];
let primaryRole = currentProfile.role || (initialClaims.role as string) || '';
let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined;
let tenant_id = currentProfile.tenant_id ?? (initialClaims.tenant_id ? Number(initialClaims.tenant_id) : undefined);
const [rolesResult, claimsResult] = await Promise.allSettled([
userSub ? getUserRolesFromApi(userSub) : Promise.resolve([]),
userSub ? getUserClaimsFromApi(userSub) : Promise.resolve([]),
]);
if (rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value) && rolesResult.value.length > 0) {
userRoleObjects = rolesResult.value;
rolesResult.value.forEach((r) => {
const rName = r.name || r.Name;
if (rName && !resolvedRoles.includes(rName)) {
resolvedRoles.push(rName);
}
});
if (resolvedRoles.length > 0) {
primaryRole = resolvedRoles[0];
}
}
if (resolvedRoles.length === 0 && primaryRole) {
resolvedRoles.push(primaryRole);
}
const allRoleClaims: Claim[] = [];
const roleClaimsPromises = resolvedRoles
.filter((roleName) => roleName.toLowerCase().trim() === 'partner')
.map(async (roleName) => {
const matchedRoleObj = userRoleObjects.find(
(r) => (r.name || r.Name || '').toLowerCase() === roleName.toLowerCase()
);
const roleIdToPass = matchedRoleObj?.id ? String(matchedRoleObj.id) : roleName;
return fetchAndCacheRoleClaims(roleIdToPass, roleName);
});
const roleClaimsResults = await Promise.allSettled(roleClaimsPromises);
roleClaimsResults.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
res.value.forEach((rc) => {
const norm = normalizeClaim(rc);
allRoleClaims.push(norm);
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue || 'view, add, export';
}
});
}
});
if (allRoleClaims.length > 0) {
mergedClaims.role_claims = allRoleClaims;
}
if (claimsResult.status === 'fulfilled' && Array.isArray(claimsResult.value) && claimsResult.value.length > 0) {
claimsResult.value.forEach((c) => {
const norm = normalizeClaim(c);
const type = (norm.claimType || '').toLowerCase().trim();
const val = norm.claimValue;
if (type === 'tenant' && val) {
tenant = String(val);
mergedClaims.tenant = String(val);
} else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
const parsed = Number(val);
if (!isNaN(parsed) && isFinite(parsed)) {
tenant_id = parsed;
mergedClaims.tenant_id = parsed;
}
}
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue;
}
});
mergedClaims.user_claims = claimsResult.value.map(normalizeClaim);
}
return {
roles: resolvedRoles,
claims: mergedClaims,
primaryRole,
tenant,
tenant_id,
};
}
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
const roles = await fetchAndCacheSystemRoles();
await Promise.allSettled(
roles.map((role) => fetchAndCacheRoleClaims(role.id || role.Id || '', role.name || role.Name || ''))
);
}
export async function getSystemClaims(): Promise<Claim[]> {
if (inMemorySystemClaims && inMemorySystemClaims.length > 0) {
return inMemorySystemClaims;
}
const claimsMap = new Map<string, Claim>();
ROLE_CLAIM_DEFINITIONS.forEach((def, index) => {
claimsMap.set(def.claim, {
claimId: index + 1,
claimType: def.claim,
claimValue: 'view, add, export',
type: def.claim,
value: 'view, add, export',
});
});
const allClaims = Array.from(claimsMap.values());
inMemorySystemClaims = allClaims;
return allClaims;
}
@@ -11,36 +11,86 @@ export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_
export function isUserSyncEndpoint(url?: string): boolean { export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false; if (!url) return false;
const clean = url.toLowerCase().split('?')[0]; const clean = url.toLowerCase().split('?')[0];
return clean.includes('/users/sync') || clean.includes('/integration/sync') || clean.includes('/user/sync'); return (
clean.includes('/users/sync') ||
clean.includes('/integration/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
);
} }
export function isTenantEndpoint(url?: string): boolean { export function isTenantEndpoint(url?: string): boolean {
if (!url) return false; if (!url) return false;
return url.toLowerCase().includes('/governance/tenants'); const clean = url.toLowerCase().split('?')[0];
return clean.includes('/governance/tenants');
} }
type SessionExpiredListener = (reason?: string) => void; type SessionExpiredListener = (reason?: string) => void;
const listeners = new Set<SessionExpiredListener>(); const listeners = new Set<SessionExpiredListener>();
let hasTriggeredSessionExpired = false; let hasTriggeredSessionExpired = false;
export function dismissSubordinateModals(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:dismiss-subordinates'));
}
}
export function cancelSessionExpiration(): void {
hasTriggeredSessionExpired = false;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:session-expired-cancelled'));
}
}
export function isSessionExpiredState(): boolean {
return hasTriggeredSessionExpired;
}
export function triggerSessionExpired(reason?: string, requestUrl?: string): void { export function triggerSessionExpired(reason?: string, requestUrl?: string): void {
if (requestUrl && isUserSyncEndpoint(requestUrl)) return; if (requestUrl && isUserSyncEndpoint(requestUrl)) {
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) return; return;
if (hasTriggeredSessionExpired) return; }
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) {
return;
}
if (hasTriggeredSessionExpired) {
return;
}
const hasExistingToken = typeof window !== 'undefined' && !!localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY); const hasExistingToken = typeof window !== 'undefined' && !!localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (!hasExistingToken) return; if (!hasExistingToken) {
return;
}
hasTriggeredSessionExpired = true; hasTriggeredSessionExpired = true;
dismissSubordinateModals();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pfm:session-expired', { detail: { reason: reason || 'HTTP 401 Unauthorized' } })); window.dispatchEvent(
new CustomEvent('pfm:session-expired', {
detail: { reason: reason || 'HTTP 401 Unauthorized' },
})
);
} }
listeners.forEach((cb) => cb(reason));
listeners.forEach((callback) => {
try {
callback(reason);
} catch (err) {
console.warn('[SessionExpiration] Callback execution error:', err);
}
});
} }
export function onSessionExpired(listener: SessionExpiredListener): () => void { export function onSessionExpired(listener: SessionExpiredListener): () => void {
listeners.add(listener); listeners.add(listener);
return () => { listeners.delete(listener); }; return () => {
listeners.delete(listener);
};
} }
export function resetSessionExpiredState(): void { export function resetSessionExpiredState(): void {
@@ -49,6 +99,7 @@ export function resetSessionExpiredState(): void {
export function clearSessionAndRedirectToLanding(): void { export function clearSessionAndRedirectToLanding(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY); localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY);
localStorage.removeItem(LOCAL_STORAGE_USER_KEY); localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
@@ -57,8 +108,16 @@ export function clearSessionAndRedirectToLanding(): void {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY); localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY); localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
} catch {} } catch (err) {
try { queryClient.clear(); } catch {} console.warn('[SessionExpiration] Storage cleanup warning:', err);
}
try {
queryClient.clear();
} catch (err) {
console.warn('[SessionExpiration] QueryClient clear warning:', err);
}
resetSessionExpiredState(); resetSessionExpiredState();
window.location.replace('/'); window.location.replace('/');
} }
@@ -0,0 +1,164 @@
import { getTenant } from './api';
import { ActivityStatus, Tenant } from '../types';
import { setActivationPending } from './userSyncService';
export interface TenantLockoutResult {
isActive: boolean;
tenant: Tenant | null;
errorMessage?: string;
statusCode?: number;
}
let cachedTenantLockout: {
key: string;
result: TenantLockoutResult;
timestamp: number;
} | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
export function clearTenantLockoutCache(): void {
cachedTenantLockout = null;
}
export function isStatusActive(status: any): boolean {
if (status === undefined || status === null || status === '') {
return false;
}
if (status === ActivityStatus.Active || status === 1 || status === '1') {
return true;
}
if (typeof status === 'string' && status.trim().toLowerCase() === 'active') {
return true;
}
if (status === true) {
return true;
}
return false;
}
export function getStatusLabel(status: any): string {
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
return 'Active';
}
if (status === ActivityStatus.Inactive || status === 2 || String(status).toLowerCase() === 'inactive') {
return 'Inactive';
}
if (status === ActivityStatus.Disabled || status === 3 || String(status).toLowerCase() === 'disabled') {
return 'Disabled';
}
if (String(status).toLowerCase() === 'suspended') {
return 'Suspended';
}
if (status === 0 || String(status).toLowerCase() === 'pending') {
return 'Pending';
}
if (status !== undefined && status !== null && status !== '') {
return String(status);
}
return 'Not Specified';
}
export async function verifyTenantLockout(
tenantId?: number | string | null,
tenantClaim?: string | null,
forceRefresh = false
): Promise<TenantLockoutResult> {
const primaryId =
tenantId !== undefined && tenantId !== null && tenantId !== ''
? tenantId
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== ''
? tenantClaim
: null;
if (primaryId === null || primaryId === undefined || primaryId === '') {
const errorMsg =
'Tenant identification claims (tenant / tenant_id) not found in credentials. Access is restricted.';
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
};
}
const cacheKey = String(primaryId).trim().toLowerCase();
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if (
cachedTenantLockout.result.isActive &&
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
) {
return cachedTenantLockout.result;
}
}
let tenantRes: Tenant | null = null;
let lastError: any = null;
try {
tenantRes = await getTenant(primaryId);
} catch (err: any) {
lastError = err;
tenantRes = null;
}
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) {
try {
tenantRes = await getTenant(tenantClaim);
lastError = null;
} catch (err: any) {
lastError = err;
tenantRes = null;
}
}
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
const serverMsg =
lastError?.response?.data?.detail ||
lastError?.response?.data?.message ||
lastError?.message ||
'No tenant record returned';
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
statusCode: lastError?.response?.status,
};
}
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
const rawStatus =
(tenantObj as any)?.status ??
(tenantObj as any)?.Status ??
(tenantObj as any)?.activityStatus ??
(tenantObj as any)?.ActivityStatus;
const active = isStatusActive(rawStatus);
if (!active) {
const statusLabel = getStatusLabel(rawStatus);
const tenantName = tenantObj?.name || String(primaryId);
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: tenantObj,
errorMessage: errorMsg,
};
}
const successResult: TenantLockoutResult = {
isActive: true,
tenant: tenantObj,
};
cachedTenantLockout = {
key: cacheKey,
result: successResult,
timestamp: Date.now(),
};
return successResult;
}
@@ -1,16 +1,26 @@
import Hashids from 'hashids'; import Hashids from 'hashids';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
/**
* Deterministic Hashids configuration matching pfm-api HasherSettings.
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
*/
export const HASHER_SETTINGS = { export const HASHER_SETTINGS = {
minHashLength: 11, minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==', salt:
(env.VITE_HASHER_SALT as string) ||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
}; };
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
export function updateHasherSettings(salt?: string, minLength?: number | string): void { export function updateHasherSettings(salt?: string, minLength?: number | string): void {
let updated = false; let updated = false;
if (salt && salt.trim() !== '') { if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim(); HASHER_SETTINGS.salt = salt.trim();
updated = true; updated = true;
} }
@@ -26,43 +36,78 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
} }
} }
function isAlreadyHashId(candidate: string): boolean {
if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
return false;
}
try {
const decoded = hasher.decode(candidate);
return Array.isArray(decoded) && decoded.length > 0;
} catch {
return false;
}
}
export function encodeHashIdSync(id: number | string | null | undefined): string { export function encodeHashIdSync(id: number | string | null | undefined): string {
if (id === undefined || id === null || id === '') return ''; if (id === undefined || id === null || id === '') {
return '';
}
if (typeof id === 'number') { if (typeof id === 'number') {
if (isNaN(id) || !isFinite(id) || id < 0) return ''; if (isNaN(id) || !isFinite(id) || id < 0) return '';
return hasher.encode(id); return hasher.encode(id);
} }
const str = String(id).trim(); const str = String(id).trim();
if (!str) return ''; if (!str) return '';
try {
const decoded = hasher.decode(str); if (isAlreadyHashId(str)) {
if (Array.isArray(decoded) && decoded.length > 0) return str; return str;
} catch {}
if (/^\d+$/.test(str)) {
const num = parseInt(str, 10);
if (!isNaN(num) && num >= 0) return hasher.encode(num);
} }
if (/^\d+$/.test(str)) {
try {
const num = parseInt(str, 10);
if (!isNaN(num) && num >= 0) {
return hasher.encode(num);
}
} catch {
// Fall through
}
}
return str; return str;
} }
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string { export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return ''; if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId; if (typeof hashId === 'number') return hashId;
const str = String(hashId).trim(); const str = String(hashId).trim();
if (!str) return '';
if (/^-?\d+$/.test(str)) { if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10); const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed; return isNaN(parsed) ? str : parsed;
} }
try { try {
const decoded = hasher.decode(str); const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) return Number(decoded[0]); if (Array.isArray(decoded) && decoded.length > 0) {
} catch {} return Number(decoded[0]);
}
} catch {
// If decoding fails, return raw string
}
return str; return str;
} }
export function decodeToNumberSync(hashId: number | string | null | undefined): number { export function decodeToNumberSync(hashId: number | string | null | undefined): number {
if (!hashId) return 0; if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') return isNaN(hashId) ? 0 : Math.trunc(hashId); if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
const str = String(hashId).trim(); const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) { if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10); const parsed = parseInt(str, 10);
@@ -70,22 +115,84 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
} }
try { try {
const decoded = hasher.decode(str); const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) return Number(decoded[0]); if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {} } catch {}
const fallback = Number(str); const fallback = Number(str);
return isNaN(fallback) ? 0 : Math.trunc(fallback); return isNaN(fallback) ? 0 : Math.trunc(fallback);
} }
export const toolsService = { export const toolsService = {
encodeIdSync: encodeHashIdSync, encodeIdSync(id: number | string | null | undefined): string {
decodeIdSync: decodeHashIdSync, return encodeHashIdSync(id);
decodeToNumberSync, },
async encodeId(id: number | string | null | undefined) { return encodeHashIdSync(id); },
async decodeId(hashId: string | null | undefined) { return decodeHashIdSync(hashId); }, decodeIdSync(hashId: string | number | null | undefined): number | string {
async resolveProjectId(id: number | string) { return encodeHashIdSync(id); }, return decodeHashIdSync(hashId);
async resolveOrganisationId(id: number | string) { return encodeHashIdSync(id); }, },
async resolveActivityId(id: number | string) { return encodeHashIdSync(id); },
async resolveMilestoneId(id: number | string) { return encodeHashIdSync(id); }, decodeToNumberSync(hashId: number | string | null | undefined): number {
return decodeToNumberSync(hashId);
},
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
return encodeHashIdSync(id);
},
async decodeId(hashId: string | null | undefined, _context = 'general'): Promise<number | string> {
return decodeHashIdSync(hashId);
},
async resolveProjectId(projectId: number | string): Promise<string> {
return encodeHashIdSync(projectId);
},
async resolveOrganisationId(organisationId: number | string): Promise<string> {
return encodeHashIdSync(organisationId);
},
async resolveProgrammeId(programmeId: number | string): Promise<string> {
return encodeHashIdSync(programmeId);
},
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
return encodeHashIdSync(portfolioId);
},
async resolveAwardId(awardId: number | string): Promise<string> {
return encodeHashIdSync(awardId);
},
async resolveContractId(contractId: number | string): Promise<string> {
return encodeHashIdSync(contractId);
},
async resolveActivityId(activityId: number | string): Promise<string> {
return encodeHashIdSync(activityId);
},
async resolveMilestoneId(milestoneId: number | string): Promise<string> {
return encodeHashIdSync(milestoneId);
},
async resolveHashedParams<T extends Record<string, any>>(
params: T,
keysToHash: (keyof T)[]
): Promise<T> {
const resolved: any = { ...params };
for (const key of keysToHash) {
const val = params[key];
if (val !== undefined && val !== null) {
resolved[key] = encodeHashIdSync(val);
}
}
return resolved;
},
async primeCache(_items: { id: number | string; context: string }[]): Promise<void> {
// No-op - instant deterministic computation
},
}; };
export function useHashId(id?: number | string | null, context = 'general') { export function useHashId(id?: number | string | null, context = 'general') {
@@ -22,6 +22,7 @@ export interface UserSyncProfile {
tenant?: string | null; tenant?: string | null;
tenant_id?: number | string | null; tenant_id?: number | string | null;
organisationId?: number | string | null; organisationId?: number | string | null;
organisationName?: string | null;
} }
export interface UserSyncResult { export interface UserSyncResult {
@@ -34,41 +35,94 @@ export interface UserSyncResult {
} }
let inFlightSyncPromise: Promise<UserSyncResult> | null = null; let inFlightSyncPromise: Promise<UserSyncResult> | null = null;
let inMemorySyncRecord: UserSyncRecord | null = null;
let inMemoryActivationPending: boolean | null = null;
let inMemoryActivationReason: string | null = null;
export function isActivationPending(): boolean { export function isActivationPending(): boolean {
try { return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true'; } catch { return false; } if (inMemoryActivationPending !== null) {
return inMemoryActivationPending;
}
try {
const val = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
inMemoryActivationPending = val;
return val;
} catch {
return false;
}
} }
export function getActivationPendingReason(): string | null { export function getActivationPendingReason(): string | null {
try { return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); } catch { return null; } if (inMemoryActivationReason !== null) {
return inMemoryActivationReason;
}
try {
const reason = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
inMemoryActivationReason = reason;
return reason;
} catch {
return null;
}
} }
export function setActivationPending(pending: boolean, reason?: string): void { export function setActivationPending(pending: boolean, reason?: string): void {
inMemoryActivationPending = pending;
inMemoryActivationReason = reason || null;
try { try {
if (pending) { if (pending) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true'); localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
if (reason) localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason); if (reason) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason);
}
} else { } else {
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY); localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
} }
} catch {} } catch (err) {
console.warn('[UserSync] Failed to update activation flag:', err);
}
} }
export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string | null): boolean { export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string | null): boolean {
try { try {
if (isActivationPending()) return false; if (isActivationPending()) {
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY); return false;
if (!raw) return false; }
const record: UserSyncRecord = JSON.parse(raw);
if (!inMemorySyncRecord) {
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return false;
const parsed: UserSyncRecord = JSON.parse(raw);
if (!parsed || parsed.status !== 'synced') return false;
inMemorySyncRecord = parsed;
}
const record = inMemorySyncRecord;
if (!record || record.status !== 'synced') return false; if (!record || record.status !== 'synced') return false;
if (currentSub && record.sub && record.sub !== currentSub) return false;
if (currentToken && record.tokenSnippet && record.tokenSnippet !== currentToken.slice(-16)) return false; if (currentSub && record.sub && record.sub !== currentSub) {
return false;
}
if (currentToken && record.tokenSnippet) {
const snippet = currentToken.slice(-16);
if (record.tokenSnippet !== snippet) {
return false;
}
}
return true; return true;
} catch { return false; } } catch {
return false;
}
} }
export function saveUserSyncInBrowser(sub: string, token: string | null | undefined, endpoint: string, userData?: any): void { export function saveUserSyncInBrowser(
sub: string,
token: string | null | undefined,
endpoint: string,
userData?: any
): void {
try { try {
const record: UserSyncRecord = { const record: UserSyncRecord = {
sub: sub || 'authenticated_user', sub: sub || 'authenticated_user',
@@ -76,13 +130,19 @@ export function saveUserSyncInBrowser(sub: string, token: string | null | undefi
syncedEndpoint: endpoint, syncedEndpoint: endpoint,
tokenSnippet: token ? token.slice(-16) : '', tokenSnippet: token ? token.slice(-16) : '',
status: 'synced', status: 'synced',
userData, userData: userData || undefined,
}; };
inMemorySyncRecord = record;
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record)); localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
} catch {} } catch (err) {
console.warn('[UserSync] Failed to write sync record to localStorage:', err);
}
} }
export function clearUserSyncInBrowser(): void { export function clearUserSyncInBrowser(): void {
inMemorySyncRecord = null;
inMemoryActivationPending = null;
inMemoryActivationReason = null;
try { try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY); localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY); localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
@@ -90,63 +150,161 @@ export function clearUserSyncInBrowser(): void {
} catch {} } catch {}
} }
export function clearSuccessfulSyncRecord(): void {
inMemorySyncRecord = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
} catch {}
}
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 = [ export const USER_SYNC_CANDIDATE_ENDPOINTS = [
'/v1/api/integration/sync', '/v1/api/integration/sync',
'/v1/api/users/sync', '/v1/api/users/sync',
'/v1/api/user/sync', '/v1/api/user/sync',
]; ];
async function executeBackendSync(userSub?: string, profile?: UserSyncProfile): Promise<{ endpoint: string; data?: any }> { export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return (
clean.includes('/integration/sync') ||
clean.includes('/users/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
);
}
async function executeBackendSync(
userSub?: string,
profile?: UserSyncProfile
): Promise<{ endpoint: string; data?: any }> {
const rawTenantId = profile?.tenant_id ?? (profile as any)?.tenantId;
const parsedTenantId =
rawTenantId !== undefined && rawTenantId !== null && rawTenantId !== ''
? Number(rawTenantId)
: undefined;
const resolvedOrgId =
parsedTenantId !== undefined
? parsedTenantId
: profile?.organisationId !== undefined && profile?.organisationId !== null && profile?.organisationId !== ''
? Number(profile.organisationId)
: undefined;
const payload = { const payload = {
id: userSub || profile?.id || profile?.sub, id: userSub || profile?.id || profile?.sub,
sub: userSub || profile?.sub, sub: userSub || profile?.sub,
email: profile?.email || undefined, email: profile?.email || undefined,
name: profile?.name || undefined, name: profile?.name || undefined,
role: profile?.role || undefined, role: profile?.role || undefined,
tenant_id: profile?.tenant_id ? Number(profile.tenant_id) : undefined, tenant: profile?.tenant || undefined,
organisationId: profile?.organisationId ? Number(profile.organisationId) : undefined, tenant_id: parsedTenantId,
organisationId: resolvedOrgId,
}; };
let lastError: any = null; let lastError: any = null;
for (const endpoint of USER_SYNC_CANDIDATE_ENDPOINTS) { for (const endpoint of USER_SYNC_CANDIDATE_ENDPOINTS) {
try { try {
const res = await apiClient.post(endpoint, payload); 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 }; return { endpoint, data: res?.data };
} catch (err: any) { } catch (err: any) {
lastError = err; lastError = err;
const status = err?.response?.status; const status = err?.response?.status;
if (status === 401 || status === 403 || status === 423) throw err; if (status === 401 || status === 403 || status === 423) {
throw err;
}
} }
} }
throw lastError || new Error('Failed to reach user sync endpoint'); throw lastError || new Error('Failed to reach user sync endpoint');
} }
export async function syncUserAccount(userSub?: string, token?: string | null, profile?: UserSyncProfile, force = false): Promise<UserSyncResult> { 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 effectiveToken = token || (typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null);
const effectiveSub = userSub || (typeof window !== 'undefined' ? localStorage.getItem('pfm_user_sub') || '' : ''); const effectiveSub = userSub || (typeof window !== 'undefined' ? localStorage.getItem('pfm_user_sub') || '' : '');
if (!force && isUserSyncedInBrowser(effectiveSub, effectiveToken) && !isActivationPending()) { if (!force && isUserSyncedInBrowser(effectiveSub, effectiveToken) && !isActivationPending()) {
return { success: true, endpoint: '/v1/api/users/sync' }; return {
success: true,
endpoint: getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync',
data: getUserSyncRecord()?.userData,
};
} }
if (inFlightSyncPromise) return inFlightSyncPromise; if (inFlightSyncPromise) {
return inFlightSyncPromise;
}
inFlightSyncPromise = (async (): Promise<UserSyncResult> => { inFlightSyncPromise = (async (): Promise<UserSyncResult> => {
try { try {
const result = await executeBackendSync(effectiveSub, profile); const result = await executeBackendSync(effectiveSub, profile);
setActivationPending(false); setActivationPending(false);
saveUserSyncInBrowser(effectiveSub, effectiveToken, result.endpoint, result.data); saveUserSyncInBrowser(effectiveSub, effectiveToken, result.endpoint, result.data);
return { success: true, endpoint: result.endpoint, data: result.data }; return {
success: true,
endpoint: result.endpoint,
data: result.data,
};
} catch (err: any) { } catch (err: any) {
const status = err?.response?.status; const status = err?.response?.status;
if (status === 401) { if (status === 401) {
clearUserSyncInBrowser(); clearUserSyncInBrowser();
setActivationPending(false); setActivationPending(false);
return { success: false, isUnauthorized: true, errorMessage: 'Session expired (HTTP 401).' }; return {
success: false,
isUnauthorized: true,
errorMessage: 'Authentication session expired or unauthorized (HTTP 401).',
};
} }
const serverMsg = err?.response?.data?.detail || err?.response?.data?.message || 'Account activation pending.';
clearSuccessfulSyncRecord();
const serverMsg =
err?.response?.data?.detail ||
err?.response?.data?.message ||
err?.response?.data?.error ||
err?.message ||
'Account is not yet enabled by the administrator.';
setActivationPending(true, serverMsg); setActivationPending(true, serverMsg);
return { success: false, isUnauthorized: false, isActivationPending: true, errorMessage: serverMsg };
return {
success: false,
isUnauthorized: false,
isActivationPending: true,
errorMessage: serverMsg,
};
} finally { } finally {
inFlightSyncPromise = null; inFlightSyncPromise = null;
} }
@@ -154,3 +312,13 @@ export async function syncUserAccount(userSub?: string, token?: string | null, p
return inFlightSyncPromise; return inFlightSyncPromise;
} }
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;
}
File diff suppressed because it is too large Load Diff
@@ -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;
}