From 1142cfd94e16fbaea5ddc253daf16308222a1ea1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 11 Oct 2026 11:16:08 +0200 Subject: [PATCH] Implemented rbac --- PostFundManagement.ConsoleUI/src/App.tsx | 115 +++- .../src/auth/AuthProvider.tsx | 45 ++ PostFundManagement.ConsoleUI/src/auth/rbac.ts | 356 ++++++++++ .../src/components/layout/AppLayout.tsx | 84 ++- .../monitoring/EvidenceReviewQueue.tsx | 199 +++--- .../src/components/ui/ExportButton.tsx | 15 +- .../src/components/users/CreateUserModal.tsx | 337 ++++++++++ .../components/users/UserSecurityModal.tsx | 625 ++++++++++++++++++ .../src/lib/exportUtils.ts | 11 +- .../src/pages/BeneficiariesPage.tsx | 61 +- .../src/pages/ChangeManagementPage.tsx | 94 +-- .../src/pages/GovernancePage.tsx | 15 +- .../src/pages/GrantsPage.tsx | 35 +- .../src/pages/LandingPage.tsx | 9 +- .../src/pages/ProjectsPage.tsx | 19 +- .../src/pages/SettingsPage.tsx | 283 ++++---- .../src/pages/SiteVisitsPage.tsx | 44 +- .../src/pages/TenantManagementPage.tsx | 228 ++++++- .../src/services/api.ts | 138 +++- .../src/services/roleClaimsCache.ts | 426 ++++++++++++ PostFundManagement.ConsoleUI/src/types.ts | 59 +- .../src/utils/exportUtils.ts | 19 +- .../src/utils/hashids.ts | 196 ++++++ PostFundManagement.ConsoleUI/vite.config.ts | 52 +- 24 files changed, 3033 insertions(+), 432 deletions(-) create mode 100644 PostFundManagement.ConsoleUI/src/auth/rbac.ts create mode 100644 PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts create mode 100644 PostFundManagement.ConsoleUI/src/utils/hashids.ts diff --git a/PostFundManagement.ConsoleUI/src/App.tsx b/PostFundManagement.ConsoleUI/src/App.tsx index 2fec0ce..df40f9d 100644 --- a/PostFundManagement.ConsoleUI/src/App.tsx +++ b/PostFundManagement.ConsoleUI/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from './lib/queryClient'; import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider'; +import { ClaimProtectedRoute, useRbac } from './auth/rbac'; import { ToastProvider } from './components/ui/Toast'; import { AppLayout } from './components/layout/AppLayout'; @@ -27,6 +28,18 @@ const RouteLoading = () => ( ); +const DefaultRouteRedirect = () => { + const { canView } = useRbac(); + if (canView('dashboard')) return ; + if (canView('projects_milestones')) return ; + if (canView('grant_financials')) return ; + if (canView('evidence_risks')) return ; + if (canView('governance')) return ; + if (canView('beneficiaries')) return ; + if (canView('settings_users') || canView('settings_invites')) return ; + return ; +}; + export default function App() { return ( @@ -47,17 +60,94 @@ export default function App() { } > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Fallback Redirect */} - } /> + } /> @@ -87,3 +177,4 @@ export default function App() { ); } + diff --git a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx index 41dbd4f..ba4d1fa 100644 --- a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx @@ -24,6 +24,7 @@ import { cancelSessionExpiration, dismissSubordinateModals, } from '../services/sessionExpiration'; +import { resolveUserRolesAndClaims } from '../services/roleClaimsCache'; export interface AuthUserProfile { id: string; @@ -495,6 +496,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setIsPendingActivation(false); setIsSynced(true); setActivationError(null); + + // Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7) + try { + const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile); + if (resolved.primaryRole) { + profile.role = resolved.primaryRole; + } + if (resolved.tenant) { + profile.tenant = resolved.tenant; + setTenantName(resolved.tenant); + } + if (resolved.tenant_id !== undefined) { + profile.tenant_id = resolved.tenant_id; + setTenantId(resolved.tenant_id); + } + setUser({ ...profile }); + setClaims({ ...resolved.claims }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); + localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); + } catch {} } } catch (syncErr: any) { if (syncErr?.response?.status === 401) { @@ -613,6 +634,30 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setIsPendingActivation(false); setIsSynced(true); setActivationError(null); + + // Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7) + try { + const resolved = await resolveUserRolesAndClaims( + storedSub || '', + parsedClaims, + resolvedUser || {} + ); + if (resolvedUser) { + if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole; + if (resolved.tenant) { + resolvedUser.tenant = resolved.tenant; + setTenantName(resolved.tenant); + } + if (resolved.tenant_id !== undefined) { + resolvedUser.tenant_id = resolved.tenant_id; + setTenantId(resolved.tenant_id); + } + setUser({ ...resolvedUser }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); + } + setClaims({ ...resolved.claims }); + localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); + } catch {} } } catch (syncErr: any) { if (syncErr?.response?.status === 401) { diff --git a/PostFundManagement.ConsoleUI/src/auth/rbac.ts b/PostFundManagement.ConsoleUI/src/auth/rbac.ts new file mode 100644 index 0000000..ea24443 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/auth/rbac.ts @@ -0,0 +1,356 @@ +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' + | '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: 'Executive Dashboard', description: 'Strategic macro metrics and ISO 31000 portfolio health' }, + { 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 verification queue and ISO 31000 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: '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: 'Settings Notifications', description: 'SMTP engines, notification dispatch, and gateways' }, + { 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 administrative 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' }, +]; + +/** + * Base Role to Claims mapping according to PFM RBAC Specification. + */ +export const ROLE_CLAIM_MAP: Record = { + admin: ['unrestricted'], + executive: [ + 'dashboard', + 'governance', + 'projects_milestones', + 'grant_financials', + 'evidence_risks', + 'tenant_self_service', + ], + supervisor: [ + 'dashboard', + 'projects_milestones', + 'grant_financials', + 'evidence_risks', + 'beneficiaries', + 'change_management', + 'site_visits', + 'settings_invites', + 'settings_users', + ], + manager: [ + 'dashboard', + 'projects_milestones', + 'grant_financials', + 'evidence_risks', + 'beneficiaries', + 'change_management', + 'site_visits', + 'settings_invites', + 'settings_users', + 'settings_auditlog', + 'settings_notifications', + 'tenant_self_service', + ], + contributor: [ + 'projects_milestones', + 'grant_financials', + 'evidence_risks', + 'beneficiaries', + 'change_management', + 'site_visits', + 'settings_invites', + ], +}; + +/** + * Resolves action capability set given a claim value string. + * - view: can view records based on role + * - add: can add/create records or give feedback, change statuses other than approve (+ view) + * - update: can use approve status change in drop downs (+ view, + add) + * - export: can click export buttons (+ view) + * - all: can do everything + */ +export function resolveClaimActionSet( + claimValue: string | null | undefined, + roleName?: string +): Set { + const actions = new Set(); + if (!claimValue && !roleName) return actions; + + const rawVal = (claimValue || '').toLowerCase().trim(); + const role = (roleName || '').toLowerCase().trim(); + + // Explicit claim assignment values + 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') { + actions.add('view'); + } else if (val === 'add') { + actions.add('view'); + actions.add('add'); + } else if (val === 'update') { + actions.add('view'); + actions.add('add'); + actions.add('update'); + } else if (val === 'export') { + actions.add('view'); + actions.add('export'); + } + } + return actions; + } + + // Default fallback by role if no overriding claim value specified + if (role === 'admin') { + actions.add('all'); + actions.add('view'); + actions.add('add'); + actions.add('update'); + actions.add('export'); + } else if (role === 'executive' || role === 'manager') { + actions.add('all'); + actions.add('view'); + actions.add('add'); + actions.add('update'); + actions.add('export'); + } else if (role === 'supervisor') { + actions.add('view'); + actions.add('add'); + actions.add('export'); + } else if (role === 'contributor') { + actions.add('view'); + actions.add('add'); + } else { + actions.add('view'); + } + + return actions; +} + +/** + * Helper to extract explicit claim value from varied dictionary structures + */ +export function extractExplicitClaimValue( + claims: Record = {}, + 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. + * If a role claim is not present, the user is fully restricted to performing actions associated with claim in role. + */ +export function evaluateUserPermission( + roles: string[] = [], + claims: Record = {}, + claim: RoleClaim, + action: ClaimAction = 'view' +): boolean { + // Security lockout: Partner role is completely locked out of this console + const isPartner = roles.some((r) => r.toLowerCase().trim() === 'partner'); + if (isPartner) return false; + + // Unrestricted Admin or unrestricted claim -> full access + const hasUnrestrictedRole = roles.some((r) => r.toLowerCase().trim() === 'admin'); + if (hasUnrestrictedRole) return true; + + const rawUnrestricted = extractExplicitClaimValue(claims, 'unrestricted'); + if ( + rawUnrestricted === 'all' || + rawUnrestricted === 'true' || + rawUnrestricted === '1' || + claims.unrestricted === true + ) { + return true; + } + + if (claim === 'unrestricted') { + return hasUnrestrictedRole; + } + + // 1. Check if user has explicit claim in claims dictionary + const rawClaimValue = extractExplicitClaimValue(claims, claim); + if (rawClaimValue !== null && rawClaimValue !== '') { + const actionSet = resolveClaimActionSet(rawClaimValue); + return actionSet.has('all') || actionSet.has(action); + } + + // 2. Check base role mappings + let claimFoundInRole = false; + for (const r of roles) { + const normalizedRole = r.toLowerCase().trim(); + const allowedClaims = ROLE_CLAIM_MAP[normalizedRole]; + if (allowedClaims && allowedClaims.includes(claim)) { + claimFoundInRole = true; + const actionSet = resolveClaimActionSet(undefined, normalizedRole); + if (actionSet.has('all') || actionSet.has(action)) { + return true; + } + } + } + + // If a role claim is not present, the user is fully restricted + return false; +} + +/** + * React hook exposing RBAC evaluation helpers for components. + */ +export function useRbac() { + const { roles, claims } = useAuth(); + + const permissions = useMemo(() => { + const isUnrestricted = + roles.some((r) => r.toLowerCase().trim() === 'admin') || + claims?.unrestricted === true || + claims?.unrestricted === 'all'; + + const canAccess = (claim: RoleClaim, action: ClaimAction = 'view'): boolean => { + 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'); + + return { + isUnrestricted, + canAccess, + canView, + canAdd, + canApprove, + canExport, + }; + }, [roles, claims]); + + return permissions; +} + +/** + * Route protection component for claim-restricted screens. + */ +export const ClaimProtectedRoute: React.FC<{ + claim: RoleClaim; + action?: ClaimAction; + children: React.ReactNode; +}> = ({ claim, action = 'view', children }) => { + const { canAccess, canView } = useRbac(); + const allowed = canAccess(claim, action); + + if (!allowed) { + let fallbackPath = '/dashboard'; + if (canView('dashboard')) fallbackPath = '/dashboard'; + else if (canView('projects_milestones')) fallbackPath = '/projects'; + else if (canView('grant_financials')) fallbackPath = '/grants'; + else if (canView('evidence_risks')) fallbackPath = '/monitoring'; + else if (canView('governance')) fallbackPath = '/governance'; + else if (canView('beneficiaries')) fallbackPath = '/projects/beneficiaries'; + else if (canView('settings_users') || canView('settings_invites')) fallbackPath = '/settings'; + + return React.createElement(Navigate, { to: fallbackPath, replace: true }); + } + + return React.createElement(React.Fragment, null, children); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index 35fc545..519851f 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -29,12 +29,14 @@ import { ErrorBoundary } from '../ui/ErrorBoundary'; import { useQuery } from '@tanstack/react-query'; import { getUsers, getTenant } from '../../services/api'; import { getUserEmail, registerUserEmail } from '../../lib/formatters'; +import { useRbac, RoleClaim } from '../../auth/rbac'; interface SubNavItem { name: string; path: string; icon: React.ElementType; help?: string; + claim?: RoleClaim; } interface NavItem { @@ -42,6 +44,7 @@ interface NavItem { path: string; icon: React.ElementType; help: string; + claim?: RoleClaim; subItems?: SubNavItem[]; } @@ -51,36 +54,42 @@ const navItems: NavItem[] = [ path: '/dashboard', icon: LayoutDashboard, help: 'Strategic macro metrics, portfolio value, disbursed funding, ISO 31000 risk heatmap, and projects requiring immediate intervention.', + claim: 'dashboard', }, { name: 'Governance & Structure', path: '/governance', icon: Building2, help: 'Master records for legal entities, holding structures, investment portfolios, and thematic programmes funded under statutory mandates.', + claim: 'governance', }, { name: 'Projects & Milestones', path: '/projects', icon: FolderKanban, help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.', + claim: 'projects_milestones', subItems: [ { name: 'Beneficiaries', path: '/projects/beneficiaries', icon: Users, help: 'Manage target beneficiaries, demographics, and statutory compliance items satisfying beneficiaries.', + claim: 'beneficiaries', }, { name: 'Change Management', path: '/projects/change-management', icon: GitPullRequest, help: 'Track project issues, contractual change requests, and resolve changes in-place.', + claim: 'change_management', }, { name: 'Site Visits', path: '/projects/site-visits', icon: MapPin, help: 'Log physical site visits, field observations, inspector findings, and project feedback.', + claim: 'site_visits', }, ], }, @@ -89,18 +98,21 @@ const navItems: NavItem[] = [ path: '/grants', icon: Coins, help: 'Financial management including statutory awards, multi-year budget line allocations, tranches, and scheduled payment disbursements.', + claim: 'grant_financials', }, { name: 'M&E, Evidence & Risks', path: '/monitoring', icon: ShieldAlert, help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.', + claim: 'evidence_risks', }, { name: 'Tenant Management', path: '/tenants', icon: Database, help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.', + claim: 'tenant_management', }, { name: 'Settings & Communications', @@ -111,7 +123,9 @@ const navItems: NavItem[] = [ ]; export const AppLayout: React.FC = () => { - const { user, claims, sub, tenantId, hashedTenantId, tenantName: authTenantName, canManageTenants, logout } = useAuth(); + const { user, claims, sub, tenantId, hashedTenantId, tenantName: authTenantName, canManageTenants, logout, roles } = useAuth(); + const userRole = user?.role || (Array.isArray(roles) && roles.length > 0 ? roles[0] : (claims?.role as string) || 'User'); + const { canView } = useRbac(); const [sidebarOpen, setSidebarOpen] = useState(true); const [userMenuOpen, setUserMenuOpen] = useState(false); const navigate = useNavigate(); @@ -120,13 +134,38 @@ export const AppLayout: React.FC = () => { await logout(); }; - // Only show Tenant Management if user's tenant_id == 1 and their role list has Admin in it - const visibleNavItems = navItems.filter((item) => { - if (item.path === '/tenants') { - return canManageTenants; - } - return true; - }); + // Filter navigation shelf strictly according to RBAC Role Claims + const visibleNavItems = navItems + .filter((item) => { + if (item.path === '/tenants') { + return canManageTenants || canView('tenant_management'); + } + if (item.path === '/settings') { + return ( + canView('settings_users') || + canView('settings_invites') || + canView('settings_notifications') || + canView('settings_auditlog') || + canView('tenant_self_service') + ); + } + if (item.claim) { + return canView(item.claim); + } + return true; + }) + .map((item) => { + if (!item.subItems) return item; + return { + ...item, + subItems: item.subItems.filter((subItem) => { + if (subItem.claim) { + return canView(subItem.claim); + } + return true; + }), + }; + }); // Pre-populate user directory cache so all components and modals resolve user emails instantly useQuery({ @@ -355,6 +394,11 @@ export const AppLayout: React.FC = () => {

