Merge pull request 'Implemented partnerui' (#160) from finishline into main
Reviewed-on: #160
This commit was merged in pull request #160.
This commit is contained in:
@@ -2,29 +2,10 @@ import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
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 { 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() {
|
||||
const { isAuthenticated, isLoading, login } = useAuth();
|
||||
@@ -55,16 +36,14 @@ function AppRoutes() {
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<AuthenticatedLayout />
|
||||
) : (
|
||||
<LandingPage onLogin={login} isLoading={isLoading} />
|
||||
)
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={isAuthenticated ? "/dashboard" : "/"} replace />}
|
||||
element={<Navigate to={isAuthenticated ? '/dashboard' : '/'} replace />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
@@ -73,11 +52,13 @@ function AppRoutes() {
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</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}
|
||||
</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">
|
||||
{project.code}
|
||||
{(project as any).code}
|
||||
</span>
|
||||
)}
|
||||
{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="text-[10px] text-slate-400 uppercase tracking-wider font-semibold">Total Budget</div>
|
||||
<div className="text-sm font-bold text-emerald-300 font-mono">
|
||||
{formatCurrency(project.budget || totalAwardsAmount)}
|
||||
{formatCurrency((project as any).budget || totalAwardsAmount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,7 +390,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<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>
|
||||
</div>
|
||||
{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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
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
|
||||
// ----------------------------------------------------------------------
|
||||
export const usersApi = {
|
||||
getUser: (userId: string | number) =>
|
||||
apiClient.get<Models.User>(`/v1/api/users/${hashParam(userId)}`),
|
||||
getUser: (userId: string) =>
|
||||
apiClient.get<Models.User>(`/v1/api/users/${userId}`),
|
||||
|
||||
getUsersPaged: (page: number, pageSize: number, filter?: Models.UserFilterRequest) =>
|
||||
apiClient.post<Models.User[]>(`/v1/api/users/${page}/${pageSize}`, filter || {}),
|
||||
@@ -23,8 +23,8 @@ export const usersApi = {
|
||||
syncIntegration: (payload: any) =>
|
||||
apiClient.post<any>('/v1/api/integration/sync', payload),
|
||||
|
||||
updateUserStatus: (userId: string | number, status: string | number) =>
|
||||
apiClient.get<any>(`/v1/api/users/${hashParam(userId)}/status/${status}`),
|
||||
updateUserStatus: (userId: string, status: string | number) =>
|
||||
apiClient.get<any>(`/v1/api/users/${userId}/status/${status}`),
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
@@ -72,7 +72,7 @@ export const projectsApi = {
|
||||
|
||||
// Outcomes
|
||||
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) =>
|
||||
apiClient.put<Models.Outcome>('/v1/api/projects/outcomes', payload),
|
||||
@@ -403,14 +403,17 @@ export const communicationsApi = {
|
||||
getInvitesPaged: (page: number, pageSize: number) =>
|
||||
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${page}/${pageSize}`),
|
||||
|
||||
getApplicantInvites: (applicantUserId: string | number) =>
|
||||
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${hashParam(applicantUserId)}`),
|
||||
getApplicantData: (applicantUserId: string) =>
|
||||
apiClient.get<Models.ApplicantData[]>(`/v1/api/communications/applicants/${applicantUserId}/all`),
|
||||
|
||||
getApplicantInvite: (applicantUserId: string | number) =>
|
||||
apiClient.get<Models.Invite>(`/v1/api/communications/invites/applicants/${hashParam(applicantUserId)}`),
|
||||
getApplicantInvites: (applicantUserId: string) =>
|
||||
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) =>
|
||||
apiClient.get<Models.Notification[]>(`/v1/api/communications/norifications/${hashParam(organisationId)}`),
|
||||
apiClient.get<Models.Notification[]>(`/v1/api/communications/notifications/${hashParam(organisationId)}`),
|
||||
};
|
||||
|
||||
// Consolidated export
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,694 +1 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
export * from '../../types';
|
||||
|
||||
@@ -3,29 +3,95 @@ import { getApiBaseUrl } from '../config';
|
||||
import { triggerSessionExpired, isUserSyncEndpoint, isTenantEndpoint } from './sessionExpiration';
|
||||
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({
|
||||
baseURL: getApiBaseUrl(),
|
||||
baseURL: BASE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
},
|
||||
timeout: 20000,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
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) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
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 (isActivationPending()) {
|
||||
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)) {
|
||||
const syncRes = await syncUserAccount(storedSub, token);
|
||||
if (!syncRes.success) {
|
||||
@@ -33,20 +99,40 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
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) => {
|
||||
if (error?.response?.status === 401) {
|
||||
const requestUrl = error.config?.url;
|
||||
const isLifecycleEndpoint = isUserSyncEndpoint(requestUrl) || isTenantEndpoint(requestUrl);
|
||||
|
||||
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);
|
||||
} 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);
|
||||
|
||||
@@ -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 {
|
||||
if (!url) return false;
|
||||
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 {
|
||||
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;
|
||||
|
||||
const listeners = new Set<SessionExpiredListener>();
|
||||
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 {
|
||||
if (requestUrl && isUserSyncEndpoint(requestUrl)) return;
|
||||
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) return;
|
||||
if (hasTriggeredSessionExpired) return;
|
||||
if (requestUrl && isUserSyncEndpoint(requestUrl)) {
|
||||
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);
|
||||
if (!hasExistingToken) return;
|
||||
if (!hasExistingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggeredSessionExpired = true;
|
||||
dismissSubordinateModals();
|
||||
|
||||
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 {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function resetSessionExpiredState(): void {
|
||||
@@ -49,6 +99,7 @@ export function resetSessionExpiredState(): void {
|
||||
|
||||
export function clearSessionAndRedirectToLanding(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(LOCAL_STORAGE_TOKEN_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_ACTIVATION_PENDING_KEY);
|
||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
||||
} catch {}
|
||||
try { queryClient.clear(); } catch {}
|
||||
} catch (err) {
|
||||
console.warn('[SessionExpiration] Storage cleanup warning:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
queryClient.clear();
|
||||
} catch (err) {
|
||||
console.warn('[SessionExpiration] QueryClient clear warning:', err);
|
||||
}
|
||||
|
||||
resetSessionExpiredState();
|
||||
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 { 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 = {
|
||||
minHashLength: 11,
|
||||
salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
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 && salt.trim() !== '') {
|
||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||
HASHER_SETTINGS.salt = salt.trim();
|
||||
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 {
|
||||
if (id === undefined || id === null || id === '') return '';
|
||||
if (id === undefined || id === null || id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof id === 'number') {
|
||||
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
||||
return hasher.encode(id);
|
||||
}
|
||||
|
||||
const str = String(id).trim();
|
||||
if (!str) return '';
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) return str;
|
||||
} catch {}
|
||||
if (/^\d+$/.test(str)) {
|
||||
const num = parseInt(str, 10);
|
||||
if (!isNaN(num) && num >= 0) return hasher.encode(num);
|
||||
|
||||
if (isAlreadyHashId(str)) {
|
||||
return str;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(str)) {
|
||||
try {
|
||||
const num = parseInt(str, 10);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
return hasher.encode(num);
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||
if (typeof hashId === 'number') return hashId;
|
||||
|
||||
const str = String(hashId).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = parseInt(str, 10);
|
||||
return isNaN(parsed) ? str : parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) return Number(decoded[0]);
|
||||
} catch {}
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, return raw string
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||
if (!hashId) return 0;
|
||||
if (typeof hashId === 'number') return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
||||
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
||||
if (typeof hashId === 'number') {
|
||||
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
||||
}
|
||||
const str = String(hashId).trim();
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
const parsed = parseInt(str, 10);
|
||||
@@ -70,22 +115,84 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
|
||||
}
|
||||
try {
|
||||
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 {}
|
||||
const fallback = Number(str);
|
||||
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
||||
}
|
||||
|
||||
export const toolsService = {
|
||||
encodeIdSync: encodeHashIdSync,
|
||||
decodeIdSync: decodeHashIdSync,
|
||||
decodeToNumberSync,
|
||||
async encodeId(id: number | string | null | undefined) { return encodeHashIdSync(id); },
|
||||
async decodeId(hashId: string | null | undefined) { return decodeHashIdSync(hashId); },
|
||||
async resolveProjectId(id: number | string) { return encodeHashIdSync(id); },
|
||||
async resolveOrganisationId(id: number | string) { return encodeHashIdSync(id); },
|
||||
async resolveActivityId(id: number | string) { return encodeHashIdSync(id); },
|
||||
async resolveMilestoneId(id: number | string) { return encodeHashIdSync(id); },
|
||||
encodeIdSync(id: number | string | null | undefined): string {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
|
||||
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
||||
return decodeHashIdSync(hashId);
|
||||
},
|
||||
|
||||
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') {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UserSyncProfile {
|
||||
tenant?: string | null;
|
||||
tenant_id?: number | string | null;
|
||||
organisationId?: number | string | null;
|
||||
organisationName?: string | null;
|
||||
}
|
||||
|
||||
export interface UserSyncResult {
|
||||
@@ -34,41 +35,94 @@ export interface UserSyncResult {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
inMemoryActivationPending = pending;
|
||||
inMemoryActivationReason = reason || null;
|
||||
try {
|
||||
if (pending) {
|
||||
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 {
|
||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_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 {
|
||||
try {
|
||||
if (isActivationPending()) return false;
|
||||
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||
if (!raw) return false;
|
||||
const record: UserSyncRecord = JSON.parse(raw);
|
||||
if (isActivationPending()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 (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;
|
||||
} 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 {
|
||||
const record: UserSyncRecord = {
|
||||
sub: sub || 'authenticated_user',
|
||||
@@ -76,13 +130,19 @@ export function saveUserSyncInBrowser(sub: string, token: string | null | undefi
|
||||
syncedEndpoint: endpoint,
|
||||
tokenSnippet: token ? token.slice(-16) : '',
|
||||
status: 'synced',
|
||||
userData,
|
||||
userData: userData || undefined,
|
||||
};
|
||||
inMemorySyncRecord = 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 {
|
||||
inMemorySyncRecord = null;
|
||||
inMemoryActivationPending = null;
|
||||
inMemoryActivationReason = null;
|
||||
try {
|
||||
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
|
||||
@@ -90,63 +150,161 @@ export function clearUserSyncInBrowser(): void {
|
||||
} 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 = [
|
||||
'/v1/api/integration/sync',
|
||||
'/v1/api/users/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 = {
|
||||
id: userSub || profile?.id || profile?.sub,
|
||||
sub: userSub || profile?.sub,
|
||||
email: profile?.email || undefined,
|
||||
name: profile?.name || undefined,
|
||||
role: profile?.role || undefined,
|
||||
tenant_id: profile?.tenant_id ? Number(profile.tenant_id) : undefined,
|
||||
organisationId: profile?.organisationId ? Number(profile.organisationId) : undefined,
|
||||
tenant: profile?.tenant || undefined,
|
||||
tenant_id: parsedTenantId,
|
||||
organisationId: resolvedOrgId,
|
||||
};
|
||||
|
||||
let lastError: any = null;
|
||||
|
||||
for (const endpoint of USER_SYNC_CANDIDATE_ENDPOINTS) {
|
||||
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 };
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
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');
|
||||
}
|
||||
|
||||
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 effectiveSub = userSub || (typeof window !== 'undefined' ? localStorage.getItem('pfm_user_sub') || '' : '');
|
||||
|
||||
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> => {
|
||||
try {
|
||||
const result = await executeBackendSync(effectiveSub, profile);
|
||||
setActivationPending(false);
|
||||
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) {
|
||||
const status = err?.response?.status;
|
||||
|
||||
if (status === 401) {
|
||||
clearUserSyncInBrowser();
|
||||
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);
|
||||
return { success: false, isUnauthorized: false, isActivationPending: true, errorMessage: serverMsg };
|
||||
|
||||
return {
|
||||
success: false,
|
||||
isUnauthorized: false,
|
||||
isActivationPending: true,
|
||||
errorMessage: serverMsg,
|
||||
};
|
||||
} finally {
|
||||
inFlightSyncPromise = null;
|
||||
}
|
||||
@@ -154,3 +312,13 @@ export async function syncUserAccount(userSub?: string, token?: string | null, p
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user