diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..14b8374 --- /dev/null +++ b/Architecture.md @@ -0,0 +1,192 @@ +# PostFundManagement (PFM) Platform — Architectural Specification + +> **Document Version**: 2.0 +> **Target Runtimes**: .NET 10.0 (C# 13) | React 19 / TypeScript 5.8 / Vite +> **Scope**: System Architecture, Security Topology, RBAC Enforcement, and Maker-Checker Pattern + +--- + +## 1. System Topology & Architectural Layers + +The PFM platform is constructed using **Clean Architecture** and **Domain-Driven Design (DDD)** principles to guarantee strict decoupling of business rules, persistence concerns, and user interfaces. + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ PRESENTATION TIER │ +│ PostFundManagement.ConsoleUI │ PostFundManagement.PartnerUI │ +│ (Internal Staff & Governance) │ (External Recipient Portal) │ +│ React 19 + TypeScript + Vite │ React 19 + WebGL (OGL) + Vite │ +└───────────────────────────────────┬────────────────────────────────────┘ + │ HTTPS / REST (v1/api/*) + OIDC PKCE +┌───────────────────────────────────▼────────────────────────────────────┐ +│ API GATEWAY │ +│ PostFundManagement.Api (ASP.NET Core Minimal APIs / Carter) │ +│ Authentication: JWT Bearer + OpenTelemetry Observability │ +└───────────────────────────────────┬────────────────────────────────────┘ + │ +┌───────────────────────────────────▼────────────────────────────────────┐ +│ APPLICATION LAYER │ +│ PostFundManagement.Application │ +│ CQRS (Mediator), Business Validators, Domain Services, Quartz Jobs │ +└──────────────────┬─────────────────────────────────┬───────────────────┘ + │ │ +┌──────────────────▼──────────────────┐ ┌──────────▼───────────────────┐ +│ DOMAIN LAYER │ │ INFRASTRUCTURE LAYER │ +│ PostFundManagement.Domain │ │ PostFundManagement.Infrastr.│ +│ Entities, Value Objects, Enums, │ │ PostgreSQL (EF Core 10), │ +│ Domain Invariants, Audit Models │ │ Skoruba STS, S3 Garage, │ +└─────────────────────────────────────┘ │ Redis HybridCache │ + └──────────────────────────────┘ +``` + +--- + +## 2. Security & Identity Architecture + +Authentication and authorization are separated cleanly across system boundaries: + +* **Security Token Service (STS)**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`) issues cryptographically signed JWT access tokens using OpenID Connect (OIDC) with Proof Key for Code Exchange (PKCE). +* **Audience & Scopes**: Web clients request standard OIDC scopes (`openid`, `profile`, `roles`, `email`, `address`) along with the target API resource scope (`pfm-api-dev`). +* **Tenant Isolation**: Every authenticated principal carries a `tenant_id` claim. Multitenant query filters in EF Core enforce cross-tenant partition boundaries at the data access layer. +* **Partner Isolation**: Grantees and external recipients possess the `partner` role. The `ConsoleUI` enforces immediate lockout for `partner` roles, restricting external users exclusively to `PartnerUI`. + +--- + +## 3. Role-Based Access Control (RBAC) Architecture + +PFM implements a fine-grained, claim-based RBAC model where system permissions are decomposed into **15 operational domains** and **5 progressive action capabilities**. + +### 3.1 Role Claims Inventory + +| Claim Key | Operational Domain | Functional Scope | +| :--- | :--- | :--- | +| `dashboard` | Executive Dashboard | Macro portfolio KPIs, disbursed capital, and ISO 31000 risk heatmap | +| `governance` | Governance & Structure | Holding entities, funder portfolios, programmes, rules, and instruments | +| `projects_milestones` | Projects & Milestones | Work breakdown structure (WBS), contractual milestones, and activities | +| `grant_financials` | Grants & Financials | Statutory funding awards, budget lines, contracts, and disbursement tranches | +| `evidence_risks` | M&E, Evidence & Risks | Deliverable evidence verification queue, indicators, and risk registers | +| `beneficiaries` | Beneficiaries & Impact | Demographic targets, POPIA compliance items, and outcome deliverables | +| `change_management` | Change Management | Project issues, contractual change requests, and budget variations | +| `site_visits` | Site Visits | Physical field inspections, checklist observations, and inspector findings | +| `settings_invites` | Settings Invites | Onboarding invitations and candidate notifications dispatch | +| `settings_users` | Settings Users | User provisioning, role assignments, and account lockouts | +| `settings_auditlog` | Settings Audit Log | Immutable audit ledger and system telemetry records | +| `settings_notifications`| Settings Notifications | SMTP engines, notification channels, and dispatch telemetry | +| `tenant_self_service` | Tenant Self-Service | Tenant profile configuration and delegated sub-user management | +| `tenant_management` | Tenant Management | Multi-tenant directory, partition allocation, and solution ownership | +| `unrestricted` | System Administration | Root administrative bypass across all system partitions | + +### 3.2 Claim Actions & Capability Hierarchy + +Action capabilities within a claim domain follow a strict subset hierarchy: + +$$\text{view} \subset \text{add} \subset \text{update} \quad \text{and} \quad \text{view} \subset \text{export}$$ + +* **`view`**: Read-only access to entity lists, detailed records, metrics, and progress scores. +* **`add`**: Can create new records, edit draft records, submit milestone evidence, and change workflow statuses *other than approval* (includes `view`). +* **`update`**: Authoritative governance capability. Can execute **approval** status transitions (`Approved`, `Active` contracts) on records (includes `view` and `add`). +* **`export`**: Can generate and download structured spreadsheets (`.xlsx`) and formal PDF reports (includes `view`). +* **`all`**: Comprehensive authority (`view`, `add`, `update`, `export`) over the claim domain. + +### 3.3 Authoritative Role-to-Claims Matrix + +| Role | Assigned Claim Domains | Standard Capabilities | Record Approval Authority (`update`) | +| :--- | :--- | :--- | :---: | +| **`Admin`** | `unrestricted` | `all` | **Yes (System-wide)** | +| **`Executive`** | `dashboard`, `governance`, `projects_milestones`, `grant_financials`, `evidence_risks`, `tenant_self_service` | `all` | **Yes** | +| **`Manager`** | `dashboard`, `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites`, `settings_users`, `settings_auditlog`, `settings_notifications`, `tenant_self_service` | `all` | **Yes** | +| **`Supervisor`** | `dashboard`, `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites`, `settings_users` | `view, add, update, export`
*(Dashboard: `view, export`)* | **Yes** | +| **`Contributor`** | `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites` | **`view, add`** | ❌ **Strictly Prohibited** | + +--- + +## 4. Maker-Checker Pattern Architecture + +To eliminate fraud risk, self-dealing, and conflicts of interest, PFM enforces the **Maker-Checker Pattern** (Segregation of Duties / Dual Authorization). + +### 4.1 Core Principle +> **The user who creates or drafts a record (the "Maker") is strictly prohibited from verifying or approving that record (the "Checker"), regardless of their administrative role or RBAC claim privileges.** + +Even if an `Executive`, `Manager`, or `Supervisor` possesses the `update` claim action on a domain, they cannot approve a record if they are identified as the record's creator. + +```mermaid +flowchart LR + A["Maker (User A)
Creates Record"] --> B["Record Persisted
with createdBy = User A"] + B --> C{"Checker Evaluation
(Approver Login)"} + C -->|Checker == User A| D["Approval BLOCKED
Maker-Checker Violation"] + C -->|Checker == User B
with 'update' claim| E["Approval GRANTED
Status -> Approved"] +``` + +### 4.2 Record Attribution Model +Every operational domain entity immutably captures the creator identity: +* `createdBy`: Cryptographic user identifier (Guid / Subject ID) populated from the verified token claim `context.GetUserId()` upon creation. +* `approvedBy`: User identifier of the independent verifier populated upon successful transition to `Approved`. + +### 4.3 Client-Side Enforcement Hooks & Helpers +In `PostFundManagement.ConsoleUI`, the Maker-Checker rule is evaluated dynamically via [`useRbac()`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.ConsoleUI/src/auth/rbac.ts): + +* **`isMaker(createdBy)`**: Compares `createdBy` against current user `user.id`, `sub`, and `user.email`. +* **`canApproveRecord(claim, createdBy)`**: Evaluates: + $$\text{canApproveRecord} = \text{canApprove}(\text{claim}) \land \neg\text{isMaker}(\text{createdBy})$$ +* **`getApprovalRestrictionReason(claim, createdBy)`**: Surfaces explanatory feedback: + - `"Requires update/approval permission"` if the claim lacks `update`. + - `"Maker-Checker policy: Record creators cannot approve their own records"` if `isMaker` is true. + +### 4.4 UI Guard Implementations +1. **Status Dropdowns**: The `Approved` option in status ` setNewStatus(Number(e.target.value))} - className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500" - > - {CONTRACT_STATUS_OPTIONS.map((opt) => ( - - ))} - + {(() => { + const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy); + const isSelf = isMaker(contractToUpdate?.createdBy); + + return ( + + ); + })()}
diff --git a/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx b/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx index db08d97..0bba4cd 100644 --- a/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx +++ b/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx @@ -87,7 +87,7 @@ export const EvidenceReviewQueue: React.FC = ({ }) => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); - const { canAdd, canApprove, canExport } = useRbac(); + const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); // Filter States: default to initialProjectId if given, otherwise when projects load default to first project const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId); @@ -824,7 +824,7 @@ export const EvidenceReviewQueue: React.FC = ({ )} - {canApprove('evidence_risks') && ( + {canApproveRecord('evidence_risks', ev.createdBy) && ( )} + {canApprove('evidence_risks') && isMaker(ev.createdBy) && ( + + Maker-Checker (Creator) + + )} )} - {canApprove('evidence_risks') && ( + {canApproveRecord('evidence_risks', inspectEvidence.createdBy) && ( )} + {canApprove('evidence_risks') && isMaker(inspectEvidence.createdBy) && ( + + + Maker-Checker Policy: As the creator of this submission, you cannot approve it. + + )}
diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx index 020bd0c..400595a 100644 --- a/PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx @@ -25,6 +25,7 @@ import { updateMilestone, } from '../../services/api'; import { Project, Milestone, Priority, ApprovalStatus } from '../../types'; +import { useRbac } from '../../auth/rbac'; import { formatDate, getApprovalStatusBadge, @@ -55,6 +56,7 @@ export const ProjectMilestonesModal: React.FC = ({ }) => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac(); const [isFormOpen, setIsFormOpen] = useState(false); const [editingMilestone, setEditingMilestone] = useState(null); @@ -175,7 +177,7 @@ export const ProjectMilestonesModal: React.FC = ({
- {!isFormOpen && ( + {!isFormOpen && canAdd('projects_milestones') && ( + )} + + +
+ ); +} diff --git a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx index 4c95cec..e288d7b 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx @@ -6,7 +6,7 @@ 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 { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api'; import { useAuth } from '../../auth/AuthProvider'; import { useRbac } from '../../auth/rbac'; import { CreateUserRequest, Role, Tenant } from '../../types'; @@ -136,8 +136,22 @@ export const CreateUserModal: React.FC = ({ 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 + if (values.initialRoleId) { + try { + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + const freshUsers = await getUsers(1, 1000, { email: values.email.trim() }); + const createdUser = freshUsers.find( + (u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase() + ); + if (createdUser) { + await assignUserRole(createdUser.id, values.initialRoleId); + } + } catch { + // Graceful fallback + } + } + return values; }, onSuccess: (values) => { diff --git a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx index b091721..2a1b48e 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx @@ -31,7 +31,9 @@ import { updateUserStatus, getSystemClaims, } from '../../services/api'; +import { clearCachedUserRbac } from '../../services/roleClaimsCache'; import { HelpTooltip } from '../ui/HelpTooltip'; +import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber'; import { decodeIdNumberHex } from '../../utils/hashids'; import { User, Role, Claim, ActivityStatus } from '../../types'; import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters'; @@ -53,9 +55,22 @@ export const UserSecurityModal: React.FC = ({ 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 canManageRolesAndClaims = + canApprove('settings_users') || + canApprove('tenant_management') || + canApprove('tenant_self_service') || + isUnrestricted; + const canManageSessions = + canApprove('settings_users') || + canApprove('tenant_management') || + hasTenantSelfHelpAccess || + isUnrestricted; + const canChangeStatus = + canAdd('settings_users') || + canAdd('tenant_management') || + canAdd('tenant_self_service') || + canApprove('settings_users') || + isUnrestricted; const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview'); @@ -104,13 +119,17 @@ export const UserSecurityModal: React.FC = ({ // 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) { + const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find( + (r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId)) + ); + const roleName = selectedRole?.name || (selectedRole as any)?.Name || ''; + if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) { throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.'); } return assignUserRole(userId, roleId); }, onSuccess: () => { + clearCachedUserRbac(userId); success('Role Assigned', 'Role membership updated successfully.'); refetchUserRoles(); setSelectedRoleToAdd(''); @@ -121,6 +140,7 @@ export const UserSecurityModal: React.FC = ({ const removeRoleMutation = useMutation({ mutationFn: (roleId: string) => removeUserRole(userId, roleId), onSuccess: () => { + clearCachedUserRbac(userId); success('Role Removed', 'Role revoked from user.'); refetchUserRoles(); }, @@ -142,6 +162,7 @@ export const UserSecurityModal: React.FC = ({ }); }, onSuccess: () => { + clearCachedUserRbac(userId); success('Claim Granted', 'User claim added successfully.'); refetchUserClaims(); setNewClaimType(''); @@ -153,6 +174,7 @@ export const UserSecurityModal: React.FC = ({ const deleteClaimMutation = useMutation({ mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId), onSuccess: () => { + clearCachedUserRbac(userId); success('Claim Revoked', 'Claim removed from user.'); refetchUserClaims(); }, @@ -191,11 +213,16 @@ export const UserSecurityModal: React.FC = ({ 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')) { + const decodedId = effectiveUser?.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; + const assignedRoleIds = new Set( + (Array.isArray(userRoles) ? userRoles : []).map((r) => r?.id || (r as any)?.Id).filter(Boolean) + ); + const availableRoles = (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => { + if (!r) return false; + const rId = r.id || (r as any).Id; + const rName = r.name || (r as any).Name || ''; + if (rId && assignedRoleIds.has(rId)) return false; + if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) { return false; } return true; @@ -203,21 +230,28 @@ export const UserSecurityModal: React.FC = ({ 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')) { + if (Array.isArray(systemClaims)) { + systemClaims.forEach((c) => { + if (!c) return; + const type = c.claimType || c.type; + if (type && typeof type === 'string') { + if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) { + return; + } + claimMap.set(type, { claim: type, label: type }); + } + }); + } + if (Array.isArray(ROLE_CLAIM_DEFINITIONS)) { + ROLE_CLAIM_DEFINITIONS.forEach((def) => { + if (!def || !def.claim) return; + const claimStr = String(def.claim); + if (hasTenantSelfHelpAccess && (claimStr.toLowerCase() === 'unrestricted' || claimStr.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})` }); - }); + claimMap.set(claimStr, { claim: claimStr, label: `${claimStr} (${def.label || claimStr})` }); + }); + } return Array.from(claimMap.values()); }, [systemClaims, hasTenantSelfHelpAccess]); @@ -298,10 +332,14 @@ export const UserSecurityModal: React.FC = ({
Id Number: -
- {effectiveUser.idNumber || '—'} +
+ {decodedId && decodedId !== effectiveUser.idNumber && ( - (Decoded: {decodedId}) +
+ (Decoded: + + ) +
)}
@@ -320,10 +358,10 @@ export const UserSecurityModal: React.FC = ({ - {getActivityStatusBadge(effectiveUser.status).label} + {(getActivityStatusBadge(effectiveUser.status) || {}).label || 'Active'}
@@ -331,8 +369,8 @@ export const UserSecurityModal: React.FC = ({ @@ -340,8 +378,8 @@ export const UserSecurityModal: React.FC = ({ @@ -360,8 +398,8 @@ export const UserSecurityModal: React.FC = ({
)} diff --git a/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx index 4cc10b4..8fcd362 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx @@ -110,7 +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 { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); const [selectedProjectId, setSelectedProjectId] = useState('all'); const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues'); @@ -1022,7 +1022,7 @@ export const ChangeManagementPage: React.FC = () => { ) : ( <> - {canApprove('change_management') && ( + {canApproveRecord('change_management', cr.createdBy) && ( )} + {canApprove('change_management') && isMaker(cr.createdBy) && ( + + Maker-Checker + + )} {canAdd('change_management') && (
diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index b755163..f37c3f4 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -21,6 +21,11 @@ import { FileText, Scale, Calendar, + UserPlus, + ChevronDown, + ChevronUp, + CheckCircle2, + Loader2, } from 'lucide-react'; import { getOrganisations, @@ -50,6 +55,10 @@ import { getProgrammeValue, getUsers, getUser, + createUser, + assignUserRole, + getSystemRoles, + getCurrentUserTenantId, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; @@ -72,6 +81,8 @@ import { OrganisationType, ActivityStatus, ApprovalStatus, + Role, + CreateUserRequest, CreateOrganisationRequest, CreatePortfolioRequest, CreateProgrammeRequest, @@ -96,8 +107,7 @@ const organisationSchema = z.object({ name: z.string().min(3, 'Name must be at least 3 characters'), registrationNo: z.string().min(4, 'Registration number is mandatory'), email: z.string().email('Enter a valid email address'), - idNumber: z.string().min(4, 'Government issued ID number is required'), - organisationUser: z.string().min(1, 'Please select a linked user'), + organisationUser: z.string().min(1, 'Please select a linked executive or manager user'), type: z.coerce.number().min(1).max(27), status: z.coerce.number().optional(), }); @@ -123,7 +133,7 @@ type ProgrammeFormValues = z.infer; export const GovernancePage: React.FC = () => { const queryClient = useQueryClient(); const { sub, user, claims } = useAuth(); - const { canAdd, canApprove, canExport } = useRbac(); + const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); const currentUserId = sub || user?.id || ''; const { success, error: toastError } = useToast(); @@ -232,6 +242,107 @@ export const GovernancePage: React.FC = () => { staleTime: 5 * 60 * 1000, }); + const { data: systemRoles = [] } = useQuery({ + queryKey: ['system-roles'], + queryFn: getSystemRoles, + enabled: isOrgModalOpen, + }); + + const assignableRolesForOrg = React.useMemo(() => { + return systemRoles.filter( + (r) => r.name.toLowerCase() !== 'admin' && r.id !== '1' + ); + }, [systemRoles]); + + const executiveAndManagerUsers = React.useMemo(() => { + return usersDirectory.filter((u) => { + const role = String(u.role || '').toLowerCase(); + return role === 'executive' || role === 'manager'; + }); + }, [usersDirectory]); + + // Quick Add Executive User Subform state + const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false); + const [quickUserEmail, setQuickUserEmail] = useState(''); + const [quickUserName, setQuickUserName] = useState(''); + const [quickUserPhone, setQuickUserPhone] = useState(''); + const [quickUserIdNumber, setQuickUserIdNumber] = useState(''); + const [quickUserRoleId, setQuickUserRoleId] = useState(''); + const [isSubmittingQuickUser, setIsSubmittingQuickUser] = useState(false); + const [quickUserError, setQuickUserError] = useState(null); + + React.useEffect(() => { + if (!quickUserRoleId && assignableRolesForOrg.length > 0) { + const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive'); + if (execRole) setQuickUserRoleId(execRole.id); + else setQuickUserRoleId(assignableRolesForOrg[0].id); + } + }, [assignableRolesForOrg, quickUserRoleId]); + + const handleQuickAddUser = async () => { + setQuickUserError(null); + if (!quickUserEmail || !quickUserEmail.includes('@')) { + setQuickUserError('Please provide a valid email address.'); + return; + } + if (!quickUserIdNumber || quickUserIdNumber.trim().length < 4) { + setQuickUserError('Government issued ID number is required (min 4 characters).'); + return; + } + + setIsSubmittingQuickUser(true); + try { + const tenantId = getCurrentUserTenantId() || 1; + const payload: CreateUserRequest = { + email: quickUserEmail.trim().toLowerCase(), + userName: quickUserName.trim() || quickUserEmail.split('@')[0], + emailConfirmed: true, + phoneNumber: quickUserPhone.trim() || null, + phoneNumberConfirmed: false, + LockoutEnabled: false, + twoFactorEnabled: false, + accessFailedCount: 0, + }; + + await createUser(tenantId, quickUserIdNumber.trim(), payload); + + // Invalidate queries and fetch fresh user list immediately + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + const freshUsers = await getUsers(1, 200); + + const createdUser = freshUsers.find( + (u) => u.email && u.email.toLowerCase() === quickUserEmail.trim().toLowerCase() + ); + + if (createdUser && quickUserRoleId) { + try { + await assignUserRole(createdUser.id, quickUserRoleId); + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + } catch { + // Handled gracefully + } + orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); + } else if (createdUser) { + orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); + } + + success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`); + setQuickUserEmail(''); + setQuickUserName(''); + setQuickUserPhone(''); + setQuickUserIdNumber(''); + setIsQuickAddUserOpen(false); + } catch (err: any) { + const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.'; + setQuickUserError(errorDetail); + toastError('Failed to create user', err); + } finally { + setIsSubmittingQuickUser(false); + } + }; + const { data: organisations = [], isLoading: loadingOrgs, @@ -427,7 +538,6 @@ export const GovernancePage: React.FC = () => { name: '', registrationNo: '', email: '', - idNumber: '', organisationUser: '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, @@ -463,7 +573,6 @@ export const GovernancePage: React.FC = () => { name: data.name, registrationNo: data.registrationNo, email: data.email, - idNumber: data.idNumber, organisationUser: data.organisationUser, type: Number(data.type), status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active, @@ -473,7 +582,6 @@ export const GovernancePage: React.FC = () => { name: data.name, registrationNo: data.registrationNo, email: data.email, - idNumber: data.idNumber, organisationUser: data.organisationUser, type: Number(data.type), }); @@ -553,24 +661,23 @@ export const GovernancePage: React.FC = () => { const openCreateOrg = () => { setEditingOrg(null); - const defaultUserId = usersDirectory[0]?.id || sub || currentUserId || ''; - const defaultUser = usersDirectory.find((u) => u.id === defaultUserId); + const defaultUser = executiveAndManagerUsers[0]; orgForm.reset({ name: '', registrationNo: '', email: '', - idNumber: defaultUser?.idNumber || '', - organisationUser: defaultUserId, + organisationUser: defaultUser?.id || '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, }); + setIsQuickAddUserOpen(false); + setQuickUserError(null); setIsOrgModalOpen(true); }; const openEditOrg = (org: Organisation) => { setEditingOrg(org); - // 1. Resolve target linked user ID synchronously from the organisation model const targetUserId = org.userId || (org as any).UserId || @@ -579,46 +686,24 @@ export const GovernancePage: React.FC = () => { org.createdBy || ''; - // 2. Fast O(1) in-memory match from cached users directory const matchedUser = targetUserId ? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase()) : null; - // 3. Resolve idNumber: prefer matchedUser's idNumber, or org's idNumber if any, otherwise blank '' - const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || (org as any).IdNumber || ''; const selectedUserId = matchedUser?.id || targetUserId || ''; - // 4. Perform assignment immediately to form state orgForm.reset({ name: org.name || '', registrationNo: org.registrationNo || '', email: org.email || '', - idNumber: resolvedIdNumber, organisationUser: selectedUserId, type: org.type || OrganisationType.Enterprise, status: org.status ?? ActivityStatus.Active, }); - // 5. Instantly open modal with fully populated data (no lag, no progressive pop-in) + setIsQuickAddUserOpen(false); + setQuickUserError(null); setIsOrgModalOpen(true); - - // 6. Non-blocking background sync if target user wasn't in cache or org has missing data - if (targetUserId && !matchedUser) { - getUser(targetUserId).then((fetchedUser) => { - if (fetchedUser) { - queryClient.setQueryData(['users-directory'], (old = []) => { - if (old.some((u) => u.id === fetchedUser.id)) return old; - return [fetchedUser, ...old]; - }); - if (fetchedUser.idNumber && !orgForm.getValues('idNumber')) { - orgForm.setValue('idNumber', fetchedUser.idNumber, { shouldValidate: true }); - } - if (!orgForm.getValues('organisationUser')) { - orgForm.setValue('organisationUser', fetchedUser.id, { shouldValidate: true }); - } - } - }).catch(() => {}); - } }; const openCreatePortfolio = () => { @@ -1477,66 +1562,6 @@ export const GovernancePage: React.FC = () => { -
-
-
- - -
- - {orgForm.formState.errors.organisationUser && ( -

{orgForm.formState.errors.organisationUser.message}

- )} -
- -
-
- - -
- - {orgForm.formState.errors.idNumber && ( -

{orgForm.formState.errors.idNumber.message}

- )} -
-
-
@@ -1561,6 +1586,164 @@ export const GovernancePage: React.FC = () => { )}
+
+
+
+ + +
+ {!editingOrg && ( + + )} +
+ + {orgForm.formState.errors.organisationUser && ( +

{orgForm.formState.errors.organisationUser.message}

+ )} + {executiveAndManagerUsers.length === 0 && !isQuickAddUserOpen && ( +

+ No Executive or Manager user found. Create one below to continue. +

+ )} +
+
+ + {/* Inline Collapsible Subform to Create Executive User */} + {isQuickAddUserOpen && !editingOrg && ( +
+
+ + + Quick Create Executive / Manager User + + Mapped to tenant automatically +
+ + {quickUserError && ( +
+ {quickUserError} +
+ )} + +
+
+ + setQuickUserEmail(e.target.value)} + placeholder="executive@organisation.org.za" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserName(e.target.value)} + placeholder="e.g. Sipho Ndlovu" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserPhone(e.target.value)} + placeholder="+27 82 123 4567" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserIdNumber(e.target.value)} + placeholder="13-digit ID or Passport" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white font-mono focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + +
+
+ +
+ + +
+
+ )} + {editingOrg && (
@@ -1586,7 +1769,6 @@ export const GovernancePage: React.FC = () => {
)} -
- + {(() => { + const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy); + const isSelf = isMaker(selectedProgrammeForDetail?.createdBy); + + return ( + + ); + })()}
diff --git a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx index 4329aec..7179395 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx @@ -99,7 +99,7 @@ type DisbursementFormValues = z.infer; export const GrantsPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); - const { canAdd, canApprove, canExport } = useRbac(); + const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards'); @@ -1002,7 +1002,7 @@ export const GrantsPage: React.FC = () => { Update - {b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && ( + {b.status !== ApprovalStatus.Approved && canApproveRecord('grant_financials', b.createdBy) && ( )} + {b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && isMaker(b.createdBy) && ( + + Maker-Checker + + )}
@@ -1159,7 +1164,7 @@ export const GrantsPage: React.FC = () => { > - {canApprove('grant_financials') && ( + {canApproveRecord('grant_financials', d.createdBy) && ( )} + {canApprove('grant_financials') && isMaker(d.createdBy) && ( + + Maker-Checker (Creator) + + )} )} @@ -1562,9 +1572,21 @@ 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" > - + {(() => { + const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy); + const isSelf = isMaker(editingBudget?.createdBy); + let reason = ''; + if (!canApprove('grant_financials')) { + reason = ' (Requires Update Permission)'; + } else if (isSelf) { + reason = ' (Maker-Checker: Cannot approve own record)'; + } + return ( + + ); + })()} diff --git a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx index b1f2acf..9b7cb72 100644 --- a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx @@ -43,6 +43,7 @@ import { useToast } from '../components/ui/Toast'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; +import { useRbac } from '../auth/rbac'; import { ContractsManagement } from '../components/monitoring/ContractsManagement'; import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue'; import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix'; @@ -100,6 +101,7 @@ export const WORKFLOW_STAGES = [ export const MonitoringPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const { canAdd, canExport } = useRbac(); const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab'); @@ -435,7 +437,7 @@ export const MonitoringPage: React.FC = () => {
- {activeTab === 'contracts' && ( + {activeTab === 'contracts' && canAdd('grant_financials') && ( )} - {activeTab === 'indicators' && ( + {activeTab === 'indicators' && canAdd('evidence_risks') && ( )} - {activeTab === 'risks' && ( + {activeTab === 'risks' && canAdd('evidence_risks') && ( { { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); - const { canAdd, canExport } = useRbac(); + const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); // Filter & Search States const [searchTerm, setSearchTerm] = useState(''); @@ -954,22 +954,41 @@ export const ProjectsPage: React.FC = () => { {/* Governance Status */} e.stopPropagation()}>
- + {(() => { + const canApproveThis = canApproveRecord('projects_milestones', p.createdBy); + const isSelf = isMaker(p.createdBy); + const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones'); + + return ( + + ); + })()}
diff --git a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx index 6bf6390..5f2f427 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx @@ -35,11 +35,13 @@ import { Hash, ChevronDown, ChevronRight, + ChevronLeft, User as UserIcon, RotateCcw, } from 'lucide-react'; import { CreateUserModal } from '../components/users/CreateUserModal'; import { UserSecurityModal } from '../components/users/UserSecurityModal'; +import { ObfuscatedIdNumber } from '../components/ui/ObfuscatedIdNumber'; import { useRbac } from '../auth/rbac'; import { getUsers, @@ -127,6 +129,13 @@ export const SettingsPage: React.FC = () => { const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false); const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false); + const [userPage, setUserPage] = useState(1); + const [userPageSize, setUserPageSize] = useState(10); + + React.useEffect(() => { + setUserPage(1); + }, [userEmailSearch, userStatusFilter, userRoleFilter]); + const userFilter: UserFilterRequest = { email: userEmailSearch.trim() || undefined, status: userStatusFilter !== 'all' ? userStatusFilter : undefined, @@ -145,7 +154,7 @@ export const SettingsPage: React.FC = () => { refetch: refetchUsers, } = useQuery({ queryKey: ['users', userStatusFilter, userEmailSearch], - queryFn: () => getUsers(1, 100, userFilter), + queryFn: () => getUsers(1, 1000, userFilter), }); const filteredUsers = useMemo(() => { @@ -489,6 +498,13 @@ export const SettingsPage: React.FC = () => { role: (u: User) => getUserRoleBadge(u.role).label, }); + const totalUsers = sortedUsers.length; + const totalPages = Math.max(1, Math.ceil(totalUsers / userPageSize)); + const paginatedUsers = React.useMemo(() => { + const start = (userPage - 1) * userPageSize; + return sortedUsers.slice(start, start + userPageSize); + }, [sortedUsers, userPage, userPageSize]); + const { sortedData: sortedInvites, sortColumn: inviteSortCol, @@ -842,7 +858,7 @@ export const SettingsPage: React.FC = () => { ) : ( - sortedUsers.map((u) => { + paginatedUsers.map((u) => { const badge = getActivityStatusBadge(u.status); return ( @@ -853,7 +869,7 @@ export const SettingsPage: React.FC = () => { {u.id} - {u.idNumber || —} + @@ -915,6 +931,64 @@ export const SettingsPage: React.FC = () => {
+ + {/* Pagination Controls */} + {totalUsers > 0 && ( +
+
+ + Showing {Math.min((userPage - 1) * userPageSize + 1, totalUsers)} to{' '} + {Math.min(userPage * userPageSize, totalUsers)} of{' '} + {totalUsers} users + +
+ +
+
+ Per page: + +
+ +
+ + + + {userPage} / {totalPages} + + + +
+
+
+ )} )} diff --git a/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx index 25f7ea5..b58cb10 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx @@ -54,7 +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 { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); const [selectedProjectId, setSelectedProjectId] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); @@ -509,16 +509,25 @@ export const SiteVisitsPage: React.FC = () => { {...visitForm.register('status')} 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) => ( - - ))} + {(() => { + const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy); + const isSelf = isMaker(editingVisit?.createdBy); + return APPROVAL_STATUS_OPTIONS.map((opt) => { + const isApprovedOpt = opt.value === ApprovalStatus.Approved; + const isOptDisabled = isApprovedOpt && !canApproveVisit; + let reason = ''; + if (isApprovedOpt && !canApprove('site_visits')) { + reason = ' (Requires Update Permission)'; + } else if (isApprovedOpt && isSelf) { + reason = ' (Maker-Checker: Cannot approve own record)'; + } + return ( + + ); + }); + })()} )} diff --git a/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx b/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx index aeb8de5..dacd499 100644 --- a/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx @@ -26,8 +26,7 @@ import { createTenant, updateTenant, getTenantOrganisations, - getOrganisations, - getUsers, + getTenantUsers, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; @@ -36,7 +35,8 @@ import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { ActivityStatusBadge } from '../components/ui/StatusBadge'; -import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types'; +import { ObfuscatedIdNumber } from '../components/ui/ObfuscatedIdNumber'; +import { Tenant, Organisation, TenantOrganisation, TenantUser, 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'; @@ -71,27 +71,21 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa staleTime: 30000, }); - // Fetch all organisations directory (from governance cache) to resolve complete organisation details - const { data: allOrganisations = [], isLoading: loadingAllOrgs } = useQuery({ - queryKey: ['organisations'], - queryFn: () => getOrganisations(), - staleTime: 60000, - }); - - // Fetch user directory to resolve linked user email per organisation - const { data: usersDirectory = [] } = useQuery({ - queryKey: ['users-directory'], - queryFn: () => getUsers(1, 200), + // Fetch tenant users directory to resolve linked user email per organisation + const { data: tenantUsers = [] } = useQuery({ + queryKey: ['tenant-users', tenantId], + queryFn: () => getTenantUsers(tenantId), staleTime: 5 * 60 * 1000, }); React.useEffect(() => { - if (usersDirectory && usersDirectory.length > 0) { - registerUserEmails(usersDirectory); + if (tenantUsers && tenantUsers.length > 0) { + const users = tenantUsers.map((tu) => tu.user).filter((u): u is User => !!u); + registerUserEmails(users); } - }, [usersDirectory]); + }, [tenantUsers]); - const isLoading = loadingTenantOrgs || (loadingAllOrgs && tenantOrganisations.length > 0 && allOrganisations.length === 0); + const isLoading = loadingTenantOrgs; return (
@@ -171,26 +165,16 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa {tenantOrganisations.map((tOrg) => { - const orgId = tOrg.organisationId || tOrg.organisation?.id || tOrg.id; - // Use the organisationId to fetch/match the organisation detail - const detailedOrg = allOrganisations.find( - (o) => String(o.id) === String(orgId) || String(o.id) === String(tOrg.organisationId) - ); - const org = detailedOrg || tOrg.organisation; - const orgName = org?.name || tOrg.organisation?.name || 'Unnamed Organisation'; - const regNo = org?.registrationNo || tOrg.organisation?.registrationNo || '—'; - const orgType = org?.type ?? tOrg.organisation?.type; - const orgEmail = org?.email || tOrg.organisation?.email || '—'; - const linkedUserId = - org?.userId || - (org as any)?.UserId || - (org as any)?.organisationUser || - (org as any)?.OrganisationUser || - (tOrg.organisation as any)?.userId || - (tOrg as any)?.userId; + const org = tOrg.organisation; + const orgId = org?.id || tOrg.organisationId || tOrg.id; + const orgName = org?.name || 'Unnamed Organisation'; + const regNo = org?.registrationNo || '—'; + const orgType = org?.type ?? tOrg.status; + const orgEmail = org?.email || '—'; + const linkedUserId = org?.userId; const matchedUser = linkedUserId - ? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(linkedUserId).toLowerCase()) + ? tenantUsers.find((tu) => tu.user?.id && String(tu.user.id).toLowerCase() === String(linkedUserId).toLowerCase())?.user : null; const userEmailDisplay = @@ -199,8 +183,8 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa (linkedUserId && String(linkedUserId).includes('@') ? String(linkedUserId) : null) || org?.email || '—'; - const status = org?.status ?? tOrg.organisation?.status ?? tOrg.status; - const createdAt = org?.createdAt || tOrg.createdAt || tOrg.organisation?.createdAt; + const status = org?.status ?? tOrg.status; + const createdAt = org?.createdAt || tOrg.createdAt; return ( @@ -274,21 +258,12 @@ const TenantUsersSubList: React.FC<{ onProvisionUser: (tenantId: number | string) => void; onManageUser: (user: User) => void; }> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => { - const { data: usersDirectory = [], isLoading, refetch } = useQuery({ + const { data: tenantUsers = [], isLoading, refetch } = useQuery({ queryKey: ['tenant-users', tenantId], - queryFn: () => getUsers(1, 200), + queryFn: () => getTenantUsers(tenantId), 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'); @@ -356,24 +331,45 @@ const TenantUsersSubList: React.FC<{ No users provisioned under Tenant #{tenantId}. ) : ( - tenantUsers.map((u) => ( - - {u.email || '—'} - {u.id} - {u.idNumber || '—'} - {u.role || 'Standard User'} - - - - - - )) + tenantUsers.map((tu) => { + const u = tu.user; + const userId = u?.id || tu.userId; + const email = u?.email || '—'; + const role = u?.role || 'Standard User'; + const idNumber = u?.idNumber; + const status = u?.status ?? tu.status; + + return ( + + {email} + {userId} + + + + {role} + + + + + + ); + }) )} @@ -541,9 +537,24 @@ export const TenantManagementPage: React.FC = () => { return 'Active'; }; + // Auto-expand active tenant for tenant self-service users + React.useEffect(() => { + if (!canManageTenants && currentTenantId) { + setExpandedTenants((prev) => ({ + ...prev, + [String(currentTenantId)]: true, + })); + } + }, [canManageTenants, currentTenantId]); + // Filter & Search const filteredTenants = useMemo(() => { return tenants.filter((t) => { + // Non-admins (tenant self-service) only see their own tenant partition boundary + if (!canManageTenants && currentTenantId && String(t.id) !== String(currentTenantId)) { + return false; + } + const s = Number(t.status ?? ActivityStatus.Active); const matchesSearch = !searchTerm.trim() || @@ -559,16 +570,16 @@ export const TenantManagementPage: React.FC = () => { return matchesSearch && matchesStatus; }); - }, [tenants, searchTerm, statusFilter]); + }, [tenants, searchTerm, statusFilter, canManageTenants, currentTenantId]); // Sorting const { sortedData, sortColumn, sortDirection, handleSort } = useTableSort(filteredTenants, 'id', 'asc'); - // Metrics - const totalCount = tenants.length; - const activeCount = tenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length; - const inactiveCount = tenants.filter((t) => Number(t.status) === 2).length; - const disabledCount = tenants.filter((t) => Number(t.status) === 3).length; + // Metrics (scoped to visible partitions) + const totalCount = filteredTenants.length; + const activeCount = filteredTenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length; + const inactiveCount = filteredTenants.filter((t) => Number(t.status) === 2).length; + const disabledCount = filteredTenants.filter((t) => Number(t.status) === 3).length; // Export columns (using integer ID only, no hashid) const exportColumns: ExportColumn[] = [ diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index 657e352..f7481f3 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -65,6 +65,7 @@ import { ActivityStatus, Tenant, TenantOrganisation, + TenantUser, CreateTenantRequest, UpdateTenantRequest, Beneficiary, @@ -227,7 +228,7 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr name: payload.name, registrationNo: payload.registrationNo, email: payload.email, - idNumber: payload.idNumber, + idNumber: payload.idNumber || null, organisationUser: payload.organisationUser, type: payload.type !== undefined ? Number(payload.type) : 5, }); @@ -1632,6 +1633,26 @@ export const getTenantOrganisations = async (tenantId: string | number): Promise } }; +export const getTenantUsers = async (tenantId: string | number): Promise => { + const hashedTenantId = await toolsService.encodeId(tenantId); + try { + const res = await apiClient.get( + `/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/users` + ); + return res.data || []; + } catch (err: any) { + if (String(tenantId) !== String(hashedTenantId)) { + try { + const fallbackRes = await apiClient.get( + `/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/users` + ); + return fallbackRes.data || []; + } catch {} + } + throw err; + } +}; + // ========================================== // 10. Projects: Beneficiaries & Outcomes APIs // ========================================== diff --git a/PostFundManagement.ConsoleUI/src/services/httpClient.ts b/PostFundManagement.ConsoleUI/src/services/httpClient.ts index 5a289c4..4d42323 100644 --- a/PostFundManagement.ConsoleUI/src/services/httpClient.ts +++ b/PostFundManagement.ConsoleUI/src/services/httpClient.ts @@ -5,6 +5,55 @@ import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './u export const BASE_API_URL = getApiBaseUrl(); +// --------------------------------------------------------------------------- +// Mutation Versioning & Cache-Busting Management +// --------------------------------------------------------------------------- +// ASP.NET Core OutputCaching caches GET responses based on Method + URI + QueryString. +// To ensure that mutations (create, update, approve, delete) immediately invalidate +// backend OutputCache without serving stale data, ConsoleUI tracks a mutation epoch. +// Every successful POST/PUT/PATCH/DELETE bumps this epoch, ensuring subsequent GET +// requests partition to a fresh cache key on the server. +// --------------------------------------------------------------------------- + +const MUTATION_EPOCH_STORAGE_KEY = 'pfm_mutation_epoch'; + +let activeMutationEpoch: number = (() => { + if (typeof window !== 'undefined') { + try { + const stored = sessionStorage.getItem(MUTATION_EPOCH_STORAGE_KEY); + if (stored) { + const parsed = Number(stored); + if (!isNaN(parsed) && parsed > 0) return parsed; + } + } catch {} + } + return Date.now(); +})(); + +/** + * Returns the current client mutation epoch timestamp. + * Appended as `_v` to internal API GET requests to partition backend OutputCache entries. + */ +export function getMutationEpoch(): number { + return activeMutationEpoch; +} + +/** + * Bumps the mutation epoch to the current timestamp. + * Called automatically after any successful POST/PUT/PATCH/DELETE request, + * or manually when cache busting is required. + * Ensures subsequent GET requests bypass stale ASP.NET Core OutputCache entries. + */ +export function bumpMutationEpoch(): number { + activeMutationEpoch = Date.now(); + if (typeof window !== 'undefined') { + try { + sessionStorage.setItem(MUTATION_EPOCH_STORAGE_KEY, String(activeMutationEpoch)); + } catch {} + } + return activeMutationEpoch; +} + // Create Axios Instance export const apiClient = axios.create({ baseURL: BASE_API_URL, @@ -15,7 +64,7 @@ export const apiClient = axios.create({ timeout: 15000, }); -// Request Interceptor to dynamically set baseURL and attach Bearer Token +// Request Interceptor to dynamically set baseURL, attach Bearer Token, and apply cache-busting apiClient.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { // Dynamically synchronize baseURL from runtime configuration @@ -25,9 +74,46 @@ apiClient.interceptors.request.use( config.headers.Authorization = `Bearer ${token}`; } + const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url); + const isExternal = + config.url?.startsWith('http://') || + config.url?.startsWith('https://') || + config.url?.includes('amazonaws.com') || + config.url?.includes('garage'); + + // Attach mutation epoch or explicit timestamp to GET requests for OutputCache management + if (config.method?.toUpperCase() === 'GET' && !isExternal) { + const headers = config.headers || {}; + const hasNoCacheHeader = + headers['Cache-Control'] === 'no-cache' || + headers['cache-control'] === 'no-cache' || + headers['Pragma'] === 'no-cache'; + + if (hasNoCacheHeader) { + // Explicit freshness requested: attach fresh timestamp + if (config.params instanceof URLSearchParams) { + config.params.set('_t', String(Date.now())); + } else { + config.params = config.params || {}; + config.params._t = Date.now(); + } + } else { + // Standard GET: attach current mutation epoch + if (config.params instanceof URLSearchParams) { + if (!config.params.has('_v') && !config.params.has('_t')) { + config.params.set('_v', String(getMutationEpoch())); + } + } else { + config.params = config.params || {}; + if (!config.params._v && !config.params._t) { + config.params._v = getMutationEpoch(); + } + } + } + } + // User Sync & Tenant Lifecycle Guard: // All API calls require an authenticated and enabled user in the backend system. - const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url); if (token && !isLifecycleRequest) { // If user account is flagged as pending administrator activation, block downstream API calls if (isActivationPending()) { @@ -48,9 +134,17 @@ apiClient.interceptors.request.use( (error) => Promise.reject(error) ); -// Response Interceptor for global HTTP 401 detection +// Response Interceptor for global HTTP 401 detection and mutation cache invalidation apiClient.interceptors.response.use( - (response) => response, + (response) => { + // If response was from a successful mutating HTTP method (POST, PUT, PATCH, DELETE), + // automatically bump the mutation epoch so subsequent refetches bypass stale OutputCache + const method = response.config?.method?.toLowerCase(); + if (method && ['post', 'put', 'patch', 'delete'].includes(method)) { + bumpMutationEpoch(); + } + return response; + }, (error) => { if (error?.response?.status === 401) { const requestUrl = error.config?.url; diff --git a/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts b/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts index 69ebf49..1523286 100644 --- a/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts +++ b/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts @@ -5,33 +5,64 @@ 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'; +export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_rbac_cache_version'; +export const CURRENT_RBAC_CACHE_VERSION = 'v2.1'; + +/** + * Invalidates and purges legacy cached claims if the schema version differs. + */ +export function ensureFreshRbacCache(): void { + if (typeof window === 'undefined') return; + try { + const version = localStorage.getItem(LOCAL_STORAGE_RBAC_VERSION_KEY); + if (version !== CURRENT_RBAC_CACHE_VERSION) { + // Clear legacy claims and caches + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && (key.startsWith(LOCAL_STORAGE_ROLE_CLAIMS_PREFIX) || key === 'pfm_user_claims')) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((k) => localStorage.removeItem(k)); + inMemoryRoleClaims.clear(); + localStorage.setItem(LOCAL_STORAGE_RBAC_VERSION_KEY, CURRENT_RBAC_CACHE_VERSION); + } + } catch {} +} // In-memory runtime caches let inMemorySystemRoles: Role[] | null = null; const inMemoryRoleClaims = new Map(); let inMemorySystemClaims: Claim[] | null = null; +const inMemoryUserRoles = new Map(); +const inMemoryUserClaims = new Map(); +const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +// Initial check on load +ensureFreshRbacCache(); // 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' }, + { claimId: 30, claimType: 'projects_milestones', claimValue: 'view, add' }, + { claimId: 31, claimType: 'grant_financials', claimValue: 'view, add' }, + { claimId: 32, claimType: 'evidence_risks', claimValue: 'view, add' }, + { claimId: 33, claimType: 'beneficiaries', claimValue: 'view, add' }, + { claimId: 34, claimType: 'change_management', claimValue: 'view, add' }, + { claimId: 35, claimType: 'site_visits', claimValue: 'view, add' }, + { claimId: 36, claimType: 'settings_invites', claimValue: 'view, add' }, ], 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' }, + { claimId: 20, claimType: 'dashboard', claimValue: 'view, export' }, + { claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, update, export' }, + { claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, update, export' }, + { claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, update, export' }, + { claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, update, export' }, + { claimId: 25, claimType: 'change_management', claimValue: 'view, add, update, export' }, + { claimId: 26, claimType: 'site_visits', claimValue: 'view, add, update, export' }, + { claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, update, export' }, + { claimId: 28, claimType: 'settings_users', claimValue: 'view, add, update, export' }, ], manager: [ { claimId: 10, claimType: 'dashboard', claimValue: 'all' }, @@ -154,8 +185,14 @@ export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void { /** * Fetches system roles from backend API, caching the result in memory and localStorage. + * Checks cache first to avoid redundant network round trips. */ -export async function fetchAndCacheSystemRoles(): Promise { +export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise { + if (!forceRefresh) { + const cached = getCachedSystemRoles(); + if (cached && cached.length > 0) return cached; + } + try { const res = await apiClient.get('/v1/api/users/system/roles'); if (res.data && Array.isArray(res.data) && res.data.length > 0) { @@ -181,30 +218,69 @@ export async function fetchAndCacheSystemRoles(): Promise { return defaults; } +const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + /** * Fetches role claims for a role from the API, caching them under role ID and role name. + * Uses cache-first strategy to eliminate sequential network waterfalls on startup. */ -export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string): Promise { +export async function fetchAndCacheRoleClaims( + roleId: string, + roleName?: string, + forceRefresh = false +): 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; + // 1. Check in-memory and localStorage cache first + if (!forceRefresh) { + const cachedById = getCachedRoleClaims(roleIdKey); + if (cachedById && cachedById.length > 0) return cachedById; + + if (roleNameKey) { + const cachedByName = getCachedRoleClaims(roleNameKey); + if (cachedByName && cachedByName.length > 0) return cachedByName; } - } catch (err) { - // Network or server error, proceed to cache / defaults fallback } - // Check cache by role ID + // 2. Resolve actual GUID if roleId is not formatted as a GUID + let effectiveGuid = GUID_REGEX.test(roleId.trim()) ? roleId.trim() : null; + if (!effectiveGuid) { + const cachedRoles = getCachedSystemRoles(); + if (cachedRoles && cachedRoles.length > 0) { + const match = cachedRoles.find( + (r) => + (r.name && r.name.toLowerCase() === roleNameKey) || + (r.name && r.name.toLowerCase() === roleIdKey) || + String(r.id).toLowerCase() === roleIdKey + ); + if (match && match.id && GUID_REGEX.test(String(match.id).trim())) { + effectiveGuid = String(match.id).trim(); + } + } + } + + if (effectiveGuid) { + try { + const res = await apiClient.get(`/v1/api/users/system/roles/${effectiveGuid}/claims`); + if (res.data && Array.isArray(res.data) && res.data.length > 0) { + const normalized = res.data.map(normalizeClaim); + setCachedRoleClaims(roleIdKey, normalized); + if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized); + if (effectiveGuid.toLowerCase() !== roleIdKey) { + setCachedRoleClaims(effectiveGuid.toLowerCase(), normalized); + } + return normalized; + } + } catch (err) { + // Network or server error, proceed to cache / defaults fallback + } + } + + // Re-check cache by role ID or name 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; @@ -218,30 +294,118 @@ export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string) 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 function clearCachedUserRbac(userId?: string): void { + if (userId) { + const key = userId.toLowerCase().trim(); + inMemoryUserRoles.delete(key); + inMemoryUserClaims.delete(key); + if (typeof window !== 'undefined') { + try { + localStorage.removeItem(`pfm_cache_user_roles_${key}`); + localStorage.removeItem(`pfm_cache_user_claims_${key}`); + } catch {} + } + } else { + inMemoryUserRoles.clear(); + inMemoryUserClaims.clear(); + if (typeof window !== 'undefined') { + try { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && (k.startsWith('pfm_cache_user_roles_') || k.startsWith('pfm_cache_user_claims_'))) { + keysToRemove.push(k); + } + } + keysToRemove.forEach((k) => localStorage.removeItem(k)); + } catch {} + } } } -export async function getUserClaimsFromApi(userId: string): Promise { +/** + * Direct API calls for user roles and user claims with session-level caching + */ +export async function getUserRolesFromApi(userId: string, forceRefresh = false): Promise { + const key = userId.toLowerCase().trim(); + if (!forceRefresh) { + const cached = inMemoryUserRoles.get(key); + if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) { + return cached.roles; + } + if (typeof window !== 'undefined') { + try { + const raw = localStorage.getItem(`pfm_cache_user_roles_${key}`); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + inMemoryUserRoles.set(key, { roles: parsed, timestamp: Date.now() }); + return parsed; + } + } + } catch {} + } + } + + try { + const res = await apiClient.get(`/v1/api/users/${userId}/roles`); + const roles = res.data || []; + if (roles.length > 0) { + inMemoryUserRoles.set(key, { roles, timestamp: Date.now() }); + if (typeof window !== 'undefined') { + try { + localStorage.setItem(`pfm_cache_user_roles_${key}`, JSON.stringify(roles)); + } catch {} + } + } + return roles; + } catch { + return inMemoryUserRoles.get(key)?.roles || []; + } +} + +export async function getUserClaimsFromApi(userId: string, forceRefresh = false): Promise { + const key = userId.toLowerCase().trim(); + if (!forceRefresh) { + const cached = inMemoryUserClaims.get(key); + if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) { + return cached.claims; + } + if (typeof window !== 'undefined') { + try { + const raw = localStorage.getItem(`pfm_cache_user_claims_${key}`); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + const normalized = parsed.map(normalizeClaim); + inMemoryUserClaims.set(key, { claims: normalized, timestamp: Date.now() }); + return normalized; + } + } + } catch {} + } + } + try { const res = await apiClient.get(`/v1/api/users/${userId}/claims`); - return res.data || []; + const claims = (res.data || []).map(normalizeClaim); + if (claims.length > 0) { + inMemoryUserClaims.set(key, { claims, timestamp: Date.now() }); + if (typeof window !== 'undefined') { + try { + localStorage.setItem(`pfm_cache_user_claims_${key}`, JSON.stringify(claims)); + } catch {} + } + } + return claims; } catch { - return []; + return inMemoryUserClaims.get(key)?.claims || []; } } /** * Resolves user roles, role claims, and user claims (including tenant and tenant_id) - * and caches system roles and role claims as a persistent reference. + * concurrently using Promise.allSettled and caches system roles and claims. */ export async function resolveUserRolesAndClaims( userSub: string, @@ -254,27 +418,30 @@ export async function resolveUserRolesAndClaims( tenant?: string; tenant_id?: number; }> { + ensureFreshRbacCache(); const mergedClaims: Record = { ...initialClaims }; const resolvedRoles: string[] = []; + let userRoleObjects: Role[] = []; let primaryRole = currentProfile.role || (initialClaims.role as string) || ''; let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined; let tenant_id = currentProfile.tenant_id ?? (initialClaims.tenant_id ? Number(initialClaims.tenant_id) : undefined); - // 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]; - } + // 1. Concurrently fetch user's roles and user's claims in parallel + const [rolesResult, claimsResult] = await Promise.allSettled([ + userSub ? getUserRolesFromApi(userSub) : Promise.resolve([]), + userSub ? getUserClaimsFromApi(userSub) : Promise.resolve([]), + ]); + + if (rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value) && rolesResult.value.length > 0) { + userRoleObjects = rolesResult.value; + rolesResult.value.forEach((r) => { + if (r.name && !resolvedRoles.includes(r.name)) { + resolvedRoles.push(r.name); } - } catch {} + }); + if (resolvedRoles.length > 0) { + primaryRole = resolvedRoles[0]; + } } // Fallback to initial role if API returned empty @@ -282,55 +449,57 @@ export async function resolveUserRolesAndClaims( resolvedRoles.push(primaryRole); } - // 2. For each role, retrieve role claims using the endpoint (Requirement 2 & 7) + // 2. Concurrently fetch role claims for all resolved roles in parallel 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 {} - } + const roleClaimsPromises = resolvedRoles.map(async (roleName) => { + const matchedRoleObj = userRoleObjects.find( + (r) => r.name && r.name.toLowerCase() === roleName.toLowerCase() + ); + const roleIdToPass = matchedRoleObj?.id ? String(matchedRoleObj.id) : roleName; + return fetchAndCacheRoleClaims(roleIdToPass, roleName); + }); + + const roleClaimsResults = await Promise.allSettled(roleClaimsPromises); + roleClaimsResults.forEach((res) => { + if (res.status === 'fulfilled' && Array.isArray(res.value)) { + res.value.forEach((rc) => { + const norm = normalizeClaim(rc); + allRoleClaims.push(norm); + if (norm.claimType) { + mergedClaims[norm.claimType] = norm.claimValue || 'all'; + } + }); + } + }); + 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); + // 3. Process user claims + if (claimsResult.status === 'fulfilled' && Array.isArray(claimsResult.value) && claimsResult.value.length > 0) { + claimsResult.value.forEach((c) => { + const norm = normalizeClaim(c); + const type = (norm.claimType || '').toLowerCase().trim(); + const val = norm.claimValue; + if (type === 'tenant' && val) { + tenant = String(val); + mergedClaims.tenant = String(val); + } else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) { + const parsed = Number(val); + if (!isNaN(parsed) && isFinite(parsed)) { + tenant_id = parsed; + mergedClaims.tenant_id = parsed; + } } - } catch {} + if (norm.claimType) { + mergedClaims[norm.claimType] = norm.claimValue; + } + }); + mergedClaims.user_claims = claimsResult.value.map(normalizeClaim); } - // 4. Background preload all system roles & role claims for reference (Requirement 7) + // 4. Background preload all system roles & role claims for reference (non-blocking) preloadAndCacheAllRoleClaims().catch(() => {}); return { @@ -343,13 +512,13 @@ export async function resolveUserRolesAndClaims( } /** - * Preloads and caches all system roles and their respective role claims. + * Preloads and caches all system roles and their respective role claims concurrently. */ export async function preloadAndCacheAllRoleClaims(): Promise { const roles = await fetchAndCacheSystemRoles(); - for (const role of roles) { - await fetchAndCacheRoleClaims(role.id, role.name); - } + await Promise.allSettled( + roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name)) + ); } /** diff --git a/PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts b/PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts index f47edfa..dfff62a 100644 --- a/PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts +++ b/PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts @@ -9,6 +9,18 @@ export interface TenantLockoutResult { statusCode?: number; } +// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations) +let cachedTenantLockout: { + key: string; + result: TenantLockoutResult; + timestamp: number; +} | null = null; +const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds + +export function clearTenantLockoutCache(): void { + cachedTenantLockout = null; +} + /** * Checks whether a given status value corresponds to ActivityStatus.Active (1) */ @@ -63,7 +75,8 @@ export function getStatusLabel(status: any): string { */ export async function verifyTenantLockout( tenantId?: number | string | null, - tenantClaim?: string | null + tenantClaim?: string | null, + forceRefresh = false ): Promise { const primaryId = tenantId !== undefined && tenantId !== null && tenantId !== '' @@ -83,6 +96,16 @@ export async function verifyTenantLockout( }; } + const cacheKey = String(primaryId).trim().toLowerCase(); + if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) { + if ( + cachedTenantLockout.result.isActive && + Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS + ) { + return cachedTenantLockout.result; + } + } + let tenantRes: Tenant | null = null; let lastError: any = null; @@ -147,8 +170,14 @@ export async function verifyTenantLockout( } // Success: Response object returned and Status is Active - return { + const successResult: TenantLockoutResult = { isActive: true, tenant: tenantObj, }; + cachedTenantLockout = { + key: cacheKey, + result: successResult, + timestamp: Date.now(), + }; + return successResult; } diff --git a/PostFundManagement.ConsoleUI/src/services/userSyncService.ts b/PostFundManagement.ConsoleUI/src/services/userSyncService.ts index a31d7be..078b57c 100644 --- a/PostFundManagement.ConsoleUI/src/services/userSyncService.ts +++ b/PostFundManagement.ConsoleUI/src/services/userSyncService.ts @@ -36,12 +36,22 @@ export interface UserSyncResult { let inFlightSyncPromise: Promise | null = null; +// In-memory cache to prevent synchronous localStorage parsing on every HTTP request +let inMemorySyncRecord: UserSyncRecord | null = null; +let inMemoryActivationPending: boolean | null = null; +let inMemoryActivationReason: string | null = null; + /** * Checks if the user is currently flagged in the browser as pending administrator activation / lockout. */ export function isActivationPending(): boolean { + if (inMemoryActivationPending !== null) { + return inMemoryActivationPending; + } try { - return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true'; + const val = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true'; + inMemoryActivationPending = val; + return val; } catch { return false; } @@ -51,8 +61,13 @@ export function isActivationPending(): boolean { * Gets the reason/error message recorded when activation check was last pending. */ export function getActivationPendingReason(): string | null { + if (inMemoryActivationReason !== null) { + return inMemoryActivationReason; + } try { - return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); + const reason = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); + inMemoryActivationReason = reason; + return reason; } catch { return null; } @@ -62,6 +77,8 @@ export function getActivationPendingReason(): string | null { * Sets or clears the pending activation flag in browser persistent storage. */ export function setActivationPending(pending: boolean, reason?: string): void { + inMemoryActivationPending = pending; + inMemoryActivationReason = reason || null; try { if (pending) { localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true'); @@ -91,10 +108,15 @@ export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string return false; } - const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY); - if (!raw) return false; + if (!inMemorySyncRecord) { + const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY); + if (!raw) return false; + const parsed: UserSyncRecord = JSON.parse(raw); + if (!parsed || parsed.status !== 'synced') return false; + inMemorySyncRecord = parsed; + } - const record: UserSyncRecord = JSON.parse(raw); + const record = inMemorySyncRecord; if (!record || record.status !== 'synced') return false; // Check if the stored sync matches the current user sub (if specified) @@ -134,6 +156,7 @@ export function saveUserSyncInBrowser( status: 'synced', userData: userData || undefined, }; + inMemorySyncRecord = record; localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record)); } catch (err) { console.warn('[UserSync] Failed to write sync record to localStorage:', err); @@ -144,6 +167,9 @@ export function saveUserSyncInBrowser( * Clears the user sync record from browser storage (used on logout or when activation fails). */ export function clearUserSyncInBrowser(): void { + inMemorySyncRecord = null; + inMemoryActivationPending = null; + inMemoryActivationReason = null; try { localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY); localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY); @@ -155,6 +181,7 @@ export function clearUserSyncInBrowser(): void { * Clears only the successful sync record, leaving activation pending intact. */ export function clearSuccessfulSyncRecord(): void { + inMemorySyncRecord = null; try { localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY); } catch {} diff --git a/PostFundManagement.ConsoleUI/src/types.ts b/PostFundManagement.ConsoleUI/src/types.ts index abd6786..f870d29 100644 --- a/PostFundManagement.ConsoleUI/src/types.ts +++ b/PostFundManagement.ConsoleUI/src/types.ts @@ -372,7 +372,7 @@ export interface CreateOrganisationRequest { name: string; registrationNo: string; email: string; - idNumber: string; + idNumber?: string | null; organisationUser: string; type: OrganisationType; } @@ -382,7 +382,7 @@ export interface UpdateOrganisationRequest { name: string; registrationNo: string; email: string; - idNumber: string; + idNumber?: string | null; organisationUser: string; type: OrganisationType; status?: ActivityStatus; @@ -400,6 +400,18 @@ export interface TenantOrganisation { creator?: User | null; } +export interface TenantUser { + id: number | string; + tenantId: number | string; + userId: string; + status: ActivityStatus; + createdAt: string; + createdBy: string; + tenant?: Tenant | null; + user?: User | null; + creator?: User | null; +} + export interface Portfolio { id: number | string; tenantId?: number | string; diff --git a/PostFundManagement.ConsoleUI/yarn.lock b/PostFundManagement.ConsoleUI/yarn.lock index 96bcd86..925672d 100644 --- a/PostFundManagement.ConsoleUI/yarn.lock +++ b/PostFundManagement.ConsoleUI/yarn.lock @@ -928,6 +928,18 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== +"@types/react-dom@^19.3.0": + version "19.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.3.0.tgz#90294a9266efd928a5266f2110d60d5dba5015d2" + integrity sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q== + +"@types/react@^19.3.0": + version "19.3.0" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.3.0.tgz#3b3ce8d8c1627fb5ccd4dadd57ef445c5a153b77" + integrity sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg== + dependencies: + csstype "^3.2.2" + "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -1198,6 +1210,11 @@ css-line-break@^2.1.0: dependencies: utrie "^1.0.2" +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + data-uri-to-buffer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" diff --git a/PostFundManagement.Domain/Entities/TenantOrganisation.cs b/PostFundManagement.Domain/Entities/TenantOrganisation.cs index bf3cc15..d4f4269 100644 --- a/PostFundManagement.Domain/Entities/TenantOrganisation.cs +++ b/PostFundManagement.Domain/Entities/TenantOrganisation.cs @@ -1,11 +1,11 @@ -namespace PostFundManagement.Domain.Entities; +namespace PostFundManagement.Domain.Entities; [EntityTypeConfiguration] public class TenantOrganisation : Models.TenantOrganisation { public virtual Tenant? Tenant { get; set; } - public virtual Organisation? Organisation { get; set; } + public new virtual Organisation? Organisation { get; set; } public virtual User? Creator { get; set; } } diff --git a/PostFundManagement.Domain/Entities/TenantUser.cs b/PostFundManagement.Domain/Entities/TenantUser.cs index bac1337..11d8d6b 100644 --- a/PostFundManagement.Domain/Entities/TenantUser.cs +++ b/PostFundManagement.Domain/Entities/TenantUser.cs @@ -1,4 +1,4 @@ -namespace PostFundManagement.Domain.Entities; +namespace PostFundManagement.Domain.Entities; [EntityTypeConfiguration] public class TenantUser : Models.TenantUser @@ -9,7 +9,7 @@ public class TenantUser : Models.TenantUser public virtual User? Creator { get; set; } [ForeignKey(nameof(UserId))] - public virtual User? User { get; set; } + public new virtual User? User { get; set; } } public sealed class TenantUserConfiguration : IEntityTypeConfiguration diff --git a/PostFundManagement.Domain/Extensions/Mappers.cs b/PostFundManagement.Domain/Extensions/Mappers.cs index ab850bc..904224d 100644 --- a/PostFundManagement.Domain/Extensions/Mappers.cs +++ b/PostFundManagement.Domain/Extensions/Mappers.cs @@ -11,7 +11,8 @@ public static class Mappers CreatedAt = entity.CreatedAt, CreatedBy = entity.CreatedBy, Status = entity.Status, - UserId = entity.UserId + UserId = entity.UserId, + User = entity.User?.Map() }; public static TenantOrganisation Map(this Entities.TenantOrganisation entity) => new() @@ -21,7 +22,8 @@ public static class Mappers CreatedAt = entity.CreatedAt, CreatedBy = entity.CreatedBy, OrganisationId = entity.OrganisationId, - TenantId = entity.TenantId + TenantId = entity.TenantId, + Organisation = entity.Organisation?.Map() }; public static Tenant Map(this Entities.Tenant entity) => new() diff --git a/PostFundManagement.Domain/Models/TenantOrganisation.cs b/PostFundManagement.Domain/Models/TenantOrganisation.cs index 5e1e17f..ab10515 100644 --- a/PostFundManagement.Domain/Models/TenantOrganisation.cs +++ b/PostFundManagement.Domain/Models/TenantOrganisation.cs @@ -1,4 +1,4 @@ -namespace PostFundManagement.Domain.Models; +namespace PostFundManagement.Domain.Models; public class TenantOrganisation { @@ -13,4 +13,6 @@ public class TenantOrganisation public long OrganisationId { get; set; } public ActivityStatus Status { get; set; } + + public Organisation? Organisation { get; set; } } diff --git a/PostFundManagement.Domain/Models/TenantUser.cs b/PostFundManagement.Domain/Models/TenantUser.cs index c9d5911..464a210 100644 --- a/PostFundManagement.Domain/Models/TenantUser.cs +++ b/PostFundManagement.Domain/Models/TenantUser.cs @@ -1,4 +1,4 @@ -namespace PostFundManagement.Domain.Models; +namespace PostFundManagement.Domain.Models; public class TenantUser { @@ -13,4 +13,6 @@ public class TenantUser public Guid UserId { get; set; } public ActivityStatus Status { get; set; } + + public User? User { get; set; } } diff --git a/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs b/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs index 082b0a4..029af00 100644 --- a/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs +++ b/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Abstractions; namespace PostFundManagement.Infrastructure.Database.Services; @@ -17,18 +17,18 @@ public sealed class TenantContextService : ITenantContextService public TenantContextService(IHttpContextAccessor httpContextAccessor) { var user = httpContextAccessor.HttpContext?.User; - - IsAdmin = user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true; - ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true; - - ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin; - - HasTenantFilter = !IsAdmin; var tenantIdClaimVal = user?.FindFirst("tenant_id")?.Value ?? user?.FindFirst("TenantId")?.Value; TenantId = long.TryParse(tenantIdClaimVal, out var id) ? id : 0L; var tenantNameClaimVal = user?.FindFirst("tenant")?.Value ?? user?.FindFirst("Tenant")?.Value; TenantName = !string.IsNullOrWhiteSpace(tenantNameClaimVal) ? tenantNameClaimVal : null; + + IsAdmin = (user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true) && TenantId == 1; + ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true; + + ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin; + + HasTenantFilter = !IsAdmin; } -} \ No newline at end of file +} diff --git a/Processes.md b/Processes.md new file mode 100644 index 0000000..7f1ffb1 --- /dev/null +++ b/Processes.md @@ -0,0 +1,167 @@ +# PostFundManagement (PFM) Platform — Operational Processes & Governance (Processes.md) + +> **Document Context**: Authoritative operational manual and process specification for the PFM Platform. Derived from the PFM SRS v2.0 and governing all lifecycle transitions, workflow gates, RBAC controls, and Maker-Checker segregation of duties. + +--- + +## 1. PFM End-to-End Operational Lifecycle + +The PFM platform governs the lifecycle of grant, development, innovation, and impact funding portfolios from the moment an award is approved through long-term post-fund monitoring. + +``` +┌────────────────────────────────────────────────────────────────────────────────────────┐ +│ POST-FUND LIFECYCLE │ +│ │ +│ [1. Award Intake] ──> [2. Contracting] ──> [3. Onboarding] │ +│ External Approval Draft Agreement KYC & Banking Gate │ +│ Recorded (BR-001) Auto-created (BR-002) Required for Disb. (BR-003) │ +│ │ │ +│ ▼ │ +│ [4. Project & WBS] ──> [5. Budget & Lines] ──> [6. Disbursement Gate] │ +│ Milestones & Tasks Allocations & Caps Requires Verified Evidence │ +│ Progress Tracking No Over-commitment & Satisfied Conditions (BR-004) │ +│ │ │ +│ ▼ │ +│ [7. M&E & Evidence] ─> [8. Change & Risk] ──> [9. Close-Out & Impact] │ +│ Indicator Actuals Variations & Issues Post-Fund Monitoring │ +│ Field Site Visits Audit Trail (BR-007) Portfolio Roll-Up (BR-010) │ +└────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Core Operational Processes + +### 2.1 Process 1: Award Intake & Funding Registration (Module M01) +* **Pre-condition**: Award has been approved by external sanctioning committee (BR-001). +* **Actor**: Funding Administrator (`grant_financials: add`). +* **Workflow**: + 1. Capture external reference number, award amount ($> 0$), funding period, programme, and recipient organisation. + 2. System enforces duplicate check against external reference numbers. + 3. Upon successful award creation, the system **automatically initializes** the corresponding legal `Contract` in `Draft` status (BR-002). + +### 2.2 Process 2: Contracting & Agreement Management (Module M02) +* **Pre-condition**: Valid award record exists. +* **Actors**: Legal / Programme Manager (`grant_financials: add`), Executive / Authorised Signatory (`grant_financials: update`). +* **Workflow**: + 1. Author attaches legal terms, clauses, total value, and effective/expiry dates. + 2. Upload signed bilateral agreement PDF. + 3. Transition contract status to `Active`. + 4. **Maker-Checker Rule**: The user who uploaded or created the contract cannot be the signatory who transitions it to `Active`. + +### 2.3 Process 3: Project WBS & Milestones Management (Module M04 & M07) +* **Pre-condition**: Project is in `Active` status. +* **Actors**: Contributor / Project Manager (`projects_milestones: add`), Supervisor / Manager (`projects_milestones: update`). +* **Workflow**: + 1. Project Manager defines contractual milestones with due dates and priority levels. + 2. Contributors log ground activities against milestones. + 3. Contributor uploads completion evidence. + 4. Status transitions from `Planned` $\to$ `In Progress` $\to$ `Submitted` $\to$ `Under Review` $\to$ `Verified & Approved`. + 5. **Verification Gate**: Only users with `projects_milestones: update` who **did not create** the milestone can verify and approve it. + +### 2.4 Process 4: Disbursement Control & Payment Release (Module M05 & M06) +* **Pre-condition**: Recipient onboarding complete, banking details verified, and linked milestone deliverable verified. +* **Actors**: Finance Officer (`grant_financials: add`), Finance Director / Manager (`grant_financials: update`). +* **Workflow**: + 1. Finance Officer drafts disbursement tranche drawdown against verified milestone. + 2. System validates conditions precedent and budget caps (commits + expenditures $\le$ approved budget line). + 3. Authoriser verifies evidence and approves tranche release. + 4. **Maker-Checker Rule**: The user who captured the payment request cannot approve the disbursement. + +### 2.5 Process 5: M&E Indicators, Risks & Evidence Review (Module M08 & M09) +* **Pre-condition**: Approved project indicators and risk registers configured. +* **Actors**: M&E Officer (`evidence_risks: add`), Programme Supervisor (`evidence_risks: update`). +* **Workflow**: + 1. Field staff or recipients submit indicator actuals and supporting verification files (`EvidenceType`). + 2. Evidence enters the `EvidenceReviewQueue`. + 3. Supervisor reviews document authenticity, geotags, and deliverable match. + 4. Decision: `Approve & Verify` or `Reject` (with mandatory rework reasoning). + 5. **Maker-Checker Rule**: The officer who uploaded the evidence document cannot approve their own submission. + +### 2.6 Process 6: Change Management & Variations (Module M15 & M16) +* **Pre-condition**: Active project with identified issue or required contractual variance. +* **Actors**: Contributor (`change_management: add`), Manager (`change_management: update`). +* **Workflow**: + 1. Contributor logs project issue or drafts contractual change request (budget reallocation, timeline extension, scope shift). + 2. Impact assessment conducted. + 3. Governance deliberation. + 4. Resolution: `Approved & Enacted` or `Rejected`. + 5. **Maker-Checker Rule**: Change request initiator cannot approve or enact the change. + +--- + +## 3. The Maker-Checker Operational Policy + +### 3.1 Policy Definition +Under PFM enterprise governance standards, **dual authorization (Maker-Checker)** is mandatory for all material lifecycle decisions: + +$$\text{Approval Granted} \iff (\text{User has 'update' permission}) \land (\text{User ID} \neq \text{Record Creator ID})$$ + +### 3.2 Matrix of Segregated Actions + +| Operational Domain | Maker Action (`add`) | Checker Action (`update`) | Maker-Checker Invariant | +| :--- | :--- | :--- | :--- | +| **Projects & Governance** | Register project, update scope | Approve project governance status | Creator cannot approve project | +| **Contractual Milestones** | Define milestone, submit evidence | Verify deliverable, approve milestone | Milestone author cannot verify | +| **Disbursements** | Capture tranche drawdown request | Authorize payment release | Requestor cannot release payment | +| **Budgets** | Define budget line, allocate funds | Approve statutory budget line | Allocator cannot approve budget | +| **Evidence Queue** | Upload proof, ingest documentation | Approve & verify evidence artifact | Submitter cannot verify evidence | +| **Change Requests** | Draft scope / budget variation | Approve & enact variation | Initiator cannot enact change | +| **Site Visits** | Log field checklist & observations | Approve site inspection report | Inspector cannot self-sign report | +| **Programmes** | Create programme structure | Approve programme governance | Creator cannot approve programme | + +### 3.3 System Behavior on Maker-Checker Collisions +When an authorised approver navigates to a record they personally created: +1. **Dropdown Controls**: The `Approved` option is disabled with the label: + `Approved (Maker-Checker: Cannot approve own record)` +2. **Action Buttons**: Fast-track approve buttons are hidden, and a visual tag `Maker-Checker (Creator)` is displayed. +3. **Audit Trail**: Any attempt to bypass client controls logs a security violation in the immutable audit log (`settings_auditlog`). + +--- + +## 4. RBAC Operational Roles & Boundaries + +### 4.1 Role Assignment Rules +1. **`Contributor`**: Operational field execution. Strictly restricted to `view` and `add`. Contributors can create activities, submit evidence, and draft change requests, but **never approve records or export datasets**. +2. **`Supervisor`**: Operational oversight. Possesses `view, add, update, export`. Verifies milestones, inspects evidence, reviews site visits, and exports reports, subject to the Maker-Checker rule. +3. **`Manager`**: Comprehensive operational management. Possesses `all` across operational domains. Manages audit logs, notifications, and tenancy. +4. **`Executive`**: Strategic leadership. Possesses `all` across strategic domains (`dashboard`, `governance`, `grant_financials`, `evidence_risks`). +5. **`Admin`**: System administrator. Possesses `unrestricted: all`. Governs tenant boundaries, partitions, and system security. +6. **`Partner`**: External grantee / recipient. Strictly confined to `PartnerUI`. Any attempt to access `ConsoleUI` triggers an immediate security lockout. + +--- + +## 5. Caching & Mutation Invalidation Workflow + +To ensure that caching never impedes record lifecycle updates or displays stale data while maximizing UI responsiveness, the following operational flow is enforced across ConsoleUI: + +```mermaid +sequenceDiagram + autonumber + actor User as User / Maker + participant UI as ConsoleUI (React Query) + participant Client as Axios HttpClient + participant OutputCache as Backend OutputCache (Redis/Memory) + participant DB as PostgreSQL Database + + User->>UI: Submits Record Mutation (Create / Update / Approve) + UI->>Client: Dispatches POST / PUT / PATCH / DELETE + Client->>DB: Executes Mutation in Database + DB-->>Client: Returns 200 OK / 201 Created + Client->>Client: Interceptor detects mutation: bumps activeMutationEpoch + UI->>UI: onSuccess triggers queryClient.invalidateQueries() + UI->>Client: Refetches active queries with _v = activeMutationEpoch + Client->>OutputCache: GET /v1/api/*?_v= + Note over OutputCache: Cache Miss (new _v key never seen before) + OutputCache->>DB: Queries PostgreSQL for fresh data + DB-->>OutputCache: Returns updated entities + OutputCache-->>UI: Caches fresh data under _v and returns to UI + UI-->>User: UI updates immediately with new status / record +``` + +### 5.1 Operating Rules for Record Modifications +1. **Zero Manual Cache Eviction Required**: Frontend developers do not need to manually configure cache busters for standard operations. The Axios response interceptor in `httpClient.ts` automatically increments `activeMutationEpoch` on any successful mutating call (`POST`, `PUT`, `PATCH`, `DELETE`). +2. **Deterministic Invalidation**: Calling standard `queryClient.invalidateQueries({ queryKey: [...] })` or `invalidateAndBustCache(queryClient, filters)` guarantees that the subsequent refetch bypasses stale ASP.NET Core OutputCache entries. +3. **Optimized Read Cache**: When users navigate across unchanged views or switch tabs, identical `_v` tokens are reused, allowing backend OutputCache and React Query in-memory caches to fulfill requests in < 5ms without unnecessary database hits. + + diff --git a/README.md b/README.md index 7490ecd..e8e88d9 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,16 @@ An enterprise system designed to manage the entire post-funding grant and projec The application follows a modular, hierarchical architecture where high-level funding directives cascade into measurable project outcomes and evidence verification. +```text [Portfolios] ───> [Programmes] ───> [Projects] ───> [Awards] ───> [Contracts] ───> [Beneficiaries] ──> [Outcomes] │ │ │ ▼ │ ▼ [Rules] ├─> [Milestones] ──> [Activities] ──> [Evidences] -├─> [Disbursements] -├─> [Indicators] -├─> [Risks] & [Issues] -└─> [SiteVisits] & [ComplianceItems] + ├─> [Disbursements] + ├─> [Indicators] + ├─> [Risks] & [Issues] + └─> [SiteVisits] & [ComplianceItems] +``` ### **1. Portfolio & Programme Governance** * **Portfolios**: Top-level containers for strategic investment themes. @@ -55,45 +57,85 @@ The application follows a modular, hierarchical architecture where high-level fu ## 🛠️ Tech Stack & Patterns -* **Backend Framework**: .NET 8 / 9 C# Web API -* **Database**: PostgreSQL -* **ORM**: Entity Framework Core (Fluent API Configuration) -* **Architecture**: Clean Architecture / Domain-Driven Design (DDD) with CQRS (`MediatR`) -* **Utilities**: `Humanizer` (Automated entity-to-table pluralization) +* **Backend Framework**: .NET 10.0 (C# 13) Web API / Minimal APIs +* **Frontend Portals**: + * `PostFundManagement.ConsoleUI`: Internal Staff & Portfolio Administration Portal (React 19 + TypeScript + Vite + Tailwind CSS) + * `PostFundManagement.PartnerUI`: External Recipient & Grantee Portal (React 19 + TypeScript + Vite + WebGL OGL) +* **Database**: PostgreSQL 14+ with Npgsql & EF Core 10 +* **Security & Authentication**: Duende IdentityServer / OIDC PKCE + Claim-Based RBAC +* **Governance Patterns**: Maker-Checker Pattern (Dual Authorization & Segregation of Duties) +* **Architecture**: Clean Architecture / Domain-Driven Design (DDD) with CQRS (`Mediator`) +* **Object Storage**: S3-compatible storage (Garage S3 / AWS S3) +* **Caching & OutputCache Invalidation**: Redis StackExchange + ASP.NET Core `OutputCaching` (2-minute base policy) coupled with TanStack Query v5 and Axios client-side mutation versioning (`_v` parameter) for instantaneous reads and guaranteed zero-staleness on mutations +* **Telemetry**: OpenTelemetry Metrics, Traces & Logs with Aspire / OTLP + +--- + +## 🔒 Role-Based Access Control (RBAC) & Maker-Checker Pattern + +The platform implements strict governance invariants across all operational domains: + +### 1. Progressive Action Hierarchy +* **`view`**: Read-only inspection of entity lists, details, and metrics. +* **`add`**: Create new records, edit drafts, submit evidence, and adjust non-approval statuses. +* **`update`**: Authoritative record **approval** authority (`Approved`, `Active` contracts). +* **`export`**: Export tabular data and reports (`.xlsx`, `.pdf`). +* **`all`**: Comprehensive authority across the claim domain. + +### 2. Role Claim Matrix +* **`Admin`**: `unrestricted: all` — Full system-wide administration. +* **`Executive`**: Strategic oversight across Dashboard, Governance, Projects, Grants, M&E (`all`). +* **`Manager`**: Comprehensive operational control across all domains (`all`). +* **`Supervisor`**: Operational oversight and verification (`view, add, update, export`). +* **`Contributor`**: Working-level execution (`view, add`). Strictly barred from record approvals and exports. +* **`Partner`**: External recipient. Confined strictly to `PartnerUI`; locked out of `ConsoleUI`. + +### 3. Maker-Checker Segregation of Duties +To eliminate conflicts of interest, record creators ("Makers") cannot approve ("Check") their own records. Even if an officer possesses the `update` claim, the system dynamically blocks self-approval on any record matching their `createdBy` identifier: +$$\text{Can Approve} = \text{Has 'update' permission} \land (\text{Current User ID} \neq \text{Record Creator ID})$$ + +For in-depth architectural and operational specifications, see: +* [Architecture.md](file:///c:/Users/Admin/source/repos/PostFundManagement/Architecture.md) +* [Processes.md](file:///c:/Users/Admin/source/repos/PostFundManagement/Processes.md) +* [GEMINI.md](file:///c:/Users/Admin/source/repos/PostFundManagement/GEMINI.md) --- ## 📁 Solution Structure ```text -src/ - ├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces - ├── PostFundManagement.Infrastructure/ # EF Core Configurations, DbContext, Migrations - ├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Logic - └── PostFundManagement.Api/ # REST Controllers, Middleware, Auth & Swagger +PostFundManagement/ + ├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces + ├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL, Skoruba STS, Migrations + ├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Validations + ├── PostFundManagement.Api/ # REST Endpoints, Middleware, Auth & Swagger/Scalar + ├── PostFundManagement.Tests/ # Unit & Integration Test Suite + ├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite) + ├── PostFundManagement.PartnerUI/ # External Grantee / Recipient Portal (React 19 + Vite) + ├── Architecture.md # System Architecture, RBAC & Maker-Checker Spec + ├── Processes.md # Operational Grant Lifecycle & Process Flows + └── docker-compose.yml # Container orchestration ``` ## 🚀 Getting Started ### Prerequisites -.NET 8.0 SDK or higher -PostgreSQL 14+ -dotnet-ef CLI Tool (dotnet tool install --global dotnet-ef) +* .NET 10.0 SDK or higher +* Node.js 20+ & Yarn (`yarn.lock` authoritative) +* PostgreSQL 14+ ## Setup & Execution -## Clone Repository - -1. Clone Repository: +### 1. Clone Repository: ```bash -git clone [https://github.com/your-org/PostFundManagement.git](https://github.com/your-org/PostFundManagement.git) +git clone https://github.com/your-org/PostFundManagement.git cd PostFundManagement ``` -2. Configure Database Connection: -Update appsettings.Development.json in the API project with your local PostgreSQL details: +### 2. Configure Database Connection: +Update `appsettings.Development.json` in the API project with your local PostgreSQL details: ```json { @@ -103,16 +145,18 @@ Update appsettings.Development.json in the API project with your local PostgreSQ } ``` -3. Apply Database Migrations: +### 3. Launch Backend API: ```bash -dotnet ef database update --project src/PostFundManagement.Infrastructure +dotnet run --project PostFundManagement.Api ``` -4. Launch Application: +### 4. Launch ConsoleUI Frontend: ```bash -dotnet run --project src/PostFundManagement.Api +cd PostFundManagement.ConsoleUI +yarn install +yarn dev ``` ## 📄 License