Finishline #142

Merged
khwezi merged 4 commits from finishline into main 2026-10-21 08:55:56 +02:00
49 changed files with 3237 additions and 630 deletions
+192
View File
@@ -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.
+1123
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Governance;
[ApiVersionTarget(1)]
public sealed class GetTenantUsersEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/governance/tenants/{tenantIdHash}/users", async (IGovernanceService service, string tenantIdHash,
CancellationToken cancellationToken = default) =>
await service.GetTenantUsersAsync(tenantIdHash, cancellationToken) switch
{
{ IsSuccess: true, Value: var tenantUsers } => Results.Ok(tenantUsers),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets users created and managed by one tenant")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetTenantUsersEndpoint).ToEndpointName("Retrieval"))
.MapToApiVersion(new ApiVersion(1))
.Produces<TenantUser[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Governance);
}
}
@@ -28,7 +28,6 @@ public class GetUsersEndpoint : IEndpoint
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Users)
.CacheOutput();
.WithTags(EndpointTags.Users);
}
}
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
@@ -95,4 +95,7 @@ public interface IGovernanceService
[Log]
Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default);
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
@@ -288,6 +288,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut
return Result.Ok(cached.FromJson<TenantOrganisation[]>());
}));
public async Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
await service.GetTenantUsersAsync(tenantIdHash, cancellationToken);
public async Task<Result<Tenant[]>> GetTenantsAsync(CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Tenant[]>>>)(async () =>
{
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
@@ -208,9 +208,9 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
linkedUser!.IdNumber = request.IdNumber.Clean();
}
var userUpdated = await context.SaveChangesAsync(cancellationToken) > 0;
await context.SaveChangesAsync(cancellationToken);
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess && userUpdated, UpdateError.Create("Failed to update organisation"));
return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess, UpdateError.Create("Failed to update organisation"));
}));
public async Task<Result> CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) =>
@@ -452,6 +452,26 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
return Result.Ok(tenantOrganisations?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<TenantUser[]>> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<TenantUser[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(tenantIdHash);
if (decodeResult.IsFailed)
return Result.Fail(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var tenantUsers = await context.TenantUsers.AsNoTracking()
.Include(i => i.User)
.Include(i => i.Tenant)
.Where(f => f.TenantId == decodeResult.Value)
.ToListAsync(cancellationToken);
return Result.Ok(tenantUsers?.Select(i => i.Map()).ToArray() ?? []);
}));
public async Task<Result<TenantOrganisation>> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<TenantOrganisation>>>)(async () =>
{
@@ -1,4 +1,4 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Governance;
@@ -37,7 +37,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
[Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string RegistrationNo,
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
[Required][MaxLength(LabelLength)] string IdNumber,
[MaxLength(LabelLength)] string? IdNumber,
[Required] Guid OrganisationUser,
[Required] OrganisationType Type,
[Required] ActivityStatus Status);
@@ -45,7 +45,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId,
public sealed record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name,
[Required][MaxLength(LabelLength)] string RegistrationNo,
[Required][EmailAddress][MaxLength(LabelLength)] string Email,
[Required][MaxLength(LabelLength)] string IdNumber,
[MaxLength(LabelLength)] string? IdNumber,
[Required] Guid OrganisationUser,
[Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
@@ -151,30 +151,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach
}));
public async Task<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<User[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetUsersAsync(pagination, request, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getusers-{pagination.Page}-{pagination.PageSize}-" +
$"{request.Email}-{request.Status}-{request.SortBy}-{request.SortDescending}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var usersResult = await service.GetUsersAsync(pagination, request, cancellationToken);
if (usersResult.IsFailed) return usersResult;
await cache.SetStringAsync(cacheKey, usersResult.Value.ToJson(),
cacheOptions, cancellationToken);
return usersResult;
}
return Result.Ok(cached.FromJson<User[]>());
}));
await service.GetUsersAsync(pagination, request, cancellationToken);
public async Task<Result> LogoutUserAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) =>
await service.LogoutUserAsync(targetUserId, userId, cancellationToken);
@@ -40,6 +40,8 @@
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"postcss": "^8.5.28",
+5 -5
View File
@@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider';
import { ClaimProtectedRoute, useRbac } from './auth/rbac';
import { ClaimProtectedRoute, TenantManagementProtectedRoute, useRbac } from './auth/rbac';
import { ToastProvider } from './components/ui/Toast';
import { AppLayout } from './components/layout/AppLayout';
@@ -151,17 +151,17 @@ export default function App() {
<Route
path="/tenants"
element={
<TenantAdminProtectedRoute>
<TenantManagementProtectedRoute>
<TenantManagementPage />
</TenantAdminProtectedRoute>
</TenantManagementProtectedRoute>
}
/>
<Route
path="/governance/tenants"
element={
<TenantAdminProtectedRoute>
<TenantManagementProtectedRoute>
<TenantManagementPage />
</TenantAdminProtectedRoute>
</TenantManagementProtectedRoute>
}
/>
<Route path="/settings" element={<SettingsPage />} />
@@ -581,62 +581,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} catch {}
}
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
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) {
if (storedTenantClaim) {
setTenantName(storedTenantClaim);
}
// Check if session is already marked pending activation or needs sync check
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
// Initial fast unblock: valid persisted session found, unblock UI immediately
setIsLoading(false);
try {
// Verify sync with browser cache priority (hotspot prevention)
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims)
(async () => {
try {
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
);
if (syncResult.isUnauthorized) {
// HTTP 401: Normal process of routing user to landing page
await logout();
return;
}
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
return;
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
} else if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
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);
}
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(
storedSub || '',
parsedClaims,
@@ -657,17 +660,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
setClaims({ ...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;
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
}
})();
return;
}
} catch (err: unknown) {
console.warn('OIDC initialization notice:', err);
+80 -5
View File
@@ -142,12 +142,14 @@ export function resolveClaimActionSet(
actions.add('add');
actions.add('update');
actions.add('export');
} else if (val === 'view') {
} else if (val === 'view' || val === 'read') {
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('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('add');
actions.add('update');
@@ -173,10 +175,13 @@ export function resolveClaimActionSet(
actions.add('update');
actions.add('export');
} else if (role === 'supervisor') {
// Supervisor performs operational review and verification/approval of milestones & evidence
actions.add('view');
actions.add('add');
actions.add('update');
actions.add('export');
} else if (role === 'contributor') {
// Contributor is strictly working-level execution: creating activities, submitting evidence, etc. (NO approve/update)
actions.add('view');
actions.add('add');
} else {
@@ -294,11 +299,36 @@ export function evaluateUserPermission(
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.
*/
export function useRbac() {
const { roles, claims } = useAuth();
const { roles, claims, user, sub } = useAuth();
const permissions = useMemo(() => {
const isUnrestricted =
@@ -315,6 +345,22 @@ export function useRbac() {
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
const isMaker = (createdBy?: string | null): boolean => {
return isRecordMaker(createdBy, user?.id, sub, user?.email);
};
const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => {
if (!canApprove(claim)) return false;
// 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 {
isUnrestricted,
canAccess,
@@ -322,8 +368,11 @@ export function useRbac() {
canAdd,
canApprove,
canExport,
isMaker,
canApproveRecord,
getApprovalRestrictionReason,
};
}, [roles, claims]);
}, [roles, claims, user, sub]);
return permissions;
}
@@ -354,3 +403,29 @@ export const ClaimProtectedRoute: React.FC<{
return React.createElement(React.Fragment, null, children);
};
/**
* Route protection for Tenant Management (Solution Owners [Tenant 1 + Admin] OR Tenant Self-Service users).
*/
export const TenantManagementProtectedRoute: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const { canManageTenants, isLoading } = useAuth();
const { canView, canAccess } = useRbac();
if (isLoading) {
return null;
}
const hasAccess =
canManageTenants ||
canView('tenant_management') ||
canView('tenant_self_service') ||
canAccess('tenant_self_service');
if (!hasAccess) {
return React.createElement(Navigate, { to: '/dashboard', replace: true });
}
return React.createElement(React.Fragment, null, children);
};
@@ -351,6 +351,7 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
</button>
<ExportButton
claim="governance"
title="Funding Instruments Registry"
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
@@ -347,6 +347,7 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
</button>
<ExportButton
claim="governance"
title="Programme Rules Registry"
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
@@ -138,7 +138,7 @@ export const AppLayout: React.FC = () => {
const visibleNavItems = navItems
.filter((item) => {
if (item.path === '/tenants') {
return canManageTenants || canView('tenant_management');
return canManageTenants || canView('tenant_management') || canView('tenant_self_service');
}
if (item.path === '/settings') {
return (
@@ -456,7 +456,7 @@ export const AppLayout: React.FC = () => {
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
<span className="text-slate-400">Tenant:</span>
{canManageTenants ? (
{canManageTenants || canView('tenant_management') || canView('tenant_self_service') ? (
<Link
to="/tenants"
className="bg-slate-900/80 hover:bg-slate-800/90 text-slate-200 hover:text-white px-2.5 py-1 rounded-lg border border-slate-800 hover:border-slate-700 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate transition group cursor-pointer"
@@ -44,6 +44,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort';
import { useRbac } from '../../auth/rbac';
import {
Contract,
Award,
@@ -73,6 +74,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
}) => {
const queryClient = useQueryClient();
const { success, error: toastError, info } = useToast();
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
// Component state
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
@@ -602,6 +604,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
</button>
<ExportButton
claim="grant_financials"
title="Contract Registry & Agreements"
subtitle="Authoritative repository of bilateral legal agreements and contract files"
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
@@ -609,17 +612,19 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
data={filteredContracts}
/>
<button
type="button"
onClick={() => {
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"
>
<Plus className="w-4 h-4" />
Create Contract
</button>
{canAdd('grant_financials') && (
<button
type="button"
onClick={() => {
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"
>
<Plus className="w-4 h-4" />
Create Contract
</button>
)}
</div>
</div>
@@ -1224,17 +1229,34 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<label className="block text-xs font-medium text-slate-200 mb-1">
Contract Lifecycle Status <span className="text-rose-400">*</span>
</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(Number(e.target.value))}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
>
{CONTRACT_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(() => {
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
const isSelf = isMaker(contractToUpdate?.createdBy);
return (
<select
value={newStatus}
onChange={(e) => setNewStatus(Number(e.target.value))}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 font-medium"
>
{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 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 { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
@@ -824,7 +824,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
</button>
)}
{canApprove('evidence_risks') && (
{canApproveRecord('evidence_risks', ev.createdBy) && (
<button
type="button"
onClick={() =>
@@ -847,6 +847,11 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
</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
type="button"
@@ -1022,7 +1027,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
</button>
)}
{canApprove('evidence_risks') && (
{canApproveRecord('evidence_risks', inspectEvidence.createdBy) && (
<button
type="button"
onClick={() =>
@@ -1045,6 +1050,12 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
</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>
@@ -25,6 +25,7 @@ import {
updateMilestone,
} from '../../services/api';
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
import { useRbac } from '../../auth/rbac';
import {
formatDate,
getApprovalStatusBadge,
@@ -55,6 +56,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
@@ -175,7 +177,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</div>
<div className="flex items-center gap-2">
{!isFormOpen && (
{!isFormOpen && canAdd('projects_milestones') && (
<button
type="button"
onClick={openCreateForm}
@@ -255,16 +257,33 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
{editingMilestone && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
<select
{...form.register('status')}
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"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(() => {
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
const isSelf = isMaker(editingMilestone.createdBy);
return (
<select
{...form.register('status')}
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"
>
{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>
)}
@@ -33,9 +33,9 @@ export function ExportButton<T>({
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
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 updatePosition = useCallback(() => {
@@ -82,7 +82,7 @@ export function ExportButton<T>({
columns,
data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
userEmail: user?.profile?.email || (claims as any)?.email || '',
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '',
});
};
@@ -96,7 +96,7 @@ export function ExportButton<T>({
columns,
data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
userEmail: user?.profile?.email || (claims as any)?.email || '',
userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '',
});
};
@@ -2,14 +2,15 @@ import React, { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react';
import { HelpTooltip } from './HelpTooltip';
interface ModalProps {
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
subtitle?: string;
helpText?: ReactNode;
children: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full' | string;
}
export const Modal: React.FC<ModalProps> = ({
@@ -20,6 +21,7 @@ export const Modal: React.FC<ModalProps> = ({
helpText,
children,
maxWidth = 'lg',
size,
}) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -43,13 +45,20 @@ export const Modal: React.FC<ModalProps> = ({
if (!isOpen) return null;
const maxWidthClass = {
const effectiveSize = (size || maxWidth) as string;
const maxWidthClassMap: Record<string, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
}[maxWidth];
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
full: 'max-w-full',
};
const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-150">
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { Eye, EyeOff, Copy, Check } from 'lucide-react';
import { useToast } from './Toast';
export interface ObfuscatedIdNumberProps {
idNumber?: string | null;
className?: string;
allowToggle?: boolean;
}
export function maskIdNumber(id: string): string {
const clean = id ? id.trim() : '';
if (clean.length <= 4) {
return '••••';
}
if (clean.length <= 8) {
return `${clean.slice(0, 2)}••••${clean.slice(-2)}`;
}
// SA ID (13 digits) or standard national IDs: keep first 6 (DOB), mask middle 5, show last 2
return `${clean.slice(0, 6)}•••••${clean.slice(-2)}`;
}
export function ObfuscatedIdNumber({
idNumber,
className = '',
allowToggle = true,
}: ObfuscatedIdNumberProps): React.ReactElement {
const [isRevealed, setIsRevealed] = useState<boolean>(false);
const [copied, setCopied] = useState<boolean>(false);
const { success } = useToast();
if (!idNumber) {
return <span className="text-slate-600 font-mono text-xs">-</span>;
}
const rawId: string = idNumber;
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(rawId);
setCopied(true);
success('Copied to Clipboard', 'ID Number copied.');
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard fallback
}
};
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
setIsRevealed((prev) => !prev);
};
const displayedText = isRevealed ? rawId : maskIdNumber(rawId);
return (
<div className={`inline-flex items-center gap-1.5 font-mono text-xs text-slate-300 ${className}`}>
<span className="tracking-wider select-all">{displayedText}</span>
{allowToggle && (
<button
type="button"
onClick={handleToggle}
title={isRevealed ? 'Hide ID Number' : 'Reveal ID Number'}
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
>
{isRevealed ? <EyeOff className="w-3.5 h-3.5 text-blue-400" /> : <Eye className="w-3.5 h-3.5" />}
</button>
)}
<button
type="button"
onClick={handleCopy}
title="Copy ID Number"
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
);
}
@@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast';
import { createUser, getSystemRoles, assignUserRole, getTenants } from '../../services/api';
import { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api';
import { useAuth } from '../../auth/AuthProvider';
import { useRbac } from '../../auth/rbac';
import { CreateUserRequest, Role, Tenant } from '../../types';
@@ -136,8 +136,22 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
await createUser(values.tenantId, values.idNumber.trim(), payload);
// If an initial role was chosen and we have a newly generated user,
// invalidate query cache to refresh directory
if (values.initialRoleId) {
try {
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
await queryClient.invalidateQueries({ queryKey: ['users'] });
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
const createdUser = freshUsers.find(
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
);
if (createdUser) {
await assignUserRole(createdUser.id, values.initialRoleId);
}
} catch {
// Graceful fallback
}
}
return values;
},
onSuccess: (values) => {
@@ -31,7 +31,9 @@ import {
updateUserStatus,
getSystemClaims,
} from '../../services/api';
import { clearCachedUserRbac } from '../../services/roleClaimsCache';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
import { decodeIdNumberHex } from '../../utils/hashids';
import { User, Role, Claim, ActivityStatus } from '../../types';
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
@@ -53,9 +55,22 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
const canManageRolesAndClaims = canApprove('settings_users') || canApprove('tenant_management') || isUnrestricted;
const canManageSessions = canApprove('settings_users') || canApprove('tenant_management') || hasTenantSelfHelpAccess || isUnrestricted;
const canChangeStatus = canAdd('settings_users') || canAdd('tenant_management') || canApprove('settings_users') || isUnrestricted;
const canManageRolesAndClaims =
canApprove('settings_users') ||
canApprove('tenant_management') ||
canApprove('tenant_self_service') ||
isUnrestricted;
const canManageSessions =
canApprove('settings_users') ||
canApprove('tenant_management') ||
hasTenantSelfHelpAccess ||
isUnrestricted;
const canChangeStatus =
canAdd('settings_users') ||
canAdd('tenant_management') ||
canAdd('tenant_self_service') ||
canApprove('settings_users') ||
isUnrestricted;
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
@@ -104,13 +119,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
// Role Mutations
const assignRoleMutation = useMutation({
mutationFn: (roleId: string) => {
const selectedRole = systemRoles.find((r) => r.id === roleId);
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1') && hasTenantSelfHelpAccess) {
const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find(
(r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId))
);
const roleName = selectedRole?.name || (selectedRole as any)?.Name || '';
if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) {
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.');
}
return assignUserRole(userId, roleId);
},
onSuccess: () => {
clearCachedUserRbac(userId);
success('Role Assigned', 'Role membership updated successfully.');
refetchUserRoles();
setSelectedRoleToAdd('');
@@ -121,6 +140,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
const removeRoleMutation = useMutation({
mutationFn: (roleId: string) => removeUserRole(userId, roleId),
onSuccess: () => {
clearCachedUserRbac(userId);
success('Role Removed', 'Role revoked from user.');
refetchUserRoles();
},
@@ -142,6 +162,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
});
},
onSuccess: () => {
clearCachedUserRbac(userId);
success('Claim Granted', 'User claim added successfully.');
refetchUserClaims();
setNewClaimType('');
@@ -153,6 +174,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
const deleteClaimMutation = useMutation({
mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId),
onSuccess: () => {
clearCachedUserRbac(userId);
success('Claim Revoked', 'Claim removed from user.');
refetchUserClaims();
},
@@ -191,11 +213,16 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
if (!user) return null;
const effectiveUser = freshUser || user;
const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
const assignedRoleIds = new Set(userRoles.map((r) => r.id));
const availableRoles = systemRoles.filter((r) => {
if (assignedRoleIds.has(r.id)) return false;
if (hasTenantSelfHelpAccess && (r.name.toLowerCase() === 'admin' || r.id === '1')) {
const decodedId = effectiveUser?.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
const assignedRoleIds = new Set(
(Array.isArray(userRoles) ? userRoles : []).map((r) => r?.id || (r as any)?.Id).filter(Boolean)
);
const availableRoles = (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => {
if (!r) return false;
const rId = r.id || (r as any).Id;
const rName = r.name || (r as any).Name || '';
if (rId && assignedRoleIds.has(rId)) return false;
if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) {
return false;
}
return true;
@@ -203,21 +230,28 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
const assignableClaims = React.useMemo(() => {
const claimMap = new Map<string, { claim: string; label: string }>();
systemClaims.forEach((c) => {
const type = c.claimType || c.type;
if (type) {
if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) {
if (Array.isArray(systemClaims)) {
systemClaims.forEach((c) => {
if (!c) return;
const type = c.claimType || c.type;
if (type && typeof type === 'string') {
if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) {
return;
}
claimMap.set(type, { claim: type, label: type });
}
});
}
if (Array.isArray(ROLE_CLAIM_DEFINITIONS)) {
ROLE_CLAIM_DEFINITIONS.forEach((def) => {
if (!def || !def.claim) return;
const claimStr = String(def.claim);
if (hasTenantSelfHelpAccess && (claimStr.toLowerCase() === 'unrestricted' || claimStr.toLowerCase() === 'admin')) {
return;
}
claimMap.set(type, { claim: type, label: type });
}
});
ROLE_CLAIM_DEFINITIONS.forEach((def) => {
if (hasTenantSelfHelpAccess && (def.claim === 'unrestricted' || (def.claim as string) === 'admin')) {
return;
}
claimMap.set(def.claim, { claim: def.claim, label: `${def.claim} (${def.label})` });
});
claimMap.set(claimStr, { claim: claimStr, label: `${claimStr} (${def.label || claimStr})` });
});
}
return Array.from(claimMap.values());
}, [systemClaims, hasTenantSelfHelpAccess]);
@@ -298,10 +332,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Id Number:</span>
<div className="flex items-center gap-2">
<span className="font-mono text-white text-xs">{effectiveUser.idNumber || '—'}</span>
<div className="flex items-center gap-2 mt-0.5">
<ObfuscatedIdNumber idNumber={effectiveUser.idNumber} />
{decodedId && decodedId !== effectiveUser.idNumber && (
<span className="text-[10px] text-blue-400 font-mono">(Decoded: {decodedId})</span>
<div className="flex items-center gap-1 text-[10px] text-blue-400 font-mono">
<span>(Decoded:</span>
<ObfuscatedIdNumber idNumber={decodedId} />
<span>)</span>
</div>
)}
</div>
</div>
@@ -320,10 +358,10 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</span>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${
getActivityStatusBadge(effectiveUser.status).className
(getActivityStatusBadge(effectiveUser.status) || {}).className || 'bg-slate-800 text-slate-300'
}`}
>
{getActivityStatusBadge(effectiveUser.status).label}
{(getActivityStatusBadge(effectiveUser.status) || {}).label || 'Active'}
</span>
</div>
<div className="flex items-center gap-2">
@@ -331,8 +369,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button
type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
disabled={statusMutation.isPending}
className="px-2.5 py-1 text-xs font-semibold text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 rounded-lg transition"
disabled={statusMutation.isPending || !canChangeStatus}
className="px-2.5 py-1 text-xs font-semibold text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 rounded-lg transition disabled:opacity-50 cursor-pointer"
>
Suspend User
</button>
@@ -340,8 +378,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button
type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Active)}
disabled={statusMutation.isPending}
className="px-2.5 py-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition"
disabled={statusMutation.isPending || !canChangeStatus}
className="px-2.5 py-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition disabled:opacity-50 cursor-pointer"
>
Activate User
</button>
@@ -360,8 +398,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<select
value={selectedRoleToAdd}
onChange={(e) => setSelectedRoleToAdd(e.target.value)}
disabled={loadingSystemRoles || availableRoles.length === 0}
className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
disabled={loadingSystemRoles || availableRoles.length === 0 || !canManageRolesAndClaims}
className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
>
<option value="">
{availableRoles.length === 0 ? 'All system roles assigned' : 'Select role to assign...'}
@@ -375,8 +413,8 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button
type="button"
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)}
disabled={!selectedRoleToAdd || assignRoleMutation.isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50"
disabled={!selectedRoleToAdd || assignRoleMutation.isPending || !canManageRolesAndClaims}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50 cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Assign Role</span>
@@ -387,29 +425,34 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
{loadingUserRoles ? (
<div className="p-4 text-center text-slate-500">Loading roles...</div>
) : userRoles.length === 0 ? (
) : (Array.isArray(userRoles) ? userRoles : []).length === 0 ? (
<div className="p-4 text-center text-slate-500">No roles assigned to this user.</div>
) : (
userRoles.map((role) => (
<div key={role.id} className="p-3 flex items-center justify-between">
<div>
<div className="text-white font-semibold flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-blue-400" />
<span>{role.name}</span>
(Array.isArray(userRoles) ? userRoles : []).map((role) => {
if (!role) return null;
const roleId = role.id || (role as any).Id || String(role);
const roleName = role.name || (role as any).Name || String(role);
return (
<div key={roleId} className="p-3 flex items-center justify-between">
<div>
<div className="text-white font-semibold flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-blue-400" />
<span>{roleName}</span>
</div>
<div className="text-[10px] text-slate-500 font-mono">Role ID: {roleId}</div>
</div>
<div className="text-[10px] text-slate-500 font-mono">Role ID: {role.id}</div>
<button
type="button"
onClick={() => removeRoleMutation.mutate(roleId)}
disabled={removeRoleMutation.isPending || !canManageRolesAndClaims}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition disabled:opacity-50 cursor-pointer"
title="Revoke Role"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<button
type="button"
onClick={() => removeRoleMutation.mutate(role.id)}
disabled={removeRoleMutation.isPending}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition"
title="Revoke Role"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
);
})
)}
</div>
</div>
@@ -492,7 +535,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button
type="button"
onClick={() => addClaimMutation.mutate()}
disabled={!newClaimType.trim() || !newClaimValue.trim() || addClaimMutation.isPending}
disabled={!newClaimType.trim() || !newClaimValue.trim() || addClaimMutation.isPending || !canManageRolesAndClaims}
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-semibold transition disabled:opacity-50 cursor-pointer text-xs"
>
<Plus className="w-3.5 h-3.5" />
@@ -505,34 +548,40 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
{loadingUserClaims ? (
<div className="p-4 text-center text-slate-500">Loading user claims...</div>
) : userClaims.length === 0 ? (
) : (Array.isArray(userClaims) ? userClaims : []).length === 0 ? (
<div className="p-4 text-center text-slate-500">No active claims found for user.</div>
) : (
userClaims.map((claim, idx) => (
<div key={idx} className="p-3 flex items-center justify-between">
<div>
<div className="text-white font-medium flex items-center gap-1.5">
<Key className="w-3.5 h-3.5 text-amber-400" />
<span>{claim.type || 'Custom Claim'}</span>
</div>
<div className="text-[11px] text-slate-300 font-mono mt-0.5">
Value: <span className="text-emerald-400">{claim.value}</span>
{claim.issuer && <span className="text-slate-500 ml-2">({claim.issuer})</span>}
(Array.isArray(userClaims) ? userClaims : []).map((claim, idx) => {
if (!claim) return null;
const claimType = claim.claimType || claim.type || 'Custom Claim';
const claimVal = claim.claimValue ?? claim.value ?? '';
const claimId = claim.claimId || (claim as any).id || claimType || String(idx);
return (
<div key={idx} className="p-3 flex items-center justify-between">
<div>
<div className="text-white font-medium flex items-center gap-1.5">
<Key className="w-3.5 h-3.5 text-amber-400" />
<span>{claimType}</span>
</div>
<div className="text-[11px] text-slate-300 font-mono mt-0.5">
Value: <span className="text-emerald-400">{claimVal}</span>
{claim.issuer && <span className="text-slate-500 ml-2">({claim.issuer})</span>}
</div>
</div>
{claimType && (
<button
type="button"
onClick={() => deleteClaimMutation.mutate(claimId)}
disabled={deleteClaimMutation.isPending || !canManageRolesAndClaims}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition disabled:opacity-50 cursor-pointer"
title="Delete Claim"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{claim.type && (
<button
type="button"
onClick={() => deleteClaimMutation.mutate(claim.type || String(idx))}
disabled={deleteClaimMutation.isPending}
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition"
title="Delete Claim"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))
);
})
)}
</div>
</div>
@@ -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({
defaultOptions: {
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,
refetchOnReconnect: true,
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 = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
@@ -1024,16 +1024,25 @@ export const BeneficiariesPage: React.FC = () => {
{...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"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries')}
>
{opt.label}
{opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries') ? ' (Requires Update Permission)' : ''}
</option>
))}
{(() => {
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
const isSelf = isMaker(editingComp?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveComp;
let reason = '';
if (isApprovedOpt && !canApprove('beneficiaries')) {
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>
)}
@@ -110,7 +110,7 @@ type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
export const ChangeManagementPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
@@ -1022,7 +1022,7 @@ export const ChangeManagementPage: React.FC = () => {
</span>
) : (
<>
{canApprove('change_management') && (
{canApproveRecord('change_management', cr.createdBy) && (
<button
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"
@@ -1030,6 +1030,11 @@ export const ChangeManagementPage: React.FC = () => {
<Check className="w-3 h-3" /> Approve
</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') && (
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
@@ -1522,9 +1527,21 @@ export const ChangeManagementPage: React.FC = () => {
>
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Review}>Under Active Review</option>
<option value={ApprovalStatus.Approved} disabled={!canApprove('change_management')}>
Approved &amp; Enacted {!canApprove('change_management') ? '(Requires Update Permission)' : ''}
</option>
{(() => {
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
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 &amp; Enacted {reason}
</option>
);
})()}
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
</div>
@@ -21,6 +21,11 @@ import {
FileText,
Scale,
Calendar,
UserPlus,
ChevronDown,
ChevronUp,
CheckCircle2,
Loader2,
} from 'lucide-react';
import {
getOrganisations,
@@ -50,6 +55,10 @@ import {
getProgrammeValue,
getUsers,
getUser,
createUser,
assignUserRole,
getSystemRoles,
getCurrentUserTenantId,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -72,6 +81,8 @@ import {
OrganisationType,
ActivityStatus,
ApprovalStatus,
Role,
CreateUserRequest,
CreateOrganisationRequest,
CreatePortfolioRequest,
CreateProgrammeRequest,
@@ -96,8 +107,7 @@ const organisationSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
registrationNo: z.string().min(4, 'Registration number is mandatory'),
email: z.string().email('Enter a valid email address'),
idNumber: z.string().min(4, 'Government issued ID number is required'),
organisationUser: z.string().min(1, 'Please select a linked user'),
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
type: z.coerce.number().min(1).max(27),
status: z.coerce.number().optional(),
});
@@ -123,7 +133,7 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user, claims } = useAuth();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
@@ -232,6 +242,107 @@ export const GovernancePage: React.FC = () => {
staleTime: 5 * 60 * 1000,
});
const { data: systemRoles = [] } = useQuery<Role[]>({
queryKey: ['system-roles'],
queryFn: getSystemRoles,
enabled: isOrgModalOpen,
});
const assignableRolesForOrg = React.useMemo(() => {
return systemRoles.filter(
(r) => r.name.toLowerCase() !== 'admin' && r.id !== '1'
);
}, [systemRoles]);
const executiveAndManagerUsers = React.useMemo(() => {
return usersDirectory.filter((u) => {
const role = String(u.role || '').toLowerCase();
return role === 'executive' || role === 'manager';
});
}, [usersDirectory]);
// Quick Add Executive User Subform state
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
const [quickUserEmail, setQuickUserEmail] = useState('');
const [quickUserName, setQuickUserName] = useState('');
const [quickUserPhone, setQuickUserPhone] = useState('');
const [quickUserIdNumber, setQuickUserIdNumber] = useState('');
const [quickUserRoleId, setQuickUserRoleId] = useState('');
const [isSubmittingQuickUser, setIsSubmittingQuickUser] = useState(false);
const [quickUserError, setQuickUserError] = useState<string | null>(null);
React.useEffect(() => {
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive');
if (execRole) setQuickUserRoleId(execRole.id);
else setQuickUserRoleId(assignableRolesForOrg[0].id);
}
}, [assignableRolesForOrg, quickUserRoleId]);
const handleQuickAddUser = async () => {
setQuickUserError(null);
if (!quickUserEmail || !quickUserEmail.includes('@')) {
setQuickUserError('Please provide a valid email address.');
return;
}
if (!quickUserIdNumber || quickUserIdNumber.trim().length < 4) {
setQuickUserError('Government issued ID number is required (min 4 characters).');
return;
}
setIsSubmittingQuickUser(true);
try {
const tenantId = getCurrentUserTenantId() || 1;
const payload: CreateUserRequest = {
email: quickUserEmail.trim().toLowerCase(),
userName: quickUserName.trim() || quickUserEmail.split('@')[0],
emailConfirmed: true,
phoneNumber: quickUserPhone.trim() || null,
phoneNumberConfirmed: false,
LockoutEnabled: false,
twoFactorEnabled: false,
accessFailedCount: 0,
};
await createUser(tenantId, quickUserIdNumber.trim(), payload);
// Invalidate queries and fetch fresh user list immediately
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
await queryClient.invalidateQueries({ queryKey: ['users'] });
const freshUsers = await getUsers(1, 200);
const createdUser = freshUsers.find(
(u) => u.email && u.email.toLowerCase() === quickUserEmail.trim().toLowerCase()
);
if (createdUser && quickUserRoleId) {
try {
await assignUserRole(createdUser.id, quickUserRoleId);
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
await queryClient.invalidateQueries({ queryKey: ['users'] });
} catch {
// Handled gracefully
}
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
} else if (createdUser) {
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
}
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
setQuickUserEmail('');
setQuickUserName('');
setQuickUserPhone('');
setQuickUserIdNumber('');
setIsQuickAddUserOpen(false);
} catch (err: any) {
const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.';
setQuickUserError(errorDetail);
toastError('Failed to create user', err);
} finally {
setIsSubmittingQuickUser(false);
}
};
const {
data: organisations = [],
isLoading: loadingOrgs,
@@ -427,7 +538,6 @@ export const GovernancePage: React.FC = () => {
name: '',
registrationNo: '',
email: '',
idNumber: '',
organisationUser: '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
@@ -463,7 +573,6 @@ export const GovernancePage: React.FC = () => {
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
idNumber: data.idNumber,
organisationUser: data.organisationUser,
type: Number(data.type),
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
@@ -473,7 +582,6 @@ export const GovernancePage: React.FC = () => {
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
idNumber: data.idNumber,
organisationUser: data.organisationUser,
type: Number(data.type),
});
@@ -553,24 +661,23 @@ export const GovernancePage: React.FC = () => {
const openCreateOrg = () => {
setEditingOrg(null);
const defaultUserId = usersDirectory[0]?.id || sub || currentUserId || '';
const defaultUser = usersDirectory.find((u) => u.id === defaultUserId);
const defaultUser = executiveAndManagerUsers[0];
orgForm.reset({
name: '',
registrationNo: '',
email: '',
idNumber: defaultUser?.idNumber || '',
organisationUser: defaultUserId,
organisationUser: defaultUser?.id || '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
});
setIsQuickAddUserOpen(false);
setQuickUserError(null);
setIsOrgModalOpen(true);
};
const openEditOrg = (org: Organisation) => {
setEditingOrg(org);
// 1. Resolve target linked user ID synchronously from the organisation model
const targetUserId =
org.userId ||
(org as any).UserId ||
@@ -579,46 +686,24 @@ export const GovernancePage: React.FC = () => {
org.createdBy ||
'';
// 2. Fast O(1) in-memory match from cached users directory
const matchedUser = targetUserId
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
: null;
// 3. Resolve idNumber: prefer matchedUser's idNumber, or org's idNumber if any, otherwise blank ''
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || (org as any).IdNumber || '';
const selectedUserId = matchedUser?.id || targetUserId || '';
// 4. Perform assignment immediately to form state
orgForm.reset({
name: org.name || '',
registrationNo: org.registrationNo || '',
email: org.email || '',
idNumber: resolvedIdNumber,
organisationUser: selectedUserId,
type: org.type || OrganisationType.Enterprise,
status: org.status ?? ActivityStatus.Active,
});
// 5. Instantly open modal with fully populated data (no lag, no progressive pop-in)
setIsQuickAddUserOpen(false);
setQuickUserError(null);
setIsOrgModalOpen(true);
// 6. Non-blocking background sync if target user wasn't in cache or org has missing data
if (targetUserId && !matchedUser) {
getUser(targetUserId).then((fetchedUser) => {
if (fetchedUser) {
queryClient.setQueryData<User[]>(['users-directory'], (old = []) => {
if (old.some((u) => u.id === fetchedUser.id)) return old;
return [fetchedUser, ...old];
});
if (fetchedUser.idNumber && !orgForm.getValues('idNumber')) {
orgForm.setValue('idNumber', fetchedUser.idNumber, { shouldValidate: true });
}
if (!orgForm.getValues('organisationUser')) {
orgForm.setValue('organisationUser', fetchedUser.id, { shouldValidate: true });
}
}
}).catch(() => {});
}
};
const openCreatePortfolio = () => {
@@ -1477,66 +1562,6 @@ export const GovernancePage: React.FC = () => {
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Linked User Account *
</label>
<HelpTooltip
title="Designated Organisation User"
content="Select the designated user account associated with this organisation profile."
size="sm"
placement="top"
/>
</div>
<select
{...orgForm.register('organisationUser', {
onChange: (e) => {
const selectedId = e.target.value;
const selected = usersDirectory.find((u) => u.id === selectedId);
// Populate selected user's ID number or blank '' if deselected or missing
orgForm.setValue('idNumber', selected?.idNumber || '', { shouldValidate: true });
},
})}
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"
>
<option value="">-- Select linked user account --</option>
{usersDirectory.map((u) => (
<option key={u.id} value={u.id}>
{u.email || u.id} {u.role ? `(${u.role})` : ''} {u.idNumber ? `• ID: ${u.idNumber}` : ''}
</option>
))}
</select>
{orgForm.formState.errors.organisationUser && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.organisationUser.message}</p>
)}
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Government Issued ID Number *
</label>
<HelpTooltip
title="Government Issued ID"
content="National identification document number or statutory passport ID of the linked user."
size="sm"
placement="top"
/>
</div>
<input
type="text"
{...orgForm.register('idNumber')}
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 font-mono text-white bg-[#090d16]"
placeholder="e.g. 8401015800085 or Passport Number"
/>
{orgForm.formState.errors.idNumber && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1">
@@ -1561,6 +1586,164 @@ export const GovernancePage: React.FC = () => {
)}
</div>
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300">
Linked Executive / Manager User *
</label>
<HelpTooltip
title="Designated Organisation User"
content="Only users with an Executive or Manager role can be assigned to an organisation."
size="sm"
placement="top"
/>
</div>
{!editingOrg && (
<button
type="button"
onClick={() => {
setIsQuickAddUserOpen((prev) => !prev);
setQuickUserError(null);
}}
className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 cursor-pointer transition-colors"
>
<UserPlus className="w-3.5 h-3.5" />
{isQuickAddUserOpen ? 'Cancel' : '+ New Executive'}
{isQuickAddUserOpen ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
)}
</div>
<select
{...orgForm.register('organisationUser')}
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"
>
<option value="">-- Select linked executive or manager account --</option>
{executiveAndManagerUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.email || u.id} ({u.role || 'Executive'})
</option>
))}
</select>
{orgForm.formState.errors.organisationUser && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.organisationUser.message}</p>
)}
{executiveAndManagerUsers.length === 0 && !isQuickAddUserOpen && (
<p className="text-[11px] text-amber-400 mt-1">
No Executive or Manager user found. Create one below to continue.
</p>
)}
</div>
</div>
{/* Inline Collapsible Subform to Create Executive User */}
{isQuickAddUserOpen && !editingOrg && (
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
<div className="flex items-center justify-between border-b border-blue-500/20 pb-2">
<span className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
<UserPlus className="w-4 h-4 text-blue-400" />
Quick Create Executive / Manager User
</span>
<span className="text-[10px] text-slate-400">Mapped to tenant automatically</span>
</div>
{quickUserError && (
<div className="p-2 rounded bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[11px]">
{quickUserError}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Email Address *</label>
<input
type="email"
value={quickUserEmail}
onChange={(e) => setQuickUserEmail(e.target.value)}
placeholder="executive@organisation.org.za"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Full / User Name</label>
<input
type="text"
value={quickUserName}
onChange={(e) => setQuickUserName(e.target.value)}
placeholder="e.g. Sipho Ndlovu"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Phone Number</label>
<input
type="text"
value={quickUserPhone}
onChange={(e) => setQuickUserPhone(e.target.value)}
placeholder="+27 82 123 4567"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Government Issued ID *</label>
<input
type="text"
value={quickUserIdNumber}
onChange={(e) => setQuickUserIdNumber(e.target.value)}
placeholder="13-digit ID or Passport"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white font-mono focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-[11px] font-medium text-slate-300 mb-1">System Role *</label>
<select
value={quickUserRoleId}
onChange={(e) => setQuickUserRoleId(e.target.value)}
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{assignableRolesForOrg.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setIsQuickAddUserOpen(false)}
className="px-2.5 py-1 text-xs text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={handleQuickAddUser}
disabled={isSubmittingQuickUser}
className="px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors cursor-pointer flex items-center gap-1.5 disabled:opacity-50"
>
{isSubmittingQuickUser ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Creating...
</>
) : (
<>
<CheckCircle2 className="w-3.5 h-3.5" />
Create & Select User
</>
)}
</button>
</div>
</div>
)}
{editingOrg && (
<div>
<div className="flex items-center gap-1 mb-1">
@@ -1586,7 +1769,6 @@ export const GovernancePage: React.FC = () => {
</select>
</div>
)}
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
@@ -1800,16 +1982,33 @@ export const GovernancePage: React.FC = () => {
placement="top"
/>
</div>
<select
{...programmeForm.register('status')}
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"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(() => {
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
return (
<select
{...programmeForm.register('status')}
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"
>
{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>
@@ -99,7 +99,7 @@ type DisbursementFormValues = z.infer<typeof disbursementSchema>;
export const GrantsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
@@ -1002,7 +1002,7 @@ export const GrantsPage: React.FC = () => {
<Edit2 className="w-3 h-3" />
<span>Update</span>
</button>
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
{b.status !== ApprovalStatus.Approved && canApproveRecord('grant_financials', b.createdBy) && (
<button
type="button"
disabled={updateBudgetMutation.isPending}
@@ -1019,6 +1019,11 @@ export const GrantsPage: React.FC = () => {
<span>Approve</span>
</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>
</td>
</tr>
@@ -1159,7 +1164,7 @@ export const GrantsPage: React.FC = () => {
>
<Edit2 className="w-3.5 h-3.5" />
</button>
{canApprove('grant_financials') && (
{canApproveRecord('grant_financials', d.createdBy) && (
<button
onClick={() => approveMutation.mutate(d.id)}
disabled={approveMutation.isPending}
@@ -1169,6 +1174,11 @@ export const GrantsPage: React.FC = () => {
Approve Payment
</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>
@@ -1562,9 +1572,21 @@ export const GrantsPage: React.FC = () => {
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value={ApprovalStatus.Approved} disabled={!canApprove('grant_financials')}>
Approved {!canApprove('grant_financials') ? '(Requires Update Permission)' : ''}
</option>
{(() => {
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
const isSelf = isMaker(editingBudget?.createdBy);
let reason = '';
if (!canApprove('grant_financials')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
Approved {reason}
</option>
);
})()}
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
</select>
@@ -43,6 +43,7 @@ import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
@@ -100,6 +101,7 @@ export const WORKFLOW_STAGES = [
export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canExport } = useRbac();
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
@@ -435,7 +437,7 @@ export const MonitoringPage: React.FC = () => {
</div>
<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
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"
@@ -444,7 +446,7 @@ export const MonitoringPage: React.FC = () => {
<span>Create Contract &amp; Upload PDF</span>
</button>
)}
{activeTab === 'indicators' && (
{activeTab === 'indicators' && canAdd('evidence_risks') && (
<button
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"
@@ -453,7 +455,7 @@ export const MonitoringPage: React.FC = () => {
<span>Define Indicator</span>
</button>
)}
{activeTab === 'risks' && (
{activeTab === 'risks' && canAdd('evidence_risks') && (
<button
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"
@@ -660,6 +662,7 @@ export const MonitoringPage: React.FC = () => {
</button>
<ExportButton
claim="evidence_risks"
title="M&E Performance Indicators"
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
@@ -861,6 +864,7 @@ export const MonitoringPage: React.FC = () => {
</button>
<ExportButton
claim="evidence_risks"
title="Project Risk Register & Heatmap"
subtitle="Itemized audit log of identified risks and mitigation controls"
filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`}
@@ -150,7 +150,7 @@ export const evaluateProjectScheduleRisk = (
export const ProjectsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
// Filter & Search States
const [searchTerm, setSearchTerm] = useState('');
@@ -954,22 +954,41 @@ export const ProjectsPage: React.FC = () => {
{/* Governance Status */}
<td className="py-4 px-4" onClick={(e) => e.stopPropagation()}>
<div className="space-y-1">
<select
value={p.status}
onChange={(e) =>
statusMutation.mutate({
projectId: p.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
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"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(() => {
const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
const isSelf = isMaker(p.createdBy);
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
return (
<select
value={p.status}
disabled={!canModifyStatus}
onChange={(e) =>
statusMutation.mutate({
projectId: p.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
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>
</td>
@@ -35,11 +35,13 @@ import {
Hash,
ChevronDown,
ChevronRight,
ChevronLeft,
User as UserIcon,
RotateCcw,
} from 'lucide-react';
import { CreateUserModal } from '../components/users/CreateUserModal';
import { UserSecurityModal } from '../components/users/UserSecurityModal';
import { ObfuscatedIdNumber } from '../components/ui/ObfuscatedIdNumber';
import { useRbac } from '../auth/rbac';
import {
getUsers,
@@ -127,6 +129,13 @@ export const SettingsPage: React.FC = () => {
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
const [userPage, setUserPage] = useState(1);
const [userPageSize, setUserPageSize] = useState(10);
React.useEffect(() => {
setUserPage(1);
}, [userEmailSearch, userStatusFilter, userRoleFilter]);
const userFilter: UserFilterRequest = {
email: userEmailSearch.trim() || undefined,
status: userStatusFilter !== 'all' ? userStatusFilter : undefined,
@@ -145,7 +154,7 @@ export const SettingsPage: React.FC = () => {
refetch: refetchUsers,
} = useQuery({
queryKey: ['users', userStatusFilter, userEmailSearch],
queryFn: () => getUsers(1, 100, userFilter),
queryFn: () => getUsers(1, 1000, userFilter),
});
const filteredUsers = useMemo(() => {
@@ -489,6 +498,13 @@ export const SettingsPage: React.FC = () => {
role: (u: User) => getUserRoleBadge(u.role).label,
});
const totalUsers = sortedUsers.length;
const totalPages = Math.max(1, Math.ceil(totalUsers / userPageSize));
const paginatedUsers = React.useMemo(() => {
const start = (userPage - 1) * userPageSize;
return sortedUsers.slice(start, start + userPageSize);
}, [sortedUsers, userPage, userPageSize]);
const {
sortedData: sortedInvites,
sortColumn: inviteSortCol,
@@ -842,7 +858,7 @@ export const SettingsPage: React.FC = () => {
</td>
</tr>
) : (
sortedUsers.map((u) => {
paginatedUsers.map((u) => {
const badge = getActivityStatusBadge(u.status);
return (
<tr key={u.id} className="hover:bg-slate-800/40 transition-colors">
@@ -853,7 +869,7 @@ export const SettingsPage: React.FC = () => {
{u.id}
</td>
<td className="py-3.5 px-4 font-mono text-slate-300 text-[11px]">
{u.idNumber || <span className="text-slate-600">—</span>}
<ObfuscatedIdNumber idNumber={u.idNumber} />
</td>
<td className="py-3.5 px-4">
<UserRoleBadge role={u.role} />
@@ -915,6 +931,64 @@ export const SettingsPage: React.FC = () => {
</tbody>
</table>
</div>
{/* Pagination Controls */}
{totalUsers > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3 border-t border-slate-800/80 bg-slate-900/40 text-xs text-slate-400">
<div className="flex items-center gap-2">
<span>
Showing <strong className="text-white font-mono">{Math.min((userPage - 1) * userPageSize + 1, totalUsers)}</strong> to{' '}
<strong className="text-white font-mono">{Math.min(userPage * userPageSize, totalUsers)}</strong> of{' '}
<strong className="text-white font-mono">{totalUsers}</strong> users
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-slate-400">Per page:</span>
<select
value={userPageSize}
onChange={(e) => {
setUserPageSize(Number(e.target.value));
setUserPage(1);
}}
className="bg-slate-900 border border-slate-700/80 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setUserPage((p) => Math.max(1, p - 1))}
disabled={userPage <= 1}
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Previous page"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-2 font-mono text-slate-300">
{userPage} / {totalPages}
</span>
<button
type="button"
onClick={() => setUserPage((p) => Math.min(totalPages, p + 1))}
disabled={userPage >= totalPages}
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Next page"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
</div>
)}
@@ -54,7 +54,7 @@ type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
export const SiteVisitsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
@@ -509,16 +509,25 @@ export const SiteVisitsPage: React.FC = () => {
{...visitForm.register('status')}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={opt.value === ApprovalStatus.Approved && !canApprove('site_visits')}
>
{opt.label}
{opt.value === ApprovalStatus.Approved && !canApprove('site_visits') ? ' (Requires Update Permission)' : ''}
</option>
))}
{(() => {
const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
const isSelf = isMaker(editingVisit?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveVisit;
let reason = '';
if (isApprovedOpt && !canApprove('site_visits')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
});
})()}
</select>
</div>
)}
@@ -26,8 +26,7 @@ import {
createTenant,
updateTenant,
getTenantOrganisations,
getOrganisations,
getUsers,
getTenantUsers,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
@@ -36,7 +35,8 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
import { ObfuscatedIdNumber } from '../components/ui/ObfuscatedIdNumber';
import { Tenant, Organisation, TenantOrganisation, TenantUser, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
import { formatDate, getOrganisationTypeLabel, getUserEmail, registerUserEmails } from '../lib/formatters';
import { CreateUserModal } from '../components/users/CreateUserModal';
import { UserSecurityModal } from '../components/users/UserSecurityModal';
@@ -71,27 +71,21 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
staleTime: 30000,
});
// Fetch all organisations directory (from governance cache) to resolve complete organisation details
const { data: allOrganisations = [], isLoading: loadingAllOrgs } = useQuery<Organisation[]>({
queryKey: ['organisations'],
queryFn: () => getOrganisations(),
staleTime: 60000,
});
// Fetch user directory to resolve linked user email per organisation
const { data: usersDirectory = [] } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
// Fetch tenant users directory to resolve linked user email per organisation
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId),
staleTime: 5 * 60 * 1000,
});
React.useEffect(() => {
if (usersDirectory && usersDirectory.length > 0) {
registerUserEmails(usersDirectory);
if (tenantUsers && tenantUsers.length > 0) {
const users = tenantUsers.map((tu) => tu.user).filter((u): u is User => !!u);
registerUserEmails(users);
}
}, [usersDirectory]);
}, [tenantUsers]);
const isLoading = loadingTenantOrgs || (loadingAllOrgs && tenantOrganisations.length > 0 && allOrganisations.length === 0);
const isLoading = loadingTenantOrgs;
return (
<div className="py-4 px-6 border-l-4 border-blue-500 bg-linear-to-r from-blue-950/20 via-slate-900/40 to-slate-950/60 space-y-3">
@@ -171,26 +165,16 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
</thead>
<tbody className="divide-y divide-slate-800/50">
{tenantOrganisations.map((tOrg) => {
const orgId = tOrg.organisationId || tOrg.organisation?.id || tOrg.id;
// Use the organisationId to fetch/match the organisation detail
const detailedOrg = allOrganisations.find(
(o) => String(o.id) === String(orgId) || String(o.id) === String(tOrg.organisationId)
);
const org = detailedOrg || tOrg.organisation;
const orgName = org?.name || tOrg.organisation?.name || 'Unnamed Organisation';
const regNo = org?.registrationNo || tOrg.organisation?.registrationNo || '—';
const orgType = org?.type ?? tOrg.organisation?.type;
const orgEmail = org?.email || tOrg.organisation?.email || '—';
const linkedUserId =
org?.userId ||
(org as any)?.UserId ||
(org as any)?.organisationUser ||
(org as any)?.OrganisationUser ||
(tOrg.organisation as any)?.userId ||
(tOrg as any)?.userId;
const org = tOrg.organisation;
const orgId = org?.id || tOrg.organisationId || tOrg.id;
const orgName = org?.name || 'Unnamed Organisation';
const regNo = org?.registrationNo || '—';
const orgType = org?.type ?? tOrg.status;
const orgEmail = org?.email || '—';
const linkedUserId = org?.userId;
const matchedUser = linkedUserId
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(linkedUserId).toLowerCase())
? tenantUsers.find((tu) => tu.user?.id && String(tu.user.id).toLowerCase() === String(linkedUserId).toLowerCase())?.user
: null;
const userEmailDisplay =
@@ -199,8 +183,8 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
(linkedUserId && String(linkedUserId).includes('@') ? String(linkedUserId) : null) ||
org?.email ||
'—';
const status = org?.status ?? tOrg.organisation?.status ?? tOrg.status;
const createdAt = org?.createdAt || tOrg.createdAt || tOrg.organisation?.createdAt;
const status = org?.status ?? tOrg.status;
const createdAt = org?.createdAt || tOrg.createdAt;
return (
<tr key={String(tOrg.id) + '-' + String(orgId)} className="hover:bg-slate-800/30 transition">
@@ -274,21 +258,12 @@ const TenantUsersSubList: React.FC<{
onProvisionUser: (tenantId: number | string) => void;
onManageUser: (user: User) => void;
}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => {
const { data: usersDirectory = [], isLoading, refetch } = useQuery<User[]>({
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId],
queryFn: () => getUsers(1, 200),
queryFn: () => getTenantUsers(tenantId),
staleTime: 30000,
});
const tenantUsers = useMemo(() => {
return usersDirectory.filter((u) => {
if (u.tenant_id !== undefined && u.tenant_id !== null) {
return String(u.tenant_id) === String(tenantId);
}
return true;
});
}, [usersDirectory, tenantId]);
const { canAdd } = useRbac();
const canProvision = canAdd('tenant_management') || canAdd('tenant_self_service') || canAdd('settings_users');
@@ -356,24 +331,45 @@ const TenantUsersSubList: React.FC<{
<td colSpan={6} className="py-4 text-center text-slate-500">No users provisioned under Tenant #{tenantId}.</td>
</tr>
) : (
tenantUsers.map((u) => (
<tr key={u.id} className="hover:bg-slate-800/30 transition">
<td className="py-2 px-3 text-white font-medium">{u.email || '—'}</td>
<td className="py-2 px-3 font-mono text-slate-400 text-[11px]">{u.id}</td>
<td className="py-2 px-3 font-mono text-slate-300 text-[11px]">{u.idNumber || '—'}</td>
<td className="py-2 px-3 text-slate-300">{u.role || 'Standard User'}</td>
<td className="py-2 px-3"><ActivityStatusBadge status={u.status} /></td>
<td className="py-2 px-3 text-right">
<button
onClick={() => onManageUser(u)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 rounded-lg transition cursor-pointer"
>
<ShieldCheck className="w-3 h-3 text-blue-400" />
Manage Access &amp; Roles
</button>
</td>
</tr>
))
tenantUsers.map((tu) => {
const u = tu.user;
const userId = u?.id || tu.userId;
const email = u?.email || '—';
const role = u?.role || 'Standard User';
const idNumber = u?.idNumber;
const status = u?.status ?? tu.status;
return (
<tr key={String(tu.id) + '-' + String(userId)} className="hover:bg-slate-800/30 transition">
<td className="py-2 px-3 text-white font-medium">{email}</td>
<td className="py-2 px-3 font-mono text-slate-400 text-[11px]">{userId}</td>
<td className="py-2 px-3 font-mono text-slate-300 text-[11px]">
<ObfuscatedIdNumber idNumber={idNumber} />
</td>
<td className="py-2 px-3 text-slate-300">{role}</td>
<td className="py-2 px-3"><ActivityStatusBadge status={status} /></td>
<td className="py-2 px-3 text-right">
<button
onClick={() => {
const userToManage: User = u || {
id: tu.userId,
email: null,
role: null,
idNumber: null,
status: tu.status,
lastLoginAt: null,
};
onManageUser(userToManage);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 rounded-lg transition cursor-pointer"
>
<ShieldCheck className="w-3 h-3 text-blue-400" />
Manage Access &amp; Roles
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
@@ -541,9 +537,24 @@ export const TenantManagementPage: React.FC = () => {
return 'Active';
};
// Auto-expand active tenant for tenant self-service users
React.useEffect(() => {
if (!canManageTenants && currentTenantId) {
setExpandedTenants((prev) => ({
...prev,
[String(currentTenantId)]: true,
}));
}
}, [canManageTenants, currentTenantId]);
// Filter & Search
const filteredTenants = useMemo(() => {
return tenants.filter((t) => {
// Non-admins (tenant self-service) only see their own tenant partition boundary
if (!canManageTenants && currentTenantId && String(t.id) !== String(currentTenantId)) {
return false;
}
const s = Number(t.status ?? ActivityStatus.Active);
const matchesSearch =
!searchTerm.trim() ||
@@ -559,16 +570,16 @@ export const TenantManagementPage: React.FC = () => {
return matchesSearch && matchesStatus;
});
}, [tenants, searchTerm, statusFilter]);
}, [tenants, searchTerm, statusFilter, canManageTenants, currentTenantId]);
// Sorting
const { sortedData, sortColumn, sortDirection, handleSort } = useTableSort(filteredTenants, 'id', 'asc');
// Metrics
const totalCount = tenants.length;
const activeCount = tenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length;
const inactiveCount = tenants.filter((t) => Number(t.status) === 2).length;
const disabledCount = tenants.filter((t) => Number(t.status) === 3).length;
// Metrics (scoped to visible partitions)
const totalCount = filteredTenants.length;
const activeCount = filteredTenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length;
const inactiveCount = filteredTenants.filter((t) => Number(t.status) === 2).length;
const disabledCount = filteredTenants.filter((t) => Number(t.status) === 3).length;
// Export columns (using integer ID only, no hashid)
const exportColumns: ExportColumn<Tenant>[] = [
@@ -65,6 +65,7 @@ import {
ActivityStatus,
Tenant,
TenantOrganisation,
TenantUser,
CreateTenantRequest,
UpdateTenantRequest,
Beneficiary,
@@ -227,7 +228,7 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
name: payload.name,
registrationNo: payload.registrationNo,
email: payload.email,
idNumber: payload.idNumber,
idNumber: payload.idNumber || null,
organisationUser: payload.organisationUser,
type: payload.type !== undefined ? Number(payload.type) : 5,
});
@@ -1632,6 +1633,26 @@ export const getTenantOrganisations = async (tenantId: string | number): Promise
}
};
export const getTenantUsers = async (tenantId: string | number): Promise<TenantUser[]> => {
const hashedTenantId = await toolsService.encodeId(tenantId);
try {
const res = await apiClient.get<TenantUser[]>(
`/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/users`
);
return res.data || [];
} catch (err: any) {
if (String(tenantId) !== String(hashedTenantId)) {
try {
const fallbackRes = await apiClient.get<TenantUser[]>(
`/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/users`
);
return fallbackRes.data || [];
} catch {}
}
throw err;
}
};
// ==========================================
// 10. Projects: Beneficiaries & Outcomes APIs
// ==========================================
@@ -5,6 +5,55 @@ import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './u
export const BASE_API_URL = getApiBaseUrl();
// ---------------------------------------------------------------------------
// Mutation Versioning & Cache-Busting Management
// ---------------------------------------------------------------------------
// ASP.NET Core OutputCaching caches GET responses based on Method + URI + QueryString.
// To ensure that mutations (create, update, approve, delete) immediately invalidate
// backend OutputCache without serving stale data, ConsoleUI tracks a mutation epoch.
// Every successful POST/PUT/PATCH/DELETE bumps this epoch, ensuring subsequent GET
// requests partition to a fresh cache key on the server.
// ---------------------------------------------------------------------------
const MUTATION_EPOCH_STORAGE_KEY = 'pfm_mutation_epoch';
let activeMutationEpoch: number = (() => {
if (typeof window !== 'undefined') {
try {
const stored = sessionStorage.getItem(MUTATION_EPOCH_STORAGE_KEY);
if (stored) {
const parsed = Number(stored);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
} catch {}
}
return Date.now();
})();
/**
* Returns the current client mutation epoch timestamp.
* Appended as `_v` to internal API GET requests to partition backend OutputCache entries.
*/
export function getMutationEpoch(): number {
return activeMutationEpoch;
}
/**
* Bumps the mutation epoch to the current timestamp.
* Called automatically after any successful POST/PUT/PATCH/DELETE request,
* or manually when cache busting is required.
* Ensures subsequent GET requests bypass stale ASP.NET Core OutputCache entries.
*/
export function bumpMutationEpoch(): number {
activeMutationEpoch = Date.now();
if (typeof window !== 'undefined') {
try {
sessionStorage.setItem(MUTATION_EPOCH_STORAGE_KEY, String(activeMutationEpoch));
} catch {}
}
return activeMutationEpoch;
}
// Create Axios Instance
export const apiClient = axios.create({
baseURL: BASE_API_URL,
@@ -15,7 +64,7 @@ export const apiClient = axios.create({
timeout: 15000,
});
// Request Interceptor to dynamically set baseURL and attach Bearer Token
// Request Interceptor to dynamically set baseURL, attach Bearer Token, and apply cache-busting
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Dynamically synchronize baseURL from runtime configuration
@@ -25,9 +74,46 @@ apiClient.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
const isExternal =
config.url?.startsWith('http://') ||
config.url?.startsWith('https://') ||
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
// Explicit freshness requested: attach fresh timestamp
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
} else {
config.params = config.params || {};
config.params._t = Date.now();
}
} else {
// Standard GET: attach current mutation epoch
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch();
}
}
}
}
// User Sync & Tenant Lifecycle Guard:
// All API calls require an authenticated and enabled user in the backend system.
const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
if (token && !isLifecycleRequest) {
// If user account is flagged as pending administrator activation, block downstream API calls
if (isActivationPending()) {
@@ -48,9 +134,17 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error)
);
// Response Interceptor for global HTTP 401 detection
// Response Interceptor for global HTTP 401 detection and mutation cache invalidation
apiClient.interceptors.response.use(
(response) => response,
(response) => {
// If response was from a successful mutating HTTP method (POST, PUT, PATCH, DELETE),
// automatically bump the mutation epoch so subsequent refetches bypass stale OutputCache
const method = response.config?.method?.toLowerCase();
if (method && ['post', 'put', 'patch', 'delete'].includes(method)) {
bumpMutationEpoch();
}
return response;
},
(error) => {
if (error?.response?.status === 401) {
const requestUrl = error.config?.url;
@@ -5,33 +5,64 @@ import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac';
export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_cache_system_roles';
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_cache_role_claims_';
export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_cache_system_claims';
export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_rbac_cache_version';
export const CURRENT_RBAC_CACHE_VERSION = 'v2.1';
/**
* Invalidates and purges legacy cached claims if the schema version differs.
*/
export function ensureFreshRbacCache(): void {
if (typeof window === 'undefined') return;
try {
const version = localStorage.getItem(LOCAL_STORAGE_RBAC_VERSION_KEY);
if (version !== CURRENT_RBAC_CACHE_VERSION) {
// Clear legacy claims and caches
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith(LOCAL_STORAGE_ROLE_CLAIMS_PREFIX) || key === 'pfm_user_claims')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
inMemoryRoleClaims.clear();
localStorage.setItem(LOCAL_STORAGE_RBAC_VERSION_KEY, CURRENT_RBAC_CACHE_VERSION);
}
} catch {}
}
// In-memory runtime caches
let inMemorySystemRoles: Role[] | null = null;
const inMemoryRoleClaims = new Map<string, Claim[]>();
let inMemorySystemClaims: Claim[] | null = null;
const inMemoryUserRoles = new Map<string, { roles: Role[]; timestamp: number }>();
const inMemoryUserClaims = new Map<string, { claims: Claim[]; timestamp: number }>();
const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
// Initial check on load
ensureFreshRbacCache();
// Standard role claim defaults matching PFM Identity Server specification
export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
contributor: [
{ claimId: 30, claimType: 'projects_milestones', claimValue: 'view, edit, update' },
{ claimId: 31, claimType: 'grant_financials', claimValue: 'view, edit, update' },
{ claimId: 32, claimType: 'evidence_risks', claimValue: 'view, edit, update' },
{ claimId: 33, claimType: 'beneficiaries', claimValue: 'view, edit, update' },
{ claimId: 34, claimType: 'change_management', claimValue: 'view, edit, update' },
{ claimId: 35, claimType: 'site_visits', claimValue: 'view, edit, update' },
{ claimId: 36, claimType: 'settings_invites', claimValue: 'view, edit, update' },
{ claimId: 30, claimType: 'projects_milestones', claimValue: 'view, add' },
{ claimId: 31, claimType: 'grant_financials', claimValue: 'view, add' },
{ claimId: 32, claimType: 'evidence_risks', claimValue: 'view, add' },
{ claimId: 33, claimType: 'beneficiaries', claimValue: 'view, add' },
{ claimId: 34, claimType: 'change_management', claimValue: 'view, add' },
{ claimId: 35, claimType: 'site_visits', claimValue: 'view, add' },
{ claimId: 36, claimType: 'settings_invites', claimValue: 'view, add' },
],
supervisor: [
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, add, export' },
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, export' },
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, export' },
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, export' },
{ claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, export' },
{ claimId: 25, claimType: 'change_management', claimValue: 'view, add, export' },
{ claimId: 26, claimType: 'site_visits', claimValue: 'view, add, export' },
{ claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, export' },
{ claimId: 28, claimType: 'settings_users', claimValue: 'view, add, export' },
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, export' },
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, update, export' },
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, update, export' },
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, update, export' },
{ claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, update, export' },
{ claimId: 25, claimType: 'change_management', claimValue: 'view, add, update, export' },
{ claimId: 26, claimType: 'site_visits', claimValue: 'view, add, update, export' },
{ claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, update, export' },
{ claimId: 28, claimType: 'settings_users', claimValue: 'view, add, update, export' },
],
manager: [
{ claimId: 10, claimType: 'dashboard', claimValue: 'all' },
@@ -154,8 +185,14 @@ export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void {
/**
* Fetches system roles from backend API, caching the result in memory and localStorage.
* Checks cache first to avoid redundant network round trips.
*/
export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise<Role[]> {
if (!forceRefresh) {
const cached = getCachedSystemRoles();
if (cached && cached.length > 0) return cached;
}
try {
const res = await apiClient.get<Role[]>('/v1/api/users/system/roles');
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
@@ -181,30 +218,69 @@ export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
return defaults;
}
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
/**
* Fetches role claims for a role from the API, caching them under role ID and role name.
* Uses cache-first strategy to eliminate sequential network waterfalls on startup.
*/
export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string): Promise<Claim[]> {
export async function fetchAndCacheRoleClaims(
roleId: string,
roleName?: string,
forceRefresh = false
): Promise<Claim[]> {
const roleIdKey = roleId.toLowerCase().trim();
const roleNameKey = (roleName || '').toLowerCase().trim();
try {
const res = await apiClient.get<Claim[]>(`/v1/api/users/system/roles/${roleId}/claims`);
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
const normalized = res.data.map(normalizeClaim);
setCachedRoleClaims(roleIdKey, normalized);
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
return normalized;
// 1. Check in-memory and localStorage cache first
if (!forceRefresh) {
const cachedById = getCachedRoleClaims(roleIdKey);
if (cachedById && cachedById.length > 0) return cachedById;
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
}
} catch (err) {
// Network or server error, proceed to cache / defaults fallback
}
// Check cache by role ID
// 2. Resolve actual GUID if roleId is not formatted as a GUID
let effectiveGuid = GUID_REGEX.test(roleId.trim()) ? roleId.trim() : null;
if (!effectiveGuid) {
const cachedRoles = getCachedSystemRoles();
if (cachedRoles && cachedRoles.length > 0) {
const match = cachedRoles.find(
(r) =>
(r.name && r.name.toLowerCase() === roleNameKey) ||
(r.name && r.name.toLowerCase() === roleIdKey) ||
String(r.id).toLowerCase() === roleIdKey
);
if (match && match.id && GUID_REGEX.test(String(match.id).trim())) {
effectiveGuid = String(match.id).trim();
}
}
}
if (effectiveGuid) {
try {
const res = await apiClient.get<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);
if (cachedById && cachedById.length > 0) return cachedById;
// Check cache by role name
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
@@ -218,30 +294,118 @@ export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string)
return normalized;
}
/**
* Direct API calls for user roles and user claims
*/
export async function getUserRolesFromApi(userId: string): Promise<Role[]> {
try {
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
return res.data || [];
} catch {
return [];
export function clearCachedUserRbac(userId?: string): void {
if (userId) {
const key = userId.toLowerCase().trim();
inMemoryUserRoles.delete(key);
inMemoryUserClaims.delete(key);
if (typeof window !== 'undefined') {
try {
localStorage.removeItem(`pfm_cache_user_roles_${key}`);
localStorage.removeItem(`pfm_cache_user_claims_${key}`);
} catch {}
}
} else {
inMemoryUserRoles.clear();
inMemoryUserClaims.clear();
if (typeof window !== 'undefined') {
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && (k.startsWith('pfm_cache_user_roles_') || k.startsWith('pfm_cache_user_claims_'))) {
keysToRemove.push(k);
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
} catch {}
}
}
}
export async function getUserClaimsFromApi(userId: string): Promise<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 {
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 {
return [];
return inMemoryUserClaims.get(key)?.claims || [];
}
}
/**
* Resolves user roles, role claims, and user claims (including tenant and tenant_id)
* and caches system roles and role claims as a persistent reference.
* concurrently using Promise.allSettled and caches system roles and claims.
*/
export async function resolveUserRolesAndClaims(
userSub: string,
@@ -254,27 +418,30 @@ export async function resolveUserRolesAndClaims(
tenant?: string;
tenant_id?: number;
}> {
ensureFreshRbacCache();
const mergedClaims: Record<string, any> = { ...initialClaims };
const resolvedRoles: string[] = [];
let userRoleObjects: Role[] = [];
let primaryRole = currentProfile.role || (initialClaims.role as string) || '';
let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined;
let tenant_id = currentProfile.tenant_id ?? (initialClaims.tenant_id ? Number(initialClaims.tenant_id) : undefined);
// 1. Get user's roles from backend API (Requirement 2)
if (userSub) {
try {
const userRoles = await getUserRolesFromApi(userSub);
if (Array.isArray(userRoles) && userRoles.length > 0) {
userRoles.forEach((r) => {
if (r.name && !resolvedRoles.includes(r.name)) {
resolvedRoles.push(r.name);
}
});
if (resolvedRoles.length > 0) {
primaryRole = resolvedRoles[0];
}
// 1. Concurrently fetch user's roles and user's claims in parallel
const [rolesResult, claimsResult] = await Promise.allSettled([
userSub ? getUserRolesFromApi(userSub) : Promise.resolve([]),
userSub ? getUserClaimsFromApi(userSub) : Promise.resolve([]),
]);
if (rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value) && rolesResult.value.length > 0) {
userRoleObjects = rolesResult.value;
rolesResult.value.forEach((r) => {
if (r.name && !resolvedRoles.includes(r.name)) {
resolvedRoles.push(r.name);
}
} catch {}
});
if (resolvedRoles.length > 0) {
primaryRole = resolvedRoles[0];
}
}
// Fallback to initial role if API returned empty
@@ -282,55 +449,57 @@ export async function resolveUserRolesAndClaims(
resolvedRoles.push(primaryRole);
}
// 2. For each role, retrieve role claims using the endpoint (Requirement 2 & 7)
// 2. Concurrently fetch role claims for all resolved roles in parallel
const allRoleClaims: Claim[] = [];
for (const roleName of resolvedRoles) {
try {
const roleClaims = await fetchAndCacheRoleClaims(roleName, roleName);
if (Array.isArray(roleClaims)) {
roleClaims.forEach((rc) => {
const norm = normalizeClaim(rc);
allRoleClaims.push(norm);
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue || 'all';
}
});
}
} catch {}
}
const roleClaimsPromises = resolvedRoles.map(async (roleName) => {
const matchedRoleObj = userRoleObjects.find(
(r) => r.name && r.name.toLowerCase() === roleName.toLowerCase()
);
const roleIdToPass = matchedRoleObj?.id ? String(matchedRoleObj.id) : roleName;
return fetchAndCacheRoleClaims(roleIdToPass, roleName);
});
const roleClaimsResults = await Promise.allSettled(roleClaimsPromises);
roleClaimsResults.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
res.value.forEach((rc) => {
const norm = normalizeClaim(rc);
allRoleClaims.push(norm);
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue || 'all';
}
});
}
});
if (allRoleClaims.length > 0) {
mergedClaims.role_claims = allRoleClaims;
}
// 3. Retrieve user claims from endpoint (Requirement 3)
if (userSub) {
try {
const userClaims = await getUserClaimsFromApi(userSub);
if (Array.isArray(userClaims) && userClaims.length > 0) {
userClaims.forEach((c) => {
const norm = normalizeClaim(c);
const type = (norm.claimType || '').toLowerCase().trim();
const val = norm.claimValue;
if (type === 'tenant' && val) {
tenant = String(val);
mergedClaims.tenant = String(val);
} else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
const parsed = Number(val);
if (!isNaN(parsed) && isFinite(parsed)) {
tenant_id = parsed;
mergedClaims.tenant_id = parsed;
}
}
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue;
}
});
mergedClaims.user_claims = userClaims.map(normalizeClaim);
// 3. Process user claims
if (claimsResult.status === 'fulfilled' && Array.isArray(claimsResult.value) && claimsResult.value.length > 0) {
claimsResult.value.forEach((c) => {
const norm = normalizeClaim(c);
const type = (norm.claimType || '').toLowerCase().trim();
const val = norm.claimValue;
if (type === 'tenant' && val) {
tenant = String(val);
mergedClaims.tenant = String(val);
} else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
const parsed = Number(val);
if (!isNaN(parsed) && isFinite(parsed)) {
tenant_id = parsed;
mergedClaims.tenant_id = parsed;
}
}
} catch {}
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue;
}
});
mergedClaims.user_claims = claimsResult.value.map(normalizeClaim);
}
// 4. Background preload all system roles & role claims for reference (Requirement 7)
// 4. Background preload all system roles & role claims for reference (non-blocking)
preloadAndCacheAllRoleClaims().catch(() => {});
return {
@@ -343,13 +512,13 @@ export async function resolveUserRolesAndClaims(
}
/**
* Preloads and caches all system roles and their respective role claims.
* Preloads and caches all system roles and their respective role claims concurrently.
*/
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
const roles = await fetchAndCacheSystemRoles();
for (const role of roles) {
await fetchAndCacheRoleClaims(role.id, role.name);
}
await Promise.allSettled(
roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
);
}
/**
@@ -9,6 +9,18 @@ export interface TenantLockoutResult {
statusCode?: number;
}
// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations)
let cachedTenantLockout: {
key: string;
result: TenantLockoutResult;
timestamp: number;
} | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds
export function clearTenantLockoutCache(): void {
cachedTenantLockout = null;
}
/**
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
*/
@@ -63,7 +75,8 @@ export function getStatusLabel(status: any): string {
*/
export async function verifyTenantLockout(
tenantId?: number | string | null,
tenantClaim?: string | null
tenantClaim?: string | null,
forceRefresh = false
): Promise<TenantLockoutResult> {
const primaryId =
tenantId !== undefined && tenantId !== null && tenantId !== ''
@@ -83,6 +96,16 @@ export async function verifyTenantLockout(
};
}
const cacheKey = String(primaryId).trim().toLowerCase();
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if (
cachedTenantLockout.result.isActive &&
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
) {
return cachedTenantLockout.result;
}
}
let tenantRes: Tenant | null = null;
let lastError: any = null;
@@ -147,8 +170,14 @@ export async function verifyTenantLockout(
}
// Success: Response object returned and Status is Active
return {
const successResult: TenantLockoutResult = {
isActive: true,
tenant: tenantObj,
};
cachedTenantLockout = {
key: cacheKey,
result: successResult,
timestamp: Date.now(),
};
return successResult;
}
@@ -36,12 +36,22 @@ export interface UserSyncResult {
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.
*/
export function isActivationPending(): boolean {
if (inMemoryActivationPending !== null) {
return inMemoryActivationPending;
}
try {
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
const val = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
inMemoryActivationPending = val;
return val;
} catch {
return false;
}
@@ -51,8 +61,13 @@ export function isActivationPending(): boolean {
* Gets the reason/error message recorded when activation check was last pending.
*/
export function getActivationPendingReason(): string | null {
if (inMemoryActivationReason !== null) {
return inMemoryActivationReason;
}
try {
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
const reason = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
inMemoryActivationReason = reason;
return reason;
} catch {
return null;
}
@@ -62,6 +77,8 @@ export function getActivationPendingReason(): string | null {
* Sets or clears the pending activation flag in browser persistent storage.
*/
export function setActivationPending(pending: boolean, reason?: string): void {
inMemoryActivationPending = pending;
inMemoryActivationReason = reason || null;
try {
if (pending) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
@@ -91,10 +108,15 @@ export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string
return false;
}
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return false;
if (!inMemorySyncRecord) {
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return false;
const parsed: UserSyncRecord = JSON.parse(raw);
if (!parsed || parsed.status !== 'synced') return false;
inMemorySyncRecord = parsed;
}
const record: UserSyncRecord = JSON.parse(raw);
const record = inMemorySyncRecord;
if (!record || record.status !== 'synced') return false;
// Check if the stored sync matches the current user sub (if specified)
@@ -134,6 +156,7 @@ export function saveUserSyncInBrowser(
status: 'synced',
userData: userData || undefined,
};
inMemorySyncRecord = record;
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
} catch (err) {
console.warn('[UserSync] Failed to write sync record to localStorage:', err);
@@ -144,6 +167,9 @@ export function saveUserSyncInBrowser(
* Clears the user sync record from browser storage (used on logout or when activation fails).
*/
export function clearUserSyncInBrowser(): void {
inMemorySyncRecord = null;
inMemoryActivationPending = null;
inMemoryActivationReason = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
@@ -155,6 +181,7 @@ export function clearUserSyncInBrowser(): void {
* Clears only the successful sync record, leaving activation pending intact.
*/
export function clearSuccessfulSyncRecord(): void {
inMemorySyncRecord = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
} catch {}
+14 -2
View File
@@ -372,7 +372,7 @@ export interface CreateOrganisationRequest {
name: string;
registrationNo: string;
email: string;
idNumber: string;
idNumber?: string | null;
organisationUser: string;
type: OrganisationType;
}
@@ -382,7 +382,7 @@ export interface UpdateOrganisationRequest {
name: string;
registrationNo: string;
email: string;
idNumber: string;
idNumber?: string | null;
organisationUser: string;
type: OrganisationType;
status?: ActivityStatus;
@@ -400,6 +400,18 @@ export interface TenantOrganisation {
creator?: User | null;
}
export interface TenantUser {
id: number | string;
tenantId: number | string;
userId: string;
status: ActivityStatus;
createdAt: string;
createdBy: string;
tenant?: Tenant | null;
user?: User | null;
creator?: User | null;
}
export interface Portfolio {
id: number | string;
tenantId?: number | string;
+17
View File
@@ -928,6 +928,18 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/react-dom@^19.3.0":
version "19.3.0"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.3.0.tgz#90294a9266efd928a5266f2110d60d5dba5015d2"
integrity sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==
"@types/react@^19.3.0":
version "19.3.0"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.3.0.tgz#3b3ce8d8c1627fb5ccd4dadd57ef445c5a153b77"
integrity sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==
dependencies:
csstype "^3.2.2"
"@types/retry@0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
@@ -1198,6 +1210,11 @@ css-line-break@^2.1.0:
dependencies:
utrie "^1.0.2"
csstype@^3.2.2:
version "3.2.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
data-uri-to-buffer@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
@@ -1,11 +1,11 @@
namespace PostFundManagement.Domain.Entities;
namespace PostFundManagement.Domain.Entities;
[EntityTypeConfiguration<TenantOrganisationConfiguration, TenantOrganisation>]
public class TenantOrganisation : Models.TenantOrganisation
{
public virtual Tenant? Tenant { get; set; }
public virtual Organisation? Organisation { get; set; }
public new virtual Organisation? Organisation { get; set; }
public virtual User? Creator { get; set; }
}
@@ -1,4 +1,4 @@
namespace PostFundManagement.Domain.Entities;
namespace PostFundManagement.Domain.Entities;
[EntityTypeConfiguration<TenantUserConfiguration, TenantUser>]
public class TenantUser : Models.TenantUser
@@ -9,7 +9,7 @@ public class TenantUser : Models.TenantUser
public virtual User? Creator { get; set; }
[ForeignKey(nameof(UserId))]
public virtual User? User { get; set; }
public new virtual User? User { get; set; }
}
public sealed class TenantUserConfiguration : IEntityTypeConfiguration<TenantUser>
@@ -11,7 +11,8 @@ public static class Mappers
CreatedAt = entity.CreatedAt,
CreatedBy = entity.CreatedBy,
Status = entity.Status,
UserId = entity.UserId
UserId = entity.UserId,
User = entity.User?.Map()
};
public static TenantOrganisation Map(this Entities.TenantOrganisation entity) => new()
@@ -21,7 +22,8 @@ public static class Mappers
CreatedAt = entity.CreatedAt,
CreatedBy = entity.CreatedBy,
OrganisationId = entity.OrganisationId,
TenantId = entity.TenantId
TenantId = entity.TenantId,
Organisation = entity.Organisation?.Map()
};
public static Tenant Map(this Entities.Tenant entity) => new()
@@ -1,4 +1,4 @@
namespace PostFundManagement.Domain.Models;
namespace PostFundManagement.Domain.Models;
public class TenantOrganisation
{
@@ -13,4 +13,6 @@ public class TenantOrganisation
public long OrganisationId { get; set; }
public ActivityStatus Status { get; set; }
public Organisation? Organisation { get; set; }
}
@@ -1,4 +1,4 @@
namespace PostFundManagement.Domain.Models;
namespace PostFundManagement.Domain.Models;
public class TenantUser
{
@@ -13,4 +13,6 @@ public class TenantUser
public Guid UserId { get; set; }
public ActivityStatus Status { get; set; }
public User? User { get; set; }
}
@@ -1,4 +1,4 @@
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Abstractions;
namespace PostFundManagement.Infrastructure.Database.Services;
@@ -17,18 +17,18 @@ public sealed class TenantContextService : ITenantContextService
public TenantContextService(IHttpContextAccessor httpContextAccessor)
{
var user = httpContextAccessor.HttpContext?.User;
IsAdmin = user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true;
ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true;
ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin;
HasTenantFilter = !IsAdmin;
var tenantIdClaimVal = user?.FindFirst("tenant_id")?.Value ?? user?.FindFirst("TenantId")?.Value;
TenantId = long.TryParse(tenantIdClaimVal, out var id) ? id : 0L;
var tenantNameClaimVal = user?.FindFirst("tenant")?.Value ?? user?.FindFirst("Tenant")?.Value;
TenantName = !string.IsNullOrWhiteSpace(tenantNameClaimVal) ? tenantNameClaimVal : null;
IsAdmin = (user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true) && TenantId == 1;
ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true;
ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin;
HasTenantFilter = !IsAdmin;
}
}
}
+167
View File
@@ -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.
+71 -27
View File
@@ -8,14 +8,16 @@ An enterprise system designed to manage the entire post-funding grant and projec
The application follows a modular, hierarchical architecture where high-level funding directives cascade into measurable project outcomes and evidence verification.
```text
[Portfolios] ───> [Programmes] ───> [Projects] ───> [Awards] ───> [Contracts] ───> [Beneficiaries] ──> [Outcomes]
│ │ │
▼ │ ▼
[Rules] ├─> [Milestones] ──> [Activities] ──> [Evidences]
├─> [Disbursements]
├─> [Indicators]
├─> [Risks] & [Issues]
└─> [SiteVisits] & [ComplianceItems]
├─> [Disbursements]
├─> [Indicators]
├─> [Risks] & [Issues]
└─> [SiteVisits] & [ComplianceItems]
```
### **1. Portfolio & Programme Governance**
* **Portfolios**: Top-level containers for strategic investment themes.
@@ -55,45 +57,85 @@ The application follows a modular, hierarchical architecture where high-level fu
## 🛠️ Tech Stack & Patterns
* **Backend Framework**: .NET 8 / 9 C# Web API
* **Database**: PostgreSQL
* **ORM**: Entity Framework Core (Fluent API Configuration)
* **Architecture**: Clean Architecture / Domain-Driven Design (DDD) with CQRS (`MediatR`)
* **Utilities**: `Humanizer` (Automated entity-to-table pluralization)
* **Backend Framework**: .NET 10.0 (C# 13) Web API / Minimal APIs
* **Frontend Portals**:
* `PostFundManagement.ConsoleUI`: Internal Staff & Portfolio Administration Portal (React 19 + TypeScript + Vite + Tailwind CSS)
* `PostFundManagement.PartnerUI`: External Recipient & Grantee Portal (React 19 + TypeScript + Vite + WebGL OGL)
* **Database**: PostgreSQL 14+ with Npgsql & EF Core 10
* **Security & Authentication**: Duende IdentityServer / OIDC PKCE + Claim-Based RBAC
* **Governance Patterns**: Maker-Checker Pattern (Dual Authorization & Segregation of Duties)
* **Architecture**: Clean Architecture / Domain-Driven Design (DDD) with CQRS (`Mediator`)
* **Object Storage**: S3-compatible storage (Garage S3 / AWS S3)
* **Caching & OutputCache Invalidation**: Redis StackExchange + ASP.NET Core `OutputCaching` (2-minute base policy) coupled with TanStack Query v5 and Axios client-side mutation versioning (`_v` parameter) for instantaneous reads and guaranteed zero-staleness on mutations
* **Telemetry**: OpenTelemetry Metrics, Traces & Logs with Aspire / OTLP
---
## 🔒 Role-Based Access Control (RBAC) & Maker-Checker Pattern
The platform implements strict governance invariants across all operational domains:
### 1. Progressive Action Hierarchy
* **`view`**: Read-only inspection of entity lists, details, and metrics.
* **`add`**: Create new records, edit drafts, submit evidence, and adjust non-approval statuses.
* **`update`**: Authoritative record **approval** authority (`Approved`, `Active` contracts).
* **`export`**: Export tabular data and reports (`.xlsx`, `.pdf`).
* **`all`**: Comprehensive authority across the claim domain.
### 2. Role Claim Matrix
* **`Admin`**: `unrestricted: all` — Full system-wide administration.
* **`Executive`**: Strategic oversight across Dashboard, Governance, Projects, Grants, M&E (`all`).
* **`Manager`**: Comprehensive operational control across all domains (`all`).
* **`Supervisor`**: Operational oversight and verification (`view, add, update, export`).
* **`Contributor`**: Working-level execution (`view, add`). Strictly barred from record approvals and exports.
* **`Partner`**: External recipient. Confined strictly to `PartnerUI`; locked out of `ConsoleUI`.
### 3. Maker-Checker Segregation of Duties
To eliminate conflicts of interest, record creators ("Makers") cannot approve ("Check") their own records. Even if an officer possesses the `update` claim, the system dynamically blocks self-approval on any record matching their `createdBy` identifier:
$$\text{Can Approve} = \text{Has 'update' permission} \land (\text{Current User ID} \neq \text{Record Creator ID})$$
For in-depth architectural and operational specifications, see:
* [Architecture.md](file:///c:/Users/Admin/source/repos/PostFundManagement/Architecture.md)
* [Processes.md](file:///c:/Users/Admin/source/repos/PostFundManagement/Processes.md)
* [GEMINI.md](file:///c:/Users/Admin/source/repos/PostFundManagement/GEMINI.md)
---
## 📁 Solution Structure
```text
src/
├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces
├── PostFundManagement.Infrastructure/ # EF Core Configurations, DbContext, Migrations
├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Logic
└── PostFundManagement.Api/ # REST Controllers, Middleware, Auth & Swagger
PostFundManagement/
├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces
├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL, Skoruba STS, Migrations
├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Validations
├── PostFundManagement.Api/ # REST Endpoints, Middleware, Auth & Swagger/Scalar
├── PostFundManagement.Tests/ # Unit & Integration Test Suite
├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite)
├── PostFundManagement.PartnerUI/ # External Grantee / Recipient Portal (React 19 + Vite)
├── Architecture.md # System Architecture, RBAC & Maker-Checker Spec
├── Processes.md # Operational Grant Lifecycle & Process Flows
└── docker-compose.yml # Container orchestration
```
## 🚀 Getting Started
### Prerequisites
.NET 8.0 SDK or higher
PostgreSQL 14+
dotnet-ef CLI Tool (dotnet tool install --global dotnet-ef)
* .NET 10.0 SDK or higher
* Node.js 20+ & Yarn (`yarn.lock` authoritative)
* PostgreSQL 14+
## Setup & Execution
## Clone Repository
1. Clone Repository:
### 1. Clone Repository:
```bash
git clone [https://github.com/your-org/PostFundManagement.git](https://github.com/your-org/PostFundManagement.git)
git clone https://github.com/your-org/PostFundManagement.git
cd PostFundManagement
```
2. Configure Database Connection:
Update appsettings.Development.json in the API project with your local PostgreSQL details:
### 2. Configure Database Connection:
Update `appsettings.Development.json` in the API project with your local PostgreSQL details:
```json
{
@@ -103,16 +145,18 @@ Update appsettings.Development.json in the API project with your local PostgreSQ
}
```
3. Apply Database Migrations:
### 3. Launch Backend API:
```bash
dotnet ef database update --project src/PostFundManagement.Infrastructure
dotnet run --project PostFundManagement.Api
```
4. Launch Application:
### 4. Launch ConsoleUI Frontend:
```bash
dotnet run --project src/PostFundManagement.Api
cd PostFundManagement.ConsoleUI
yarn install
yarn dev
```
## 📄 License