RBAC Improvements
Caching improvements Maker-Checker Implementation
This commit is contained in:
+192
@@ -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`<br>*(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)<br/>Creates Record"] --> B["Record Persisted<br/>with createdBy = User A"]
|
||||||
|
B --> C{"Checker Evaluation<br/>(Approver Login)"}
|
||||||
|
C -->|Checker == User A| D["Approval BLOCKED<br/>Maker-Checker Violation"]
|
||||||
|
C -->|Checker == User B<br/>with 'update' claim| E["Approval GRANTED<br/>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 `<select>` controls is disabled with contextual labels:
|
||||||
|
```tsx
|
||||||
|
<option value={ApprovalStatus.Approved} disabled={!canApproveRecord('projects_milestones', record.createdBy)}>
|
||||||
|
Approved {isMaker(record.createdBy) ? '(Maker-Checker: Cannot approve own record)' : ''}
|
||||||
|
</option>
|
||||||
|
```
|
||||||
|
2. **Action Buttons**: Fast-track "Approve" buttons (e.g. Budget Approval, Disbursement Authorisation, Evidence Review) are hidden or accompanied by a Maker-Checker indicator badge when viewed by the creator.
|
||||||
|
3. **Export Controls**: `<ExportButton>` enforces domain claim checks explicitly (`claim="grant_financials"`), rejecting unauthenticated exports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Caching Strategy, Mutation Versioning & Performance Optimization
|
||||||
|
|
||||||
|
To achieve sub-50ms page transitions while guaranteeing data freshness across mutations in a distributed architecture, PFM employs a multi-tiered caching topology.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ REACT QUERY CACHE (In-Memory Browser Tier) │
|
||||||
|
│ staleTime: 2m (dynamic) | 5m (taxonomy) | gcTime: 10m │
|
||||||
|
└───────────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ Cache Miss / Background Refetch
|
||||||
|
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ AXIOS CLIENT INTERCEPTOR & MUTATION VERSIONING (_v) │
|
||||||
|
│ Reads/Browsing: attaches active `_v` epoch token │
|
||||||
|
│ Mutations (POST/PUT/PATCH/DELETE): auto-bumps epoch -> new `_v` token │
|
||||||
|
└───────────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ HTTP GET /v1/api/*?_v=<epoch>
|
||||||
|
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ ASP.NET CORE OUTPUTCACHE (Redis / Memory Tier) │
|
||||||
|
│ Base Policy: 2-minute expiration partitioned by URI + QueryString │
|
||||||
|
│ Hits same `_v` -> 2ms response | New `_v` -> DB query & cache refresh │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 The OutputCache Stale Data Challenge
|
||||||
|
ASP.NET Core Minimal APIs in `PostFundManagement.Api` attach `.CacheOutput()` to GET endpoints, using a 2-minute base sliding expiration backed by Redis / Memory cache.
|
||||||
|
|
||||||
|
Because OutputCache keys are generated from the HTTP Method, Host, Path, and **Query String**, a naive client refetch after a mutation (e.g. `queryClient.invalidateQueries({ queryKey: ['projects'] })`) calls `GET /v1/api/projects`. OutputCache intercepts this identical URI, returning the pre-mutation response cached earlier. The updated project status or approved milestone does not reflect in the UI until the 2-minute cache expires.
|
||||||
|
|
||||||
|
### 5.2 Client-Side Mutation Epoch Versioning
|
||||||
|
PFM solves this without modifying backend endpoints via **Client-Side Mutation Versioning**:
|
||||||
|
1. **Epoch Tracking (`activeMutationEpoch`)**: `httpClient.ts` tracks an active mutation timestamp initialized from `sessionStorage` or `Date.now()`.
|
||||||
|
2. **Automatic Mutation Interceptor**: Every successful mutating HTTP response (`POST`, `PUT`, `PATCH`, `DELETE` with HTTP 2xx) triggers `bumpMutationEpoch()`.
|
||||||
|
3. **Query Parameter Injection**: Outgoing `GET` requests to internal APIs automatically append `_v: getMutationEpoch()`.
|
||||||
|
4. **Instant Invalidation**: When a mutation succeeds, the epoch is bumped. Subsequent React Query refetches send the new `_v`, producing an immediate **cache miss** in ASP.NET Core OutputCache. The backend queries PostgreSQL, returns fresh data, and caches the new state under the new `_v` key.
|
||||||
|
5. **Cache Hit Snappiness**: Navigations between pages while no mutations have occurred reuse the stable `_v`, yielding sub-5ms responses from Redis/Memory OutputCache and 0ms from React Query in-memory cache.
|
||||||
|
6. **Explicit Freshness (`_t`)**: When `Cache-Control: no-cache` is specified or during explicit refresh operations, a unique timestamp `_t` is attached to force a guaranteed fresh fetch.
|
||||||
|
|
||||||
|
### 5.3 RBAC Startup Pipeline Acceleration
|
||||||
|
Following the introduction of fine-grained RBAC and Maker-Checker checks, startup performance was streamlined to eliminate sequential network waterfalls:
|
||||||
|
* **Cache-First Role Claims**: `fetchAndCacheSystemRoles` and `fetchAndCacheRoleClaims` check in-memory and `localStorage` caches *before* dispatching network calls.
|
||||||
|
* **Parallel User Resolution**: `getUserRolesFromApi` and `getUserClaimsFromApi` are resolved concurrently using `Promise.allSettled`.
|
||||||
|
* **Parallel Claim Resolution**: Claims across all assigned roles are retrieved concurrently rather than in serial loops.
|
||||||
|
* **Immediate Non-Blocking Session Render**: If valid session tokens and user profiles exist in `localStorage`, `AuthProvider` immediately unblocks UI rendering (`isLoading: false`) and runs tenant lockout checks, account synchronization, and claim revalidation asynchronously in the background.
|
||||||
|
* **In-Memory User Sync & Lockout Caching**: `isUserSyncedInBrowser` and `verifyTenantLockout` maintain in-memory references to eliminate synchronous `JSON.parse` operations from the request pipeline.
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ PostFundManagement/
|
|||||||
### 2.3 Infrastructure, Storage & Security Topology
|
### 2.3 Infrastructure, Storage & Security Topology
|
||||||
* **Database**: PostgreSQL 14+ (Separate connection strings for primary transactional store `pfm` and scheduler store `pfm-scheduler`).
|
* **Database**: PostgreSQL 14+ (Separate connection strings for primary transactional store `pfm` and scheduler store `pfm-scheduler`).
|
||||||
* **Object / Evidence Store**: S3-compatible storage (e.g., Garage S3 / AWS S3) with pre-signed upload URLs and CDN acceleration.
|
* **Object / Evidence Store**: S3-compatible storage (e.g., Garage S3 / AWS S3) with pre-signed upload URLs and CDN acceleration.
|
||||||
* **Cache**: Redis (`StackExchange.Redis` + ASP.NET HybridCache) for dashboard KPIs and taxonomy lookup caching.
|
* **Cache**: Redis (`StackExchange.Redis` + ASP.NET Core `OutputCaching` 2-minute base policy on Minimal API GET endpoints + HybridCache). Paired with TanStack React Query in `ConsoleUI` and Axios mutation versioning (`_v` parameter) to guarantee instant reads and zero stale data after mutations.
|
||||||
* **Authentication / STS**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`).
|
* **Authentication / STS**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`).
|
||||||
* **Observability**: OpenTelemetry exporters streaming traces, metrics, and logs to Aspire Dashboard / OTLP collectors.
|
* **Observability**: OpenTelemetry exporters streaming traces, metrics, and logs to Aspire Dashboard / OTLP collectors.
|
||||||
|
|
||||||
@@ -554,6 +554,19 @@ The following baseline mappings define the claims assigned to system roles:
|
|||||||
* Endpoints inspect JWT claims (`role_claim:<claim_name>` or array claims) to enforce authorization before executing command/query handlers.
|
* Endpoints inspect JWT claims (`role_claim:<claim_name>` or array claims) to enforce authorization before executing command/query handlers.
|
||||||
* Violations immediately return `403 Forbidden` and log an audit security event (`NFR-004`, `AT-13`).
|
* Violations immediately return `403 Forbidden` and log an audit security event (`NFR-004`, `AT-13`).
|
||||||
|
|
||||||
|
### 10.5 Maker-Checker Segregation of Duties Pattern
|
||||||
|
|
||||||
|
In addition to RBAC role and claim constraints, the platform enforces the **Maker-Checker Pattern** across all operational lifecycle modules:
|
||||||
|
|
||||||
|
* **Separation of Duty Rule**: **The user who creates or drafts a record (the "Maker") is strictly barred from verifying or approving that record (the "Checker"), regardless of their role or administrative privileges.**
|
||||||
|
* **Attribution**: Every domain entity immutably captures the creator's user identifier in `createdBy` (Guid/Subject ID resolved from `context.GetUserId()`).
|
||||||
|
* **Evaluation Formula**:
|
||||||
|
$$\text{Can Approve} = \text{Has 'update' claim permission} \land (\text{Authenticated User ID} \neq \text{Record createdBy})$$
|
||||||
|
* **Client UI Enforcement**:
|
||||||
|
* Components use `const { canApproveRecord, isMaker, getApprovalRestrictionReason } = useRbac()`.
|
||||||
|
* Status transition dropdowns disable the `Approved` / `Active` option when `!canApproveRecord(claim, record.createdBy)` with contextual indicator `(Maker-Checker: Cannot approve own record)`.
|
||||||
|
* Fast-track approval buttons (budget lines, disbursement tranches, evidence submissions, change requests) are hidden or marked with a Maker-Checker warning badge when inspected by the creator.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Functional API Surface & Endpoint Catalog
|
## 11. Functional API Surface & Endpoint Catalog
|
||||||
|
|||||||
@@ -581,62 +581,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
|
if (storedTenantClaim) {
|
||||||
const tenantLockoutResult = await verifyTenantLockout(
|
|
||||||
storedTenantId ?? tenantClaimValues.tenantId,
|
|
||||||
storedTenantClaim
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!tenantLockoutResult.isActive) {
|
|
||||||
setIsPendingActivation(true);
|
|
||||||
setIsSynced(false);
|
|
||||||
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tenantLockoutResult.tenant?.name) {
|
|
||||||
if (resolvedUser) {
|
|
||||||
resolvedUser.tenant = tenantLockoutResult.tenant.name;
|
|
||||||
setUser({ ...resolvedUser });
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
|
||||||
}
|
|
||||||
setTenantName(tenantLockoutResult.tenant.name);
|
|
||||||
} else if (storedTenantClaim) {
|
|
||||||
setTenantName(storedTenantClaim);
|
setTenantName(storedTenantClaim);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session is already marked pending activation or needs sync check
|
// Initial fast unblock: valid persisted session found, unblock UI immediately
|
||||||
const wasPending = isActivationPending();
|
setIsLoading(false);
|
||||||
setIsPendingActivation(wasPending);
|
|
||||||
setActivationError(getActivationPendingReason());
|
|
||||||
|
|
||||||
try {
|
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims)
|
||||||
// Verify sync with browser cache priority (hotspot prevention)
|
(async () => {
|
||||||
const syncResult = await syncUserAccount(
|
try {
|
||||||
storedSub || '',
|
const tenantLockoutResult = await verifyTenantLockout(
|
||||||
storedToken,
|
storedTenantId ?? tenantClaimValues.tenantId,
|
||||||
resolvedUser || undefined,
|
storedTenantClaim
|
||||||
false
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (syncResult.isUnauthorized) {
|
if (!tenantLockoutResult.isActive) {
|
||||||
// HTTP 401: Normal process of routing user to landing page
|
setIsPendingActivation(true);
|
||||||
await logout();
|
setIsSynced(false);
|
||||||
return;
|
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (syncResult.isActivationPending) {
|
if (tenantLockoutResult.tenant?.name) {
|
||||||
setIsPendingActivation(true);
|
if (resolvedUser) {
|
||||||
setIsSynced(false);
|
resolvedUser.tenant = tenantLockoutResult.tenant.name;
|
||||||
setActivationError(syncResult.errorMessage || null);
|
setUser({ ...resolvedUser });
|
||||||
} else if (syncResult.success) {
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||||
setIsPendingActivation(false);
|
}
|
||||||
setIsSynced(true);
|
setTenantName(tenantLockoutResult.tenant.name);
|
||||||
setActivationError(null);
|
}
|
||||||
|
|
||||||
|
const wasPending = isActivationPending();
|
||||||
|
setIsPendingActivation(wasPending);
|
||||||
|
setActivationError(getActivationPendingReason());
|
||||||
|
|
||||||
|
const syncResult = await syncUserAccount(
|
||||||
|
storedSub || '',
|
||||||
|
storedToken,
|
||||||
|
resolvedUser || undefined,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (syncResult.isUnauthorized) {
|
||||||
|
await logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncResult.isActivationPending) {
|
||||||
|
setIsPendingActivation(true);
|
||||||
|
setIsSynced(false);
|
||||||
|
setActivationError(syncResult.errorMessage || null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncResult.success) {
|
||||||
|
setIsPendingActivation(false);
|
||||||
|
setIsSynced(true);
|
||||||
|
setActivationError(null);
|
||||||
|
|
||||||
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
|
||||||
try {
|
|
||||||
const resolved = await resolveUserRolesAndClaims(
|
const resolved = await resolveUserRolesAndClaims(
|
||||||
storedSub || '',
|
storedSub || '',
|
||||||
parsedClaims,
|
parsedClaims,
|
||||||
@@ -657,17 +660,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
setClaims({ ...resolved.claims });
|
setClaims({ ...resolved.claims });
|
||||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||||
} catch {}
|
}
|
||||||
|
} catch (syncErr: any) {
|
||||||
|
if (syncErr?.response?.status === 401) {
|
||||||
|
await logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsPendingActivation(true);
|
||||||
|
setIsSynced(false);
|
||||||
|
setActivationError(syncErr?.message || 'Account activation pending');
|
||||||
}
|
}
|
||||||
} catch (syncErr: any) {
|
})();
|
||||||
if (syncErr?.response?.status === 401) {
|
|
||||||
await logout();
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsPendingActivation(true);
|
|
||||||
setIsSynced(false);
|
|
||||||
setActivationError(syncErr?.message || 'Account activation pending');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.warn('OIDC initialization notice:', err);
|
console.warn('OIDC initialization notice:', err);
|
||||||
|
|||||||
@@ -142,12 +142,14 @@ export function resolveClaimActionSet(
|
|||||||
actions.add('add');
|
actions.add('add');
|
||||||
actions.add('update');
|
actions.add('update');
|
||||||
actions.add('export');
|
actions.add('export');
|
||||||
} else if (val === 'view') {
|
} else if (val === 'view' || val === 'read') {
|
||||||
actions.add('view');
|
actions.add('view');
|
||||||
} else if (val === 'add') {
|
} else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') {
|
||||||
|
// 'add' encompasses create and non-approval edits
|
||||||
actions.add('view');
|
actions.add('view');
|
||||||
actions.add('add');
|
actions.add('add');
|
||||||
} else if (val === 'update') {
|
} else if (val === 'update' || val === 'approve' || val === 'verify') {
|
||||||
|
// 'update' strictly grants approval capability (+ view and add)
|
||||||
actions.add('view');
|
actions.add('view');
|
||||||
actions.add('add');
|
actions.add('add');
|
||||||
actions.add('update');
|
actions.add('update');
|
||||||
@@ -173,10 +175,13 @@ export function resolveClaimActionSet(
|
|||||||
actions.add('update');
|
actions.add('update');
|
||||||
actions.add('export');
|
actions.add('export');
|
||||||
} else if (role === 'supervisor') {
|
} else if (role === 'supervisor') {
|
||||||
|
// Supervisor performs operational review and verification/approval of milestones & evidence
|
||||||
actions.add('view');
|
actions.add('view');
|
||||||
actions.add('add');
|
actions.add('add');
|
||||||
|
actions.add('update');
|
||||||
actions.add('export');
|
actions.add('export');
|
||||||
} else if (role === 'contributor') {
|
} else if (role === 'contributor') {
|
||||||
|
// Contributor is strictly working-level execution: creating activities, submitting evidence, etc. (NO approve/update)
|
||||||
actions.add('view');
|
actions.add('view');
|
||||||
actions.add('add');
|
actions.add('add');
|
||||||
} else {
|
} else {
|
||||||
@@ -294,11 +299,36 @@ export function evaluateUserPermission(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates Maker-Checker separation of duties constraint.
|
||||||
|
* In PFM governance, the user who created (or initiated) a record ("Maker")
|
||||||
|
* is strictly barred from approving ("Checking") their own record.
|
||||||
|
*
|
||||||
|
* @param createdBy The user identifier of the record creator (Guid, sub, email)
|
||||||
|
* @param currentUserId Current logged-in user ID (user.id or sub)
|
||||||
|
* @param currentUserSub Current logged-in user subject claim
|
||||||
|
* @param currentUserEmail Current logged-in user email
|
||||||
|
* @returns boolean true if user is the creator (Maker), false otherwise
|
||||||
|
*/
|
||||||
|
export function isRecordMaker(
|
||||||
|
createdBy?: string | null,
|
||||||
|
currentUserId?: string | null,
|
||||||
|
currentUserSub?: string | null,
|
||||||
|
currentUserEmail?: string | null
|
||||||
|
): boolean {
|
||||||
|
if (!createdBy) return false;
|
||||||
|
const creator = createdBy.trim().toLowerCase();
|
||||||
|
if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true;
|
||||||
|
if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true;
|
||||||
|
if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook exposing RBAC evaluation helpers for components.
|
* React hook exposing RBAC evaluation helpers for components.
|
||||||
*/
|
*/
|
||||||
export function useRbac() {
|
export function useRbac() {
|
||||||
const { roles, claims } = useAuth();
|
const { roles, claims, user, sub } = useAuth();
|
||||||
|
|
||||||
const permissions = useMemo(() => {
|
const permissions = useMemo(() => {
|
||||||
const isUnrestricted =
|
const isUnrestricted =
|
||||||
@@ -315,6 +345,22 @@ export function useRbac() {
|
|||||||
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
|
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
|
||||||
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
|
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
|
||||||
|
|
||||||
|
const isMaker = (createdBy?: string | null): boolean => {
|
||||||
|
return isRecordMaker(createdBy, user?.id, sub, user?.email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => {
|
||||||
|
if (!canApprove(claim)) return false;
|
||||||
|
// Maker-Checker policy: record creator cannot approve their own record
|
||||||
|
return !isMaker(createdBy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => {
|
||||||
|
if (!canApprove(claim)) return 'Requires update/approval permission';
|
||||||
|
if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isUnrestricted,
|
isUnrestricted,
|
||||||
canAccess,
|
canAccess,
|
||||||
@@ -322,8 +368,11 @@ export function useRbac() {
|
|||||||
canAdd,
|
canAdd,
|
||||||
canApprove,
|
canApprove,
|
||||||
canExport,
|
canExport,
|
||||||
|
isMaker,
|
||||||
|
canApproveRecord,
|
||||||
|
getApprovalRestrictionReason,
|
||||||
};
|
};
|
||||||
}, [roles, claims]);
|
}, [roles, claims, user, sub]);
|
||||||
|
|
||||||
return permissions;
|
return permissions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="governance"
|
||||||
title="Funding Instruments Registry"
|
title="Funding Instruments Registry"
|
||||||
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
|
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
|
||||||
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="governance"
|
||||||
title="Programme Rules Registry"
|
title="Programme Rules Registry"
|
||||||
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
|
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
|
||||||
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
|
|||||||
import { ExportButton } from '../ui/ExportButton';
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
import { ExportColumn } from '../../utils/exportUtils';
|
import { ExportColumn } from '../../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../../utils/tableSort';
|
import { useTableSort, SortableTh } from '../../utils/tableSort';
|
||||||
|
import { useRbac } from '../../auth/rbac';
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
Award,
|
Award,
|
||||||
@@ -73,6 +74,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError, info } = useToast();
|
const { success, error: toastError, info } = useToast();
|
||||||
|
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
||||||
|
|
||||||
// Component state
|
// Component state
|
||||||
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
|
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
|
||||||
@@ -602,6 +604,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="grant_financials"
|
||||||
title="Contract Registry & Agreements"
|
title="Contract Registry & Agreements"
|
||||||
subtitle="Authoritative repository of bilateral legal agreements and contract files"
|
subtitle="Authoritative repository of bilateral legal agreements and contract files"
|
||||||
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
|
||||||
@@ -609,17 +612,19 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
data={filteredContracts}
|
data={filteredContracts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canAdd('grant_financials') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
resetCreateForm();
|
onClick={() => {
|
||||||
setCreateModalOpen(true);
|
resetCreateForm();
|
||||||
}}
|
setCreateModalOpen(true);
|
||||||
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
}}
|
||||||
>
|
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||||
<Plus className="w-4 h-4" />
|
>
|
||||||
Create Contract
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
Create Contract
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1224,17 +1229,34 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
<label className="block text-xs font-medium text-slate-200 mb-1">
|
<label className="block text-xs font-medium text-slate-200 mb-1">
|
||||||
Contract Lifecycle Status <span className="text-rose-400">*</span>
|
Contract Lifecycle Status <span className="text-rose-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
{(() => {
|
||||||
value={newStatus}
|
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
|
||||||
onChange={(e) => setNewStatus(Number(e.target.value))}
|
const isSelf = isMaker(contractToUpdate?.createdBy);
|
||||||
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"
|
|
||||||
>
|
return (
|
||||||
{CONTRACT_STATUS_OPTIONS.map((opt) => (
|
<select
|
||||||
<option key={opt.value} value={opt.value}>
|
value={newStatus}
|
||||||
{opt.label}
|
onChange={(e) => setNewStatus(Number(e.target.value))}
|
||||||
</option>
|
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 font-medium"
|
||||||
))}
|
>
|
||||||
</select>
|
{CONTRACT_STATUS_OPTIONS.map((opt) => {
|
||||||
|
const isActiveOpt = opt.value === ContractStatus.Active;
|
||||||
|
const isOptDisabled = isActiveOpt && !canApproveContract;
|
||||||
|
let reasonText = '';
|
||||||
|
if (isActiveOpt && !canApprove('grant_financials')) {
|
||||||
|
reasonText = ' (Requires Update Permission)';
|
||||||
|
} else if (isActiveOpt && isSelf) {
|
||||||
|
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reasonText}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
|
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
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
|
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
|
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
|
||||||
@@ -824,7 +824,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canApprove('evidence_risks') && (
|
{canApproveRecord('evidence_risks', ev.createdBy) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -847,6 +847,11 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canApprove('evidence_risks') && isMaker(ev.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400/90 italic font-mono px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded-md">
|
||||||
|
Maker-Checker (Creator)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1022,7 +1027,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canApprove('evidence_risks') && (
|
{canApproveRecord('evidence_risks', inspectEvidence.createdBy) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -1045,6 +1050,12 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
|
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canApprove('evidence_risks') && isMaker(inspectEvidence.createdBy) && (
|
||||||
|
<span className="text-xs text-amber-400 font-medium px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||||
|
<span>Maker-Checker Policy: As the creator of this submission, you cannot approve it.</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
updateMilestone,
|
updateMilestone,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
|
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
|
||||||
|
import { useRbac } from '../../auth/rbac';
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
getApprovalStatusBadge,
|
getApprovalStatusBadge,
|
||||||
@@ -55,6 +56,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
||||||
|
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
|
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
|
||||||
@@ -175,7 +177,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isFormOpen && (
|
{!isFormOpen && canAdd('projects_milestones') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCreateForm}
|
onClick={openCreateForm}
|
||||||
@@ -255,16 +257,33 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
|||||||
{editingMilestone && (
|
{editingMilestone && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
||||||
<select
|
{(() => {
|
||||||
{...form.register('status')}
|
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
|
||||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
const isSelf = isMaker(editingMilestone.createdBy);
|
||||||
>
|
|
||||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
return (
|
||||||
<option key={opt.value} value={opt.value}>
|
<select
|
||||||
{opt.label}
|
{...form.register('status')}
|
||||||
</option>
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||||
))}
|
>
|
||||||
</select>
|
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
|
const isOptDisabled = isApprovedOpt && !canApproveMilestone;
|
||||||
|
let reasonText = '';
|
||||||
|
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||||
|
reasonText = ' (Requires Update Permission)';
|
||||||
|
} else if (isApprovedOpt && isSelf) {
|
||||||
|
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reasonText}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export function ExportButton<T>({
|
|||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const { user, claims, tenantId } = useAuth();
|
const { user, claims, tenantId } = useAuth();
|
||||||
const { canExport } = useRbac();
|
const { canExport, isUnrestricted } = useRbac();
|
||||||
|
|
||||||
const isClaimAllowed = claim ? canExport(claim) : true;
|
const isClaimAllowed = claim ? canExport(claim) : isUnrestricted;
|
||||||
const isDisabled = !data || data.length === 0 || !isClaimAllowed;
|
const isDisabled = !data || data.length === 0 || !isClaimAllowed;
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
updateUserStatus,
|
updateUserStatus,
|
||||||
getSystemClaims,
|
getSystemClaims,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
|
import { clearCachedUserRbac } from '../../services/roleClaimsCache';
|
||||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||||
@@ -58,7 +59,6 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
canApprove('settings_users') ||
|
canApprove('settings_users') ||
|
||||||
canApprove('tenant_management') ||
|
canApprove('tenant_management') ||
|
||||||
canApprove('tenant_self_service') ||
|
canApprove('tenant_self_service') ||
|
||||||
canAdd('tenant_self_service') ||
|
|
||||||
isUnrestricted;
|
isUnrestricted;
|
||||||
const canManageSessions =
|
const canManageSessions =
|
||||||
canApprove('settings_users') ||
|
canApprove('settings_users') ||
|
||||||
@@ -129,6 +129,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
return assignUserRole(userId, roleId);
|
return assignUserRole(userId, roleId);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
clearCachedUserRbac(userId);
|
||||||
success('Role Assigned', 'Role membership updated successfully.');
|
success('Role Assigned', 'Role membership updated successfully.');
|
||||||
refetchUserRoles();
|
refetchUserRoles();
|
||||||
setSelectedRoleToAdd('');
|
setSelectedRoleToAdd('');
|
||||||
@@ -139,6 +140,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
const removeRoleMutation = useMutation({
|
const removeRoleMutation = useMutation({
|
||||||
mutationFn: (roleId: string) => removeUserRole(userId, roleId),
|
mutationFn: (roleId: string) => removeUserRole(userId, roleId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
clearCachedUserRbac(userId);
|
||||||
success('Role Removed', 'Role revoked from user.');
|
success('Role Removed', 'Role revoked from user.');
|
||||||
refetchUserRoles();
|
refetchUserRoles();
|
||||||
},
|
},
|
||||||
@@ -160,6 +162,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
clearCachedUserRbac(userId);
|
||||||
success('Claim Granted', 'User claim added successfully.');
|
success('Claim Granted', 'User claim added successfully.');
|
||||||
refetchUserClaims();
|
refetchUserClaims();
|
||||||
setNewClaimType('');
|
setNewClaimType('');
|
||||||
@@ -171,6 +174,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
const deleteClaimMutation = useMutation({
|
const deleteClaimMutation = useMutation({
|
||||||
mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId),
|
mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
clearCachedUserRbac(userId);
|
||||||
success('Claim Revoked', 'Claim removed from user.');
|
success('Claim Revoked', 'Claim removed from user.');
|
||||||
refetchUserClaims();
|
refetchUserClaims();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient, QueryKey, InvalidateQueryFilters } from '@tanstack/react-query';
|
||||||
|
import { bumpMutationEpoch } from '../services/httpClient';
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 1000 * 60 * 3, // 3 minutes default cache
|
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
|
||||||
|
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: true,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||||
|
* This guarantees that the background refetch generates a fresh query parameter (_v)
|
||||||
|
* that bypasses backend ASP.NET Core OutputCaching.
|
||||||
|
*/
|
||||||
|
export async function invalidateAndBustCache(
|
||||||
|
client: QueryClient,
|
||||||
|
filters?: InvalidateQueryFilters
|
||||||
|
): Promise<void> {
|
||||||
|
bumpMutationEpoch();
|
||||||
|
await client.invalidateQueries(filters);
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ type OutcomeFormValues = z.infer<typeof outcomeSchema>;
|
|||||||
export const BeneficiariesPage: React.FC = () => {
|
export const BeneficiariesPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
const { canAdd, canApprove, canExport } = useRbac();
|
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
||||||
@@ -1024,16 +1024,25 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
{...compForm.register('status')}
|
{...compForm.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-blue-500"
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
>
|
>
|
||||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
{(() => {
|
||||||
<option
|
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
|
||||||
key={opt.value}
|
const isSelf = isMaker(editingComp?.createdBy);
|
||||||
value={opt.value}
|
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
disabled={opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries')}
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
>
|
const isOptDisabled = isApprovedOpt && !canApproveComp;
|
||||||
{opt.label}
|
let reason = '';
|
||||||
{opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries') ? ' (Requires Update Permission)' : ''}
|
if (isApprovedOpt && !canApprove('beneficiaries')) {
|
||||||
</option>
|
reason = ' (Requires Update Permission)';
|
||||||
))}
|
} else if (isApprovedOpt && isSelf) {
|
||||||
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
|
|||||||
export const ChangeManagementPage: React.FC = () => {
|
export const ChangeManagementPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
const { canAdd, canApprove, canExport } = useRbac();
|
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
||||||
@@ -1022,7 +1022,7 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{canApprove('change_management') && (
|
{canApproveRecord('change_management', cr.createdBy) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
||||||
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||||
@@ -1030,6 +1030,11 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
<Check className="w-3 h-3" /> Approve
|
<Check className="w-3 h-3" /> Approve
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canApprove('change_management') && isMaker(cr.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{canAdd('change_management') && (
|
{canAdd('change_management') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
||||||
@@ -1522,9 +1527,21 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||||
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
||||||
<option value={ApprovalStatus.Approved} disabled={!canApprove('change_management')}>
|
{(() => {
|
||||||
Approved & Enacted {!canApprove('change_management') ? '(Requires Update Permission)' : ''}
|
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
|
||||||
</option>
|
const isSelf = isMaker(editingChange?.createdBy);
|
||||||
|
let reason = '';
|
||||||
|
if (!canApprove('change_management')) {
|
||||||
|
reason = ' (Requires Update Permission)';
|
||||||
|
} else if (isSelf) {
|
||||||
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
|
||||||
|
Approved & Enacted {reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
|||||||
export const GovernancePage: React.FC = () => {
|
export const GovernancePage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { sub, user, claims } = useAuth();
|
const { sub, user, claims } = useAuth();
|
||||||
const { canAdd, canApprove, canExport } = useRbac();
|
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||||
const currentUserId = sub || user?.id || '';
|
const currentUserId = sub || user?.id || '';
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
@@ -1982,16 +1982,33 @@ export const GovernancePage: React.FC = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
{(() => {
|
||||||
{...programmeForm.register('status')}
|
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
||||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
||||||
>
|
|
||||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
return (
|
||||||
<option key={opt.value} value={opt.value}>
|
<select
|
||||||
{opt.label}
|
{...programmeForm.register('status')}
|
||||||
</option>
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||||
))}
|
>
|
||||||
</select>
|
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
|
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
|
||||||
|
let reason = '';
|
||||||
|
if (isApprovedOpt && !canApprove('governance')) {
|
||||||
|
reason = ' (Requires Update Permission)';
|
||||||
|
} else if (isApprovedOpt && isSelf) {
|
||||||
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ type DisbursementFormValues = z.infer<typeof disbursementSchema>;
|
|||||||
export const GrantsPage: React.FC = () => {
|
export const GrantsPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
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');
|
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
|
||||||
|
|
||||||
@@ -1002,7 +1002,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
<span>Update</span>
|
<span>Update</span>
|
||||||
</button>
|
</button>
|
||||||
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
|
{b.status !== ApprovalStatus.Approved && canApproveRecord('grant_financials', b.createdBy) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={updateBudgetMutation.isPending}
|
disabled={updateBudgetMutation.isPending}
|
||||||
@@ -1019,6 +1019,11 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span>Approve</span>
|
<span>Approve</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && isMaker(b.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1159,7 +1164,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{canApprove('grant_financials') && (
|
{canApproveRecord('grant_financials', d.createdBy) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => approveMutation.mutate(d.id)}
|
onClick={() => approveMutation.mutate(d.id)}
|
||||||
disabled={approveMutation.isPending}
|
disabled={approveMutation.isPending}
|
||||||
@@ -1169,6 +1174,11 @@ export const GrantsPage: React.FC = () => {
|
|||||||
Approve Payment
|
Approve Payment
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canApprove('grant_financials') && isMaker(d.createdBy) && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||||
|
Maker-Checker (Creator)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1562,9 +1572,21 @@ export const GrantsPage: React.FC = () => {
|
|||||||
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<option value={ApprovalStatus.Approved} disabled={!canApprove('grant_financials')}>
|
{(() => {
|
||||||
Approved {!canApprove('grant_financials') ? '(Requires Update Permission)' : ''}
|
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
|
||||||
</option>
|
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 (
|
||||||
|
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
|
||||||
|
Approved {reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { useToast } from '../components/ui/Toast';
|
|||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
||||||
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
||||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||||
@@ -100,6 +101,7 @@ export const WORKFLOW_STAGES = [
|
|||||||
export const MonitoringPage: React.FC = () => {
|
export const MonitoringPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canExport } = useRbac();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const tabParam = searchParams.get('tab');
|
const tabParam = searchParams.get('tab');
|
||||||
@@ -435,7 +437,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||||
{activeTab === 'contracts' && (
|
{activeTab === 'contracts' && canAdd('grant_financials') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreateContractModalOpen(true)}
|
onClick={() => setIsCreateContractModalOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||||
@@ -444,7 +446,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
<span>Create Contract & Upload PDF</span>
|
<span>Create Contract & Upload PDF</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'indicators' && (
|
{activeTab === 'indicators' && canAdd('evidence_risks') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsIndicatorModalOpen(true)}
|
onClick={() => setIsIndicatorModalOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||||
@@ -453,7 +455,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
<span>Define Indicator</span>
|
<span>Define Indicator</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'risks' && (
|
{activeTab === 'risks' && canAdd('evidence_risks') && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateRisk}
|
onClick={openCreateRisk}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||||
@@ -660,6 +662,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="evidence_risks"
|
||||||
title="M&E Performance Indicators"
|
title="M&E Performance Indicators"
|
||||||
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
|
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
|
||||||
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
|
||||||
@@ -861,6 +864,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="evidence_risks"
|
||||||
title="Project Risk Register & Heatmap"
|
title="Project Risk Register & Heatmap"
|
||||||
subtitle="Itemized audit log of identified risks and mitigation controls"
|
subtitle="Itemized audit log of identified risks and mitigation controls"
|
||||||
filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const evaluateProjectScheduleRisk = (
|
|||||||
export const ProjectsPage: React.FC = () => {
|
export const ProjectsPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
const { canAdd, canExport } = useRbac();
|
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||||
|
|
||||||
// Filter & Search States
|
// Filter & Search States
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -954,22 +954,41 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
{/* Governance Status */}
|
{/* Governance Status */}
|
||||||
<td className="py-4 px-4" onClick={(e) => e.stopPropagation()}>
|
<td className="py-4 px-4" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<select
|
{(() => {
|
||||||
value={p.status}
|
const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
|
||||||
onChange={(e) =>
|
const isSelf = isMaker(p.createdBy);
|
||||||
statusMutation.mutate({
|
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
|
||||||
projectId: p.id,
|
|
||||||
status: Number(e.target.value) as ApprovalStatus,
|
return (
|
||||||
})
|
<select
|
||||||
}
|
value={p.status}
|
||||||
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
disabled={!canModifyStatus}
|
||||||
>
|
onChange={(e) =>
|
||||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
statusMutation.mutate({
|
||||||
<option key={opt.value} value={opt.value}>
|
projectId: p.id,
|
||||||
{opt.label}
|
status: Number(e.target.value) as ApprovalStatus,
|
||||||
</option>
|
})
|
||||||
))}
|
}
|
||||||
</select>
|
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
|
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
||||||
|
let reasonText = '';
|
||||||
|
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||||
|
reasonText = ' (Requires Update Permission)';
|
||||||
|
} else if (isApprovedOpt && isSelf) {
|
||||||
|
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reasonText}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
|
|||||||
export const SiteVisitsPage: React.FC = () => {
|
export const SiteVisitsPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
const { canAdd, canApprove, canExport } = useRbac();
|
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -509,16 +509,25 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
{...visitForm.register('status')}
|
{...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"
|
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) => (
|
{(() => {
|
||||||
<option
|
const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
|
||||||
key={opt.value}
|
const isSelf = isMaker(editingVisit?.createdBy);
|
||||||
value={opt.value}
|
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||||
disabled={opt.value === ApprovalStatus.Approved && !canApprove('site_visits')}
|
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||||
>
|
const isOptDisabled = isApprovedOpt && !canApproveVisit;
|
||||||
{opt.label}
|
let reason = '';
|
||||||
{opt.value === ApprovalStatus.Approved && !canApprove('site_visits') ? ' (Requires Update Permission)' : ''}
|
if (isApprovedOpt && !canApprove('site_visits')) {
|
||||||
</option>
|
reason = ' (Requires Update Permission)';
|
||||||
))}
|
} else if (isApprovedOpt && isSelf) {
|
||||||
|
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||||
|
{opt.label}{reason}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,55 @@ import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './u
|
|||||||
|
|
||||||
export const BASE_API_URL = getApiBaseUrl();
|
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
|
// Create Axios Instance
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: BASE_API_URL,
|
baseURL: BASE_API_URL,
|
||||||
@@ -15,7 +64,7 @@ export const apiClient = axios.create({
|
|||||||
timeout: 15000,
|
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(
|
apiClient.interceptors.request.use(
|
||||||
async (config: InternalAxiosRequestConfig) => {
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
// Dynamically synchronize baseURL from runtime configuration
|
// Dynamically synchronize baseURL from runtime configuration
|
||||||
@@ -25,9 +74,46 @@ apiClient.interceptors.request.use(
|
|||||||
config.headers.Authorization = `Bearer ${token}`;
|
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:
|
// User Sync & Tenant Lifecycle Guard:
|
||||||
// All API calls require an authenticated and enabled user in the backend system.
|
// 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 (token && !isLifecycleRequest) {
|
||||||
// If user account is flagged as pending administrator activation, block downstream API calls
|
// If user account is flagged as pending administrator activation, block downstream API calls
|
||||||
if (isActivationPending()) {
|
if (isActivationPending()) {
|
||||||
@@ -48,9 +134,17 @@ apiClient.interceptors.request.use(
|
|||||||
(error) => Promise.reject(error)
|
(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(
|
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) => {
|
(error) => {
|
||||||
if (error?.response?.status === 401) {
|
if (error?.response?.status === 401) {
|
||||||
const requestUrl = error.config?.url;
|
const requestUrl = error.config?.url;
|
||||||
|
|||||||
@@ -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_SYSTEM_ROLES_KEY = 'pfm_cache_system_roles';
|
||||||
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_cache_role_claims_';
|
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_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
|
// In-memory runtime caches
|
||||||
let inMemorySystemRoles: Role[] | null = null;
|
let inMemorySystemRoles: Role[] | null = null;
|
||||||
const inMemoryRoleClaims = new Map<string, Claim[]>();
|
const inMemoryRoleClaims = new Map<string, Claim[]>();
|
||||||
let inMemorySystemClaims: Claim[] | null = null;
|
let inMemorySystemClaims: Claim[] | null = null;
|
||||||
|
const inMemoryUserRoles = new Map<string, { roles: Role[]; timestamp: number }>();
|
||||||
|
const inMemoryUserClaims = new Map<string, { claims: Claim[]; timestamp: number }>();
|
||||||
|
const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// Initial check on load
|
||||||
|
ensureFreshRbacCache();
|
||||||
|
|
||||||
// Standard role claim defaults matching PFM Identity Server specification
|
// Standard role claim defaults matching PFM Identity Server specification
|
||||||
export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
|
export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
|
||||||
contributor: [
|
contributor: [
|
||||||
{ claimId: 30, claimType: 'projects_milestones', claimValue: 'view, edit, update' },
|
{ claimId: 30, claimType: 'projects_milestones', claimValue: 'view, add' },
|
||||||
{ claimId: 31, claimType: 'grant_financials', claimValue: 'view, edit, update' },
|
{ claimId: 31, claimType: 'grant_financials', claimValue: 'view, add' },
|
||||||
{ claimId: 32, claimType: 'evidence_risks', claimValue: 'view, edit, update' },
|
{ claimId: 32, claimType: 'evidence_risks', claimValue: 'view, add' },
|
||||||
{ claimId: 33, claimType: 'beneficiaries', claimValue: 'view, edit, update' },
|
{ claimId: 33, claimType: 'beneficiaries', claimValue: 'view, add' },
|
||||||
{ claimId: 34, claimType: 'change_management', claimValue: 'view, edit, update' },
|
{ claimId: 34, claimType: 'change_management', claimValue: 'view, add' },
|
||||||
{ claimId: 35, claimType: 'site_visits', claimValue: 'view, edit, update' },
|
{ claimId: 35, claimType: 'site_visits', claimValue: 'view, add' },
|
||||||
{ claimId: 36, claimType: 'settings_invites', claimValue: 'view, edit, update' },
|
{ claimId: 36, claimType: 'settings_invites', claimValue: 'view, add' },
|
||||||
],
|
],
|
||||||
supervisor: [
|
supervisor: [
|
||||||
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, add, export' },
|
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, export' },
|
||||||
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, export' },
|
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, update, export' },
|
||||||
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, export' },
|
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, update, export' },
|
||||||
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, export' },
|
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, update, export' },
|
||||||
{ claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, export' },
|
{ claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, update, export' },
|
||||||
{ claimId: 25, claimType: 'change_management', claimValue: 'view, add, export' },
|
{ claimId: 25, claimType: 'change_management', claimValue: 'view, add, update, export' },
|
||||||
{ claimId: 26, claimType: 'site_visits', claimValue: 'view, add, export' },
|
{ claimId: 26, claimType: 'site_visits', claimValue: 'view, add, update, export' },
|
||||||
{ claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, export' },
|
{ claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, update, export' },
|
||||||
{ claimId: 28, claimType: 'settings_users', claimValue: 'view, add, export' },
|
{ claimId: 28, claimType: 'settings_users', claimValue: 'view, add, update, export' },
|
||||||
],
|
],
|
||||||
manager: [
|
manager: [
|
||||||
{ claimId: 10, claimType: 'dashboard', claimValue: 'all' },
|
{ 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.
|
* 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<Role[]> {
|
export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise<Role[]> {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = getCachedSystemRoles();
|
||||||
|
if (cached && cached.length > 0) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<Role[]>('/v1/api/users/system/roles');
|
const res = await apiClient.get<Role[]>('/v1/api/users/system/roles');
|
||||||
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
@@ -181,30 +218,69 @@ export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
|
|||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches role claims for a role from the API, caching them under role ID and role name.
|
* 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<Claim[]> {
|
export async function fetchAndCacheRoleClaims(
|
||||||
|
roleId: string,
|
||||||
|
roleName?: string,
|
||||||
|
forceRefresh = false
|
||||||
|
): Promise<Claim[]> {
|
||||||
const roleIdKey = roleId.toLowerCase().trim();
|
const roleIdKey = roleId.toLowerCase().trim();
|
||||||
const roleNameKey = (roleName || '').toLowerCase().trim();
|
const roleNameKey = (roleName || '').toLowerCase().trim();
|
||||||
|
|
||||||
try {
|
// 1. Check in-memory and localStorage cache first
|
||||||
const res = await apiClient.get<Claim[]>(`/v1/api/users/system/roles/${roleId}/claims`);
|
if (!forceRefresh) {
|
||||||
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
const cachedById = getCachedRoleClaims(roleIdKey);
|
||||||
const normalized = res.data.map(normalizeClaim);
|
if (cachedById && cachedById.length > 0) return cachedById;
|
||||||
setCachedRoleClaims(roleIdKey, normalized);
|
|
||||||
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
|
if (roleNameKey) {
|
||||||
return normalized;
|
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<Claim[]>(`/v1/api/users/system/roles/${effectiveGuid}/claims`);
|
||||||
|
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
const normalized = res.data.map(normalizeClaim);
|
||||||
|
setCachedRoleClaims(roleIdKey, normalized);
|
||||||
|
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
|
||||||
|
if (effectiveGuid.toLowerCase() !== roleIdKey) {
|
||||||
|
setCachedRoleClaims(effectiveGuid.toLowerCase(), normalized);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network or server error, proceed to cache / defaults fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check cache by role ID or name
|
||||||
const cachedById = getCachedRoleClaims(roleIdKey);
|
const cachedById = getCachedRoleClaims(roleIdKey);
|
||||||
if (cachedById && cachedById.length > 0) return cachedById;
|
if (cachedById && cachedById.length > 0) return cachedById;
|
||||||
|
|
||||||
// Check cache by role name
|
|
||||||
if (roleNameKey) {
|
if (roleNameKey) {
|
||||||
const cachedByName = getCachedRoleClaims(roleNameKey);
|
const cachedByName = getCachedRoleClaims(roleNameKey);
|
||||||
if (cachedByName && cachedByName.length > 0) return cachedByName;
|
if (cachedByName && cachedByName.length > 0) return cachedByName;
|
||||||
@@ -218,30 +294,118 @@ export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string)
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function clearCachedUserRbac(userId?: string): void {
|
||||||
* Direct API calls for user roles and user claims
|
if (userId) {
|
||||||
*/
|
const key = userId.toLowerCase().trim();
|
||||||
export async function getUserRolesFromApi(userId: string): Promise<Role[]> {
|
inMemoryUserRoles.delete(key);
|
||||||
try {
|
inMemoryUserClaims.delete(key);
|
||||||
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
|
if (typeof window !== 'undefined') {
|
||||||
return res.data || [];
|
try {
|
||||||
} catch {
|
localStorage.removeItem(`pfm_cache_user_roles_${key}`);
|
||||||
return [];
|
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<Claim[]> {
|
/**
|
||||||
|
* Direct API calls for user roles and user claims with session-level caching
|
||||||
|
*/
|
||||||
|
export async function getUserRolesFromApi(userId: string, forceRefresh = false): Promise<Role[]> {
|
||||||
|
const key = userId.toLowerCase().trim();
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = inMemoryUserRoles.get(key);
|
||||||
|
if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
|
||||||
|
return cached.roles;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`pfm_cache_user_roles_${key}`);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
inMemoryUserRoles.set(key, { roles: parsed, timestamp: Date.now() });
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
|
||||||
|
const roles = res.data || [];
|
||||||
|
if (roles.length > 0) {
|
||||||
|
inMemoryUserRoles.set(key, { roles, timestamp: Date.now() });
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`pfm_cache_user_roles_${key}`, JSON.stringify(roles));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roles;
|
||||||
|
} catch {
|
||||||
|
return inMemoryUserRoles.get(key)?.roles || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserClaimsFromApi(userId: string, forceRefresh = false): Promise<Claim[]> {
|
||||||
|
const key = userId.toLowerCase().trim();
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = inMemoryUserClaims.get(key);
|
||||||
|
if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
|
||||||
|
return cached.claims;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`pfm_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 {
|
try {
|
||||||
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
|
const res = await apiClient.get<Claim[]>(`/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 {
|
} catch {
|
||||||
return [];
|
return inMemoryUserClaims.get(key)?.claims || [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves user roles, role claims, and user claims (including tenant and tenant_id)
|
* 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(
|
export async function resolveUserRolesAndClaims(
|
||||||
userSub: string,
|
userSub: string,
|
||||||
@@ -254,27 +418,30 @@ export async function resolveUserRolesAndClaims(
|
|||||||
tenant?: string;
|
tenant?: string;
|
||||||
tenant_id?: number;
|
tenant_id?: number;
|
||||||
}> {
|
}> {
|
||||||
|
ensureFreshRbacCache();
|
||||||
const mergedClaims: Record<string, any> = { ...initialClaims };
|
const mergedClaims: Record<string, any> = { ...initialClaims };
|
||||||
const resolvedRoles: string[] = [];
|
const resolvedRoles: string[] = [];
|
||||||
|
let userRoleObjects: Role[] = [];
|
||||||
let primaryRole = currentProfile.role || (initialClaims.role as string) || '';
|
let primaryRole = currentProfile.role || (initialClaims.role as string) || '';
|
||||||
let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined;
|
let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined;
|
||||||
let tenant_id = currentProfile.tenant_id ?? (initialClaims.tenant_id ? Number(initialClaims.tenant_id) : 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)
|
// 1. Concurrently fetch user's roles and user's claims in parallel
|
||||||
if (userSub) {
|
const [rolesResult, claimsResult] = await Promise.allSettled([
|
||||||
try {
|
userSub ? getUserRolesFromApi(userSub) : Promise.resolve([]),
|
||||||
const userRoles = await getUserRolesFromApi(userSub);
|
userSub ? getUserClaimsFromApi(userSub) : Promise.resolve([]),
|
||||||
if (Array.isArray(userRoles) && userRoles.length > 0) {
|
]);
|
||||||
userRoles.forEach((r) => {
|
|
||||||
if (r.name && !resolvedRoles.includes(r.name)) {
|
if (rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value) && rolesResult.value.length > 0) {
|
||||||
resolvedRoles.push(r.name);
|
userRoleObjects = rolesResult.value;
|
||||||
}
|
rolesResult.value.forEach((r) => {
|
||||||
});
|
if (r.name && !resolvedRoles.includes(r.name)) {
|
||||||
if (resolvedRoles.length > 0) {
|
resolvedRoles.push(r.name);
|
||||||
primaryRole = resolvedRoles[0];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
});
|
||||||
|
if (resolvedRoles.length > 0) {
|
||||||
|
primaryRole = resolvedRoles[0];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to initial role if API returned empty
|
// Fallback to initial role if API returned empty
|
||||||
@@ -282,55 +449,57 @@ export async function resolveUserRolesAndClaims(
|
|||||||
resolvedRoles.push(primaryRole);
|
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[] = [];
|
const allRoleClaims: Claim[] = [];
|
||||||
for (const roleName of resolvedRoles) {
|
const roleClaimsPromises = resolvedRoles.map(async (roleName) => {
|
||||||
try {
|
const matchedRoleObj = userRoleObjects.find(
|
||||||
const roleClaims = await fetchAndCacheRoleClaims(roleName, roleName);
|
(r) => r.name && r.name.toLowerCase() === roleName.toLowerCase()
|
||||||
if (Array.isArray(roleClaims)) {
|
);
|
||||||
roleClaims.forEach((rc) => {
|
const roleIdToPass = matchedRoleObj?.id ? String(matchedRoleObj.id) : roleName;
|
||||||
const norm = normalizeClaim(rc);
|
return fetchAndCacheRoleClaims(roleIdToPass, roleName);
|
||||||
allRoleClaims.push(norm);
|
});
|
||||||
if (norm.claimType) {
|
|
||||||
mergedClaims[norm.claimType] = norm.claimValue || 'all';
|
const roleClaimsResults = await Promise.allSettled(roleClaimsPromises);
|
||||||
}
|
roleClaimsResults.forEach((res) => {
|
||||||
});
|
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
|
||||||
}
|
res.value.forEach((rc) => {
|
||||||
} catch {}
|
const norm = normalizeClaim(rc);
|
||||||
}
|
allRoleClaims.push(norm);
|
||||||
|
if (norm.claimType) {
|
||||||
|
mergedClaims[norm.claimType] = norm.claimValue || 'all';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (allRoleClaims.length > 0) {
|
if (allRoleClaims.length > 0) {
|
||||||
mergedClaims.role_claims = allRoleClaims;
|
mergedClaims.role_claims = allRoleClaims;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Retrieve user claims from endpoint (Requirement 3)
|
// 3. Process user claims
|
||||||
if (userSub) {
|
if (claimsResult.status === 'fulfilled' && Array.isArray(claimsResult.value) && claimsResult.value.length > 0) {
|
||||||
try {
|
claimsResult.value.forEach((c) => {
|
||||||
const userClaims = await getUserClaimsFromApi(userSub);
|
const norm = normalizeClaim(c);
|
||||||
if (Array.isArray(userClaims) && userClaims.length > 0) {
|
const type = (norm.claimType || '').toLowerCase().trim();
|
||||||
userClaims.forEach((c) => {
|
const val = norm.claimValue;
|
||||||
const norm = normalizeClaim(c);
|
if (type === 'tenant' && val) {
|
||||||
const type = (norm.claimType || '').toLowerCase().trim();
|
tenant = String(val);
|
||||||
const val = norm.claimValue;
|
mergedClaims.tenant = String(val);
|
||||||
if (type === 'tenant' && val) {
|
} else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
|
||||||
tenant = String(val);
|
const parsed = Number(val);
|
||||||
mergedClaims.tenant = String(val);
|
if (!isNaN(parsed) && isFinite(parsed)) {
|
||||||
} else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
|
tenant_id = parsed;
|
||||||
const parsed = Number(val);
|
mergedClaims.tenant_id = parsed;
|
||||||
if (!isNaN(parsed) && isFinite(parsed)) {
|
}
|
||||||
tenant_id = parsed;
|
|
||||||
mergedClaims.tenant_id = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (norm.claimType) {
|
|
||||||
mergedClaims[norm.claimType] = norm.claimValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mergedClaims.user_claims = userClaims.map(normalizeClaim);
|
|
||||||
}
|
}
|
||||||
} catch {}
|
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(() => {});
|
preloadAndCacheAllRoleClaims().catch(() => {});
|
||||||
|
|
||||||
return {
|
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<void> {
|
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
|
||||||
const roles = await fetchAndCacheSystemRoles();
|
const roles = await fetchAndCacheSystemRoles();
|
||||||
for (const role of roles) {
|
await Promise.allSettled(
|
||||||
await fetchAndCacheRoleClaims(role.id, role.name);
|
roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ export interface TenantLockoutResult {
|
|||||||
statusCode?: number;
|
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)
|
* 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(
|
export async function verifyTenantLockout(
|
||||||
tenantId?: number | string | null,
|
tenantId?: number | string | null,
|
||||||
tenantClaim?: string | null
|
tenantClaim?: string | null,
|
||||||
|
forceRefresh = false
|
||||||
): Promise<TenantLockoutResult> {
|
): Promise<TenantLockoutResult> {
|
||||||
const primaryId =
|
const primaryId =
|
||||||
tenantId !== undefined && tenantId !== null && tenantId !== ''
|
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 tenantRes: Tenant | null = null;
|
||||||
let lastError: any = null;
|
let lastError: any = null;
|
||||||
|
|
||||||
@@ -147,8 +170,14 @@ export async function verifyTenantLockout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Success: Response object returned and Status is Active
|
// Success: Response object returned and Status is Active
|
||||||
return {
|
const successResult: TenantLockoutResult = {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
tenant: tenantObj,
|
tenant: tenantObj,
|
||||||
};
|
};
|
||||||
|
cachedTenantLockout = {
|
||||||
|
key: cacheKey,
|
||||||
|
result: successResult,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
return successResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,22 @@ export interface UserSyncResult {
|
|||||||
|
|
||||||
let inFlightSyncPromise: Promise<UserSyncResult> | null = null;
|
let inFlightSyncPromise: Promise<UserSyncResult> | 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.
|
* Checks if the user is currently flagged in the browser as pending administrator activation / lockout.
|
||||||
*/
|
*/
|
||||||
export function isActivationPending(): boolean {
|
export function isActivationPending(): boolean {
|
||||||
|
if (inMemoryActivationPending !== null) {
|
||||||
|
return inMemoryActivationPending;
|
||||||
|
}
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -51,8 +61,13 @@ export function isActivationPending(): boolean {
|
|||||||
* Gets the reason/error message recorded when activation check was last pending.
|
* Gets the reason/error message recorded when activation check was last pending.
|
||||||
*/
|
*/
|
||||||
export function getActivationPendingReason(): string | null {
|
export function getActivationPendingReason(): string | null {
|
||||||
|
if (inMemoryActivationReason !== null) {
|
||||||
|
return inMemoryActivationReason;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
const reason = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
||||||
|
inMemoryActivationReason = reason;
|
||||||
|
return reason;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -62,6 +77,8 @@ export function getActivationPendingReason(): string | null {
|
|||||||
* Sets or clears the pending activation flag in browser persistent storage.
|
* Sets or clears the pending activation flag in browser persistent storage.
|
||||||
*/
|
*/
|
||||||
export function setActivationPending(pending: boolean, reason?: string): void {
|
export function setActivationPending(pending: boolean, reason?: string): void {
|
||||||
|
inMemoryActivationPending = pending;
|
||||||
|
inMemoryActivationReason = reason || null;
|
||||||
try {
|
try {
|
||||||
if (pending) {
|
if (pending) {
|
||||||
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
|
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
|
||||||
@@ -91,10 +108,15 @@ export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
if (!inMemorySyncRecord) {
|
||||||
if (!raw) return false;
|
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;
|
if (!record || record.status !== 'synced') return false;
|
||||||
|
|
||||||
// Check if the stored sync matches the current user sub (if specified)
|
// Check if the stored sync matches the current user sub (if specified)
|
||||||
@@ -134,6 +156,7 @@ export function saveUserSyncInBrowser(
|
|||||||
status: 'synced',
|
status: 'synced',
|
||||||
userData: userData || undefined,
|
userData: userData || undefined,
|
||||||
};
|
};
|
||||||
|
inMemorySyncRecord = record;
|
||||||
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
|
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[UserSync] Failed to write sync record to localStorage:', 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).
|
* Clears the user sync record from browser storage (used on logout or when activation fails).
|
||||||
*/
|
*/
|
||||||
export function clearUserSyncInBrowser(): void {
|
export function clearUserSyncInBrowser(): void {
|
||||||
|
inMemorySyncRecord = null;
|
||||||
|
inMemoryActivationPending = null;
|
||||||
|
inMemoryActivationReason = null;
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
|
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
|
||||||
@@ -155,6 +181,7 @@ export function clearUserSyncInBrowser(): void {
|
|||||||
* Clears only the successful sync record, leaving activation pending intact.
|
* Clears only the successful sync record, leaving activation pending intact.
|
||||||
*/
|
*/
|
||||||
export function clearSuccessfulSyncRecord(): void {
|
export function clearSuccessfulSyncRecord(): void {
|
||||||
|
inMemorySyncRecord = null;
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
+167
@@ -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=<newEpoch>
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
@@ -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.
|
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]
|
[Portfolios] ───> [Programmes] ───> [Projects] ───> [Awards] ───> [Contracts] ───> [Beneficiaries] ──> [Outcomes]
|
||||||
│ │ │
|
│ │ │
|
||||||
▼ │ ▼
|
▼ │ ▼
|
||||||
[Rules] ├─> [Milestones] ──> [Activities] ──> [Evidences]
|
[Rules] ├─> [Milestones] ──> [Activities] ──> [Evidences]
|
||||||
├─> [Disbursements]
|
├─> [Disbursements]
|
||||||
├─> [Indicators]
|
├─> [Indicators]
|
||||||
├─> [Risks] & [Issues]
|
├─> [Risks] & [Issues]
|
||||||
└─> [SiteVisits] & [ComplianceItems]
|
└─> [SiteVisits] & [ComplianceItems]
|
||||||
|
```
|
||||||
|
|
||||||
### **1. Portfolio & Programme Governance**
|
### **1. Portfolio & Programme Governance**
|
||||||
* **Portfolios**: Top-level containers for strategic investment themes.
|
* **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
|
## 🛠️ Tech Stack & Patterns
|
||||||
|
|
||||||
* **Backend Framework**: .NET 8 / 9 C# Web API
|
* **Backend Framework**: .NET 10.0 (C# 13) Web API / Minimal APIs
|
||||||
* **Database**: PostgreSQL
|
* **Frontend Portals**:
|
||||||
* **ORM**: Entity Framework Core (Fluent API Configuration)
|
* `PostFundManagement.ConsoleUI`: Internal Staff & Portfolio Administration Portal (React 19 + TypeScript + Vite + Tailwind CSS)
|
||||||
* **Architecture**: Clean Architecture / Domain-Driven Design (DDD) with CQRS (`MediatR`)
|
* `PostFundManagement.PartnerUI`: External Recipient & Grantee Portal (React 19 + TypeScript + Vite + WebGL OGL)
|
||||||
* **Utilities**: `Humanizer` (Automated entity-to-table pluralization)
|
* **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
|
## 📁 Solution Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
PostFundManagement/
|
||||||
├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces
|
├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces
|
||||||
├── PostFundManagement.Infrastructure/ # EF Core Configurations, DbContext, Migrations
|
├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL, Skoruba STS, Migrations
|
||||||
├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Logic
|
├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Validations
|
||||||
└── PostFundManagement.Api/ # REST Controllers, Middleware, Auth & Swagger
|
├── 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
|
## 🚀 Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
.NET 8.0 SDK or higher
|
* .NET 10.0 SDK or higher
|
||||||
PostgreSQL 14+
|
* Node.js 20+ & Yarn (`yarn.lock` authoritative)
|
||||||
dotnet-ef CLI Tool (dotnet tool install --global dotnet-ef)
|
* PostgreSQL 14+
|
||||||
|
|
||||||
## Setup & Execution
|
## Setup & Execution
|
||||||
|
|
||||||
## Clone Repository
|
### 1. Clone Repository:
|
||||||
|
|
||||||
1. Clone Repository:
|
|
||||||
|
|
||||||
```bash
|
```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
|
cd PostFundManagement
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Configure Database Connection:
|
### 2. Configure Database Connection:
|
||||||
Update appsettings.Development.json in the API project with your local PostgreSQL details:
|
Update `appsettings.Development.json` in the API project with your local PostgreSQL details:
|
||||||
|
|
||||||
```json
|
```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
|
```bash
|
||||||
dotnet ef database update --project src/PostFundManagement.Infrastructure
|
dotnet run --project PostFundManagement.Api
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Launch Application:
|
### 4. Launch ConsoleUI Frontend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project src/PostFundManagement.Api
|
cd PostFundManagement.ConsoleUI
|
||||||
|
yarn install
|
||||||
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|||||||
Reference in New Issue
Block a user