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