{tokenUserEmail || 'Authenticated User'}

+
+ + Role: {userRole} + +
= ({ data={filteredEvidence} /> - + {canAdd('evidence_risks') && ( + + )} @@ -795,49 +800,53 @@ export const EvidenceReviewQueue: React.FC = ({ Inspect - + {canAdd('evidence_risks') && ( + + )} - + {canApprove('evidence_risks') && ( + + )} + {canAdd('evidence_risks') && ( + + )} - + {canApprove('evidence_risks') && ( + + )} diff --git a/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx b/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx index 79969f0..2ab69e6 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react'; import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils'; import { useAuth } from '../../auth/AuthProvider'; +import { useRbac, RoleClaim } from '../../auth/rbac'; export interface ExportButtonProps { title: string; @@ -13,6 +14,7 @@ export interface ExportButtonProps { label?: string; size?: 'sm' | 'md'; className?: string; + claim?: RoleClaim; } export function ExportButton({ @@ -24,14 +26,17 @@ export function ExportButton({ label = 'Export', size = 'md', className = '', + claim, }: ExportButtonProps) { const [isOpen, setIsOpen] = useState(false); const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null); const dropdownRef = useRef(null); const menuRef = useRef(null); const { user, claims, tenantId } = useAuth(); + const { canExport } = useRbac(); - const isDisabled = !data || data.length === 0; + const isClaimAllowed = claim ? canExport(claim) : true; + const isDisabled = !data || data.length === 0 || !isClaimAllowed; const updatePosition = useCallback(() => { if (!dropdownRef.current) return; @@ -67,10 +72,10 @@ export function ExportButton({ }; }, [isOpen, updatePosition]); - const handleExportSpreadsheet = () => { + const handleExportSpreadsheet = async () => { if (isDisabled) return; setIsOpen(false); - exportToSpreadsheet({ + await exportToSpreadsheet({ title, subtitle, filename, @@ -81,10 +86,10 @@ export function ExportButton({ }); }; - const handleExportPdf = () => { + const handleExportPdf = async () => { if (isDisabled) return; setIsOpen(false); - exportToPdf({ + await exportToPdf({ title, subtitle, filename, diff --git a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx new file mode 100644 index 0000000..4c95cec --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx @@ -0,0 +1,337 @@ +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react'; +import { Modal } from '../ui/Modal'; +import { useToast } from '../ui/Toast'; +import { createUser, getSystemRoles, assignUserRole, getTenants } from '../../services/api'; +import { useAuth } from '../../auth/AuthProvider'; +import { useRbac } from '../../auth/rbac'; +import { CreateUserRequest, Role, Tenant } from '../../types'; + +const createUserSchema = z.object({ + tenantId: z.string().min(1, 'Tenant is required'), + idNumber: z + .string() + .min(5, 'Identification Number must be at least 5 characters') + .max(30, 'Identification Number is too long') + .regex(/^[a-zA-Z0-9_-]+$/, 'ID Number must be alphanumeric (Hex-encodable)'), + email: z.string().email('Please enter a valid email address'), + userName: z.string().optional(), + phoneNumber: z.string().optional(), + initialRoleId: z.string().optional(), + lockoutEnabled: z.boolean().default(false), + twoFactorEnabled: z.boolean().default(false), +}); + +type CreateUserFormValues = z.infer; + +interface CreateUserModalProps { + isOpen: boolean; + onClose: () => void; + defaultTenantId?: number | string | null; +} + +export const CreateUserModal: React.FC = ({ + isOpen, + onClose, + defaultTenantId, +}) => { + const { tenantId: userTenantId, canManageTenants } = useAuth(); + const { canAdd, canView, isUnrestricted } = useRbac(); + const canCreate = canAdd('settings_users') || canAdd('tenant_self_service') || canAdd('tenant_management') || isUnrestricted; + const hasTenantSelfHelpAccess = canAdd('tenant_self_service') || canView('tenant_self_service'); + const queryClient = useQueryClient(); + const { success, error: toastError } = useToast(); + + const effectiveDefaultTenant = defaultTenantId + ? String(defaultTenantId) + : userTenantId + ? String(userTenantId) + : '1'; + + const { + register, + handleSubmit, + reset, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createUserSchema), + defaultValues: { + tenantId: effectiveDefaultTenant, + idNumber: '', + email: '', + userName: '', + phoneNumber: '', + initialRoleId: '', + lockoutEnabled: false, + twoFactorEnabled: false, + }, + }); + + React.useEffect(() => { + if (isOpen) { + reset({ + tenantId: effectiveDefaultTenant, + idNumber: '', + email: '', + userName: '', + phoneNumber: '', + initialRoleId: '', + lockoutEnabled: false, + twoFactorEnabled: false, + }); + } + }, [isOpen, effectiveDefaultTenant, reset]); + + // System roles for assignment + const { data: systemRoles = [], isLoading: loadingRoles } = useQuery({ + queryKey: ['system-roles'], + queryFn: getSystemRoles, + enabled: isOpen, + }); + + // Filter out Admin role if user has access to tenant self-help (Requirement 1) + const assignableRoles = React.useMemo(() => { + return systemRoles.filter((r) => { + const isRoleAdmin = r.name.toLowerCase() === 'admin' || r.id === '1'; + if (isRoleAdmin && hasTenantSelfHelpAccess) { + return false; + } + return true; + }); + }, [systemRoles, hasTenantSelfHelpAccess]); + + // Tenants list for super-admins + const { data: tenantsList = [] } = useQuery({ + queryKey: ['tenants'], + queryFn: getTenants, + enabled: isOpen && canManageTenants, + }); + + const createUserMutation = useMutation({ + mutationFn: async (values: CreateUserFormValues) => { + // Security enforcement: Users with tenant self-help access cannot create admin users + if (values.initialRoleId && hasTenantSelfHelpAccess) { + const selectedRole = systemRoles.find((r) => r.id === values.initialRoleId); + if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1')) { + throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.'); + } + } + + const payload: CreateUserRequest = { + email: values.email.trim(), + userName: values.userName?.trim() || values.email.split('@')[0], + emailConfirmed: true, + phoneNumber: values.phoneNumber?.trim() || null, + phoneNumberConfirmed: false, + LockoutEnabled: values.lockoutEnabled, + twoFactorEnabled: values.twoFactorEnabled, + accessFailedCount: 0, + }; + + await createUser(values.tenantId, values.idNumber.trim(), payload); + + // If an initial role was chosen and we have a newly generated user, + // invalidate query cache to refresh directory + return values; + }, + onSuccess: (values) => { + success('User Created', `User ${values.email} provisioned under Tenant #${values.tenantId}.`); + queryClient.invalidateQueries({ queryKey: ['users'] }); + queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + onClose(); + }, + onError: (err: any) => { + toastError('User Creation Failed', err); + }, + }); + + const onSubmit = (data: CreateUserFormValues) => { + createUserMutation.mutate(data); + }; + + return ( + +
+ {/* Tenant Scope Selector */} +
+ + {canManageTenants && tenantsList.length > 0 ? ( + + ) : ( + + )} + {errors.tenantId &&

{errors.tenantId.message}

} +
+ + {/* ID Number with Hashids Hex guidance */} +
+ + +

+ Standard South African National ID (13 digits) or alphanumeric identifier. The client automatically converts and hashes this via Hashids Hex. +

+ {errors.idNumber &&

{errors.idNumber.message}

} +
+ + {/* Email & Username */} +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + +
+
+ + {/* Phone & Initial Role */} +
+
+ + +
+ +
+ + + {hasTenantSelfHelpAccess && ( +

+ Tenant Self-Help Policy: Admin user creation is restricted. +

+ )} +
+
+ + {/* Security Flags */} +
+
+ Security & Authentication Flags +
+
+ + + +
+
+ + {!canCreate && ( +
+ User provisioning is restricted by your RBAC claim assignment. +
+ )} + + {/* Form Actions */} +
+ + +
+
+
+ ); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx new file mode 100644 index 0000000..b091721 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx @@ -0,0 +1,625 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Shield, + Key, + Lock, + Unlock, + LogOut, + Plus, + Trash2, + RefreshCw, + User as UserIcon, + CheckCircle2, + AlertTriangle, + FileText, + Hash, +} from 'lucide-react'; +import { Modal } from '../ui/Modal'; +import { useToast } from '../ui/Toast'; +import { + getUser, + getUserRoles, + getSystemRoles, + assignUserRole, + removeUserRole, + getUserClaims, + addUserClaim, + deleteUserClaim, + forceLogoutUser, + setUserLockout, + updateUserStatus, + getSystemClaims, +} from '../../services/api'; +import { HelpTooltip } from '../ui/HelpTooltip'; +import { decodeIdNumberHex } from '../../utils/hashids'; +import { User, Role, Claim, ActivityStatus } from '../../types'; +import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters'; +import { useRbac, ROLE_CLAIM_DEFINITIONS, CLAIM_ACTION_OPTIONS } from '../../auth/rbac'; + +interface UserSecurityModalProps { + user: User | null; + isOpen: boolean; + onClose: () => void; +} + +export const UserSecurityModal: React.FC = ({ + user, + isOpen, + onClose, +}) => { + const queryClient = useQueryClient(); + const { success, error: toastError } = useToast(); + const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac(); + const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service'); + + const canManageRolesAndClaims = canApprove('settings_users') || canApprove('tenant_management') || isUnrestricted; + const canManageSessions = canApprove('settings_users') || canApprove('tenant_management') || hasTenantSelfHelpAccess || isUnrestricted; + const canChangeStatus = canAdd('settings_users') || canAdd('tenant_management') || canApprove('settings_users') || isUnrestricted; + + const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview'); + + // Claim creation form state + const [newClaimType, setNewClaimType] = useState(''); + const [newClaimValue, setNewClaimValue] = useState(''); + const [selectedRoleToAdd, setSelectedRoleToAdd] = useState(''); + + const userId = user?.id || ''; + + // 1. Fetch fresh user details + const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery({ + queryKey: ['user-detail', userId], + queryFn: () => (userId ? getUser(userId) : null), + enabled: isOpen && !!userId, + }); + + // 2. Fetch assigned user roles + const { data: userRoles = [], isLoading: loadingUserRoles, refetch: refetchUserRoles } = useQuery({ + queryKey: ['user-roles', userId], + queryFn: () => (userId ? getUserRoles(userId) : []), + enabled: isOpen && !!userId, + }); + + // 3. Fetch system roles directory + const { data: systemRoles = [], isLoading: loadingSystemRoles } = useQuery({ + queryKey: ['system-roles'], + queryFn: getSystemRoles, + enabled: isOpen, + }); + + // 4. Fetch user claims + const { data: userClaims = [], isLoading: loadingUserClaims, refetch: refetchUserClaims } = useQuery({ + queryKey: ['user-claims', userId], + queryFn: () => (userId ? getUserClaims(userId) : []), + enabled: isOpen && !!userId, + }); + + // 5. Fetch all system claims across the identity management system (Requirement 4 & 7) + const { data: systemClaims = [], isLoading: loadingSystemClaims } = useQuery({ + queryKey: ['system-claims'], + queryFn: getSystemClaims, + enabled: isOpen, + }); + + // Role Mutations + const assignRoleMutation = useMutation({ + mutationFn: (roleId: string) => { + const selectedRole = systemRoles.find((r) => r.id === roleId); + if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1') && hasTenantSelfHelpAccess) { + throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.'); + } + return assignUserRole(userId, roleId); + }, + onSuccess: () => { + success('Role Assigned', 'Role membership updated successfully.'); + refetchUserRoles(); + setSelectedRoleToAdd(''); + }, + onError: (err: any) => toastError('Role Assignment Failed', err), + }); + + const removeRoleMutation = useMutation({ + mutationFn: (roleId: string) => removeUserRole(userId, roleId), + onSuccess: () => { + success('Role Removed', 'Role revoked from user.'); + refetchUserRoles(); + }, + onError: (err: any) => toastError('Role Removal Failed', err), + }); + + // Claim Mutations + const addClaimMutation = useMutation({ + mutationFn: () => { + const normalizedType = newClaimType.toLowerCase().trim(); + if ((normalizedType === 'unrestricted' || normalizedType === 'admin') && hasTenantSelfHelpAccess) { + throw new Error('Security policy: Users with tenant self-help access cannot assign Admin or Unrestricted claims.'); + } + return addUserClaim({ + userId, + claimId: Math.floor(Math.random() * 10000) + 1, + claimType: newClaimType.trim(), + claimValue: newClaimValue.trim(), + }); + }, + onSuccess: () => { + success('Claim Granted', 'User claim added successfully.'); + refetchUserClaims(); + setNewClaimType(''); + setNewClaimValue(''); + }, + onError: (err: any) => toastError('Claim Creation Failed', err), + }); + + const deleteClaimMutation = useMutation({ + mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId), + onSuccess: () => { + success('Claim Revoked', 'Claim removed from user.'); + refetchUserClaims(); + }, + onError: (err: any) => toastError('Claim Revocation Failed', err), + }); + + // Session & Lockout Mutations + const logoutMutation = useMutation({ + mutationFn: () => forceLogoutUser(userId), + onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'), + onError: (err: any) => toastError('Logout Failed', err), + }); + + const lockoutMutation = useMutation({ + mutationFn: (disableLock: boolean) => setUserLockout(userId, disableLock), + onSuccess: (_, disableLock) => { + success( + disableLock ? 'Account Unlocked' : 'Account Locked', + `User account lockout status successfully modified.` + ); + refetchUser(); + }, + onError: (err: any) => toastError('Lockout Update Failed', err), + }); + + const statusMutation = useMutation({ + mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus), + onSuccess: () => { + success('Status Updated', 'User activity status changed.'); + refetchUser(); + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + onError: (err: any) => toastError('Status Update Failed', err), + }); + + if (!user) return null; + + const effectiveUser = freshUser || user; + const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; + const assignedRoleIds = new Set(userRoles.map((r) => r.id)); + const availableRoles = systemRoles.filter((r) => { + if (assignedRoleIds.has(r.id)) return false; + if (hasTenantSelfHelpAccess && (r.name.toLowerCase() === 'admin' || r.id === '1')) { + return false; + } + return true; + }); + + const assignableClaims = React.useMemo(() => { + const claimMap = new Map(); + systemClaims.forEach((c) => { + const type = c.claimType || c.type; + if (type) { + if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) { + return; + } + claimMap.set(type, { claim: type, label: type }); + } + }); + ROLE_CLAIM_DEFINITIONS.forEach((def) => { + if (hasTenantSelfHelpAccess && (def.claim === 'unrestricted' || (def.claim as string) === 'admin')) { + return; + } + claimMap.set(def.claim, { claim: def.claim, label: `${def.claim} (${def.label})` }); + }); + return Array.from(claimMap.values()); + }, [systemClaims, hasTenantSelfHelpAccess]); + + return ( + +
+ {/* Navigation Tabs */} +
+ + + + +
+ + {/* TAB 1: Overview & Profile */} + {activeTab === 'overview' && ( +
+
+
+
+ Email: + {effectiveUser.email || '—'} +
+
+ User UUID: + {effectiveUser.id} +
+
+ +
+
+ Id Number: +
+ {effectiveUser.idNumber || '—'} + {decodedId && decodedId !== effectiveUser.idNumber && ( + (Decoded: {decodedId}) + )} +
+
+
+ Last Login: + + {effectiveUser.lastLoginAt ? formatDate(effectiveUser.lastLoginAt) : 'Never'} + +
+
+ +
+
+ + Account Status: + + + {getActivityStatusBadge(effectiveUser.status).label} + +
+
+ {effectiveUser.status === ActivityStatus.Active ? ( + + ) : ( + + )} +
+
+
+
+ )} + + {/* TAB 2: Roles */} + {activeTab === 'roles' && ( +
+ {/* Add Role selector */} +
+ + +
+ + {/* List of currently assigned roles */} +
+ {loadingUserRoles ? ( +
Loading roles...
+ ) : userRoles.length === 0 ? ( +
No roles assigned to this user.
+ ) : ( + userRoles.map((role) => ( +
+
+
+ + {role.name} +
+
Role ID: {role.id}
+
+ +
+ )) + )} +
+
+ )} + + {/* TAB 3: Claims */} + {activeTab === 'claims' && ( +
+ {/* Add claim inline form */} +
+
+ Grant Role Claim / Custom Claim + RBAC Standard Policy +
+
+
+ + + {(!assignableClaims.some((d) => d.claim === newClaimType) || newClaimType === '') && ( + setNewClaimType(e.target.value)} + className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500" + /> + )} +
+
+ + + {(!CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) || newClaimValue === '') && ( + setNewClaimValue(e.target.value)} + className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500" + /> + )} +
+
+
+ +
+
+ + {/* Existing claims list */} +
+ {loadingUserClaims ? ( +
Loading user claims...
+ ) : userClaims.length === 0 ? ( +
No active claims found for user.
+ ) : ( + userClaims.map((claim, idx) => ( +
+
+
+ + {claim.type || 'Custom Claim'} +
+
+ Value: {claim.value} + {claim.issuer && ({claim.issuer})} +
+
+ {claim.type && ( + + )} +
+ )) + )} +
+
+ )} + + {/* TAB 4: Sessions & Direct Account Interventions */} + {activeTab === 'sessions' && ( +
+
+ {/* Force Logout Action */} +
+
+

+ + Revoke Active User Session + +

+

+ Terminates all active JWT tokens and sessions immediately without direct database intervention. +

+
+ +
+ + {/* Lock / Unlock Account */} +
+
+

+ + Account Lockout Control + +

+

+ Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years. +

+
+
+ + +
+
+
+
+ )} + + {/* Modal Footer */} +
+ +
+
+
+ ); +}; diff --git a/PostFundManagement.ConsoleUI/src/lib/exportUtils.ts b/PostFundManagement.ConsoleUI/src/lib/exportUtils.ts index 4c40f74..23438aa 100644 --- a/PostFundManagement.ConsoleUI/src/lib/exportUtils.ts +++ b/PostFundManagement.ConsoleUI/src/lib/exportUtils.ts @@ -1,5 +1,3 @@ -import { jsPDF } from 'jspdf'; -import autoTable from 'jspdf-autotable'; export interface ExportColumn { header: string; @@ -53,7 +51,7 @@ export function exportToSpreadsheet({ filename, columns, data }: ExportDataOptio /** * Draws the high-visibility official PFM vector brand insignia onto the jsPDF document. */ -function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void { +function drawPfmLogo(doc: any, x: number, y: number, size: number): void { const scale = size / 64; doc.saveGraphicsState(); @@ -183,9 +181,14 @@ function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void { * Generates an executive-grade PDF document in LANDSCAPE mode with clear PFM logo visibility, * metadata header, and structured autoTable data. */ -export function exportToPdf({ title, subtitle, filename, columns, data }: ExportDataOptions): void { +export async function exportToPdf({ title, subtitle, filename, columns, data }: ExportDataOptions): Promise { if (!data || data.length === 0) return; + const [{ jsPDF }, { default: autoTable }] = await Promise.all([ + import('jspdf'), + import('jspdf-autotable'), + ]); + // Enforce Landscape orientation const doc = new jsPDF({ orientation: 'landscape', diff --git a/PostFundManagement.ConsoleUI/src/pages/BeneficiariesPage.tsx b/PostFundManagement.ConsoleUI/src/pages/BeneficiariesPage.tsx index 5610d46..dca6cad 100644 --- a/PostFundManagement.ConsoleUI/src/pages/BeneficiariesPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/BeneficiariesPage.tsx @@ -40,6 +40,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; +import { useRbac } from '../auth/rbac'; import { ApprovalStatusBadge } from '../components/ui/StatusBadge'; import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types'; import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters'; @@ -70,6 +71,7 @@ type OutcomeFormValues = z.infer; export const BeneficiariesPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const { canAdd, canApprove, canExport } = useRbac(); const [selectedProjectId, setSelectedProjectId] = useState('all'); const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries'); @@ -574,23 +576,26 @@ export const BeneficiariesPage: React.FC = () => { - + {canAdd('beneficiaries') && ( + + )}
@@ -759,24 +764,27 @@ export const BeneficiariesPage: React.FC = () => { - + {canAdd('beneficiaries') && ( + + )}
@@ -1017,8 +1025,13 @@ export const BeneficiariesPage: React.FC = () => { className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500" > {APPROVAL_STATUS_OPTIONS.map((opt) => ( - ))} diff --git a/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx index ae88b75..4cc10b4 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx @@ -39,6 +39,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; +import { useRbac } from '../auth/rbac'; import { ApprovalStatusBadge, PriorityBadge } from '../components/ui/StatusBadge'; import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types'; import { @@ -109,6 +110,7 @@ type ResolveChangeFormValues = z.infer; export const ChangeManagementPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const { canAdd, canApprove, canExport } = useRbac(); const [selectedProjectId, setSelectedProjectId] = useState('all'); const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues'); @@ -722,23 +724,26 @@ export const ChangeManagementPage: React.FC = () => { - + {canAdd('change_management') && ( + + )} @@ -920,28 +925,31 @@ export const ChangeManagementPage: React.FC = () => { - + {canAdd('change_management') && ( + + )} @@ -1014,18 +1022,22 @@ export const ChangeManagementPage: React.FC = () => { ) : ( <> - - + {canApprove('change_management') && ( + + )} + {canAdd('change_management') && ( + + )} )} @@ -1510,7 +1522,9 @@ export const ChangeManagementPage: React.FC = () => { > - + diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index 6bfaa4c..b755163 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -57,6 +57,7 @@ import { useToast } from '../components/ui/Toast'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useAuth } from '../auth/AuthProvider'; +import { useRbac } from '../auth/rbac'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection'; import { RulesSection } from '../components/governance/RulesSection'; @@ -122,6 +123,7 @@ type ProgrammeFormValues = z.infer; export const GovernancePage: React.FC = () => { const queryClient = useQueryClient(); const { sub, user, claims } = useAuth(); + const { canAdd, canApprove, canExport } = useRbac(); const currentUserId = sub || user?.id || ''; const { success, error: toastError } = useToast(); @@ -808,7 +810,7 @@ export const GovernancePage: React.FC = () => { {/* Tab Actions */}
- {activeTab === 'organisations' && ( + {activeTab === 'organisations' && canAdd('governance') && ( )} - {activeTab === 'portfolios' && ( + {activeTab === 'portfolios' && canAdd('governance') && ( )} - {activeTab === 'programmes' && ( + {activeTab === 'programmes' && canAdd('governance') && ( )} - {(activeTab === 'instruments' || activeTab === 'instructions') && ( + {(activeTab === 'instruments' || activeTab === 'instructions') && canAdd('governance') && ( )} - {activeTab === 'rules' && ( + {activeTab === 'rules' && canAdd('governance') && ( { Refresh Portfolios { Refresh Programmes ; export const GrantsPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const { canAdd, canApprove, canExport } = useRbac(); const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards'); @@ -666,7 +668,7 @@ export const GrantsPage: React.FC = () => {
- {activeTab === 'awards' && ( + {activeTab === 'awards' && canAdd('grant_financials') && ( )} - {activeTab === 'budgets' && ( + {activeTab === 'budgets' && canAdd('grant_financials') && ( )} - {activeTab === 'disbursements' && ( + {activeTab === 'disbursements' && canAdd('grant_financials') && ( { Refresh Budgets { Update - {b.status !== ApprovalStatus.Approved && ( + {b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && ( { > - + {canApprove('grant_financials') && ( + + )} )}
@@ -1555,7 +1562,9 @@ export const GrantsPage: React.FC = () => { onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)} className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white" > - + diff --git a/PostFundManagement.ConsoleUI/src/pages/LandingPage.tsx b/PostFundManagement.ConsoleUI/src/pages/LandingPage.tsx index 85768c3..dfefbc1 100644 --- a/PostFundManagement.ConsoleUI/src/pages/LandingPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/LandingPage.tsx @@ -18,7 +18,7 @@ import { useAuth } from '../auth/AuthProvider'; import { BrandLogo } from '../components/ui/BrandLogo'; export const LandingPage: React.FC = () => { - const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation } = useAuth(); + const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation, roles } = useAuth(); const navigate = useNavigate(); const handleEnterConsole = async () => { @@ -192,6 +192,13 @@ export const LandingPage: React.FC = () => { )} + +
+ Assigned Role: + + {user?.role || (roles.length > 0 ? roles[0] : (claims.role as string) || 'User')} + +
{/* Guidance Message */} diff --git a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx index c94fff2..a55259f 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx @@ -36,6 +36,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useToast } from '../components/ui/Toast'; +import { useRbac } from '../auth/rbac'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { Project, @@ -149,6 +150,7 @@ export const evaluateProjectScheduleRisk = ( export const ProjectsPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const { canAdd, canExport } = useRbac(); // Filter & Search States const [searchTerm, setSearchTerm] = useState(''); @@ -477,13 +479,15 @@ export const ProjectsPage: React.FC = () => {
- + {canAdd('projects_milestones') && ( + + )}
@@ -628,6 +632,7 @@ export const ProjectsPage: React.FC = () => { {/* Export Button */} { const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users'); + const { canView, canAdd, canExport } = useRbac(); + const canViewUsers = canView('settings_users') || canView('tenant_self_service'); + const canViewInvites = canView('settings_invites'); + const canViewNotifications = canView('settings_notifications'); + const canViewAudit = canView('settings_auditlog'); + + const canAddUser = canAdd('settings_users') || canAdd('tenant_self_service'); + const canAddInvite = canAdd('settings_invites'); + + React.useEffect(() => { + if (activeTab === 'users' && !canViewUsers) { + if (canViewInvites) setActiveTab('invites'); + else if (canViewNotifications) setActiveTab('notifications'); + else if (canViewAudit) setActiveTab('audit'); + } + }, [activeTab, canViewUsers, canViewInvites, canViewNotifications, canViewAudit]); + // ========================================== // 1. User Directory State // ========================================== @@ -105,6 +125,7 @@ export const SettingsPage: React.FC = () => { const [userRoleFilter, setUserRoleFilter] = useState('all'); const [selectedUserForDetail, setSelectedUserForDetail] = useState(null); const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false); + const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false); const userFilter: UserFilterRequest = { email: userEmailSearch.trim() || undefined, @@ -572,77 +593,85 @@ export const SettingsPage: React.FC = () => { {/* Navigation Tabs */}
- + {canViewUsers && ( + + )} - + {canViewInvites && ( + + )} - + {canViewNotifications && ( + + )} - + {canViewAudit && ( + + )}
{/* ============================================================== */} @@ -725,12 +754,25 @@ export const SettingsPage: React.FC = () => { Refresh + {canAddUser && ( + + )} + {/* Status switch actions */} @@ -948,15 +991,18 @@ export const SettingsPage: React.FC = () => { filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`} columns={inviteExportColumns} data={sortedInvites} + claim="settings_invites" /> - + {canAddInvite && ( + + )} @@ -1227,6 +1273,7 @@ export const SettingsPage: React.FC = () => { filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`} columns={notificationExportColumns} data={filteredNotifications} + claim="settings_notifications" /> @@ -1493,6 +1540,7 @@ export const SettingsPage: React.FC = () => { filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`} columns={auditExportColumns} data={filteredAuditLogs} + claim="settings_auditlog" /> @@ -2092,73 +2140,24 @@ export const SettingsPage: React.FC = () => { {/* ============================================================== */} - {/* Modal: User Inspection */} + {/* Modal: User Administration & Role/Claim/Session Control */} {/* ============================================================== */} - setIsUserDetailModalOpen(false)} - title="User Access & Security Details" - > - {selectedUserForDetail && ( -
- {loadingUserFreshDetail ? ( -
- - Fetching user profile details... -
- ) : ( -
-
- User Email: - - {userFreshDetail?.email || selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'} - -
-
- User Identifier: - - {userFreshDetail?.id || selectedUserForDetail.id} - -
-
- Id Number: - - {userFreshDetail?.idNumber || selectedUserForDetail.idNumber || '—'} - -
-
- System Role: - -
-
-
- Status: - - {getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).label} - -
-
- Last Login: - - {(userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt) ? formatDate((userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt)!) : 'Never'} - -
-
-
- )} + onClose={() => { + setIsUserDetailModalOpen(false); + setSelectedUserForDetail(null); + }} + user={selectedUserForDetail} + /> -
- -
-
- )} -
+ {/* ============================================================== */} + {/* Modal: Provision Tenant User */} + {/* ============================================================== */} + setIsCreateUserModalOpen(false)} + /> {/* ============================================================== */} {/* Modal: Notification Payload & Data Inspection */} diff --git a/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx index 8495b37..25f7ea5 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx @@ -33,6 +33,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; +import { useRbac } from '../auth/rbac'; import { ApprovalStatusBadge } from '../components/ui/StatusBadge'; import { SiteVisit, ApprovalStatus } from '../types'; import { @@ -53,6 +54,7 @@ type SiteVisitFormValues = z.infer; export const SiteVisitsPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const { canAdd, canApprove, canExport } = useRbac(); const [selectedProjectId, setSelectedProjectId] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); @@ -349,28 +351,31 @@ export const SiteVisitsPage: React.FC = () => { - + {canAdd('site_visits') && ( + + )} @@ -505,8 +510,13 @@ export const SiteVisitsPage: React.FC = () => { className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500" > {APPROVAL_STATUS_OPTIONS.map((opt) => ( - ))} diff --git a/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx b/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx index fcafa35..aeb8de5 100644 --- a/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx @@ -38,7 +38,10 @@ import { useTableSort, SortableTh } from '../utils/tableSort'; import { ActivityStatusBadge } from '../components/ui/StatusBadge'; import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types'; import { formatDate, getOrganisationTypeLabel, getUserEmail, registerUserEmails } from '../lib/formatters'; +import { CreateUserModal } from '../components/users/CreateUserModal'; +import { UserSecurityModal } from '../components/users/UserSecurityModal'; import { useAuth } from '../auth/AuthProvider'; +import { useRbac } from '../auth/rbac'; const createTenantSchema = z.object({ name: z.string().min(2, 'Tenant name must be at least 2 characters').max(100, 'Tenant name too long'), @@ -265,10 +268,177 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa ); }; +const TenantUsersSubList: React.FC<{ + tenantId: number | string; + tenantName?: string | null; + onProvisionUser: (tenantId: number | string) => void; + onManageUser: (user: User) => void; +}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => { + const { data: usersDirectory = [], isLoading, refetch } = useQuery({ + queryKey: ['tenant-users', tenantId], + queryFn: () => getUsers(1, 200), + staleTime: 30000, + }); + + const tenantUsers = useMemo(() => { + return usersDirectory.filter((u) => { + if (u.tenant_id !== undefined && u.tenant_id !== null) { + return String(u.tenant_id) === String(tenantId); + } + return true; + }); + }, [usersDirectory, tenantId]); + + const { canAdd } = useRbac(); + const canProvision = canAdd('tenant_management') || canAdd('tenant_self_service') || canAdd('settings_users'); + + return ( +
+
+
+
+ +
+
+
+

+ Authorized Users for {tenantName || `Tenant #${tenantId}`} +

+ + {tenantUsers.length} Users + +
+

+ Users provisioned under Tenant #{tenantId} partition boundary. Manage roles, claims, and session security without DB access. +

+
+
+ +
+ + {canProvision && ( + + )} +
+
+ +
+ + + + + + + + + + + + + {isLoading ? ( + + + + ) : tenantUsers.length === 0 ? ( + + + + ) : ( + tenantUsers.map((u) => ( + + + + + + + + + )) + )} + +
Email AddressUser UUIDId Number (Hex)RoleStatusActions
Loading tenant users...
No users provisioned under Tenant #{tenantId}.
{u.email || '—'}{u.id}{u.idNumber || '—'}{u.role || 'Standard User'} + +
+
+
+ ); +}; + +const TenantExpandedPanel: React.FC<{ + tenantId: number | string; + tenantName?: string | null; + onProvisionUser: (tenantId: number | string) => void; + onManageUser: (user: User) => void; +}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => { + const [activeTab, setActiveTab] = useState<'orgs' | 'users'>('orgs'); + + return ( +
+
+ + +
+ + {activeTab === 'orgs' ? ( + + ) : ( + + )} +
+ ); +}; + export const TenantManagementPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); - const { tenantId: currentTenantId, tenantName: currentTenantName } = useAuth(); + const { tenantId: currentTenantId, tenantName: currentTenantName, canManageTenants } = useAuth(); + const { canAdd, isUnrestricted, canApprove } = useRbac(); + const canCreateTenant = canManageTenants || (canAdd('tenant_management') && isUnrestricted); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); @@ -279,6 +449,10 @@ export const TenantManagementPage: React.FC = () => { const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); const [selectedTenant, setSelectedTenant] = useState(null); + const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false); + const [createUserTenantId, setCreateUserTenantId] = useState(null); + const [securityUser, setSecurityUser] = useState(null); + const [isSecurityModalOpen, setIsSecurityModalOpen] = useState(false); // Query Tenants const { @@ -460,15 +634,18 @@ export const TenantManagementPage: React.FC = () => { data={sortedData} columns={exportColumns} label="Export Tenants" + claim="tenant_management" /> - + {canCreateTenant && ( + + )} @@ -740,11 +917,22 @@ export const TenantManagementPage: React.FC = () => { - {/* Expanded Sub-List of Organisations using getTenantOrganisations */} + {/* Expanded Sub-List of Organisations and Users */} {isExpanded && ( - + { + setCreateUserTenantId(tId); + setIsCreateUserModalOpen(true); + }} + onManageUser={(u) => { + setSecurityUser(u); + setIsSecurityModalOpen(true); + }} + /> )} @@ -987,6 +1175,26 @@ export const TenantManagementPage: React.FC = () => { )} + + {/* Provision User Modal */} + { + setIsCreateUserModalOpen(false); + setCreateUserTenantId(null); + }} + defaultTenantId={createUserTenantId} + /> + + {/* User Security & Administration Modal */} + { + setIsSecurityModalOpen(false); + setSecurityUser(null); + }} + user={securityUser} + /> ); }; diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index bd29ca0..657e352 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -92,8 +92,18 @@ import { Notification, NotificationPlatform, NotificationStatus, + Role, + Claim, + CreateUserRequest, + AddUserClaimRequest, } from '../types'; +import { + encodeIdNumberHex, + encodeEntityId, + toSafeInt64String, +} from '../utils/hashids'; + import { parseFriendlyError, FriendlyError, @@ -1364,6 +1374,90 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus | await apiClient.get(`/v1/api/users/${userId}/status/${status}`); }; +export const createUser = async ( + tenantId: number | string, + idNumber: string, + payload: CreateUserRequest +): Promise => { + const encodedTenant = encodeEntityId(tenantId) || String(tenantId); + const encodedIdNumber = encodeIdNumberHex(idNumber); + await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, { + id: payload.id || null, + userName: payload.userName || null, + email: payload.email || null, + emailConfirmed: payload.emailConfirmed ?? true, + phoneNumber: payload.phoneNumber || null, + phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false, + LockoutEnabled: payload.LockoutEnabled ?? false, + twoFactorEnabled: payload.twoFactorEnabled ?? false, + accessFailedCount: payload.accessFailedCount !== undefined ? Number(payload.accessFailedCount) : 0, + lockoutEnd: payload.lockoutEnd || null, + }); +}; + +import { + fetchAndCacheSystemRoles, + fetchAndCacheRoleClaims, + getSystemClaims, + getCachedSystemRoles, + getCachedRoleClaims, + preloadAndCacheAllRoleClaims, +} from './roleClaimsCache'; + +export { + getSystemClaims, + getCachedSystemRoles, + getCachedRoleClaims, + preloadAndCacheAllRoleClaims, +}; + +export const getSystemRoles = async (): Promise => { + return await fetchAndCacheSystemRoles(); +}; + +export const getUserRoles = async (userId: string): Promise => { + const res = await apiClient.get(`/v1/api/users/${userId}/roles`); + return res.data || []; +}; + +export const assignUserRole = async (userId: string, roleId: string): Promise => { + await apiClient.patch(`/v1/api/users/${userId}/roles/${roleId}`); +}; + +export const removeUserRole = async (userId: string, roleId: string): Promise => { + await apiClient.delete(`/v1/api/users/${userId}/roles/${roleId}`); +}; + +export const getRoleClaims = async (roleId: string, roleName?: string): Promise => { + return await fetchAndCacheRoleClaims(roleId, roleName); +}; + +export const getUserClaims = async (userId: string): Promise => { + const res = await apiClient.get(`/v1/api/users/${userId}/claims`); + return res.data || []; +}; + +export const addUserClaim = async (payload: AddUserClaimRequest): Promise => { + await apiClient.patch('/v1/api/users/user/claims', { + userId: payload.userId, + claimId: Number(payload.claimId), + claimType: payload.claimType, + claimValue: payload.claimValue, + }); +}; + +export const deleteUserClaim = async (targetUserId: string, claimId: string | number): Promise => { + await apiClient.delete(`/v1/api/users/${targetUserId}/claims/${claimId}`); +}; + +export const forceLogoutUser = async (userId: string): Promise => { + await apiClient.put(`/v1/api/users/${userId}/session/logout`); +}; + +export const setUserLockout = async (userId: string, disable: boolean): Promise => { + await apiClient.put(`/v1/api/users/${userId}/session/lockout/${disable}`); +}; + import { syncUserAccount, ensureUserSynced, @@ -1421,29 +1515,21 @@ export const sendInvite = async (payload: InviteRequest): Promise => { const tenantId = getCurrentUserTenantId(); const hostOrgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null - ? toIntegerId(payload.organisationId) - : (tenantId !== null ? tenantId : 0); + ? toSafeInt64String(payload.organisationId) + : (tenantId !== null ? String(tenantId) : '0'); const body = { organisationId: hostOrgId, - invitedOrganisationId: toIntegerId(payload.invitedOrganisationId), - awardId: toIntegerId(payload.awardId), - contractId: toIntegerId(payload.contractId), + invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId), + awardId: toSafeInt64String(payload.awardId), + contractId: toSafeInt64String(payload.contractId), recipient: payload.recipient, message: payload.message, platform: payload.platform !== undefined ? Number(payload.platform) : 1, }; - try { - const res = await apiClient.post('/v1/api/communications/invites', body); - return res.data; - } catch (err: any) { - if (err?.response?.status === 404) { - const res = await apiClient.post('/v1/api/communications/invite', body); - return res.data; - } - throw err; - } + const res = await apiClient.post('/v1/api/communications/invites', body); + return res.data; }; export const getInvites = async (page = 1, pageSize = 100): Promise => { @@ -1451,6 +1537,11 @@ export const getInvites = async (page = 1, pageSize = 100): Promise => return res.data || []; }; +export const getApplicantData = async (applicantUserId: string): Promise => { + const res = await apiClient.get(`/v1/api/communications/applicants/${applicantUserId}/all`); + return res.data; +}; + export const getApplicantInvite = async (applicantUserId: string): Promise => { try { const res = await apiClient.get(`/v1/api/communications/invites/applicants/${applicantUserId}`); @@ -1481,26 +1572,13 @@ export const getNotifications = async (organisationId?: string | number): Promis const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId); try { const res = await apiClient.get( - `/v1/api/communications/norifications/${encodeURIComponent(hashedOrgId)}` + `/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}` ); if (res.data && Array.isArray(res.data)) { return res.data; } } catch (err: any) { - if (err?.response?.status === 404) { - try { - const fallbackRes = await apiClient.get( - `/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}` - ); - if (fallbackRes.data && Array.isArray(fallbackRes.data)) { - return fallbackRes.data; - } - } catch (fallbackErr) { - console.warn('[getNotifications] Notification fallback route error:', fallbackErr); - } - } else { - console.warn('[getNotifications] API error fetching notifications:', err); - } + console.warn('[getNotifications] API error fetching notifications:', err); } return []; diff --git a/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts b/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts new file mode 100644 index 0000000..69ebf49 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts @@ -0,0 +1,426 @@ +import { apiClient } from './httpClient'; +import { Role, Claim } from '../types'; +import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac'; + +export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_cache_system_roles'; +export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_cache_role_claims_'; +export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_cache_system_claims'; + +// In-memory runtime caches +let inMemorySystemRoles: Role[] | null = null; +const inMemoryRoleClaims = new Map(); +let inMemorySystemClaims: Claim[] | null = null; + +// Standard role claim defaults matching PFM Identity Server specification +export const DEFAULT_ROLE_CLAIMS: Record = { + contributor: [ + { claimId: 30, claimType: 'projects_milestones', claimValue: 'view, edit, update' }, + { claimId: 31, claimType: 'grant_financials', claimValue: 'view, edit, update' }, + { claimId: 32, claimType: 'evidence_risks', claimValue: 'view, edit, update' }, + { claimId: 33, claimType: 'beneficiaries', claimValue: 'view, edit, update' }, + { claimId: 34, claimType: 'change_management', claimValue: 'view, edit, update' }, + { claimId: 35, claimType: 'site_visits', claimValue: 'view, edit, update' }, + { claimId: 36, claimType: 'settings_invites', claimValue: 'view, edit, update' }, + ], + supervisor: [ + { claimId: 20, claimType: 'dashboard', claimValue: 'view, add, export' }, + { claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, export' }, + { claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, export' }, + { claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, export' }, + { claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, export' }, + { claimId: 25, claimType: 'change_management', claimValue: 'view, add, export' }, + { claimId: 26, claimType: 'site_visits', claimValue: 'view, add, export' }, + { claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, export' }, + { claimId: 28, claimType: 'settings_users', claimValue: 'view, add, export' }, + ], + manager: [ + { claimId: 10, claimType: 'dashboard', claimValue: 'all' }, + { claimId: 11, claimType: 'projects_milestones', claimValue: 'all' }, + { claimId: 12, claimType: 'grant_financials', claimValue: 'all' }, + { claimId: 13, claimType: 'evidence_risks', claimValue: 'all' }, + { claimId: 14, claimType: 'beneficiaries', claimValue: 'all' }, + { claimId: 15, claimType: 'change_management', claimValue: 'all' }, + { claimId: 16, claimType: 'site_visits', claimValue: 'all' }, + { claimId: 17, claimType: 'settings_invites', claimValue: 'all' }, + { claimId: 18, claimType: 'settings_users', claimValue: 'all' }, + { claimId: 19, claimType: 'settings_auditlog', claimValue: 'all' }, + { claimId: 29, claimType: 'settings_notifications', claimValue: 'all' }, + { claimId: 37, claimType: 'tenant_self_service', claimValue: 'all' }, + ], + executive: [ + { claimId: 1, claimType: 'dashboard', claimValue: 'all' }, + { claimId: 2, claimType: 'governance', claimValue: 'all' }, + { claimId: 3, claimType: 'projects_milestones', claimValue: 'all' }, + { claimId: 4, claimType: 'grant_financials', claimValue: 'all' }, + { claimId: 5, claimType: 'evidence_risks', claimValue: 'all' }, + { claimId: 6, claimType: 'tenant_self_service', claimValue: 'all' }, + ], + admin: [ + { claimId: 99, claimType: 'unrestricted', claimValue: 'all' }, + ], +}; + +/** + * Normalizes claim objects to contain both claimType/claimValue and type/value + */ +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, + }; +} + +/** + * Retrieve cached system roles from memory or localStorage. + */ +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; +} + +/** + * Store system roles into memory and localStorage. + */ +export function setCachedSystemRoles(roles: Role[]): void { + inMemorySystemRoles = roles; + if (typeof window !== 'undefined') { + try { + localStorage.setItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY, JSON.stringify(roles)); + } catch {} + } +} + +/** + * Retrieve cached role claims for a specific role ID or role name. + */ +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; +} + +/** + * Store role claims into memory and localStorage. + */ +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 {} + } +} + +/** + * Fetches system roles from backend API, caching the result in memory and localStorage. + */ +export async function fetchAndCacheSystemRoles(): Promise { + try { + const res = await apiClient.get('/v1/api/users/system/roles'); + if (res.data && Array.isArray(res.data) && res.data.length > 0) { + setCachedSystemRoles(res.data); + return res.data; + } + } catch (err) { + // If request fails, fall back to cached roles + } + + const cached = getCachedSystemRoles(); + if (cached && cached.length > 0) return cached; + + // Fallback defaults if offline / bootstrapping + const defaults: Role[] = [ + { id: '1', name: 'Admin' }, + { id: '2', name: 'Executive' }, + { id: '3', name: 'Supervisor' }, + { id: '4', name: 'Manager' }, + { id: '5', name: 'Contributor' }, + ]; + setCachedSystemRoles(defaults); + return defaults; +} + +/** + * Fetches role claims for a role from the API, caching them under role ID and role name. + */ +export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string): Promise { + const roleIdKey = roleId.toLowerCase().trim(); + const roleNameKey = (roleName || '').toLowerCase().trim(); + + try { + const res = await apiClient.get(`/v1/api/users/system/roles/${roleId}/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); + return normalized; + } + } catch (err) { + // Network or server error, proceed to cache / defaults fallback + } + + // Check cache by role ID + const cachedById = getCachedRoleClaims(roleIdKey); + if (cachedById && cachedById.length > 0) return cachedById; + + // Check cache by role name + if (roleNameKey) { + const cachedByName = getCachedRoleClaims(roleNameKey); + if (cachedByName && cachedByName.length > 0) return cachedByName; + } + + // Fallback from predefined matrix + 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; +} + +/** + * Direct API calls for user roles and user claims + */ +export async function getUserRolesFromApi(userId: string): Promise { + try { + const res = await apiClient.get(`/v1/api/users/${userId}/roles`); + return res.data || []; + } catch { + return []; + } +} + +export async function getUserClaimsFromApi(userId: string): Promise { + try { + const res = await apiClient.get(`/v1/api/users/${userId}/claims`); + return res.data || []; + } catch { + return []; + } +} + +/** + * Resolves user roles, role claims, and user claims (including tenant and tenant_id) + * and caches system roles and role claims as a persistent reference. + */ +export async function resolveUserRolesAndClaims( + userSub: string, + initialClaims: Record = {}, + currentProfile: { role?: string; tenant?: string; tenant_id?: number } = {} +): Promise<{ + roles: string[]; + claims: Record; + primaryRole: string; + tenant?: string; + tenant_id?: number; +}> { + const mergedClaims: Record = { ...initialClaims }; + const resolvedRoles: string[] = []; + 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); + + // 1. Get user's roles from backend API (Requirement 2) + if (userSub) { + try { + const userRoles = await getUserRolesFromApi(userSub); + if (Array.isArray(userRoles) && userRoles.length > 0) { + userRoles.forEach((r) => { + if (r.name && !resolvedRoles.includes(r.name)) { + resolvedRoles.push(r.name); + } + }); + if (resolvedRoles.length > 0) { + primaryRole = resolvedRoles[0]; + } + } + } catch {} + } + + // Fallback to initial role if API returned empty + if (resolvedRoles.length === 0 && primaryRole) { + resolvedRoles.push(primaryRole); + } + + // 2. For each role, retrieve role claims using the endpoint (Requirement 2 & 7) + const allRoleClaims: Claim[] = []; + for (const roleName of resolvedRoles) { + try { + const roleClaims = await fetchAndCacheRoleClaims(roleName, roleName); + if (Array.isArray(roleClaims)) { + roleClaims.forEach((rc) => { + const norm = normalizeClaim(rc); + allRoleClaims.push(norm); + if (norm.claimType) { + mergedClaims[norm.claimType] = norm.claimValue || 'all'; + } + }); + } + } catch {} + } + if (allRoleClaims.length > 0) { + mergedClaims.role_claims = allRoleClaims; + } + + // 3. Retrieve user claims from endpoint (Requirement 3) + if (userSub) { + try { + const userClaims = await getUserClaimsFromApi(userSub); + if (Array.isArray(userClaims) && userClaims.length > 0) { + userClaims.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 = userClaims.map(normalizeClaim); + } + } catch {} + } + + // 4. Background preload all system roles & role claims for reference (Requirement 7) + preloadAndCacheAllRoleClaims().catch(() => {}); + + return { + roles: resolvedRoles, + claims: mergedClaims, + primaryRole, + tenant, + tenant_id, + }; +} + +/** + * Preloads and caches all system roles and their respective role claims. + */ +export async function preloadAndCacheAllRoleClaims(): Promise { + const roles = await fetchAndCacheSystemRoles(); + for (const role of roles) { + await fetchAndCacheRoleClaims(role.id, role.name); + } +} + +/** + * Retrieves all system claims in the identity management system. + * Any claim is assignable to an authorized user (with admin being a special case). + */ +export async function getSystemClaims(): Promise { + if (inMemorySystemClaims && inMemorySystemClaims.length > 0) { + return inMemorySystemClaims; + } + + if (typeof window !== 'undefined') { + try { + const raw = localStorage.getItem(LOCAL_STORAGE_SYSTEM_CLAIMS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + inMemorySystemClaims = parsed; + return parsed; + } + } + } catch {} + } + + const claimsMap = new Map(); + + // 1. Try system claims endpoint directly + try { + const res = await apiClient.get('/v1/api/users/system/claims'); + if (res.data && Array.isArray(res.data)) { + res.data.forEach((c) => { + const norm = normalizeClaim(c); + if (norm.claimType) claimsMap.set(norm.claimType, norm); + }); + } + } catch {} + + // 2. Aggregate role claims from all system roles + try { + const roles = await fetchAndCacheSystemRoles(); + for (const r of roles) { + const claims = await fetchAndCacheRoleClaims(r.id, r.name); + claims.forEach((c) => { + const norm = normalizeClaim(c); + if (norm.claimType && !claimsMap.has(norm.claimType)) { + claimsMap.set(norm.claimType, norm); + } + }); + } + } catch {} + + // 3. Ensure all standard defined claims from RBAC specification exist + ROLE_CLAIM_DEFINITIONS.forEach((def, index) => { + if (!claimsMap.has(def.claim)) { + claimsMap.set(def.claim, { + claimId: index + 1, + claimType: def.claim, + claimValue: 'all', + type: def.claim, + value: 'all', + }); + } + }); + + const allClaims = Array.from(claimsMap.values()); + inMemorySystemClaims = allClaims; + if (typeof window !== 'undefined') { + try { + localStorage.setItem(LOCAL_STORAGE_SYSTEM_CLAIMS_KEY, JSON.stringify(allClaims)); + } catch {} + } + + return allClaims; +} diff --git a/PostFundManagement.ConsoleUI/src/types.ts b/PostFundManagement.ConsoleUI/src/types.ts index 02f6eff..abd6786 100644 --- a/PostFundManagement.ConsoleUI/src/types.ts +++ b/PostFundManagement.ConsoleUI/src/types.ts @@ -256,6 +256,57 @@ export enum UserRole { RecipientViewer = 5, } +export interface Role { + id: string; + name: string; +} + +export interface Claim { + id?: string | number | null; + claimId?: number | string | null; + claimType?: string | null; + claimValue?: string | null; + issuer?: string | null; + originalIssuer?: string | null; + properties?: Record | null; + subject?: ClaimsIdentity | null; + type?: string | null; + value?: string | null; + valueType?: string | null; +} + +export interface ClaimsIdentity { + authenticationType?: string | null; + isAuthenticated: boolean; + actor?: ClaimsIdentity | null; + bootstrapContext?: any; + claims?: Claim[] | null; + label?: string | null; + name?: string | null; + nameClaimType?: string | null; + roleClaimType?: string | null; +} + +export interface CreateUserRequest { + id?: string | null; + userName?: string | null; + email?: string | null; + emailConfirmed?: boolean; + phoneNumber?: string | null; + phoneNumberConfirmed?: boolean; + LockoutEnabled?: boolean; + twoFactorEnabled?: boolean; + accessFailedCount?: number | string; + lockoutEnd?: string | null; +} + +export interface AddUserClaimRequest { + userId: string; + claimId: number | string; + claimType: string; + claimValue: string; +} + export interface User { id: string; email: string | null; @@ -264,10 +315,16 @@ export interface User { role?: string | UserRole | null; idNumber?: string | null; name?: string; + userName?: string | null; + phoneNumber?: string | null; + lockoutEnabled?: boolean; + lockoutEnd?: string | null; + twoFactorEnabled?: boolean; + accessFailedCount?: number | string; organisationId?: number | string; organisationName?: string; tenant?: string; - tenant_id?: number; + tenant_id?: number | string; } export type SystemUser = User; diff --git a/PostFundManagement.ConsoleUI/src/utils/exportUtils.ts b/PostFundManagement.ConsoleUI/src/utils/exportUtils.ts index e15025a..c3f8bf3 100644 --- a/PostFundManagement.ConsoleUI/src/utils/exportUtils.ts +++ b/PostFundManagement.ConsoleUI/src/utils/exportUtils.ts @@ -1,7 +1,3 @@ -import * as XLSX from 'xlsx'; -import jsPDF from 'jspdf'; -import autoTable from 'jspdf-autotable'; - export interface ExportColumn { header: string; key?: keyof T | string; @@ -23,7 +19,7 @@ export interface ExportOptions { * Draws the official PFM Shield and Brand Header on jsPDF in landscape mode */ function drawPfmPdfHeader( - doc: jsPDF, + doc: any, title: string, subtitle?: string, tenantName?: string, @@ -127,7 +123,7 @@ function drawPfmPdfHeader( /** * Export tabular data to an Excel / Spreadsheet file (.xlsx) */ -export function exportToSpreadsheet(options: ExportOptions): void { +export async function exportToSpreadsheet(options: ExportOptions): Promise { const { title, filename, columns, data } = options; if (!data || data.length === 0) { @@ -135,6 +131,8 @@ export function exportToSpreadsheet(options: ExportOptions): void { return; } + const XLSX = await import('xlsx'); + // Format table rows based on columns config const rows = data.map((item) => { const rowObj: Record = {}; @@ -185,7 +183,7 @@ export function exportToSpreadsheet(options: ExportOptions): void { /** * Export tabular data to a landscape PDF file with PFM Logo and branding */ -export function exportToPdf(options: ExportOptions): void { +export async function exportToPdf(options: ExportOptions): Promise { const { title, subtitle, filename, columns, data, tenantName, userEmail } = options; if (!data || data.length === 0) { @@ -193,6 +191,11 @@ export function exportToPdf(options: ExportOptions): void { return; } + const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([ + import('jspdf'), + import('jspdf-autotable'), + ]); + // Landscape A4 doc: width: 841.89 pt, height: 595.28 pt const doc = new jsPDF({ orientation: 'landscape', @@ -240,7 +243,7 @@ export function exportToPdf(options: ExportOptions): void { alternateRowStyles: { fillColor: [248, 250, 252], // slate-50 }, - didDrawPage: (dataArg) => { + didDrawPage: (dataArg: any) => { // Footer on all pages const pageCount = (doc.internal as any).getNumberOfPages(); const currentPage = dataArg.pageNumber; diff --git a/PostFundManagement.ConsoleUI/src/utils/hashids.ts b/PostFundManagement.ConsoleUI/src/utils/hashids.ts new file mode 100644 index 0000000..17a59e8 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/utils/hashids.ts @@ -0,0 +1,196 @@ +import Hashids from 'hashids'; + +const env = (typeof import.meta !== 'undefined' && import.meta.env + ? import.meta.env + : {}) as Record; + +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 ''; + + // Prevent double-encoding if already a Hashid + if (isHashId(str)) { + return str; + } + + // If already pure hex digits (e.g. standard decimal SA ID 9501015800085 or hex hash) + if (/^[0-9a-fA-F]+$/.test(str)) { + try { + const encoded = hasher.encodeHex(str.toLowerCase()); + if (encoded) return encoded; + } catch { + // Fallback below + } + } + + // For arbitrary alphanumeric ID strings, encode character bytes to hex + 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; + + // Check if decoded hex represents ASCII text with 2-char hex pairs + 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 safe integer range + if (/^\d+$/.test(str)) { + try { + const num = Number(str); + if (num <= Number.MAX_SAFE_INTEGER && num >= 0) { + return hasher.encode(num); + } + // For integers above 2^53 - 1, encode as hex + 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; +} diff --git a/PostFundManagement.ConsoleUI/vite.config.ts b/PostFundManagement.ConsoleUI/vite.config.ts index 7840ff9..47eb384 100644 --- a/PostFundManagement.ConsoleUI/vite.config.ts +++ b/PostFundManagement.ConsoleUI/vite.config.ts @@ -48,6 +48,7 @@ export default defineConfig(async ({ command }) => { watch: process.env.DISABLE_HMR === 'true' ? null : {}, }, build: { + chunkSizeWarningLimit: 600, rollupOptions: { onwarn(warning, defaultHandler) { // Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__` @@ -62,24 +63,63 @@ export default defineConfig(async ({ command }) => { output: { manualChunks(id) { if (id.includes('node_modules')) { - if (id.includes('react') || id.includes('scheduler')) { - return 'vendor-react'; + // 1. Heavy export and PDF document generation + if (id.includes('xlsx')) { + return 'vendor-xlsx'; } - if (id.includes('@tanstack')) { - return 'vendor-query'; + if (id.includes('html2canvas')) { + return 'vendor-html2canvas'; } - if (id.includes('oidc-client-ts')) { - return 'vendor-auth'; + if (id.includes('fflate')) { + return 'vendor-fflate'; } + if (id.includes('dompurify')) { + return 'vendor-purify'; + } + if (id.includes('fast-png')) { + return 'vendor-png'; + } + if (id.includes('polyfills.es.js') || id.includes('jspdf/dist/polyfills')) { + return 'vendor-pdf-polyfills'; + } + if (id.includes('jspdf-autotable')) { + return 'vendor-pdf-autotable'; + } + if (id.includes('jspdf') || id.includes('canvg')) { + return 'vendor-pdf'; + } + // 2. Iconography if (id.includes('lucide-react')) { return 'vendor-icons'; } + // 3. Query & state management + if (id.includes('@tanstack')) { + return 'vendor-query'; + } + // 4. OIDC Authentication + if (id.includes('oidc-client-ts')) { + return 'vendor-auth'; + } + // 5. Schema & form validation if (id.includes('zod') || id.includes('react-hook-form') || id.includes('@hookform')) { return 'vendor-forms'; } + // 6. HTTP networking if (id.includes('axios')) { return 'vendor-http'; } + // 7. Client routing + if (id.includes('react-router')) { + return 'vendor-router'; + } + // 8. Core React framework runtime + if ( + id.includes('/react/') || + id.includes('/react-dom/') || + id.includes('/scheduler/') + ) { + return 'vendor-react'; + } } }, },