71 Commits
Author SHA1 Message Date
hermes b8c3201c58 Refactored quartz healthcheck on api 2026-10-27 23:59:58 +02:00
hermes a8daea830f Refactored quartz client registration 2026-10-27 23:48:36 +02:00
hermes 5be7c8c56a Removed defaults from feature flags 2026-10-27 23:17:18 +02:00
hermes d24b0dafb1 Added http accessor to scheduler 2026-10-27 23:04:50 +02:00
hermes cd47a2b93a Fixed scheduler and api configuration 2026-10-27 22:08:12 +02:00
hermes c11535ebfc Added scheduler 2026-10-27 21:31:27 +02:00
hermes 129d10c7d1 Added jobs project and updated nuget packages 2026-10-27 08:28:24 +02:00
hermes 9f132eeb29 Fixed UI misalignments 2026-10-26 19:57:32 +02:00
hermes 80c798e5e6 Added Portfolio Analytics refresh button and card-per-award with contract, programme, and portfolio details 2026-10-26 17:45:52 +02:00
hermes 2690b3230e Refactored Evidence to UpdateBy is optional When creating evidence 2026-10-26 17:03:53 +02:00
hermes 155a379ab4 Resolved reply recipient userId from TenantUsers and enabled Beneficiary compliance exploration modal 2026-10-26 10:02:14 +02:00
hermes f56cb70b55 Fixed dshboard render crash 2026-10-26 09:34:15 +02:00
hermes bb77a4dc9a PartnerUI evidence, notification, indicators and actuals fixes 2026-10-26 08:53:05 +02:00
hermes 5ac317cbe3 Fixed partnerui hashing and over all theming 2026-10-25 19:44:46 +02:00
hermes 0bcc3484dc Updated evidence handling 2026-10-25 18:58:30 +02:00
hermes fddf400ddd Updated partner project listing to use returned data 2026-10-25 18:30:32 +02:00
hermes a1b8410bc2 Updated invite creation 2026-10-25 17:46:59 +02:00
hermes cb0da32ce4 Fixed project status update on approval to do perform compliance check 2026-10-25 17:14:42 +02:00
hermes 70e286670e Light refactor 2026-10-25 13:48:15 +02:00
hermes c6075a5c39 Fixed update organisation endpoint constraints 2026-10-25 13:47:33 +02:00
hermes 4e1211a1e1 Fixed idNumber missed assignment when updating organisation 2026-10-25 13:34:52 +02:00
hermes c8e54815ef Fixed update modal linked user assignment 2026-10-25 13:23:25 +02:00
hermes 31d775c917 Fixed frozen org editor modal 2026-10-25 12:50:35 +02:00
hermes 78d147b002 Fix UI freeze by removing cyclical useEffect and stabilizing executive candidate memo 2026-10-25 12:13:15 +02:00
hermes 07018c3ce4 Auto-assign linked executive user and persist userId in organisation update modal 2026-10-25 12:02:30 +02:00
hermes db08bbf162 Added admin override when updating organisation 2026-10-25 11:19:59 +02:00
hermes ac357285c3 Enhanced shieldcheck 2026-10-25 11:07:05 +02:00
hermes a4927f9742 Fix vite env type declarations and tsconfig in PartnerUI 2026-10-25 11:04:29 +02:00
hermes fac88f3471 Fix missing lucide icon imports in GovernancePage and null-safety in DashboardPage 2026-10-25 10:35:27 +02:00
hermes aeb7542cfe Optimised cach usage, and enforced maker checker 2026-10-25 10:11:57 +02:00
hermes 9724bbac24 Removed award validation from budget creation process 2026-10-25 09:17:00 +02:00
hermes deb21dbb9a Redesigned dashboard 2026-10-25 08:52:14 +02:00
khwezi 98fe1e3e3d Merge pull request 'Moved top bar to top' (#173) from finishline into main
Reviewed-on: #173
2026-10-25 08:27:23 +02:00
hermes 48edfa1cbb Moved top bar to top 2026-10-25 08:26:37 +02:00
khwezi d9b65010cf Merge pull request 'Redesigned PartnerUi dashboard' (#172) from finishline into main
Reviewed-on: #172
2026-10-25 08:02:27 +02:00
hermes 3e1d8bc1cc Redesigned PartnerUi dashboard 2026-10-25 08:01:34 +02:00
khwezi bc441c1255 Merge pull request 'Fixed ConsoleUi salt handling' (#171) from finishline into main
Reviewed-on: #171
2026-10-24 18:10:42 +02:00
hermes 17a4149a73 Fixed ConsoleUi salt handling 2026-10-24 18:09:59 +02:00
khwezi 6e786ca796 Merge pull request 'Finishline' (#170) from finishline into main
Reviewed-on: #170
2026-10-24 17:37:15 +02:00
hermes 25e251aa79 Overhauled lockout process 2026-10-24 17:36:30 +02:00
hermes a731c979bb Overhauled lockout process 2026-10-24 17:29:48 +02:00
khwezi 90631d22ae Merge pull request 'Applied extensive leaked secret protection' (#169) from finishline into main
Reviewed-on: #169
2026-10-24 15:57:01 +02:00
hermes 17a99ff084 Applied extensive leaked secret protection 2026-10-24 15:56:25 +02:00
khwezi 010f2d5eaa Merge pull request 'Stopped leaking secrets' (#168) from finishline into main
Reviewed-on: #168
2026-10-24 15:44:08 +02:00
hermes 29a11f0e0b Stopped leaking secrets 2026-10-24 15:43:10 +02:00
khwezi b8f5ebf69a Merge pull request 'Finishline' (#167) from finishline into main
Reviewed-on: #167
2026-10-24 15:23:51 +02:00
hermes 385c76510b Fixed partnerui message viewer 2026-10-24 15:23:12 +02:00
hermes 631a0b7a9a Fixed partner message reader 2026-10-24 15:18:34 +02:00
khwezi 957e75a853 Merge pull request 'Applied CORS fix' (#166) from finishline into main
Reviewed-on: #166
2026-10-24 14:59:28 +02:00
hermes 1233f15b91 Applied CORS fix 2026-10-24 14:58:55 +02:00
khwezi 8a06a86eb3 Merge pull request 'Fixed Console user lockout and unlock implementation' (#165) from finishline into main
Reviewed-on: #165
2026-10-24 14:39:40 +02:00
hermes c43e18f184 Fixed Console user lockout and unlock implementation 2026-10-24 14:38:55 +02:00
khwezi f8f826979d Merge pull request 'Finishline' (#164) from finishline into main
Reviewed-on: #164
2026-10-24 12:51:15 +02:00
hermes d912c5c6c3 Added partner to api cors list 2026-10-24 12:50:28 +02:00
hermes 20bdd8b209 Removed joborchestrator 2026-10-24 12:41:03 +02:00
khwezi c4a0469bae Merge pull request 'Finishline' (#163) from finishline into main
Reviewed-on: #163
2026-10-24 12:13:11 +02:00
hermes bf384d7e6a Reverted changes 2026-10-24 12:12:27 +02:00
hermes 957d2210a9 Refactored sync process 2026-10-24 12:11:48 +02:00
khwezi a3f461478c Merge pull request 'PartnerUi fixes' (#162) from finishline into main
Reviewed-on: #162
2026-10-24 11:49:45 +02:00
hermes 16fb6b9434 Simplified lockout modals 2026-10-24 11:48:19 +02:00
hermes 7220700bac Removed setUserLockout from PartnerUI 2026-10-24 11:39:47 +02:00
khwezi cc62696017 Merge pull request 'Fixed sync handshake' (#161) from finishline into main
Reviewed-on: #161
2026-10-23 10:20:48 +02:00
hermes 3316fc0275 Fixed sync handshake 2026-10-23 10:19:30 +02:00
khwezi d4598e0c6c Merge pull request 'Implemented partnerui' (#160) from finishline into main
Reviewed-on: #160
2026-10-23 00:59:39 +02:00
hermes 8f58e15f0f Implemented partnerui 2026-10-23 00:58:47 +02:00
khwezi 43656e848a Merge pull request 'Applied mailbox constraints' (#159) from finishline into main
Reviewed-on: #159
2026-10-22 23:58:12 +02:00
hermes 9ed210613e Applied mailbox constraints 2026-10-22 23:57:38 +02:00
khwezi 3a3245b643 Merge pull request 'Fixed notification composition' (#158) from finishline into main
Reviewed-on: #158
2026-10-22 23:36:39 +02:00
hermes 5050c901bc Fixed notification composition 2026-10-22 23:36:03 +02:00
khwezi fdaf12a183 Merge pull request 'Implemented notification strategy' (#157) from finishline into main
Reviewed-on: #157
2026-10-22 23:01:14 +02:00
hermes 46c6ddcc80 Implemented notification strategy 2026-10-22 22:59:33 +02:00
125 changed files with 22512 additions and 3716 deletions
+1
View File
@@ -44,6 +44,7 @@
<Using Include="Microsoft.Extensions.Logging" />
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Microsoft.IdentityModel.Tokens" />
<Using Include="Microsoft.FeatureManagement" />
<!-- Domain Constants -->
<Using Static="true" Include="PostFundManagement.Domain.Extensions.Constants" />
+56 -55
View File
@@ -12,67 +12,68 @@
<PackageVersion Include="Scrutor" Version="7.0.0" />
<!-- Microsoft Extensions & Configuration -->
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.10.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.12" />
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.7.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.11.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.11.0" />
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.14" />
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.8.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.14" />
<!-- Security, Authentication & Identity -->
<PackageVersion Include="AccessTokenClient.Extensions" Version="5.1.0" />
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.2.0" />
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.2.0" />
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.3.0" />
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.3.0" />
<PackageVersion Include="IdentityModel" Version="6.2.0" />
<PackageVersion Include="IdentityModel.AspNetCore" Version="4.3.0" />
<PackageVersion Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
<PackageVersion Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageVersion Include="KubernetesClient" Version="19.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="KubernetesClient" Version="21.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.14" />
<!-- Upgraded to latest non-vulnerable version -->
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.13" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.14" />
<!-- Resilience -->
<PackageVersion Include="Polly" Version="8.8.0" />
<PackageVersion Include="Polly.Extensions" Version="8.8.0" />
<PackageVersion Include="Polly" Version="8.8.1" />
<PackageVersion Include="Polly.Extensions" Version="8.8.1" />
<!-- API, Web & Documentation -->
<PackageVersion Include="Asp.Versioning.Abstractions" Version="10.2.1" />
<PackageVersion Include="Asp.Versioning.Http" Version="10.2.3" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.12" />
<PackageVersion Include="Refit.HttpClientFactory" Version="15.2.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.4" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.14" />
<PackageVersion Include="Refit.HttpClientFactory" Version="16.4.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.18.3" />
<!-- Database & Entity Framework Core -->
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.14" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<!-- Quartz Scheduler -->
<PackageVersion Include="Quartz" Version="4.1.0" />
<PackageVersion Include="Quartz.AspNetCore" Version="4.1.0" />
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.1.0" />
<PackageVersion Include="Quartz.Plugins" Version="4.1.0" />
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.1.0" />
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.1.0" />
<PackageVersion Include="Quartz" Version="4.6.2" />
<PackageVersion Include="Quartz.AspNetCore" Version="4.6.2" />
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.6.2" />
<PackageVersion Include="Quartz.Plugins" Version="4.6.2" />
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.6.2" />
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.6.2" />
<!-- Health Checks -->
<PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
@@ -83,17 +84,17 @@
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<!-- Observability & OpenTelemetry -->
<PackageVersion Include="OpenTelemetry" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.20.1" />
<!-- Email & Messaging -->
<PackageVersion Include="MailKit" Version="4.18.0" />
<PackageVersion Include="MimeKit" Version="4.18.0" />
<PackageVersion Include="MailKit" Version="4.18.2" />
<PackageVersion Include="MimeKit" Version="4.18.2" />
<!-- CQRS & Mediator -->
<PackageVersion Include="FluentResults" Version="4.0.0" />
@@ -101,17 +102,17 @@
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
<!-- Cloud Storage -->
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.4" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.3" />
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.102.7" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.104.7" />
<!-- Analyzers -->
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.259" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.317" />
<!-- Testing -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.1" />
</ItemGroup>
</Project>
@@ -11,7 +11,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/evidence/{projectId}", async (string projectId, IEvidenceService service,
builder.MapGet("api/evidence/projects/{projectId}", async (string projectId, IEvidenceService service,
CancellationToken cancellationToken = default) =>
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
{
@@ -12,7 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync)
builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Add a new beneficiary outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
@@ -22,25 +29,5 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
builder.MapPost("api/projects/beneficiaris/outcomes", HandleCreateAsync)
.WithDescription("Add a new beneficiary outcome (legacy route)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName("TypoAlias"))
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
private static async Task<IResult> HandleCreateAsync(CreateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
@@ -13,22 +13,29 @@ public sealed class SetUserLockoutEndpoint : IEndpoint
{
builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context,
IUserService service, CancellationToken cancellationToken = default) =>
await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Lock user session")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(SetUserLockoutEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Users);
{
var (_, role) = context.User.GetUserRole();
if (role != null && role.Equals("Partner", StringComparison.OrdinalIgnoreCase))
return Results.Forbid();
return await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
})
.WithDescription("Lock user session")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(SetUserLockoutEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Users);
}
}
@@ -11,8 +11,8 @@ public class SyncUserEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator,
HttpContext context, IUserService service, CancellationToken cancellationToken = default) =>
builder.MapPost("api/integration/sync", async (HttpContext context, IUserService service,
CancellationToken cancellationToken = default) =>
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
+26 -10
View File
@@ -4,7 +4,6 @@ using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Health;
using PostFundManagement.Domain.Mediator;
using PostFundManagement.Infrastructure.Database.Services;
using static PostFundManagement.Domain.Extensions.Constants;
var builder = WebApplication.CreateBuilder(args);
@@ -45,7 +44,7 @@ builder.Services.AddSkorubaApiService(builder.Configuration);
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
builder.Services.AddQuartzSchedulerClient(DefaultSchedulerName, builder.Configuration);
builder.Services.AddEmailServices(builder.Configuration);
builder.Services.AddHashServices(builder.Configuration);
builder.Services.AddGarageS3(builder.Configuration);
@@ -116,7 +115,29 @@ builder.Services.AddCors(options =>
{
options.AddPolicy(CorsPolicy, policy =>
{
policy.WithOrigins(allowedOrigins)
policy.SetIsOriginAllowed(origin =>
{
if (string.IsNullOrWhiteSpace(origin)) return false;
var clean = origin.TrimEnd('/');
if (allowedOrigins.Any(a => string.Equals(a.TrimEnd('/'), clean, StringComparison.OrdinalIgnoreCase)))
return true;
try
{
var host = new Uri(origin).Host;
if (host.EndsWith("khongisa.co.za", StringComparison.OrdinalIgnoreCase) ||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
catch { /* eat all exceptions */ }
return false;
})
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
@@ -125,12 +146,7 @@ builder.Services.AddCors(options =>
var app = builder.Build();
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler();
if (scheduler!.Status != SchedulerStatus.Running)
await scheduler.Start();
app.UseCors(CorsPolicy);
app.UseHsts();
app.UseHttpsRedirection();
app.UseRouting();
@@ -218,4 +234,4 @@ app.MapGet("/", () => Results.Redirect($"/openapi/{latestVersionGroup}"))
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.Run();
app.Run();
+2 -8
View File
@@ -1,6 +1,7 @@
{
"EnableServiceCaching": true,
"CorsOrigins": [
"https://partner.ifri.khongisa.co.za",
"https://console.ifri.khongisa.co.za",
"https://localhost:7208"
],
@@ -30,14 +31,7 @@
"Microsoft.EntityFrameworkCore.Database": "Error",
"Microsoft.EntityFrameworkCore.Update": "Error"
}
},
"EmailProcessorCron": "0 */5 * * * ?",
"Email": {
"SmtpServer": "smtp.example.com",
"SmtpPort": 587,
"SenderEmail": "noreply@example.com",
"SenderName": "Example"
},
},
"HasherSettings": {
"MinHashLength": 11
},
@@ -25,7 +25,6 @@ public interface ICommunicationsService
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
[Log]
@@ -20,7 +20,7 @@ public static class Quartz
services.AddQuartz(config =>
{
config.UseSimpleTypeLoader();
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
config.UseThreadPool<global::Quartz.Impl.ZeroSizeThreadPool>();
config.UseTimeZoneConverter();
config.UsePersistentStore(storage =>
@@ -1,9 +1,3 @@
using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using Castle.DynamicProxy;
using FluentResults;
using Microsoft.AspNetCore.Http;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain.Attributes;
@@ -20,6 +20,7 @@ public sealed record InviteRequest([Required] long OrganisationId,
[Required] long InvitedOrganisationId,
[Required] long AwardId,
[Required] long ContractId,
[Required] long ProjectId,
[Required] Guid Recipient,
[Required][MaxLength(LabelLength)] string Message,
[Required] NotificationPlatform Platform);
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
return Result.Fail(ValidationError.Create("Organisation user not found"));
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
if (!tenantContext.IsAdmin && !await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
var changedTotal = await context.Organisations
@@ -192,6 +192,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
.SetProperty(u => u.Type, request.Type)
.SetProperty(u => u.UserId, request.OrganisationUser)
.SetProperty(u => u.Status, request.Status), cancellationToken);
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
@@ -159,12 +159,7 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.New);
var hasApprovedAward = await context.Awards
.AnyAsync(a => a.ProjectId == request.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken);
if (!hasApprovedAward)
return Result.Fail(ValidationError.Create("Cannot create budget: The project's linked award is not approved or does not exist."));
return Result.Fail(NotFoundError.New);
var budget = new Domain.Entities.Budget
{
@@ -20,27 +20,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
return Result.Fail(NotFoundError.New);
if (request.Status == ApprovalStatus.Approved)
{
var hasUnmetCompliance = await context.ComplianceItems
.AnyAsync(c => c.ProjectId == request.ProjectId && c.Status == ComplianceStatus.NotMet, cancellationToken);
if (hasUnmetCompliance)
return Result.Fail(ValidationError.Create("Project cannot be approved because some compliance items are not met."));
var projectBeneficiaryIds = await context.Beneficiaries
.Where(b => b.ProjectId == request.ProjectId)
.Select(b => b.Id)
.ToListAsync(cancellationToken);
if (projectBeneficiaryIds.Count > 0)
{
var hasUnmetOutcomes = await context.Outcomes
.AnyAsync(o => projectBeneficiaryIds.Contains(o.BeneficiaryId) && o.Status == ComplianceStatus.NotMet, cancellationToken);
if (hasUnmetOutcomes)
return Result.Fail(ValidationError.Create("Project cannot be approved because some beneficiary outcomes are not met."));
}
}
var changedTotal = await context.Projects
.Where(f => f.Id == request.ProjectId)
.ExecuteUpdateAsync(f => f
@@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService
int[] decoded = hasher.Decode(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token."))
.ToResult(decoded[0]);
if (decoded.Length <= 0)
return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
return Result.Ok(decoded[0]);
}
public Result<long> DecodeLongIdHash(string hash)
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
long[] decoded = hasher.DecodeLong(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token."))
.ToResult(decoded[0]);
if (decoded.Length <= 0)
return Result.Fail<long>(ValidationError.Create("Invalid or modified Long hash token."));
return Result.Ok(decoded[0]);
}
public async Task<Result<string>> DecodeHexHash(string hex) =>
@@ -12,7 +12,6 @@ APP_URL="MY_APP_URL"
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
VITE_OIDC_AUDIENCE="pfm-api-dev"
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27"
# PFM Backend API Base URL
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
@@ -26,15 +26,12 @@ OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audien
# 4. OIDC Client ID
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
# 5. OIDC Client Secret
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
# 6. OIDC Scope
# 5. OIDC Scope
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 7. Hasher Settings
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-}}}}}"
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
# 6. Hasher Settings
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
if [ -d "$TARGET_DIR" ]; then
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
@@ -51,14 +48,12 @@ window.__RUNTIME_CONFIG__ = {
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
VITE_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
OIDC_SCOPE: "${OIDC_SCOPE}",
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
HASHER_SALT: "${HASHER_SALT}",
VITE_HASHER_SALT: "${HASHER_SALT}",
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
HASHER_SALT: "${HASHER_SALT}",
VITE_HASHER_SALT: "${HASHER_SALT}",
ApiSettings: {
BaseUrl: "${API_BASE_URL}"
},
@@ -66,7 +61,6 @@ window.__RUNTIME_CONFIG__ = {
Authority: "${OIDC_AUTHORITY}",
Audience: "${OIDC_AUDIENCE}",
ClientId: "${OIDC_CLIENT_ID}",
ClientSecret: "${OIDC_CLIENT_SECRET}",
Scope: "${OIDC_SCOPE}"
},
HasherSettings: {
@@ -86,7 +80,6 @@ EOF
"Authority": "${OIDC_AUTHORITY}",
"Audience": "${OIDC_AUDIENCE}",
"ClientId": "${OIDC_CLIENT_ID}",
"ClientSecret": "${OIDC_CLIENT_SECRET}",
"Scope": "${OIDC_SCOPE}"
},
"HasherSettings": {
@@ -6,11 +6,10 @@
"Authority": "",
"Audience": "",
"ClientId": "",
"ClientSecret": "",
"Scope": ""
},
"HasherSettings": {
"Salt": "",
"MinHashLength": ""
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
"MinHashLength": "11"
}
}
@@ -1,4 +1,13 @@
// Development and fallback runtime configuration.
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
VITE_HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
HASHER_MIN_LENGTH: "11",
VITE_HASHER_MIN_LENGTH: "11",
HasherSettings: {
Salt: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
MinHashLength: "11"
}
};
@@ -10,13 +10,14 @@ import {
isActivationPending,
getActivationPendingReason,
clearUserSyncInBrowser,
setActivationPending,
UserSyncResult,
} from '../services/userSyncService';
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService';
import { verifyTenantLockout } from '../services/tenantLockoutService';
import { encodeHashIdSync } from '../utils/hashids';
import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
import {
onSessionExpired,
triggerSessionExpired,
@@ -307,11 +308,6 @@ export function resetUserManager(): void {
userManagerInstance = null;
}
// Automatically reset UserManager when runtime configuration is updated
onConfigChanged(() => {
resetUserManager();
});
export function getUserManager(): UserManager {
if (!userManagerInstance && typeof window !== 'undefined') {
const sec = getSecuritySettings();
@@ -320,7 +316,7 @@ export function getUserManager(): UserManager {
userManagerInstance = new UserManager({
authority: sec.Authority,
client_id: sec.ClientId,
client_secret: sec.ClientSecret,
...(sec.ClientSecret ? { client_secret: sec.ClientSecret } : {}),
redirect_uri: `${origin}/dashboard`,
post_logout_redirect_uri: `${origin}/`,
response_type: 'code',
@@ -437,8 +433,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token);
let tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
let parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId);
const profile: AuthUserProfile = {
@@ -458,29 +454,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname);
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
const tenantLockoutResult = await verifyTenantLockout(
parsedTenantId ?? tenantClaimValues.tenantId,
tenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
profile.tenant = tenantLockoutResult.tenant.name;
setTenantName(tenantLockoutResult.tenant.name);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
} else if (tenantClaim) {
setTenantName(tenantClaim);
}
// Synchronize authenticated user profile with backend directory
// Step 1: Synchronize authenticated user profile with backend directory FIRST (/v1/api/integration/sync)
try {
const syncResult = await syncUserAccount(userSub, token, profile, true);
if (syncResult.isUnauthorized) {
@@ -488,6 +462,51 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
window.location.replace('/');
return;
}
// Step 2: Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
tenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
parsedTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...profile });
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
// Step 3: Verify tenant lockout now that user is synced and authenticated
const tenantLockoutResult = await verifyTenantLockout(
parsedTenantId ?? tenantClaimValues.tenantId,
tenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
profile.tenant = tenantLockoutResult.tenant.name;
setTenantName(tenantLockoutResult.tenant.name);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
} else if (tenantClaim) {
setTenantName(tenantClaim);
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
@@ -496,26 +515,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
try {
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
if (resolved.primaryRole) {
profile.role = resolved.primaryRole;
}
if (resolved.tenant) {
profile.tenant = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
profile.tenant_id = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...profile });
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
@@ -525,7 +524,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
const rawMsg = syncErr?.message || 'Account activation pending';
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setActivationError(cleanMsg);
}
setIsLoading(false);
@@ -561,10 +562,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub);
}
const storedTenantId = extractTenantId(parsedClaims, storedToken);
let storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId);
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
let storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
let resolvedUser: AuthUserProfile | null = null;
if (storedUser) {
@@ -588,9 +589,53 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Initial fast unblock: valid persisted session found, unblock UI immediately
setIsLoading(false);
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims)
// Asynchronous background revalidation (User Sync, Role Claims, Tenant Lockout)
(async () => {
try {
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
// Step 1: Sync user account FIRST
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
if (syncResult.isUnauthorized) {
await logout();
return;
}
// Step 2: Retrieve user roles, role claims, and user claims
try {
const resolved = await resolveUserRolesAndClaims(
storedSub || '',
parsedClaims,
resolvedUser || {}
);
if (resolvedUser) {
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
if (resolved.tenant) {
resolvedUser.tenant = resolved.tenant;
storedTenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
resolvedUser.tenant_id = resolved.tenant_id;
storedTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
// Step 3: Verify tenant lockout now that user is verified active
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
@@ -612,22 +657,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
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);
@@ -639,27 +668,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
const resolved = await resolveUserRolesAndClaims(
storedSub || '',
parsedClaims,
resolvedUser || {}
);
if (resolvedUser) {
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
if (resolved.tenant) {
resolvedUser.tenant = resolved.tenant;
setTenantName(resolved.tenant);
}
if (resolved.tenant_id !== undefined) {
resolvedUser.tenant_id = resolved.tenant_id;
setTenantId(resolved.tenant_id);
}
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
@@ -668,7 +676,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
const rawMsg = syncErr?.message || 'Account activation pending';
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setActivationError(cleanMsg);
}
})();
@@ -687,12 +697,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
try {
// Re-verify tenant status via GetTenant as part of the lockout resolution
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
await loadRuntimeConfig();
clearTenantLockoutCache();
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim);
// Step 1: Proceed with user sync FIRST (/v1/api/integration/sync)
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
return result;
}
if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
return result;
}
// Step 2: Refresh user roles and claims from backend
let currentTenantId: number | string | null = tenantId;
let currentTenantClaim: string | undefined =
(claims.tenant as string) || user?.tenant || undefined;
try {
const resolved = await resolveUserRolesAndClaims(sub, claims, user || {});
const updatedProfile = user ? { ...user } : null;
if (updatedProfile && resolved.primaryRole) {
updatedProfile.role = resolved.primaryRole;
}
if (resolved.tenant) {
currentTenantClaim = resolved.tenant;
setTenantName(resolved.tenant);
if (updatedProfile) updatedProfile.tenant = resolved.tenant;
}
if (resolved.tenant_id !== undefined) {
currentTenantId = resolved.tenant_id;
setTenantId(resolved.tenant_id);
if (updatedProfile) updatedProfile.tenant_id = resolved.tenant_id;
}
if (updatedProfile) {
setUser(updatedProfile);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(updatedProfile));
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
// Step 3: Re-verify tenant status via GetTenant now that user is synced
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
const effectiveTenantId =
currentTenantId ??
result.data?.tenantId ??
result.data?.organisationId ??
tenantClaimValues.tenantId;
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
const tenantLockoutResult = await verifyTenantLockout(
effectiveTenantId,
effectiveTenantClaim,
true
);
if (!tenantLockoutResult.isActive) {
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
setIsPendingActivation(true);
@@ -705,28 +768,22 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
};
}
// Tenant is verified Active: proceed with user sync
const result = await syncUserAccount(sub, accessToken, user || undefined, force);
if (result.isUnauthorized) {
await logout();
return result;
}
if (result.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
} else if (result.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(result.errorMessage || null);
if (tenantLockoutResult.tenant?.name) {
setTenantName(tenantLockoutResult.tenant.name);
}
setActivationPending(false);
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
return result;
} catch (err: any) {
if (err?.response?.status === 401) {
await logout();
return { success: false, isUnauthorized: true };
}
const msg = err?.message || 'Sync failed';
const rawMsg = err?.message || 'Sync failed';
const msg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(msg);
@@ -115,141 +115,94 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
navigate('/');
};
const isTenantIssue = !!errorMessage?.toLowerCase().includes('tenant');
const modalContent = (
<div
role="dialog"
aria-modal="true"
aria-labelledby="activation-modal-title"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-[#060a12]/90 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-[#060a12]/85 backdrop-blur-md animate-in fade-in duration-200 select-none overflow-y-auto"
>
{/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
<div
className="relative w-full max-w-xl bg-[#0d1322] border border-amber-500/30 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
className="relative w-full max-w-lg bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col text-slate-100 my-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Amber accent top bar */}
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500" />
{/* Subtle accent top border */}
<div className={`h-1 w-full ${isTenantIssue ? 'bg-amber-500' : 'bg-blue-600'}`} />
{/* Modal Header */}
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
{/* Header */}
<div className="p-5 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3.5">
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400">
<ShieldAlert className="w-6 h-6" />
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
</span>
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl border ${isTenantIssue ? 'bg-amber-500/10 border-amber-500/20 text-amber-400' : 'bg-blue-500/10 border-blue-500/20 text-blue-400'}`}>
<ShieldAlert className="w-5 h-5" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-full mb-1">
Access Restricted • Lockout Active
</span>
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
{errorMessage?.toLowerCase().includes('tenant')
? 'Tenant Lockout • Account Inactive'
: 'Account Pending Administrator Activation'}
<h2 id="activation-modal-title" className="text-base font-bold text-white tracking-tight">
{isTenantIssue ? 'Tenant Access Restricted' : 'Account Pending Activation'}
</h2>
<p className="text-xs text-slate-400 mt-0.5">
{isTenantIssue
? 'The assigned tenant partition is currently inactive or restricted.'
: 'Your account is authenticated and awaiting administrator enablement.'}
</p>
</div>
</div>
<BrandLogo size={36} showGlow={false} animateOnHover={false} />
<BrandLogo size={32} showGlow={false} animateOnHover={false} />
</div>
</div>
{/* Modal Body */}
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]">
{/* Status notification block */}
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>Identity Verified & Signed In Successfully</span>
{/* Body */}
<div className="p-5 space-y-4">
{/* User Profile Summary */}
<div className="rounded-xl border border-slate-800/80 bg-slate-900/60 p-3.5 space-y-2 text-xs">
<div className="grid grid-cols-2 gap-2.5">
<div>
<span className="text-[10px] uppercase font-semibold text-slate-500 block">User</span>
<span className="font-semibold text-white truncate block">{displayName}</span>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Email</span>
<span className="text-slate-300 truncate block">{displayEmail || '—'}</span>
</div>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
You are a registered user and have successfully authenticated via Enterprise Single Sign-On.
However, your account has not yet been enabled in the system directory by a system administrator.
</p>
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
If your administrator informs you that your account has been activated, please click the{' '}
<strong className="text-white">Sync Account</strong> button below to refresh your status and unlock the system.
</p>
</div>
{/* User Profile Card */}
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
<span>Authenticated User Profile</span>
<span className="text-amber-400/90 text-[10px] font-mono bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Lockout Active
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<UserCheck className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Name</span>
<span className="font-semibold text-white truncate block">{displayName}</span>
</div>
<div className="grid grid-cols-2 gap-2.5 border-t border-slate-800/60 pt-2">
<div>
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Tenant</span>
<span className="text-slate-300 truncate block">{tenant}</span>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Mail className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Email</span>
<span className="font-semibold text-slate-200 truncate block">{displayEmail || 'Not specified'}</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Building2 className="w-4 h-4 text-indigo-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Tenant / Role</span>
<span className="font-semibold text-slate-200 truncate block">
{tenant} ({role})
</span>
</div>
</div>
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
<Fingerprint className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-slate-500 block">Subject ID</span>
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
</div>
<div>
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Role</span>
<span className="text-blue-400 font-medium truncate block">{role}</span>
</div>
</div>
</div>
{/* Sync Attempt Feedback / Error Notice */}
{/* Feedback notice if sync failed */}
{errorMessage && (
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 flex items-start gap-3 animate-in fade-in duration-150">
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs font-semibold text-rose-300">
{errorMessage?.toLowerCase().includes('tenant')
? 'Tenant Lockout In Effect'
: 'Account Not Activated Yet'}
</p>
<p className="text-xs text-rose-200/90 leading-relaxed">{errorMessage}</p>
{syncAttemptCount > 0 && (
<p className="text-[11px] text-rose-400/80 pt-1">
Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()}
</p>
)}
<div className="rounded-xl border border-amber-500/25 bg-amber-500/10 p-3 flex items-start gap-2.5 text-xs text-amber-200">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="min-w-0">
<span className="font-semibold text-amber-300 block">Notice:</span>
<p className="text-[11px] text-amber-200/90 leading-relaxed mt-0.5">{errorMessage}</p>
</div>
</div>
)}
<p className="text-[11px] text-slate-400 text-center">
Once your administrator enables your account, click <strong className="text-white">Sync Account</strong> to enter the console.
</p>
</div>
{/* Modal Actions */}
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3">
{/* Footer Actions */}
<div className="p-4 border-t border-slate-800 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-2.5">
<div className="flex items-center gap-2 w-full sm:w-auto">
<button
onClick={handleNavigateLanding}
type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-slate-200 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
title="View public landing page. Entering the console again will display this modal until enabled."
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded-xl transition cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Landing Page</span>
@@ -258,8 +211,7 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
<button
onClick={handleSignOut}
type="button"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/30 rounded-xl transition cursor-pointer"
title="Sign out of current SSO session"
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-400 hover:text-rose-400 bg-slate-900 hover:bg-rose-500/10 border border-slate-800 hover:border-rose-500/20 rounded-xl transition cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
@@ -271,10 +223,10 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
disabled={isSyncing}
type="button"
id="activation-sync-btn"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-blue-600/30 transition cursor-pointer"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:opacity-50 rounded-xl shadow-md transition cursor-pointer"
>
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span>
<RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
<span>{isSyncing ? 'Checking...' : 'Sync Account'}</span>
</button>
</div>
</div>
@@ -61,114 +61,57 @@ export const PartnerAccessDeniedModal: React.FC<PartnerAccessDeniedModalProps> =
aria-modal="true"
aria-labelledby="partner-denied-title"
aria-describedby="partner-denied-description"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-150 overflow-y-auto"
>
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl bg-[#0e1424] border border-rose-900/60 shadow-2xl shadow-rose-950/50 my-auto">
{/* Glow Header Accent */}
<div className="h-2 w-full bg-linear-to-r from-rose-600 via-red-500 to-amber-500" />
<div className="relative w-full max-w-md overflow-hidden rounded-2xl bg-[#0d1322] border border-slate-800 shadow-2xl my-auto text-slate-100">
<div className="h-1 w-full bg-rose-600" />
<div className="p-6 sm:p-8 space-y-6">
{/* Header */}
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-rose-500/15 border border-rose-500/30 text-rose-400">
<Ban className="h-6 w-6 text-rose-400" />
<div className="p-5 space-y-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400">
<Ban className="h-5 w-5" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-rose-400 bg-rose-950/60 px-2 py-0.5 rounded-md border border-rose-800/60">
<Lock className="w-3 h-3" />
Restricted Role
</span>
<span className="text-xs text-slate-500">Security Policy</span>
</div>
<h2 id="partner-denied-title" className="text-lg font-bold text-white tracking-tight">
Access Denied: Partner Role
<div>
<h2 id="partner-denied-title" className="text-base font-bold text-white tracking-tight">
Partner Portal Required
</h2>
<p id="partner-denied-description" className="text-xs text-slate-400 leading-relaxed">
Users assigned the <strong className="text-rose-300">Partner</strong> role by security are not permitted to access the PFM Backoffice Console.
<p id="partner-denied-description" className="text-xs text-slate-400 mt-0.5">
Accounts with the Partner role must access the Partner Portal.
</p>
</div>
</div>
{/* User & Identity Summary Card */}
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs">
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium">Account Name</span>
<span className="text-white font-semibold">{displayName}</span>
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-3.5 space-y-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-slate-500 text-[11px] uppercase font-semibold">User</span>
<span className="text-white font-medium">{displayName}</span>
</div>
{displayEmail && (
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5 text-slate-500" />
Email
</span>
<span className="text-slate-200 font-mono">{displayEmail}</span>
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
<span className="text-slate-500 text-[11px] uppercase font-semibold">Email</span>
<span className="text-slate-300 font-mono text-[11px]">{displayEmail}</span>
</div>
)}
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-slate-500" />
Assigned Role
</span>
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
{role}
</span>
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
<span className="text-slate-500 text-[11px] uppercase font-semibold">Role</span>
<span className="text-rose-400 font-medium">{role}</span>
</div>
{tenant && (
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
<span className="text-slate-400 font-medium flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-slate-500" />
Tenant Context
</span>
<span className="text-slate-200 font-medium truncate max-w-[220px]">{tenant}</span>
</div>
)}
{sub && (
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span className="flex items-center gap-1">
<Fingerprint className="w-3 h-3" />
Subject Key
</span>
<span className="font-mono text-slate-400 truncate max-w-[200px]">{sub}</span>
</div>
)}
</div>
{/* Security Policy Information */}
<div className="p-4 rounded-xl bg-rose-950/20 border border-rose-900/40 text-xs text-rose-200 leading-relaxed space-y-1.5">
<p className="font-semibold text-rose-300 flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4 text-rose-400" />
Administrative Boundary Notice
</p>
<p className="text-[11px] text-rose-300/90">
The PFM Console is reserved for statutory administrators, sponsors, programme managers, and governance officers. Partner credentials cannot access this environment. If you require backoffice administrative access, please request an updated role assignment from your system administrator.
</p>
</div>
<p className="text-[11px] text-slate-400 text-center leading-relaxed">
The Console is reserved for statutory administrators and programme managers.
</p>
{/* Actions */}
<div className="pt-2">
<div className="pt-1">
<button
onClick={handleLogoutClick}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-semibold border border-slate-700 transition cursor-pointer shadow-sm"
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md transition cursor-pointer"
>
<LogOut className="w-4 h-4 text-rose-400" />
<span>Sign Out & Return to Home</span>
<LogOut className="w-4 h-4" />
<span>Sign Out & Return Home</span>
</button>
</div>
</div>
{/* Footer Brand watermark */}
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
<div className="flex items-center gap-2">
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
<span>Post Fund Management (PFM)</span>
</div>
<span>National Security Gateway</span>
</div>
</div>
</div>
);
@@ -87,7 +87,7 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
const modalContent = (
<div
id="session-expired-modal-backdrop"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 animate-in fade-in duration-200 overflow-y-auto"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-150 overflow-y-auto"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
role="dialog"
@@ -96,27 +96,20 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
>
<div
id="session-expired-modal-content"
className="relative w-full max-w-md bg-[#0c121e] border border-amber-500/40 rounded-2xl shadow-2xl shadow-black/90 p-6 overflow-hidden text-slate-200 space-y-5 my-auto"
className="relative w-full max-w-md bg-[#0d1322] border border-slate-800 rounded-2xl shadow-2xl p-5 overflow-hidden text-slate-200 space-y-4 my-auto"
>
{/* Subtle decorative top accent bar */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
<div className="absolute top-0 left-0 right-0 h-1 bg-amber-500" />
{/* Top bar with close button */}
<div className="flex items-start justify-between pt-1">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0 shadow-lg shadow-amber-500/10">
<ShieldAlert className="w-6 h-6" />
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400 shrink-0">
<ShieldAlert className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
Session Expired
</h3>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-rose-950/60 text-rose-300 border border-rose-800/60">
HTTP 401
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">Authorization token invalidated</p>
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
Session Expired
</h3>
<p className="text-xs text-slate-400">Authorization credentials expired</p>
</div>
</div>
@@ -125,60 +118,46 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
type="button"
onClick={handleAction}
aria-label="Dismiss and return to landing page"
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors"
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Informative Explanation (non-technical and clear) */}
<div className="space-y-2.5 text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-4 rounded-xl border border-slate-800/80">
<div className="text-xs text-slate-300 leading-relaxed bg-slate-900/60 p-3.5 rounded-xl border border-slate-800/80 space-y-1.5">
<p>
Your active working session has ended or your security credentials have expired. To protect your data and prevent unauthorized changes, your session has been closed.
Your active session has ended to protect account security. Please sign in again to continue working.
</p>
{reason && (
<div className="flex items-start gap-2 pt-1 text-[11px] text-amber-300/90 font-mono">
<div className="flex items-start gap-1.5 pt-1 text-[11px] text-amber-300/90 font-mono">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
<span className="break-words">{reason}</span>
</div>
)}
</div>
{/* Progress Bar Countdown */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-400 flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-amber-400" />
Returning to landing page in{' '}
<strong className="text-white font-mono">{secondsRemaining}s</strong>
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-amber-400" />
Redirecting in <strong className="text-white font-mono">{secondsRemaining}s</strong>
</span>
{isPaused && (
<span className="text-[10px] text-amber-400/80 px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20">
Paused
</span>
{isPaused && (
<span className="text-[10px] text-amber-400/80 font-medium px-1.5 py-0.2 rounded bg-amber-950/40 border border-amber-900/60">
Paused on hover
</span>
)}
</div>
{/* Progress bar line */}
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 transition-all duration-75 ease-linear"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
</div>
{/* Actions */}
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
<div className="pt-1">
<button
id="session-expired-return-btn"
type="button"
onClick={handleAction}
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition-all flex items-center justify-center gap-2"
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition-all flex items-center justify-center gap-2 cursor-pointer"
>
<LogOut className="w-4 h-4" />
<span>Return to Landing Page Now</span>
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
<span>Return to Landing Page</span>
<ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
@@ -48,7 +48,7 @@ const instrumentSchema = z.object({
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
code: z.string().optional(),
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
status: z.coerce.number().default(ActivityStatus.Active),
status: z.coerce.number(),
});
type InstrumentFormValues = z.infer<typeof instrumentSchema>;
@@ -46,8 +46,8 @@ const ruleSchema = z.object({
programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'),
name: z.string().min(3, 'Rule name must be at least 3 characters'),
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
version: z.coerce.number().min(1, 'Version must be at least 1').default(1),
status: z.coerce.number().default(ActivityStatus.Active),
version: z.coerce.number().min(1, 'Version must be at least 1'),
status: z.coerce.number(),
});
type RuleFormValues = z.infer<typeof ruleSchema>;
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
import { NavLink, Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
import { NotificationBell } from './NotificationBell';
import {
Inbox,
@@ -124,7 +124,7 @@ const navItems: NavItem[] = [
claim: 'tenant_management',
},
{
name: 'Mailbox & Inbox',
name: 'Inbox',
path: '/inbox',
icon: Inbox,
help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.',
@@ -144,6 +144,27 @@ export const AppLayout: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
const isSectionCollapsed = (path: string, subItems?: SubNavItem[]): boolean => {
if (collapsedSections[path] !== undefined) {
return collapsedSections[path];
}
if (subItems?.some((s) => location.pathname.startsWith(s.path))) {
return false;
}
return false;
};
const toggleSection = (path: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCollapsedSections((prev) => ({
...prev,
[path]: !isSectionCollapsed(path),
}));
};
const handleLogout = async () => {
await logout();
@@ -186,7 +207,6 @@ export const AppLayout: React.FC = () => {
useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
@@ -194,7 +214,6 @@ export const AppLayout: React.FC = () => {
queryKey: ['active-tenant-header', tenantId],
queryFn: () => getTenant(tenantId!),
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
staleTime: 5 * 60 * 1000,
});
// Ensure current logged-in user is registered in the email cache
@@ -346,26 +365,45 @@ export const AppLayout: React.FC = () => {
);
}
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
return (
<div key={item.path} className="space-y-1">
<NavLink
to={item.path}
end={hasSub}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
title={item.name}
>
<Icon className="w-4.5 h-4.5 shrink-0" />
<span className="truncate flex-1">{item.name}</span>
</NavLink>
<div className="flex items-center gap-1">
<NavLink
to={item.path}
end={hasSub}
className={({ isActive }) =>
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
title={item.name}
>
<Icon className="w-4.5 h-4.5 shrink-0" />
<span className="truncate flex-1">{item.name}</span>
</NavLink>
{hasSub && (
<button
type="button"
onClick={(e) => toggleSection(item.path, e)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800/80 rounded-lg transition-colors cursor-pointer"
title={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
aria-label={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform duration-200 ${
isCollapsed ? '-rotate-90 text-slate-500' : 'rotate-0 text-slate-300'
}`}
/>
</button>
)}
</div>
{/* Sub-items under parent item */}
{hasSub && (
{hasSub && !isCollapsed && (
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
{item.subItems?.map((subItem) => {
const SubIcon = subItem.icon;
@@ -36,7 +36,7 @@ import {
getAllProgrammes,
getProjects,
} from '../../services/api';
import { toolsService } from '../../services/toolsService';
import { toolsService } from '../../utils/hashids';
import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast';
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
@@ -898,7 +898,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
className="space-y-4"
>
{/* ProblemDetails Error Alert */}
{createApiError && (
{Boolean(createApiError) && (
<ProblemDetailsAlert
error={createApiError}
onDismiss={() => setCreateApiError(null)}
@@ -1126,7 +1126,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
}}
className="space-y-4"
>
{uploadApiError && (
{Boolean(uploadApiError) && (
<ProblemDetailsAlert
error={uploadApiError}
onDismiss={() => setUploadApiError(null)}
@@ -1228,7 +1228,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
}}
className="space-y-4"
>
{updateApiError && (
{Boolean(updateApiError) && (
<ProblemDetailsAlert
error={updateApiError}
onDismiss={() => setUpdateApiError(null)}
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
<span>Active / Executed</span>
</div>
) : (
(() => {
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
title="Approved evidence is immutable. Status cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<>
@@ -9,12 +9,12 @@ import {
CheckCircle2,
Bold,
Italic,
Underline,
Heading2,
List,
ListOrdered,
Quote,
Table,
Code,
Minus,
Sparkles,
Eye,
@@ -25,7 +25,13 @@ import {
import { UserSearchPicker, SelectedUser } from './UserSearchPicker';
import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications';
import { NotificationPlatform } from '../../types';
import { renderMarkdownToHtml, parseMarkdownToHtml, PFM_LOGO_DATA_URI } from '../../lib/markdownUtils';
import {
renderMarkdownToHtml,
parseMarkdownToHtml,
isHtmlContent,
stripMarkdown,
PFM_LOGO_DATA_URI,
} from '../../lib/markdownUtils';
import { BrandLogo } from '../ui/BrandLogo';
import { useAuth } from '../../auth/AuthProvider';
@@ -49,7 +55,12 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
draftToEdit,
}) => {
const { user, claims } = useAuth();
const tenantName = (claims?.tenant as string) || user?.tenant || 'Department of Science, Innovation & Technology';
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
const tenantName =
(claims?.tenant as string) ||
user?.tenant ||
'Department of Science, Innovation & Technology';
const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications();
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
@@ -60,40 +71,78 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
const [error, setError] = useState<string | null>(null);
const [successNotice, setSuccessNotice] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const isCurrentUser = (candidate?: { id?: string; email?: string }) => {
if (!candidate) return false;
const cid = String(candidate.id || '').trim().toLowerCase();
const cemail = String(candidate.email || '').trim().toLowerCase();
if (currentUserId && cid === currentUserId) return true;
if (currentUserEmail && cemail === currentUserEmail) return true;
return false;
};
const toInitialRichHtml = (raw: string): string => {
if (!raw || !raw.trim()) return '';
if (isHtmlContent(raw)) return raw;
return parseMarkdownToHtml(raw);
};
// Initialize or populate from draft or props
useEffect(() => {
if (!isOpen) return;
let initialHtml = '';
if (draftToEdit) {
setSelectedUsers(
draftToEdit.recipientId
? [
{
id: draftToEdit.recipientId,
email: draftToEdit.recipientEmail,
name: draftToEdit.recipientName,
},
]
: []
);
const candidate = draftToEdit.recipientId
? {
id: draftToEdit.recipientId,
email: draftToEdit.recipientEmail,
name: draftToEdit.recipientName,
}
: undefined;
setSelectedUsers(candidate && !isCurrentUser(candidate) ? [candidate] : []);
setSubject(draftToEdit.subject || '');
setMessage(draftToEdit.message || '');
setPlatform(NotificationPlatform.InApp);
initialHtml = toInitialRichHtml(draftToEdit.message || '');
setMessage(initialHtml);
setPlatform(draftToEdit.platform || NotificationPlatform.InApp);
} else {
setSelectedUsers(initialRecipient ? [initialRecipient] : []);
setSelectedUsers(
initialRecipient && !isCurrentUser(initialRecipient) ? [initialRecipient] : []
);
setSubject(initialSubject || '');
setMessage(initialMessage || '');
initialHtml = toInitialRichHtml(initialMessage || '');
setMessage(initialHtml);
setPlatform(initialPlatform);
}
setActiveTab('write');
setError(null);
setSuccessNotice(null);
setTimeout(() => {
if (editorRef.current) {
editorRef.current.innerHTML = initialHtml;
}
}, 0);
}, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]);
// Restore editor innerHTML when switching back from preview tab
useEffect(() => {
if (activeTab === 'write' && editorRef.current) {
if (editorRef.current.innerHTML !== message) {
editorRef.current.innerHTML = message;
}
}
}, [activeTab]);
if (!isOpen) return null;
const handleSelectUser = (user: SelectedUser) => {
setSelectedUsers([user]);
const handleSelectUser = (selected: SelectedUser) => {
if (isCurrentUser(selected)) {
setError('You cannot send a message to yourself.');
return;
}
setSelectedUsers([selected]);
setError(null);
};
@@ -101,51 +150,59 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
setSelectedUsers([]);
};
// Helper to insert markdown text at cursor in textarea
const insertMarkdown = (before: string, after: string = '', defaultText: string = '') => {
const textarea = textareaRef.current;
if (!textarea) return;
const syncEditorContent = () => {
if (editorRef.current) {
setMessage(editorRef.current.innerHTML);
}
};
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selection = text.substring(start, end) || defaultText;
const applyFormat = (command: string, value?: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
document.execCommand(command, false, value);
syncEditorContent();
};
const replacement = `${before}${selection}${after}`;
const newText = text.substring(0, start) + replacement + text.substring(end);
setMessage(newText);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(
start + before.length,
start + before.length + selection.length
);
}, 0);
const insertHtmlBlock = (htmlSnippet: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const inserted = document.execCommand('insertHTML', false, htmlSnippet);
if (!inserted) {
editor.innerHTML += htmlSnippet;
}
syncEditorContent();
};
// Quick insertions for PFM branding and templates
const insertBrandedCallout = () => {
insertMarkdown(
`> [!NOTE]\n> **Post-Fund Management Official Notice**\n> Please verify all supporting evidence documentation before tranche disbursement sign-off.\n\n`
insertHtmlBlock(
`<div style="background:rgba(12,74,110,0.25);border-left:4px solid #38bdf8;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>Post-Fund Management Official Notice</strong><br/>Please verify all supporting evidence documentation before tranche disbursement sign-off.</div><p><br/></p>`
);
};
const insertMilestoneTable = () => {
insertMarkdown(
`| Deliverable Code | Milestone Name | Status | Due Date |\n| :--- | :--- | :--- | :--- |\n| DEL-01 | Technical Architecture Review | Verified | 2026-11-15 |\n| DEL-02 | Financial Expenditure Proof | Under Review | 2026-12-01 |\n\n`
insertHtmlBlock(
`<div style="overflow-x:auto;margin:12px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;font-size:12px;text-align:left;"><thead><tr style="background:#1e293b;color:#f8fafc;"><th style="padding:8px 12px;border:1px solid #334155;">Deliverable Code</th><th style="padding:8px 12px;border:1px solid #334155;">Milestone Name</th><th style="padding:8px 12px;border:1px solid #334155;">Status</th><th style="padding:8px 12px;border:1px solid #334155;">Due Date</th></tr></thead><tbody><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-01</td><td style="padding:8px 12px;border:1px solid #334155;">Technical Architecture Review</td><td style="padding:8px 12px;border:1px solid #334155;">Verified</td><td style="padding:8px 12px;border:1px solid #334155;">2026-11-15</td></tr><tr style="background:#0f172a;color:#cbd5e1;"><td style="padding:8px 12px;border:1px solid #334155;">DEL-02</td><td style="padding:8px 12px;border:1px solid #334155;">Financial Expenditure Proof</td><td style="padding:8px 12px;border:1px solid #334155;">Under Review</td><td style="padding:8px 12px;border:1px solid #334155;">2026-12-01</td></tr></tbody></table></div><p><br/></p>`
);
};
const insertPlatformLogo = () => {
insertMarkdown(
`![PFM Platform Logo](${PFM_LOGO_DATA_URI})\n\n`
insertHtmlBlock(
`<p><img src="${PFM_LOGO_DATA_URI}" alt="PFM Platform Logo" width="48" height="48" style="display:inline-block;border-radius:10px;margin:6px 0;" /></p><p><br/></p>`
);
};
const hasMeaningfulContent = (html: string) => {
if (!html) return false;
if (stripMarkdown(html).trim().length > 0) return true;
return /<(img|table|hr)\b/i.test(html);
};
const handleSaveDraft = () => {
if (!subject.trim() && !message.trim()) {
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
if (!subject.trim() && !hasMeaningfulContent(currentHtml)) {
setError('Please add a subject or message to save as draft');
return;
}
@@ -157,29 +214,42 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
recipientEmail: firstUser?.email || '',
recipientName: firstUser?.name || '',
subject: subject.trim() || 'Untitled Draft',
message: message.trim(),
message: currentHtml.trim(),
platform,
});
setSuccessNotice('Draft saved successfully in Drafts folder');
setTimeout(() => {
onClose();
}, 800);
}, 600);
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
const currentHtml = editorRef.current ? editorRef.current.innerHTML : message;
if (selectedUsers.length === 0) {
setError('Please select a recipient from the tenant directory');
return;
}
if (selectedUsers.length > 1) {
setError('Only one recipient is allowed per message');
return;
}
if (isCurrentUser(selectedUsers[0])) {
setError('You cannot send a message to yourself');
return;
}
if (!subject.trim()) {
setError('Please enter a subject');
return;
}
if (!message.trim()) {
if (!hasMeaningfulContent(currentHtml)) {
setError('Please enter a message body');
return;
}
@@ -188,18 +258,15 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
try {
const recipient = selectedUsers[0];
// Format payload according to chosen platform:
// InApp messages are saved in markdown format.
// Email notifications are transformed from markdown to branded executive HTML before saving.
const finalMessage =
platform === NotificationPlatform.Email
? renderMarkdownToHtml(message.trim(), {
? renderMarkdownToHtml(currentHtml.trim(), {
applyBranding: true,
emailFormat: true,
subject: subject.trim(),
tenantName,
})
: message.trim();
: currentHtml.trim();
await sendMessage({
recipientId: recipient.id,
@@ -207,13 +274,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
recipientName: recipient.name,
subject: subject.trim(),
message: finalMessage,
platform,
draftId: draftToEdit?.id,
});
setSuccessNotice(
platform === NotificationPlatform.Email
? 'Email notification rendered to HTML and dispatched!'
: 'In-App Markdown message sent successfully!'
? 'Email notification dispatched!'
: 'In-App message sent successfully!'
);
setTimeout(() => {
onClose();
@@ -239,7 +307,9 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
subject: subject || 'Notice Preview',
tenantName,
})
: parseMarkdownToHtml(message);
: message;
const plainCharCount = stripMarkdown(message).length;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
@@ -258,11 +328,11 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
: 'Compose New Communication'}
</h3>
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
Rich Markdown Engine
Rich Text Composer
</span>
</div>
<p className="text-[11px] text-slate-400">
Author in Markdown with live preview, SVG branding, and dual InApp / Email delivery
Type and format your message using the toolbar for In-App or branded Email delivery
</p>
</div>
</div>
@@ -312,7 +382,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`}
>
<Smartphone className="w-3.5 h-3.5" />
<span>In-App (Markdown)</span>
<span>In-App</span>
</button>
<button
@@ -325,7 +395,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`}
>
<Mail className="w-3.5 h-3.5" />
<span>Email (HTML + Brand)</span>
<span>Email</span>
</button>
</div>
</div>
@@ -335,12 +405,12 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
{platform === NotificationPlatform.InApp ? (
<span className="text-blue-300 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Saved directly as <strong>Markdown</strong>. Delivered instantly to the recipient's InApp Mailbox & Notification Bell.
Delivered instantly to the recipient's <strong>In-App</strong> Mailbox & Notification Bell.
</span>
) : (
<span className="text-emerald-300 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
Composed in Markdown, transformed into <strong>Branded Executive HTML</strong> with embedded SVG logo before dispatching.
Formatted with <strong>PFM Executive Branding</strong> and embedded logo before dispatching via email.
</span>
)}
</div>
@@ -373,14 +443,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
/>
</div>
{/* Markdown Editor / Preview Tabs */}
{/* Rich Text Editor / Branded Preview Tabs */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-300">
Message Content <span className="text-rose-400">*</span>
</label>
{/* Write vs Preview Tabs */}
{/* Compose vs Email Preview Tabs */}
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
<button
type="button"
@@ -392,11 +462,14 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`}
>
<Edit3 className="w-3 h-3" />
<span>Write Markdown</span>
<span>Compose</span>
</button>
<button
type="button"
onClick={() => setActiveTab('preview')}
onClick={() => {
syncEditorContent();
setActiveTab('preview');
}}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
activeTab === 'preview'
? 'bg-blue-600 text-white font-semibold shadow-xs'
@@ -404,37 +477,48 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
}`}
>
<Eye className="w-3 h-3" />
<span>Preview Rendered</span>
<span>Preview Delivery</span>
</button>
</div>
</div>
{/* Markdown Toolbar (Only when activeTab === 'write') */}
{activeTab === 'write' ? (
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
{/* Toolbar */}
{/* WYSIWYG Formatting Toolbar */}
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
<button
type="button"
onClick={() => insertMarkdown('**', '**', 'bold text')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bold (**text**)"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('bold')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bold"
>
<Bold className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown('*', '*', 'italic text')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Italic (*text*)"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('italic')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Italic"
>
<Italic className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown('### ', '', 'Heading Title')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Heading (### )"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('underline')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Underline"
>
<Underline className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'H3')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Heading"
>
<Heading2 className="w-3.5 h-3.5" />
</button>
@@ -443,25 +527,28 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button
type="button"
onClick={() => insertMarkdown('- ', '', 'List item')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bullet List (- )"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertUnorderedList')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Bullet List"
>
<List className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown('1. ', '', 'First item')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Numbered List (1. )"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertOrderedList')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Numbered List"
>
<ListOrdered className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown('> ', '', 'Quoted text')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Blockquote (> )"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Quote Block"
>
<Quote className="w-3.5 h-3.5" />
</button>
@@ -470,25 +557,19 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertMilestoneTable}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Insert Deliverable Table"
>
<Table className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown('```\n', '\n```', 'code block')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Code Block (```)"
>
<Code className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => insertMarkdown('\n---\n')}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Horizontal Rule (---)"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertHorizontalRule')}
className="p-1.5 rounded-lg text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Insert Divider Line"
>
<Minus className="w-3.5 h-3.5" />
</button>
@@ -498,6 +579,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
{/* Platform Brand Insertions */}
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertBrandedCallout}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
title="Insert official PFM callout block"
@@ -508,46 +590,54 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={insertPlatformLogo}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
title="Embed official SVG PFM logo into markdown"
title="Embed official PFM logo"
>
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
<span>Embed Logo</span>
</button>
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
rows={8}
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Compose your message in Markdown... Use formatting buttons above for headings, bullet points, deliverable tables, and official PFM notices."
className="w-full p-4 bg-transparent text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition resize-y leading-relaxed font-mono"
/>
{/* Rich Text WYSIWYG Surface */}
<div className="relative">
{!hasMeaningfulContent(message) && (
<div className="pointer-events-none absolute top-4 left-4 right-4 text-slate-500 text-xs select-none">
Type your message here and highlight text or use the toolbar above to format headings, bullet points, tables, and official notices...
</div>
)}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={syncEditorContent}
onBlur={syncEditorContent}
className="w-full min-h-[200px] max-h-[320px] overflow-y-auto p-4 bg-transparent text-slate-100 text-xs focus:outline-none leading-relaxed font-sans [&_h1]:text-lg [&_h1]:font-bold [&_h1]:text-white [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-bold [&_h2]:text-white [&_h2]:my-2 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:text-white [&_h3]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-1.5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-300 [&_blockquote]:my-2 [&_hr]:border-slate-700 [&_hr]:my-3"
/>
</div>
</div>
) : (
/* Live Fully-Rendered Markdown / HTML Preview */
/* Live Fully-Rendered Delivery Preview */
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
Live Rendered Preview ({platform === NotificationPlatform.Email ? 'HTML Email' : 'InApp Markdown'})
Delivery Preview ({platform === NotificationPlatform.Email ? 'Email' : 'In-App'})
</span>
<span className="text-[10px] text-slate-400 font-mono">
Subject: {subject || 'Untitled'}
</span>
</div>
{message.trim() ? (
{hasMeaningfulContent(message) ? (
<div
className="prose prose-invert max-w-none text-xs leading-relaxed"
className="prose prose-invert max-w-none text-xs leading-relaxed [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-3 [&_blockquote]:italic"
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
/>
) : (
<div className="py-12 text-center text-slate-400 text-xs italic">
Type your markdown message in the Write tab to see it live-rendered here.
Type your message in the Compose tab to preview how it will appear upon delivery.
</div>
)}
</div>
@@ -560,7 +650,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
<span>Tenant Partition:</span>
<strong className="text-slate-300">{tenantName}</strong>
</span>
<span className="font-mono">{message.length} characters</span>
<span className="font-mono">{plainCharCount} characters</span>
</div>
{/* Modal Footer Action Buttons */}
@@ -608,7 +698,7 @@ export const ComposeMessageModal: React.FC<ComposeMessageModalProps> = ({
)}
<span>
{platform === NotificationPlatform.Email
? 'Dispatch Email (HTML)'
? 'Dispatch Email'
: 'Send In-App Message'}
</span>
</button>
@@ -1,14 +1,11 @@
import React, { useState, useMemo } from 'react';
import {
Code,
Eye,
Copy,
Check,
ShieldCheck,
ExternalLink,
} from 'lucide-react';
import { BrandLogo } from '../ui/BrandLogo';
import { parseMarkdownToHtml, isHtmlContent } from '../../lib/markdownUtils';
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
interface MarkdownViewerProps {
content: string;
@@ -19,11 +16,9 @@ interface MarkdownViewerProps {
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content,
subject,
showBrandingHeader = true,
className = '',
}) => {
const [viewMode, setViewMode] = useState<'rendered' | 'raw'>('rendered');
const [copied, setCopied] = useState(false);
const isHtml = useMemo(() => isHtmlContent(content), [content]);
@@ -35,7 +30,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}, [content, isHtml]);
const handleCopy = () => {
navigator.clipboard.writeText(content);
navigator.clipboard.writeText(stripMarkdown(content) || content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
@@ -48,49 +43,21 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
{showBrandingHeader && (
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
<span className="text-[11px] tracking-tight">PFM Markdown Engine</span>
<span className="text-[11px] tracking-tight">PFM Communications</span>
</div>
)}
<span className="text-[10px] text-blue-400 font-mono">
{isHtml ? 'Format: HTML Email' : 'Format: GitHub Flavored Markdown'}
In-App Message
</span>
</div>
<div className="flex items-center gap-2">
{/* Toggle View Mode */}
<div className="flex items-center rounded-lg bg-slate-950/70 p-0.5 border border-slate-800">
<button
type="button"
onClick={() => setViewMode('rendered')}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
viewMode === 'rendered'
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
>
<Eye className="w-3 h-3" />
<span>Rendered</span>
</button>
<button
type="button"
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
viewMode === 'raw'
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
: 'text-slate-400 hover:text-white'
}`}
>
<Code className="w-3 h-3" />
<span>Raw Source</span>
</button>
</div>
{/* Copy Button */}
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
title="Copy content to clipboard"
title="Copy message text to clipboard"
>
{copied ? (
<>
@@ -108,25 +75,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
</div>
{/* Content Rendering */}
{viewMode === 'rendered' ? (
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
{isHtml ? (
<div
className="prose prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
) : (
<div
className="prose prose-invert max-w-none [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_a]:text-blue-400 [&_a:hover]:underline [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
)}
</div>
) : (
<pre className="p-5 overflow-x-auto font-mono text-xs text-slate-300 bg-[#070b13] select-all whitespace-pre-wrap leading-relaxed">
{content}
</pre>
)}
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
<div
className="prose prose-invert max-w-none [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-blue-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
{/* Branded Footer Stamp */}
{showBrandingHeader && (
@@ -136,7 +90,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<span>Verified PFM Secure Platform Formatting</span>
</div>
<span className="font-mono text-[10px] text-slate-400">
{content.length} characters
{stripMarkdown(content).length} characters
</span>
</div>
)}
@@ -15,6 +15,7 @@ import {
} from 'lucide-react';
import { getUsers } from '../../services/api';
import { User, ActivityStatus } from '../../types';
import { useAuth } from '../../auth/AuthProvider';
export interface SelectedUser {
id: string;
@@ -43,9 +44,12 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
selectedUsers,
onSelectUser,
onRemoveUser,
multiple = false,
placeholder = 'Search by name, email, or role...',
}) => {
const { user, claims } = useAuth();
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
const currentUserEmail = String(user?.email || claims?.email || '').trim().toLowerCase();
const [searchTerm, setSearchTerm] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
@@ -54,13 +58,19 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
// Filter active users
// Filter active users and exclude the current logged-in user so they can never message themselves
const activeUsers = useMemo(() => {
return users.filter(u => u.status === ActivityStatus.Active && !!u.email);
}, [users]);
return users.filter(u => {
if (u.status !== ActivityStatus.Active || !u.email) return false;
const uid = String(u.id || '').trim().toLowerCase();
const uemail = u.email.trim().toLowerCase();
if (currentUserId && uid === currentUserId) return false;
if (currentUserEmail && uemail === currentUserEmail) return false;
return true;
});
}, [users, currentUserId, currentUserEmail]);
// Organize users into intuitive functional groups
const groupedUsers = useMemo(() => {
@@ -172,87 +182,83 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
if (!multiple) {
// Single selection replaces
onSelectUser({
id: u.id,
email: u.email,
name: formattedName,
role: String(u.role ?? 'User'),
});
setIsDropdownOpen(false);
} else {
onSelectUser({
id: u.id,
email: u.email,
name: formattedName,
role: String(u.role ?? 'User'),
});
}
// Strictly single-recipient selection
onSelectUser({
id: u.id,
email: u.email,
name: formattedName,
role: String(u.role ?? 'User'),
});
setSearchTerm('');
setIsDropdownOpen(false);
}
};
const singleSelectedUser = selectedUsers[0];
return (
<div className="relative w-full">
{/* Selected Users Chips */}
{selectedUsers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedUsers.map(su => (
<span
key={su.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-blue-500/15 border border-blue-500/30 text-blue-200 text-xs font-medium shadow-xs"
>
<div className="w-4 h-4 rounded-full bg-blue-600/40 text-[9px] font-bold flex items-center justify-center text-blue-300">
{su.name ? su.name.slice(0, 2).toUpperCase() : su.email.slice(0, 2).toUpperCase()}
</div>
<span className="truncate max-w-[180px]">{su.name || su.email}</span>
{su.role && (
<span className="text-[10px] text-blue-400 bg-blue-900/40 px-1 py-0.2 rounded border border-blue-800/40">
{su.role}
{/* Single Selected Recipient or Search Input Box */}
{singleSelectedUser ? (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-100 text-xs shadow-xs">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-6 h-6 rounded-full bg-blue-600/40 text-[10px] font-bold flex items-center justify-center text-blue-200 shrink-0 border border-blue-400/30">
{(singleSelectedUser.name || singleSelectedUser.email).slice(0, 2).toUpperCase()}
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-white truncate">
{singleSelectedUser.name || singleSelectedUser.email}
</span>
<span className="text-[11px] text-slate-400 font-mono truncate">
&lt;{singleSelectedUser.email}&gt;
</span>
{singleSelectedUser.role && (
<span className="text-[10px] text-blue-300 bg-blue-900/40 px-1.5 py-0.5 rounded border border-blue-800/40 shrink-0">
{singleSelectedUser.role}
</span>
)}
<button
type="button"
onClick={() => onRemoveUser(su.id)}
className="text-blue-400 hover:text-white hover:bg-blue-600/30 p-0.5 rounded transition cursor-pointer"
title="Remove recipient"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
</div>
<button
type="button"
onClick={() => onRemoveUser(singleSelectedUser.id)}
className="flex items-center gap-1 text-[11px] text-blue-300 hover:text-white hover:bg-blue-600/30 px-2 py-1 rounded-lg transition cursor-pointer shrink-0"
title="Remove recipient to select another user"
>
<X className="w-3.5 h-3.5" />
<span>Change</span>
</button>
</div>
) : (
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
<Search className="w-4 h-4" />
</div>
<input
type="text"
value={searchTerm}
onChange={e => {
setSearchTerm(e.target.value);
if (!isDropdownOpen) setIsDropdownOpen(true);
}}
onFocus={() => setIsDropdownOpen(true)}
placeholder={placeholder}
className="w-full pl-9 pr-8 py-2 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
{/* Search Input Box */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
<Search className="w-4 h-4" />
</div>
<input
type="text"
value={searchTerm}
onChange={e => {
setSearchTerm(e.target.value);
if (!isDropdownOpen) setIsDropdownOpen(true);
}}
onFocus={() => setIsDropdownOpen(true)}
placeholder={placeholder}
className="w-full pl-9 pr-8 py-2 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Organized User Directory Dropdown */}
{isDropdownOpen && (
{/* Organized User Directory Dropdown (only when no recipient is locked in) */}
{isDropdownOpen && !singleSelectedUser && (
<div className="absolute left-0 right-0 top-full mt-1.5 max-h-72 overflow-y-auto bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl z-50 p-2 space-y-3 backdrop-blur-md animate-in fade-in slide-in-from-top-1">
{isLoading ? (
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
@@ -37,7 +37,7 @@ const activitySchema = z.object({
title: z.string().min(3, 'Activity title required'),
description: z.string().min(5, 'Description required'),
category: z.string().min(2, 'Category required'),
amount: z.coerce.number().min(1, 'Amount must be positive'),
amount: z.coerce.number().min(0, 'Amount must be non-negative'),
milestoneId: z.string().optional().nullable(),
performedAt: z.string().optional().nullable(),
});
@@ -20,6 +20,7 @@ import {
getProjectIndicators,
getProjectRisks,
getBudgets,
getDisbursements,
} from '../../services/api';
import { Project, ApprovalStatus, Budget } from '../../types';
import {
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
);
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
const { data: disbursements = [] } = useQuery({
queryKey: ['disbursements', projectId],
queryFn: () => getDisbursements(1, 100),
});
const projectDisbursements = disbursements.filter(
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
);
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const projectValue = totalActivityBudget - totalDisbursed;
const projectVariance = approvedBudgetTotal - projectValue;
return (
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
Approved Budget: {formatZAR(approvedBudgetTotal)}
</span>
<span className="text-slate-600">&bull;</span>
<span className="text-xs text-blue-400 font-mono font-semibold shrink-0" title={`Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}`}>
Project Value: {formatZAR(projectValue)}
</span>
<span className="text-slate-600">&bull;</span>
<span
className={`text-xs font-mono font-semibold shrink-0 ${
projectVariance >= 0 ? 'text-emerald-400' : 'text-rose-400'
}`}
title="Approved Budget minus Net Project Value"
>
Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)}
</span>
</>
)}
</div>
@@ -33,6 +33,7 @@ import {
getProjectIndicators,
getProjectRisks,
getBudgets,
getDisbursements,
updateProjectStatus,
} from '../../services/api';
import { Project, ApprovalStatus, Programme } from '../../types';
@@ -150,11 +151,23 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
);
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
// Milestone Progress
// Milestone Progress & Disbursements
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const { data: disbursements = [] } = useQuery({
queryKey: ['disbursements', project?.id],
queryFn: () => getDisbursements(1, 100),
enabled: !!project?.id,
});
const projectDisbursements = disbursements.filter(
(d) => String(d.projectId) === String(project?.id) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
);
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
const projectValue = totalActivityBudget - totalDisbursed;
const projectVariance = approvedBudgetTotal - projectValue;
// Schedule Risk
const scheduleRisk = useMemo(() => {
if (!project) return null;
@@ -244,20 +257,20 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
Budget: {formatZAR(approvedBudgetTotal)}
</span>
<span className="text-slate-600">&bull;</span>
<span className="text-blue-400 font-mono font-bold">
Project Value: {formatZAR(totalActivityBudget)}
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
Project Value: {formatZAR(projectValue)}
</span>
<span className="text-slate-600">&bull;</span>
<span
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
approvedBudgetTotal - totalActivityBudget >= 0
projectVariance >= 0
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
}`}
title="Approved Budget minus Total Project Value"
title={`Approved Budget: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
>
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
Variance: {projectVariance >= 0 ? '+' : ''}
{formatZAR(projectVariance)}
</span>
</div>
</div>
@@ -83,7 +83,7 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
const projectMap = useMemo(() => {
const map: Record<string, string> = {};
projects.forEach((p) => {
map[String(p.id)] = p.name;
map[String(p.id)] = p.name || '';
});
return map;
}, [projects]);
@@ -6,10 +6,17 @@ 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, getUsers } from '../../services/api';
import {
createUser,
getSystemRoles,
assignUserRole,
getTenants,
getUsers,
notifyNewUserCreated,
} from '../../services/api';
import { useAuth } from '../../auth/AuthProvider';
import { useRbac } from '../../auth/rbac';
import { CreateUserRequest, Role, Tenant } from '../../types';
import { CreateUserRequest, Role, Tenant, User } from '../../types';
const createUserSchema = z.object({
tenantId: z.string().min(1, 'Tenant is required'),
@@ -22,8 +29,8 @@ const createUserSchema = z.object({
userName: z.string().optional(),
phoneNumber: z.string().optional(),
initialRoleId: z.string().optional(),
lockoutEnabled: z.boolean().default(false),
twoFactorEnabled: z.boolean().default(false),
lockoutEnabled: z.boolean(),
twoFactorEnabled: z.boolean(),
});
type CreateUserFormValues = z.infer<typeof createUserSchema>;
@@ -98,7 +105,9 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
// Filter out Admin role if user has access to tenant self-help (Requirement 1)
const assignableRoles = React.useMemo(() => {
return systemRoles.filter((r) => {
const isRoleAdmin = r.name.toLowerCase() === 'admin' || r.id === '1';
const rName = r.name || r.Name || '';
const rId = r.id || r.Id || '';
const isRoleAdmin = rName.toLowerCase() === 'admin' || rId === '1';
if (isRoleAdmin && hasTenantSelfHelpAccess) {
return false;
}
@@ -117,8 +126,10 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
mutationFn: async (values: CreateUserFormValues) => {
// Security enforcement: Users with tenant self-help access cannot create admin users
if (values.initialRoleId && hasTenantSelfHelpAccess) {
const selectedRole = systemRoles.find((r) => r.id === values.initialRoleId);
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1')) {
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
const selName = selectedRole?.name || selectedRole?.Name || '';
const selId = selectedRole?.id || selectedRole?.Id || '';
if (selectedRole && (selName.toLowerCase() === 'admin' || selId === '1')) {
throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.');
}
}
@@ -141,22 +152,40 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
await createUser(values.tenantId, values.idNumber.trim(), payload);
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
let createdUser: User | undefined;
try {
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
await queryClient.invalidateQueries({ queryKey: ['users'] });
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
createdUser = freshUsers.find(
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
);
if (createdUser && values.initialRoleId) {
await assignUserRole(createdUser.id, values.initialRoleId);
}
} catch {
// Graceful fallback
}
// RULE 3: Issue InApp and Email message directing new user to appropriate portal
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
const roleName = selectedRole?.name || selectedRole?.Name || '';
const isPartner =
roleName.toLowerCase().trim() === 'partner' ||
String(values.initialRoleId || '').toLowerCase().trim() === 'partner';
const targetUserId = createdUser?.id || values.idNumber.trim();
await notifyNewUserCreated({
userId: targetUserId,
email: rawEmail,
isPartner,
roleName,
tenantId: values.tenantId,
}).catch(console.warn);
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
return values;
},
onSuccess: (values) => {
@@ -286,11 +315,15 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
>
<option value="">Select Initial Role (Optional)</option>
{assignableRoles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
{assignableRoles.map((r) => {
const roleId = r.id || r.Id || '';
const roleLabel = r.name || r.Name || '';
return (
<option key={roleId} value={roleId}>
{roleLabel}
</option>
);
})}
</select>
{hasTenantSelfHelpAccess && (
<p className="text-[10px] text-amber-400 mt-1">
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
import { HelpTooltip } from '../ui/HelpTooltip';
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
import { decodeIdNumberHex } from '../../utils/hashids';
import { useAuth } from '../../auth/AuthProvider';
import { User, Role, Claim, ActivityStatus } from '../../types';
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { user: currentAuthUser } = useAuth();
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
const userId = user?.id || '';
const isSelf =
Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) ||
Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase());
const canManageRolesAndClaims =
canApprove('settings_users') ||
canApprove('tenant_management') ||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
hasTenantSelfHelpAccess ||
isUnrestricted;
const canChangeStatus =
canAdd('settings_users') ||
canAdd('tenant_management') ||
canAdd('tenant_self_service') ||
canApprove('settings_users') ||
isUnrestricted;
!isSelf &&
(canAdd('settings_users') ||
canAdd('tenant_management') ||
canAdd('tenant_self_service') ||
canApprove('settings_users') ||
isUnrestricted);
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
const userId = user?.id || '';
// 1. Fetch fresh user details
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
queryKey: ['user-detail', userId],
@@ -237,20 +243,35 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
onError: (err: unknown) => toastError('Logout Failed', err),
});
const lockoutMutation = useMutation({
mutationFn: (disableLock: boolean) => setUserLockout(userId, disableLock),
onSuccess: (_, disableLock) => {
success(
disableLock ? 'Account Unlocked' : 'Account Locked',
`User account lockout status successfully modified.`
);
const lockUserMutation = useMutation({
mutationFn: () => setUserLockout(userId, true),
onSuccess: () => {
success('Account Locked', 'User account lockout enabled (disable = true).');
refetchUser();
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
},
onError: (err: unknown) => toastError('Lockout Update Failed', err),
onError: (err: unknown) => toastError('Lock User Failed', err),
});
const unlockUserMutation = useMutation({
mutationFn: () => setUserLockout(userId, false),
onSuccess: () => {
success('Account Unlocked', 'User account lockout disabled (disable = false).');
refetchUser();
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
},
onError: (err: unknown) => toastError('Unlock User Failed', err),
});
const statusMutation = useMutation({
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
mutationFn: (newStatus: ActivityStatus) => {
if (isSelf) {
throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.');
}
return updateUserStatus(userId, newStatus);
},
onSuccess: () => {
success('Status Updated', 'User activity status changed.');
refetchUser();
@@ -280,18 +301,18 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<Modal
isOpen={isOpen}
onClose={onClose}
title={`User Administration: ${effectiveUser.email || effectiveUser.id}`}
subtitle="Direct tenant-level user, role, claims, and session administration without database intervention."
title={`User Security: ${effectiveUser.email || effectiveUser.id}`}
subtitle="Manage account status, role assignment, permissions, and active sessions."
>
<div className="space-y-4 text-xs">
{/* Navigation Tabs */}
<div className="flex gap-2 border-b border-slate-800 pb-2">
<div className="flex gap-1.5 border-b border-slate-800 pb-2">
<button
type="button"
onClick={() => setActiveTab('overview')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer ${
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer ${
activeTab === 'overview'
? 'bg-blue-600 text-white'
? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
@@ -300,21 +321,21 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button
type="button"
onClick={() => setActiveTab('roles')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'roles'
? 'bg-blue-600 text-white'
? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<Shield className="w-3.5 h-3.5" />
<span>Roles ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
<span>Role ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('claims')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'claims'
? 'bg-blue-600 text-white'
? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
@@ -324,9 +345,9 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
<button
type="button"
onClick={() => setActiveTab('sessions')}
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
activeTab === 'sessions'
? 'bg-blue-600 text-white'
? 'bg-blue-600 text-white font-semibold'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
@@ -386,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</span>
</div>
<div className="flex items-center gap-2">
{effectiveUser?.status === ActivityStatus.Active ? (
{isSelf ? (
<span
className="px-2.5 py-1 text-[11px] font-semibold text-slate-400 bg-slate-900 border border-slate-800 rounded-lg"
title="Maker-Checker policy: Administrators cannot change their own account activity status"
>
Self-Account (Protected)
</span>
) : effectiveUser?.status === ActivityStatus.Active ? (
<button
type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
@@ -592,70 +620,66 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</div>
)}
{/* TAB 4: Sessions & Direct Account Interventions */}
{/* TAB 4: Sessions & Security Interventions */}
{activeTab === 'sessions' && (
<div className="space-y-3">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
{/* Force Logout Action */}
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3 gap-3">
<div>
<h4 className="text-white font-semibold flex items-center gap-1.5">
<LogOut className="w-4 h-4 text-amber-400" />
<span>Revoke Active User Session</span>
<HelpTooltip
title="User Session Invalidation"
content="Enables a tenant self-service user to immediately logout a user if they are currently logged in, terminating their active token and session."
size="sm"
placement="top"
/>
</h4>
<p className="text-[11px] text-slate-400 mt-0.5">
Terminates all active JWT tokens and sessions immediately without direct database intervention.
Immediately invalidates active JWT tokens and logs the user out.
</p>
</div>
<button
type="button"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50 shrink-0"
>
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
</button>
</div>
{/* Lock / Unlock Account */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-white font-semibold flex items-center gap-1.5">
<Lock className="w-4 h-4 text-rose-400" />
<span>Account Lockout Control</span>
<HelpTooltip
title="Account Lockout Control"
content="Enables a tenant self-service user to change a user's lockout status. The system defaults to a lockout period of 100 years when locking a user account."
size="sm"
placement="top"
/>
<span>Account Lockout</span>
</h4>
<p className="text-[11px] text-slate-400 mt-0.5">
Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years.
Control tenant login authorization for this account.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => lockoutMutation.mutate(false)}
disabled={lockoutMutation.isPending || !canManageSessions}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
lockUserMutation.mutate();
}}
disabled={lockUserMutation.isPending || unlockUserMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
>
Lock User
{lockUserMutation.isPending ? 'Locking...' : 'Lock User'}
</button>
<button
type="button"
onClick={() => lockoutMutation.mutate(true)}
disabled={lockoutMutation.isPending || !canManageSessions}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
unlockUserMutation.mutate();
}}
disabled={unlockUserMutation.isPending || lockUserMutation.isPending || !canManageSessions}
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
>
Unlock User
{unlockUserMutation.isPending ? 'Unlocking...' : 'Unlock User'}
</button>
</div>
</div>
+14 -12
View File
@@ -1,10 +1,10 @@
import { updateHasherSettings } from './services/toolsService';
import { updateHasherSettings } from './utils/hashids';
export interface SecuritySettings {
Authority: string;
Audience: string;
ClientId: string;
ClientSecret: string;
ClientSecret?: string;
Scope?: string;
}
@@ -13,7 +13,7 @@ export interface ApiSettings {
}
export interface HasherSettings {
Salt: string;
Salt?: string;
MinHashLength: number;
}
@@ -36,7 +36,6 @@ export const defaultSecuritySettings: SecuritySettings = {
Authority: 'https://sts.security.khongisa.co.za',
Audience: 'pfm-api-dev',
ClientId: 'pfm-console-dev',
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
Scope: 'openid profile roles email address pfm-api-dev',
};
@@ -49,6 +48,10 @@ export const defaultHasherSettings: HasherSettings = {
MinHashLength: 11,
};
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
updateHasherSettings(salt, minHashLength);
}
/**
* Extracts first non-empty, trimmed string from candidate values.
*/
@@ -255,9 +258,8 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
const clientSecret = pickFirstNonEmpty(
windowConfig.clientSecret,
fetchedConfig.clientSecret,
buildEnvConfig.clientSecret,
defaultSecuritySettings.ClientSecret
)!;
buildEnvConfig.clientSecret
);
const scope = pickFirstNonEmpty(
windowConfig.scope,
@@ -278,7 +280,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
fetchedConfig.hasherSalt,
buildEnvConfig.hasherSalt,
defaultHasherSettings.Salt
)!;
);
const minHashLength =
windowConfig.hasherMinLength ||
@@ -291,14 +293,14 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
Authority: normalizeAuthorityUrl(authority),
Audience: audience,
ClientId: clientId,
ClientSecret: clientSecret,
...(clientSecret ? { ClientSecret: clientSecret } : {}),
Scope: scope,
},
ApiSettings: {
BaseUrl: normalizeBaseUrl(baseUrl),
},
HasherSettings: {
Salt: salt,
Salt: salt || defaultHasherSettings.Salt,
MinHashLength: minHashLength,
},
ConfigSource: source,
@@ -310,7 +312,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
let runtimeConfig: AppConfig = resolveConfig();
// Immediately apply deterministic hasher settings
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
const configListeners: Array<(config: AppConfig) => void> = [];
@@ -360,7 +362,7 @@ export async function loadRuntimeConfig(): Promise<AppConfig> {
runtimeConfig = resolveConfig(fetchedJson);
// Synchronize deterministic hasher settings
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
console.info(
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
@@ -6,6 +6,7 @@ import {
getNotifications,
createNotification,
markNotificationAsRead,
archiveNotification,
updateNotification,
getCurrentUserTenantId,
} from '../services/api';
@@ -39,70 +40,73 @@ export function useInAppNotifications() {
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
// Starred messages set
const [starredIds, setStarredIds] = useState<string[]>(() => {
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
try {
const stored = localStorage.getItem(starredStorageKey);
return stored ? JSON.parse(stored) : [];
const stored = localStorage.getItem(key);
const parsed: T[] = stored ? JSON.parse(stored) : [];
if (fallbackKey && fallbackKey !== key) {
const fallbackStored = localStorage.getItem(fallbackKey);
const fallbackParsed: T[] = fallbackStored ? JSON.parse(fallbackStored) : [];
if (fallbackParsed.length > 0) {
const merged = [...parsed];
for (const item of fallbackParsed) {
const itemId = (item as any)?.id ?? item;
if (!merged.some(m => ((m as any)?.id ?? m) === itemId)) {
merged.push(item);
}
}
localStorage.setItem(key, JSON.stringify(merged));
localStorage.removeItem(fallbackKey);
return merged;
}
}
return parsed;
} catch {
return [];
}
});
}, []);
// Starred messages set
const [starredIds, setStarredIds] = useState<string[]>(() =>
readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default')
);
// Trashed messages set
const [trashedIds, setTrashedIds] = useState<string[]>(() => {
try {
const stored = localStorage.getItem(trashStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default')
);
// Local drafts list
const [drafts, setDrafts] = useState<DraftMessage[]>(() => {
try {
const stored = localStorage.getItem(draftsStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default')
);
// Local sent messages history
const [localSent, setLocalSent] = useState<Notification[]>(() => {
try {
const stored = localStorage.getItem(sentStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const [localSent, setLocalSent] = useState<Notification[]>(() =>
readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default')
);
// Sync state to localStorage
useEffect(() => {
try {
localStorage.setItem(starredStorageKey, JSON.stringify(starredIds));
} catch {}
}, [starredIds, starredStorageKey]);
// Synchronize across hook instances and userId changes
const syncFromStorage = useCallback(() => {
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_mailbox_starred_default'));
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_mailbox_trash_default'));
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default'));
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_mailbox_sent_default'));
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
useEffect(() => {
try {
localStorage.setItem(trashStorageKey, JSON.stringify(trashedIds));
} catch {}
}, [trashedIds, trashStorageKey]);
syncFromStorage();
}, [syncFromStorage]);
useEffect(() => {
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(drafts));
} catch {}
}, [drafts, draftsStorageKey]);
useEffect(() => {
try {
localStorage.setItem(sentStorageKey, JSON.stringify(localSent));
} catch {}
}, [localSent, sentStorageKey]);
const handleSync = () => syncFromStorage();
window.addEventListener('pfm-mailbox-sync', handleSync);
window.addEventListener('storage', handleSync);
return () => {
window.removeEventListener('pfm-mailbox-sync', handleSync);
window.removeEventListener('storage', handleSync);
};
}, [syncFromStorage]);
// Fetch notifications from backend
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
@@ -121,7 +125,6 @@ export function useInAppNotifications() {
},
// Live polling every 30 seconds
refetchInterval: 30000,
staleTime: 10000,
});
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
@@ -131,23 +134,19 @@ export function useInAppNotifications() {
);
}, [rawNotifications]);
// Active inbox notifications (InApp + Email for mailbox, excluding trashed and drafts)
const allMailboxNotifications = useMemo(() => {
return rawNotifications.filter(
n =>
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email) &&
!trashedIds.includes(String(n.id)) &&
Number(n.status) !== NotificationStatus.Draft
);
}, [rawNotifications, trashedIds]);
// Active in-app notifications in inbox
// Active in-app notifications in inbox (excluding trashed and drafts)
const inboxNotifications = useMemo(() => {
return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
n =>
!trashedIds.includes(String(n.id)) &&
Number(n.status) !== NotificationStatus.Archived &&
Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
// Active mailbox notifications (always In-App notifications)
const allMailboxNotifications = inboxNotifications;
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
const unreadNotifications = useMemo(() => {
return inboxNotifications.filter(
@@ -161,20 +160,21 @@ export function useInAppNotifications() {
const unreadCount = unreadNotifications.length;
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
// Starred notifications
// Starred notifications (In-App only)
const starredNotifications = useMemo(() => {
return rawNotifications.filter(
return inAppNotifications.filter(
n =>
starredIds.includes(String(n.id)) &&
!trashedIds.includes(String(n.id)) &&
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email)
!trashedIds.includes(String(n.id))
);
}, [rawNotifications, starredIds, trashedIds]);
}, [inAppNotifications, starredIds, trashedIds]);
// Trashed notifications
// Trashed notifications (In-App only)
const trashedNotifications = useMemo(() => {
return rawNotifications.filter(n => trashedIds.includes(String(n.id)));
}, [rawNotifications, trashedIds]);
return inAppNotifications.filter(
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
);
}, [inAppNotifications, trashedIds]);
// Mutation to mark notification as read
const markAsReadMutation = useMutation({
@@ -206,27 +206,85 @@ export function useInAppNotifications() {
// Toggle star
const toggleStar = useCallback((id: string | number) => {
const strId = String(id);
setStarredIds(prev => (prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId]));
}, []);
setStarredIds(prev => {
const next = prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId];
try {
localStorage.setItem(starredStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [starredStorageKey]);
// Delete message (move to trash)
const deleteMessage = useCallback((id: string | number) => {
// Delete message (NotificationStatus == Archived & move to trash)
const deleteMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => (prev.includes(strId) ? prev : [...prev, strId]));
}, []);
setTrashedIds(prev => {
const next = prev.includes(strId) ? prev : [...prev, strId];
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
// Restore from trash
const restoreMessage = useCallback((id: string | number) => {
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
);
try {
await archiveNotification(id);
} catch (err) {
console.warn('[deleteMessage] Error archiving notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
// Restore from trash / unarchive
const restoreMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => prev.filter(x => x !== strId));
}, []);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
);
try {
await markNotificationAsRead(id);
} catch (err) {
console.warn('[restoreMessage] Error restoring notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
// Permanent delete
const permanentDelete = useCallback((id: string | number) => {
const strId = String(id);
setTrashedIds(prev => prev.filter(x => x !== strId));
setLocalSent(prev => prev.filter(n => String(n.id) !== strId));
}, []);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
try {
localStorage.setItem(trashStorageKey, JSON.stringify(next));
} catch {}
return next;
});
setLocalSent(prev => {
const next = prev.filter(n => String(n.id) !== strId);
try {
localStorage.setItem(sentStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [trashStorageKey, sentStorageKey]);
// Draft management
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
@@ -237,15 +295,31 @@ export function useInAppNotifications() {
updatedAt: new Date().toISOString(),
};
setDrafts(prev => {
const filtered = prev.filter(d => d.id !== draftId);
return [newDraft, ...filtered];
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
const base = current.length >= prev.length ? current : prev;
const filtered = base.filter(d => d.id !== draftId);
const next = [newDraft, ...filtered];
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
return draftId;
}, []);
}, [draftsStorageKey, readStorageList]);
const deleteDraft = useCallback((id: string) => {
setDrafts(prev => prev.filter(d => d.id !== id));
}, []);
setDrafts(prev => {
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_mailbox_drafts_default');
const base = current.length >= prev.length ? current : prev;
const next = base.filter(d => d.id !== id);
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
}, [draftsStorageKey, readStorageList]);
// Send new notification (InApp or Email)
const sendMessageMutation = useMutation({
@@ -288,7 +362,14 @@ export function useInAppNotifications() {
sentAt: new Date().toISOString(),
};
setLocalSent(prev => [sentItem, ...prev]);
setLocalSent(prev => {
const next = [sentItem, ...prev];
try {
localStorage.setItem(sentStorageKey, JSON.stringify(next));
window.dispatchEvent(new Event('pfm-mailbox-sync'));
} catch {}
return next;
});
// If sending a draft, remove the draft
if (payload.draftId) {
@@ -80,6 +80,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' },
{ value: NotificationStatus.Draft, label: 'Draft' },
{ value: NotificationStatus.Archived, label: 'Archived' },
];
export const NOTIFICATION_PLATFORM_OPTIONS = [
@@ -510,6 +511,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status);
switch (s) {
case NotificationStatus.Archived:
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
case NotificationStatus.Draft:
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
case NotificationStatus.Delivered:
@@ -64,8 +64,13 @@ export function isHtmlContent(content: string): boolean {
trimmed.startsWith('<html') ||
trimmed.startsWith('<div') ||
trimmed.startsWith('<table') ||
trimmed.startsWith('<p>') ||
(trimmed.includes('</') && trimmed.includes('>'))
trimmed.startsWith('<p') ||
trimmed.startsWith('<h') ||
trimmed.startsWith('<ul') ||
trimmed.startsWith('<ol') ||
trimmed.startsWith('<blockquote') ||
(trimmed.includes('</') && trimmed.includes('>')) ||
/<(strong|b|em|i|u|h[1-6]|ul|ol|li|table|blockquote|div|p|img|hr|br)\b/i.test(trimmed)
);
}
@@ -224,7 +229,14 @@ export function renderMarkdownToHtml(
markdown: string,
options?: MarkdownRenderOptions
): string {
const contentHtml = parseMarkdownToHtml(markdown);
const trimmed = (markdown || '').trim();
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return trimmed;
}
const contentHtml = isHtmlContent(trimmed)
? trimmed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
: parseMarkdownToHtml(trimmed);
if (!options?.applyBranding && !options?.emailFormat) {
return contentHtml;
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
refetchOnWindowFocus: false,
refetchOnReconnect: true,
// Remove caching from all pages: zero stale time and zero cache retention
// Whenever a component needs data, it must call its corresponding endpoint(s)
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always',
retry: 1,
},
},
});
// Retain caching ONLY for sync status, auth token, roles and grants
const RETAINED_CACHE_OPTIONS = {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
};
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
/**
* Invalidates queries while simultaneously bumping the mutation epoch.
* This guarantees that the background refetch generates a fresh query parameter (_v)
@@ -353,6 +353,12 @@ export const BeneficiariesPage: React.FC = () => {
const updateCompMutation = useMutation({
mutationFn: (vals: ComplianceFormValues) => {
if (!editingComp) throw new Error('No item selected');
if (
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
isMaker(editingComp.createdBy)
) {
throw new Error('Maker-Checker Violation: You created this compliance item and cannot approve it.');
}
return updateComplianceItem({
complianceItemId: editingComp.id,
title: vals.title,
@@ -399,6 +405,12 @@ export const BeneficiariesPage: React.FC = () => {
const updateOutcomeMutation = useMutation({
mutationFn: (vals: OutcomeFormValues) => {
if (!editingOutcome) throw new Error('No outcome selected for update');
if (
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
isMaker(editingOutcome.createdBy)
) {
throw new Error('Maker-Checker Violation: You created this outcome record and cannot approve or certify fulfillment.');
}
return updateOutcome({
outcomeId: editingOutcome.id,
name: vals.name,
@@ -1467,11 +1479,15 @@ 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 cursor-pointer"
>
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
const makerRestricted = editingComp && isApprovedOpt && isMaker(editingComp.createdBy);
return (
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
</option>
);
})}
</select>
</div>
@@ -1680,11 +1696,15 @@ export const BeneficiariesPage: React.FC = () => {
{...outcomeForm.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 cursor-pointer"
>
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
const isMetOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
const makerRestricted = editingOutcome && isMetOpt && isMaker(editingOutcome.createdBy);
return (
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
</option>
);
})}
</select>
</div>
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
{Number(cr.status) === ApprovalStatus.Approved ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
<Lock className="w-3 h-3 text-emerald-400" />
<span>Resolved &amp; Enacted (Immutable)</span>
<span>Resolved &amp; Enacted</span>
</span>
) : (
<>
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
title="Approved and enacted change requests are immutable."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Enacted (Immutable - Cannot Be Changed)</span>
<span>Approved &amp; Enacted</span>
</div>
) : (
<select
@@ -11,6 +11,7 @@ import {
Edit2,
Mail,
Shield,
ShieldCheck,
Layers,
ChevronRight,
Search,
@@ -24,9 +25,11 @@ import {
UserPlus,
ChevronDown,
ChevronUp,
CheckCircle,
CheckCircle2,
Loader2,
Lock,
AlertTriangle,
} from 'lucide-react';
import {
getOrganisations,
@@ -60,6 +63,7 @@ import {
assignUserRole,
getSystemRoles,
getCurrentUserTenantId,
notifyNewUserCreated,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -68,6 +72,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider';
import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection';
@@ -109,6 +114,7 @@ const organisationSchema = z.object({
registrationNo: z.string().min(4, 'Registration number is mandatory'),
email: z.string().email('Enter a valid email address'),
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
idNumber: z.string().optional(),
type: z.coerce.number().min(1).max(27),
status: z.coerce.number().optional(),
});
@@ -133,8 +139,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user, claims } = useAuth();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const { sub, user, claims, roles, tenantId } = useAuth();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
@@ -240,7 +246,6 @@ export const GovernancePage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { data: systemRoles = [] } = useQuery<Role[]>({
@@ -251,7 +256,7 @@ export const GovernancePage: React.FC = () => {
const assignableRolesForOrg = React.useMemo(() => {
return systemRoles.filter(
(r) => r.name.toLowerCase() !== 'admin' && r.id !== '1'
(r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1'
);
}, [systemRoles]);
@@ -262,6 +267,37 @@ export const GovernancePage: React.FC = () => {
});
}, [usersDirectory]);
const modalExecutiveUsers = React.useMemo(() => {
const list: User[] = [...executiveAndManagerUsers];
const linkedUserId =
editingOrg?.userId ||
editingOrg?.UserId ||
'';
if (linkedUserId) {
const linkedStr = String(linkedUserId).toLowerCase();
const alreadyIncluded = list.some((u) => u.id && String(u.id).toLowerCase() === linkedStr);
if (!alreadyIncluded) {
const inDirectory = usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === linkedStr);
if (inDirectory) {
list.unshift(inDirectory);
} else {
const email = getUserEmail(linkedUserId);
const hasRealEmail = email && email !== '—' && email.includes('@') && email !== String(linkedUserId);
list.unshift({
id: String(linkedUserId),
email: hasRealEmail ? email : String(linkedUserId),
role: 'Executive',
status: ActivityStatus.Active,
lastLoginAt: null,
});
}
}
}
return list;
}, [executiveAndManagerUsers, usersDirectory, editingOrg?.id, editingOrg?.userId]);
// Quick Add Executive User Subform state
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
const [quickUserEmail, setQuickUserEmail] = useState('');
@@ -274,9 +310,9 @@ export const GovernancePage: React.FC = () => {
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);
const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive');
if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || '');
else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || '');
}
}, [assignableRolesForOrg, quickUserRoleId]);
@@ -334,6 +370,19 @@ export const GovernancePage: React.FC = () => {
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
}
// RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console)
if (createdUser) {
await notifyNewUserCreated({
userId: createdUser.id,
email: quickUserEmail,
isPartner: false,
roleName: 'Executive',
tenantId,
}).catch(console.warn);
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
}
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
setQuickUserEmail('');
setQuickUserName('');
@@ -546,11 +595,23 @@ export const GovernancePage: React.FC = () => {
registrationNo: '',
email: '',
organisationUser: '',
idNumber: '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
status: ActivityStatus.Inactive,
},
});
const watchedOrgUser = orgForm.watch('organisationUser');
const selectedExecutiveUser = React.useMemo(() => {
if (!watchedOrgUser) return null;
const str = String(watchedOrgUser).toLowerCase();
return (
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str) ||
null
);
}, [watchedOrgUser, usersDirectory, modalExecutiveUsers]);
const portfolioForm = useForm<PortfolioFormValues>({
resolver: zodResolver(portfolioSchema),
defaultValues: {
@@ -566,7 +627,7 @@ export const GovernancePage: React.FC = () => {
defaultValues: {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail,
},
});
@@ -575,11 +636,20 @@ export const GovernancePage: React.FC = () => {
const orgMutation = useMutation({
mutationFn: (data: OrganisationFormValues) => {
if (editingOrg) {
if (!data.idNumber || !data.idNumber.trim()) {
orgForm.setError('idNumber', {
type: 'manual',
message: 'Government ID number is required to assign this executive user.',
});
throw new Error('Executive user ID number is required when updating an organisation.');
}
return updateOrganisation({
organisationId: editingOrg.id,
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
idNumber: data.idNumber.trim(),
organisationUser: data.organisationUser,
type: Number(data.type),
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
@@ -589,11 +659,13 @@ export const GovernancePage: React.FC = () => {
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
idNumber: data.idNumber?.trim() || null,
organisationUser: data.organisationUser,
type: Number(data.type),
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['organisations'] });
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
setIsOrgModalOpen(false);
@@ -625,6 +697,7 @@ export const GovernancePage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
setIsPortfolioModalOpen(false);
@@ -636,15 +709,43 @@ export const GovernancePage: React.FC = () => {
},
});
const approveProgrammeMutation = useMutation({
mutationFn: async (pr: Programme) => {
if (isMaker(pr.createdBy)) {
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
}
return updateProgramme({
programmeId: pr.id,
name: pr.name || '',
portfolioId: Number(pr.portfolioId),
status: ApprovalStatus.Approved,
ownerId: pr.ownedBy,
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
},
onError: (err: unknown) => {
toastError('Programme Approval Failed', err);
},
});
const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) {
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
}
return updateProgramme({
programmeId: editingProgramme.id,
name: data.name,
portfolioId: Number(data.portfolioId),
status: data.status !== undefined ? Number(data.status) : undefined,
status: targetStatus,
ownerId: resolvedOwnerId,
});
}
@@ -655,7 +756,9 @@ export const GovernancePage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
setIsProgrammeModalOpen(false);
setEditingProgramme(null);
@@ -674,8 +777,9 @@ export const GovernancePage: React.FC = () => {
registrationNo: '',
email: '',
organisationUser: defaultUser?.id || '',
idNumber: defaultUser?.idNumber || '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
status: ActivityStatus.Inactive,
});
setIsQuickAddUserOpen(false);
setQuickUserError(null);
@@ -688,26 +792,51 @@ export const GovernancePage: React.FC = () => {
const targetUserId =
org.userId ||
org.UserId ||
org.organisationUser ||
org.OrganisationUser ||
org.createdBy ||
'';
const matchedUser = targetUserId
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
: null;
let selectedUserId = '';
let matchedUser: User | undefined;
if (targetUserId) {
const targetStr = String(targetUserId).toLowerCase();
matchedUser = usersDirectory.find(
(u) =>
(u.id && String(u.id).toLowerCase() === targetStr) ||
(u.email && u.email.toLowerCase() === targetStr)
);
const selectedUserId = matchedUser?.id || targetUserId || '';
if (matchedUser) {
selectedUserId = matchedUser.id;
if (matchedUser.email) {
registerUserEmail(matchedUser.id, matchedUser.email);
}
} else {
selectedUserId = String(targetUserId);
const email = getUserEmail(targetUserId);
if (email && email !== '—' && email.includes('@')) {
registerUserEmail(selectedUserId, email);
}
}
}
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || '';
orgForm.reset({
name: org.name || '',
registrationNo: org.registrationNo || '',
email: org.email || '',
organisationUser: selectedUserId,
idNumber: resolvedIdNumber,
type: org.type || OrganisationType.Enterprise,
status: org.status ?? ActivityStatus.Active,
});
if (selectedUserId && !resolvedIdNumber) {
orgForm.setError('idNumber', {
type: 'manual',
message: 'This executive user has no ID number on file. An ID number is required to update the organisation.',
});
}
setIsQuickAddUserOpen(false);
setQuickUserError(null);
setIsOrgModalOpen(true);
@@ -741,7 +870,7 @@ export const GovernancePage: React.FC = () => {
programmeForm.reset({
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail,
});
setIsProgrammeModalOpen(true);
@@ -819,6 +948,7 @@ export const GovernancePage: React.FC = () => {
status: (p) => getApprovalStatusBadge(p.status).label,
createdAt: (p) => p.createdAt,
ownedBy: (p) => getUserEmail(p.ownedBy),
createdBy: (p) => getUserEmail(p.createdBy),
});
// Export Columns Definitions
@@ -849,6 +979,7 @@ export const GovernancePage: React.FC = () => {
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
},
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Description', key: 'description', format: (val) => val || '—' },
];
@@ -861,12 +992,13 @@ export const GovernancePage: React.FC = () => {
key: 'portfolioId',
format: (val) => {
const port = portfolios.find((p) => String(p.id) === String(val));
return port ? port.name : `Portfolio #${val}`;
return port && port.name ? port.name : `Portfolio #${val}`;
},
},
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
];
return (
@@ -1136,6 +1268,11 @@ export const GovernancePage: React.FC = () => {
<Mail className="w-3.5 h-3.5 text-slate-500" />
{org.email}
</span>
{org.userId && (
<span className="text-[10px] text-blue-400/90 font-mono block mt-0.5" title={`Executive User: ${org.userId}`}>
Exec: {getUserEmail(org.userId)}
</span>
)}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
@@ -1253,8 +1390,8 @@ export const GovernancePage: React.FC = () => {
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
{org ? org.name : `Org #${p.organisationId}`}
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org && org.name ? org.name : `Org #${p.organisationId}`}>
{org && org.name ? org.name : `Org #${p.organisationId}`}
</span>
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
</div>
@@ -1368,19 +1505,22 @@ export const GovernancePage: React.FC = () => {
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Owner Email
</SortableTh>
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Created By
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{loadingProgrammes ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading programmes...
</td>
</tr>
) : sortedProgrammes.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
<td colSpan={7} className="py-8 text-center text-slate-500">
No programmes found matching query.
</td>
</tr>
@@ -1388,6 +1528,7 @@ export const GovernancePage: React.FC = () => {
sortedProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status);
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
return (
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
@@ -1406,8 +1547,49 @@ export const GovernancePage: React.FC = () => {
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
{getUserEmail(pr.ownedBy)}
</td>
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
{getUserEmail(pr.createdBy)}
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
{isApproved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved programmes cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved</span>
</span>
) : (
<>
<button
type="button"
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Edit Programme"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
{canApproveRecord('governance', pr.createdBy) && (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={() => approveProgrammeMutation.mutate(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Approve Programme"
>
<CheckCircle className="w-3 h-3" />
<span>Approve</span>
</button>
)}
{canApprove('governance') && isMaker(pr.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: Record creator cannot approve
</span>
)}
</>
)}
<button
type="button"
onClick={() => {
@@ -1444,14 +1626,6 @@ export const GovernancePage: React.FC = () => {
<Scale className="w-3 h-3" />
Rules
</button>
<button
type="button"
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</td>
</tr>
@@ -1597,11 +1771,11 @@ export const GovernancePage: React.FC = () => {
<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 *
Responsible Executive User *
</label>
<HelpTooltip
title="Designated Organisation User"
content="Only users with an Executive or Manager role can be assigned to an organisation."
title="Executive / Organisation User"
content="The Executive user responsible for this organisation. Stored as userId in the organisation record and submitted as organisationUser to the API."
size="sm"
placement="top"
/>
@@ -1622,11 +1796,36 @@ export const GovernancePage: React.FC = () => {
)}
</div>
<select
{...orgForm.register('organisationUser')}
{...orgForm.register('organisationUser', {
onChange: (e) => {
const selectedId = e.target.value;
if (!selectedId) {
orgForm.setValue('idNumber', '');
return;
}
const str = String(selectedId).toLowerCase();
const matched =
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str);
if (matched?.idNumber && matched.idNumber.trim()) {
orgForm.setValue('idNumber', matched.idNumber.trim(), { shouldValidate: true });
orgForm.clearErrors('idNumber');
} else {
orgForm.setValue('idNumber', '', { shouldValidate: true });
if (editingOrg) {
orgForm.setError('idNumber', {
type: 'manual',
message: 'Selected user has no ID number on file. Please enter their ID number to enable executive assignment.',
});
}
}
},
})}
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) => (
{modalExecutiveUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.email || u.id} ({u.role || 'Executive'})
</option>
@@ -1640,9 +1839,61 @@ export const GovernancePage: React.FC = () => {
No Executive or Manager user found. Create one below to continue.
</p>
)}
{watchedOrgUser && selectedExecutiveUser && selectedExecutiveUser.idNumber && selectedExecutiveUser.idNumber.trim() && (
<div className="flex items-center gap-1.5 text-[11px] text-emerald-400 mt-1">
<CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span>ID on file: <strong className="font-mono text-emerald-300">{selectedExecutiveUser.idNumber}</strong></span>
</div>
)}
</div>
</div>
{/* Missing ID Number Alert */}
{watchedOrgUser && selectedExecutiveUser && (!selectedExecutiveUser.idNumber || !selectedExecutiveUser.idNumber.trim()) && (
<div className="p-3 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-semibold text-amber-200">
Missing Executive ID Number
</p>
<p className="text-[11px] text-amber-300/90 leading-relaxed">
The selected executive (<strong className="font-mono text-white">{selectedExecutiveUser.email || watchedOrgUser}</strong>) does not have a Government ID Number on file. An ID number is required to assign this executive user; otherwise the update will be ignored. Please enter their ID number below.
</p>
</div>
</div>
)}
{/* Executive User ID Number Input */}
<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">
Executive ID Number {editingOrg ? '*' : '(Optional)'}
</label>
<HelpTooltip
title="Executive ID Requirement"
content="Government Issued ID or Passport of the executive. Required when updating to persist the executive user link; otherwise the update will be ignored."
size="sm"
placement="top"
/>
</div>
{selectedExecutiveUser?.idNumber && (
<span className="text-[10px] text-emerald-400 font-mono">Sourced from profile</span>
)}
</div>
<input
type="text"
{...orgForm.register('idNumber')}
className={`w-full px-3 py-2 text-xs border rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16] font-mono ${
orgForm.formState.errors.idNumber ? 'border-rose-500 ring-1 ring-rose-500' : 'border-slate-700/80'
}`}
placeholder="Enter 13-digit ID or Passport Number"
/>
{orgForm.formState.errors.idNumber && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
)}
</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">
@@ -1990,18 +2241,18 @@ export const GovernancePage: React.FC = () => {
placement="top"
/>
</div>
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
<div
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
title="Approved programmes are immutable."
title="Approved programmes cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
(() => {
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
const isSelf = isMaker(editingProgramme?.createdBy);
return (
<select
@@ -2446,11 +2697,17 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Executive User:</span>
<span className="text-blue-400 font-mono text-[11px] truncate">
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
</span>
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created By:</span>
<span className="text-slate-300 font-mono text-[11px] truncate">
{getUserEmail(orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy) || orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy || '—'}
</span>
</div>
</div>
</div>
@@ -2703,13 +2960,38 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={async () => {
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
<span>Approve Programme</span>
</button>
) : (
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
<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: Record creator cannot approve
</span>
)
)
)}
</div>
</div>
{/* Core Details Grid: Owner & Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
{/* Core Details Grid: Owner, Maker, Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{/* Creation Date */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
@@ -2731,6 +3013,17 @@ export const GovernancePage: React.FC = () => {
</span>
</div>
</div>
{/* Maker */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
</span>
</div>
</div>
</div>
{/* Metrics: Total Rules and Total Instructions */}
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import {
Award,
Budget,
@@ -123,7 +124,7 @@ export const GrantsPage: React.FC = () => {
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Approved);
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
const updateAwardMutation = useMutation({
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['awards'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success('Award Updated', 'Award details saved successfully.');
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
const budgetMutation = useMutation({
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget line established', 'Budget line created against project');
setIsBudgetModalOpen(false);
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
const updateBudgetMutation = useMutation({
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget Updated', 'Budget compliance and approval status updated.');
setIsEditBudgetModalOpen(false);
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
success(
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
const approveMutation = useMutation({
mutationFn: (id: string | number) => approveDisbursement(id),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success(
@@ -637,8 +643,14 @@ export const GrantsPage: React.FC = () => {
handleSort: handleAwardSort,
} = useTableSort(filteredAwards, null, null, {
referenceNo: (a) => a.referenceNo,
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
organisationId: (a) => {
const org = organisations.find((o) => String(o.id) === String(a.organisationId));
return (org?.name || a.organisationName || `Organisation #${a.organisationId}`).toLowerCase();
},
programmeId: (a) => {
const prog = programmes.find((p) => String(p.id) === String(a.programmeId));
return (prog?.name || a.programmeName || `Programme #${a.programmeId}`).toLowerCase();
},
amount: (a) => a.amount,
status: (a) => getApprovalStatusBadge(a.status).label,
createdAt: (a) => a.createdAt,
@@ -651,7 +663,10 @@ export const GrantsPage: React.FC = () => {
handleSort: handleBudgetSort,
} = useTableSort(filteredBudgets, null, null, {
name: (b) => b.name,
projectId: (b) => b.projectId,
projectId: (b) => {
const proj = projects.find((p) => String(p.id) === String(b.projectId));
return (proj?.name || `Project #${b.projectId}`).toLowerCase();
},
amount: (b) => b.amount,
createdAt: (b) => b.createdAt,
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
@@ -665,8 +680,15 @@ export const GrantsPage: React.FC = () => {
handleSort: handleDisbSort,
} = useTableSort(filteredDisbursements, null, null, {
id: (d) => d.id,
projectId: (d) => d.projectId,
milestoneId: (d) => d.milestoneId || 0,
projectId: (d) => {
const proj = projects.find((p) => String(p.id) === String(d.projectId));
return (proj?.name || `Project #${d.projectId}`).toLowerCase();
},
milestoneId: (d) => {
if (!d.milestoneId) return 'Advance Tranche';
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
return (ms?.name || `Milestone #${d.milestoneId}`).toLowerCase();
},
amount: (d) => d.amount,
paymentReference: (d) => d.paymentReference || '',
status: (d) => getApprovalStatusBadge(d.status).label,
@@ -826,8 +848,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchAwards();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['awards'] });
await refetchAwards();
success('Refreshed', 'Awards intake list refreshed from API.');
}}
disabled={isFetchingAwards}
@@ -899,10 +923,28 @@ export const GrantsPage: React.FC = () => {
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-200 font-medium">
{awd.organisationName || `Org #${awd.organisationId}`}
{(() => {
const org = organisations.find((o) => String(o.id) === String(awd.organisationId));
const orgName = org?.name || awd.organisationName || (awd.organisationId ? `Organisation #${awd.organisationId}` : '—');
return (
<div>
<span className="text-white block font-medium">{orgName}</span>
{awd.organisationId && <span className="text-[10px] text-slate-500 font-mono">ID: #{awd.organisationId}</span>}
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-slate-400">
{awd.programmeName || `Programme #${awd.programmeId}`}
{(() => {
const prog = programmes.find((p) => String(p.id) === String(awd.programmeId));
const progName = prog?.name || awd.programmeName || (awd.programmeId ? `Programme #${awd.programmeId}` : '—');
return (
<div>
<span className="text-slate-200 block">{progName}</span>
{awd.programmeId && <span className="text-[10px] text-slate-500 font-mono">Programme #{awd.programmeId}</span>}
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(awd.amount)}
@@ -919,10 +961,10 @@ export const GrantsPage: React.FC = () => {
{Number(awd.status) === ApprovalStatus.Approved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved award is immutable. Details cannot be modified."
title="Approved award details cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<button
@@ -973,8 +1015,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchBudgets();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
await refetchBudgets();
success('Refreshed', 'Budget line categories refreshed from API.');
}}
disabled={isFetchingBudgets}
@@ -1004,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
Budget Line Category
</SortableTh>
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
Project ID
Project
</SortableTh>
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
Approved Amount (ZAR)
@@ -1043,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
<span className="font-semibold text-white block">{b.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{b.projectId}</td>
<td className="py-3.5 px-4 text-slate-200">
{(() => {
const proj = projects.find((p) => String(p.id) === String(b.projectId));
const projName = proj?.name || `Project #${b.projectId}`;
return (
<div>
<span className="font-medium text-white block">{projName}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.projectId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
{formatZAR(b.amount)}
</td>
@@ -1059,10 +1114,10 @@ export const GrantsPage: React.FC = () => {
{Number(b.status) === ApprovalStatus.Approved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved budget is immutable. Status cannot be modified."
title="Approved budget status cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<>
@@ -1133,8 +1188,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchDisbursements();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
await refetchDisbursements();
success('Refreshed', 'Disbursement tranches refreshed from API.');
}}
disabled={isFetchingDisbursements}
@@ -1172,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
Disbursement ID
</SortableTh>
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Project ID
Project
</SortableTh>
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Milestone Association
@@ -1211,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
<td className="py-3.5 px-5 font-mono font-bold text-white">
DSB-{d.id}
</td>
<td className="py-3.5 px-4 text-slate-300 font-mono">Project #{d.projectId}</td>
<td className="py-3.5 px-4 text-slate-200">
{(() => {
const proj = projects.find((p) => String(p.id) === String(d.projectId));
const projName = proj?.name || `Project #${d.projectId}`;
return (
<div>
<span className="font-medium text-white block">{projName}</span>
<span className="text-[10px] text-slate-500 font-mono">Project ID: #{d.projectId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-slate-400 font-medium">
{d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'}
{(() => {
if (!d.milestoneId) {
return <span className="text-slate-400 italic">Initial Advance Tranche</span>;
}
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
const msName = ms?.name || `Milestone #${d.milestoneId}`;
return (
<div>
<span className="font-medium block">{msName}</span>
<span className="text-[10px] text-slate-500 font-mono">Milestone ID: #{d.milestoneId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(d.amount)}
@@ -1549,7 +1629,14 @@ export const GrantsPage: React.FC = () => {
onSubmit={editAwardForm.handleSubmit((values) => {
if (!editingAward) return;
if (Number(editingAward.status) === ApprovalStatus.Approved) {
toastError('Record Immutable', 'Approved awards cannot be modified.');
toastError('Record Approved', 'Approved awards cannot be modified.');
return;
}
if (
selectedAwardStatus === ApprovalStatus.Approved &&
!canApproveRecord('grant_financials', editingAward.createdBy)
) {
toastError('Maker-Checker Violation', 'Record creators cannot approve their own award records.');
return;
}
updateAwardMutation.mutate({
@@ -1630,7 +1717,7 @@ export const GrantsPage: React.FC = () => {
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
<select
@@ -1640,7 +1727,21 @@ export const GrantsPage: React.FC = () => {
>
<option value={ApprovalStatus.Pending}>Pending Approval</option>
<option value={ApprovalStatus.Review}>Under Review</option>
<option value={ApprovalStatus.Approved}>Approved &amp; Activated</option>
{(() => {
const canApproveAwd = canApproveRecord('grant_financials', editingAward?.createdBy);
const isSelf = isMaker(editingAward?.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={!canApproveAwd}>
Approved &amp; Activated {reason}
</option>
);
})()}
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
)}
@@ -1783,7 +1884,7 @@ export const GrantsPage: React.FC = () => {
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Status Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
<select
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Inbox,
Send,
@@ -17,26 +18,32 @@ import {
RotateCcw,
X,
Smartphone,
Filter,
ShieldAlert,
} from 'lucide-react';
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
import { formatDate } from '../lib/formatters';
import { Notification, NotificationPlatform, NotificationStatus } from '../types';
import { Notification, NotificationPlatform, NotificationStatus, User } from '../types';
import { useAuth } from '../auth/AuthProvider';
import { stripMarkdown } from '../lib/markdownUtils';
import { getUsers } from '../services/api';
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
type PlatformFilter = 'all' | 'inapp' | 'email';
export const InboxPage: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const targetIdParam = searchParams.get('id');
const { user, claims } = useAuth();
const currentUserId = String(user?.id || claims?.sub || '').trim().toLowerCase();
const currentUserEmail = (user?.email || claims?.email || '') as string;
const { data: directoryUsers = [] } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
});
const {
allMailboxNotifications,
inboxNotifications,
@@ -60,8 +67,6 @@ export const InboxPage: React.FC = () => {
// Active folder
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
// Platform filter
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
// Search query
const [searchTerm, setSearchTerm] = useState('');
// Filter for unread only in Inbox
@@ -81,12 +86,14 @@ export const InboxPage: React.FC = () => {
// Inline quick reply state
const [inlineReplyText, setInlineReplyText] = useState('');
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
// Self-reply prevention modal state
const [isSelfReplyModalOpen, setIsSelfReplyModalOpen] = useState(false);
// Active folder message items
// Active folder message items (always In-App notifications)
const folderMessages = useMemo(() => {
switch (activeFolder) {
case 'inbox':
return allMailboxNotifications;
return inboxNotifications;
case 'starred':
return starredNotifications;
case 'sent':
@@ -96,20 +103,13 @@ export const InboxPage: React.FC = () => {
case 'drafts':
return [];
default:
return allMailboxNotifications;
return inboxNotifications;
}
}, [activeFolder, allMailboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
}, [activeFolder, inboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
// Filtered by search, platform, and unread toggle
// Filtered by search and unread toggle
const filteredMessages = useMemo(() => {
return folderMessages.filter(item => {
// Platform filter
if (platformFilter === 'inapp' && Number(item.platform) !== NotificationPlatform.InApp) {
return false;
}
if (platformFilter === 'email' && Number(item.platform) !== NotificationPlatform.Email) {
return false;
}
// Unread only filter
if (activeFolder === 'inbox' && showUnreadOnly) {
if (Number(item.status) === NotificationStatus.Read) return false;
@@ -122,7 +122,7 @@ export const InboxPage: React.FC = () => {
const dest = (item.destination || '').toLowerCase();
return subject.includes(term) || message.includes(term) || dest.includes(term);
});
}, [folderMessages, platformFilter, activeFolder, showUnreadOnly, searchTerm]);
}, [folderMessages, activeFolder, showUnreadOnly, searchTerm]);
// Filtered drafts for Drafts folder
const filteredDrafts = useMemo(() => {
@@ -148,7 +148,11 @@ export const InboxPage: React.FC = () => {
}
}
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
setSelectedMessage(folderMessages[0]);
const first = folderMessages[0];
setSelectedMessage(first);
if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) {
markAsRead(first.id);
}
}
}, [targetIdParam, folderMessages, activeFolder]);
@@ -173,15 +177,51 @@ export const InboxPage: React.FC = () => {
setIsComposeOpen(true);
};
const extractSenderEmail = (msg: Notification): string => {
const rawDest = msg.destination || '';
if (rawDest.includes('From:')) {
const fromPart = rawDest.split('From:')[1]?.split('|')[0]?.trim() || '';
return fromPart.replace(/[<>]/g, '').trim();
}
if (!rawDest.includes('To:') && rawDest.includes('@')) {
return rawDest.trim();
}
return '';
};
const isNotificationFromSelf = (msg: Notification): boolean => {
if (activeFolder === 'sent' || String(msg.id).startsWith('sent_')) {
return true;
}
const senderEmail = extractSenderEmail(msg).toLowerCase();
const myEmail = currentUserEmail.trim().toLowerCase();
if (myEmail && senderEmail && senderEmail === myEmail) {
return true;
}
if (!senderEmail && String(msg.recipient || '').trim().toLowerCase() === currentUserId) {
return true;
}
return false;
};
// Open Compose Modal for a Reply
const handleReplyMessage = (msg: Notification) => {
const rawDest = msg.destination || '';
let replyEmail = '';
if (rawDest.includes('From:')) {
const fromPart = rawDest.split('From:')[1]?.trim() || '';
replyEmail = fromPart.replace(/[<>]/g, '').trim();
} else if (rawDest.includes('@')) {
replyEmail = rawDest.trim();
if (isNotificationFromSelf(msg)) {
setIsSelfReplyModalOpen(true);
return;
}
const replyEmail = extractSenderEmail(msg);
const matchedSender = replyEmail
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
: undefined;
if (
(matchedSender && String(matchedSender.id).trim().toLowerCase() === currentUserId) ||
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
) {
setIsSelfReplyModalOpen(true);
return;
}
const replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`;
@@ -189,9 +229,9 @@ export const InboxPage: React.FC = () => {
setSelectedDraft(null);
setComposeInitialRecipient(
replyEmail
replyEmail && matchedSender
? {
id: String(msg.recipient),
id: String(matchedSender.id),
email: replyEmail,
name: replyEmail.split('@')[0],
}
@@ -225,18 +265,29 @@ export const InboxPage: React.FC = () => {
const handleSendInlineReply = async () => {
if (!selectedMessage || !inlineReplyText.trim()) return;
const rawDest = selectedMessage.destination || '';
let replyEmail = '';
if (rawDest.includes('From:')) {
replyEmail = rawDest.split('From:')[1]?.trim().replace(/[<>]/g, '') || '';
} else if (rawDest.includes('@')) {
replyEmail = rawDest.trim();
if (isNotificationFromSelf(selectedMessage)) {
setIsSelfReplyModalOpen(true);
return;
}
const replyEmail = extractSenderEmail(selectedMessage);
const matchedSender = replyEmail
? directoryUsers.find(u => (u.email || '').trim().toLowerCase() === replyEmail.toLowerCase())
: undefined;
const targetRecipientId = matchedSender ? String(matchedSender.id) : String(selectedMessage.recipient);
if (
targetRecipientId.trim().toLowerCase() === currentUserId ||
(replyEmail && replyEmail.toLowerCase() === currentUserEmail.trim().toLowerCase())
) {
setIsSelfReplyModalOpen(true);
return;
}
try {
await sendMessage({
recipientId: String(selectedMessage.recipient),
recipientEmail: replyEmail || currentUserEmail || 'team@dsi.gov.za',
recipientId: targetRecipientId,
recipientEmail: replyEmail || 'team@dsi.gov.za',
subject: selectedMessage.subject?.startsWith('Re:')
? selectedMessage.subject
: `Re: ${selectedMessage.subject || 'Notification'}`,
@@ -266,11 +317,11 @@ export const InboxPage: React.FC = () => {
<div className="flex items-center gap-2">
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
Markdown & Branded HTML Engine
In-App Messaging
</span>
</div>
<p className="text-[11px] text-slate-400">
In-App markdown messaging and executive branded email notifications
In-App notifications and internal platform communications
</p>
</div>
</div>
@@ -406,10 +457,14 @@ export const InboxPage: React.FC = () => {
<FileText className="w-4 h-4 text-purple-400" />
<span>Drafts</span>
</div>
{drafts.length > 0 && (
{drafts.length > 0 ? (
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
{drafts.length}
</span>
) : (
<span className="text-[11px] text-slate-400 font-mono">
0
</span>
)}
</button>
@@ -436,57 +491,6 @@ export const InboxPage: React.FC = () => {
</button>
</nav>
{/* Platform Filter Buttons */}
<div className="pt-3 border-t border-slate-800/80 space-y-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
Channel Filter
</span>
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => setPlatformFilter('all')}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
platformFilter === 'all'
? 'bg-slate-800 text-white font-semibold'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
}`}
>
<div className="flex items-center gap-2">
<Filter className="w-3.5 h-3.5 text-blue-400" />
<span>All Channels</span>
</div>
</button>
<button
type="button"
onClick={() => setPlatformFilter('inapp')}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
platformFilter === 'inapp'
? 'bg-blue-600/25 text-blue-200 font-semibold border border-blue-500/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
}`}
>
<div className="flex items-center gap-2">
<Smartphone className="w-3.5 h-3.5 text-blue-400" />
<span>In-App Markdown</span>
</div>
</button>
<button
type="button"
onClick={() => setPlatformFilter('email')}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
platformFilter === 'email'
? 'bg-emerald-600/25 text-emerald-200 font-semibold border border-emerald-500/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
}`}
>
<div className="flex items-center gap-2">
<Mail className="w-3.5 h-3.5 text-emerald-400" />
<span>Email HTML</span>
</div>
</button>
</div>
</div>
{/* Quick Filter: Unread Toggle */}
{activeFolder === 'inbox' && (
<div className="pt-2 border-t border-slate-800/80">
@@ -507,10 +511,10 @@ export const InboxPage: React.FC = () => {
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span>Format Standards</span>
<span>In-App Notifications</span>
</div>
<p className="text-[10px] text-slate-400 leading-snug">
InApp messages are stored as Markdown. Email notifications are transformed to branded HTML.
Messages are delivered directly to your In-App Mailbox and Notification Bell.
</p>
</div>
</aside>
@@ -571,7 +575,7 @@ export const InboxPage: React.FC = () => {
>
<div className="flex items-center justify-between gap-1">
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
Draft (Markdown)
Draft
</span>
<span className="text-[10px] text-slate-400 font-mono">
{formatDate(d.updatedAt)}
@@ -620,7 +624,6 @@ export const InboxPage: React.FC = () => {
const isSelected = selectedMessage?.id === item.id;
const isUnread = Number(item.status) !== NotificationStatus.Read;
const isStarred = starredIds.includes(String(item.id));
const isEmail = Number(item.platform) === NotificationPlatform.Email;
return (
<div
@@ -649,15 +652,9 @@ export const InboxPage: React.FC = () => {
</div>
<div className="flex items-center gap-1.5 shrink-0">
{isEmail ? (
<span className="text-[9px] font-bold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-1.5 py-0.2 rounded font-mono">
Email
</span>
) : (
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
InApp
</span>
)}
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
In-App
</span>
<button
type="button"
@@ -672,6 +669,22 @@ export const InboxPage: React.FC = () => {
>
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
</button>
<button
type="button"
onClick={e => {
e.stopPropagation();
deleteMessage(item.id);
if (selectedMessage?.id === item.id) {
setSelectedMessage(null);
}
}}
className="p-0.5 rounded text-slate-400 hover:text-rose-400 transition cursor-pointer"
title="Delete message (Archive)"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<span className="text-[10px] text-slate-400 font-mono">
{formatDate(item.createdAt)}
</span>
@@ -707,7 +720,7 @@ export const InboxPage: React.FC = () => {
<FileText className="w-12 h-12 text-slate-600 mb-3" />
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
Click any draft on the left pane to resume composing in Markdown and dispatch your notification.
Click any draft on the left pane to resume composing and dispatch your notification.
</p>
<button
type="button"
@@ -725,7 +738,7 @@ export const InboxPage: React.FC = () => {
</div>
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
Select an in-app conversation or email notice from the list to view its rendered markdown and attachments.
Select an in-app conversation or email notice from the list to view its contents and attachments.
</p>
<button
type="button"
@@ -815,17 +828,10 @@ export const InboxPage: React.FC = () => {
{selectedMessage.subject || 'In-App Notification'}
</h2>
<div className="flex flex-wrap items-center gap-2 mt-2">
{Number(selectedMessage.platform) === NotificationPlatform.Email ? (
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
<Mail className="w-3 h-3 text-emerald-400" />
Channel: Email (Branded HTML)
</span>
) : (
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
<Smartphone className="w-3 h-3 text-blue-400" />
Channel: InApp (Markdown)
</span>
)}
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
<Smartphone className="w-3 h-3 text-blue-400" />
Channel: In-App
</span>
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
ID: #{selectedMessage.id}
@@ -852,7 +858,7 @@ export const InboxPage: React.FC = () => {
: selectedMessage.destination || 'PFM Automated Dispatcher'}
</p>
<span className="text-[10px] text-slate-400 font-mono">
({Number(selectedMessage.platform) === NotificationPlatform.Email ? 'SMTP Protocol' : 'InApp Protocol'})
(In-App Notification)
</span>
</div>
<p className="text-[11px] text-slate-400 mt-0.5">
@@ -884,10 +890,10 @@ export const InboxPage: React.FC = () => {
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
<Reply className="w-3.5 h-3.5 text-blue-400" />
<span>Quick Markdown Reply</span>
<span>Quick Reply</span>
</h4>
<span className="text-[10px] text-slate-400">
Supports Markdown formatting (e.g. **bold**, - lists, `code`)
Send a quick response below or open the rich text composer
</span>
</div>
@@ -902,8 +908,8 @@ export const InboxPage: React.FC = () => {
rows={4}
value={inlineReplyText}
onChange={e => setInlineReplyText(e.target.value)}
placeholder="Type your markdown reply... (e.g. **Approved** milestone deliverable proof)"
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y font-mono leading-relaxed"
placeholder="Type your reply..."
className="w-full p-3.5 rounded-2xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner resize-y leading-relaxed"
/>
<div className="flex items-center justify-between">
@@ -946,6 +952,46 @@ export const InboxPage: React.FC = () => {
initialPlatform={composeInitialPlatform}
draftToEdit={selectedDraft}
/>
{/* Self-Reply Prevention Modal */}
{isSelfReplyModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-150">
<div className="w-full max-w-md bg-[#0d1322] border border-rose-500/30 rounded-2xl shadow-2xl overflow-hidden p-6 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-rose-500/15 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
<ShieldAlert className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold text-white">Action Not Allowed</h3>
<p className="text-[11px] text-rose-300 font-medium">Self-Reply Restricted</p>
</div>
</div>
<button
type="button"
onClick={() => setIsSelfReplyModalOpen(false)}
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
You cannot reply to a notification that originated from your own account. Please compose a new message and select another recipient from the directory if you wish to follow up with a colleague.
</p>
<div className="pt-2 border-t border-slate-800 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsSelfReplyModalOpen(false)}
className="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/25 transition cursor-pointer"
>
Understood
</button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -42,6 +42,7 @@ import {
recordIndicatorActual,
getBudgets,
getActivities,
getDisbursements,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -242,6 +243,11 @@ export const ProjectsPage: React.FC = () => {
queryFn: () => getBudgets(1, 100),
});
const { data: allDisbursements = [] } = useQuery({
queryKey: ['disbursements'],
queryFn: () => getDisbursements(1, 200),
});
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
queryFn: async () => {
@@ -311,6 +317,18 @@ export const ProjectsPage: React.FC = () => {
return map;
}, [allBudgets]);
// Disbursed capital per project
const disbursedByProject = useMemo(() => {
const map = new Map<string | number, number>();
allDisbursements.forEach((d) => {
if (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3) {
const current = map.get(d.projectId) || 0;
map.set(d.projectId, current + (Number(d.amount) || 0));
}
});
return map;
}, [allDisbursements]);
// Filtered Projects
const filteredProjects = useMemo(() => {
return projects.filter((p) => {
@@ -380,9 +398,22 @@ export const ProjectsPage: React.FC = () => {
return amt > 0 ? formatZAR(amt) : 'R 0';
},
},
{
header: 'Project Variance (ZAR)',
key: 'id',
format: (_, p) => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
const pVariance = approvedBudget - projectValue;
return `${pVariance >= 0 ? '+' : ''}${formatZAR(pVariance)}`;
},
},
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]);
// Table Sorting
const customExtractors = {
@@ -404,6 +435,14 @@ export const ProjectsPage: React.FC = () => {
if (risk.isAtRisk) return 2;
return 3;
},
variance: (p: Project) => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
return approvedBudget - projectValue;
},
};
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
@@ -1007,17 +1046,17 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
</SortableTh>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">
<SortableTh column="variance" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} align="right" className="py-3.5 px-4 text-right whitespace-nowrap w-36">
<div className="flex items-center justify-end gap-1">
<span>Project Variance</span>
<HelpTooltip
title="Project Variance"
content="Approved allocated budget minus scheduled milestone activity values. Positive (green) indicates within budget constraints; negative (red) indicates cost overrun."
content="Approved allocated budget minus net project value (milestone activity commitments minus disbursements). Positive (green) indicates remaining budget headroom; negative (red) indicates cost overrun."
size="sm"
placement="bottom"
/>
</div>
</th>
</SortableTh>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
</tr>
</thead>
@@ -1212,7 +1251,9 @@ export const ProjectsPage: React.FC = () => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pVariance = approvedBudget - pActsTotal;
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
const pVariance = approvedBudget - projectValue;
const isPos = pVariance >= 0;
return (
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
@@ -1222,7 +1263,7 @@ export const ProjectsPage: React.FC = () => {
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
}`}
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Disbursements: ${formatZAR(pDisbursedTotal)} | Project Value: ${formatZAR(projectValue)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
>
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
</span>
@@ -57,9 +57,16 @@ import {
getOrganisations,
getAwards,
getContracts,
getProjects,
getAuditLogs,
getNotifications,
notifyInviteApproved,
getCurrentUserTenantId,
} from '../services/api';
import {
getApprovedInviteIds,
markInviteAsApprovedInStorage,
} from '../services/notificationRulesService';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
@@ -73,6 +80,8 @@ import {
NotificationStatus,
NotificationPlatform,
ActivityStatus,
ApprovalStatus,
ContractStatus,
User,
Invite,
InviteRequest,
@@ -104,7 +113,7 @@ export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
const { canView, canAdd, canExport } = useRbac();
const { canView, canAdd, canExport, canApproveRecord, isMaker } = useRbac();
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
const canViewInvites = canView('settings_invites');
const canViewNotifications = canView('settings_notifications');
@@ -146,7 +155,6 @@ export const SettingsPage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const {
@@ -235,35 +243,39 @@ export const SettingsPage: React.FC = () => {
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
const [inviteContractId, setInviteContractId] = useState<string | number>('');
const [inviteProjectId, setInviteProjectId] = useState<string | number>('');
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const [inviteAutoApprove, setInviteAutoApprove] = useState<boolean>(true);
// Invites filtering & search
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
// Approved invites tracking state
const currentTenantId = getCurrentUserTenantId();
const [approvedInviteIds, setApprovedInviteIds] = useState<string[]>(() =>
getApprovedInviteIds(currentTenantId)
);
const {
data: invitesList = [],
isLoading: loadingInvites,
isFetching: isFetchingInvites,
refetch: refetchInvites,
} = useQuery({
queryKey: ['invites'],
queryFn: () => getInvites(1, 100),
});
const handleApproveInvite = async (inv: Invite, idx: number) => {
if (isMaker(inv.createdBy)) {
toastError('Maker-Checker Violation', 'You cannot approve an invitation that you created.');
return;
}
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
const filteredInvites = useMemo(() => {
return invitesList.filter((inv) => {
const matchSearch =
!inviteSearchTerm.trim() ||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
(inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase()));
const matchPlatform =
invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter);
return matchSearch && matchPlatform;
});
}, [invitesList, inviteSearchTerm, invitePlatformFilter]);
await notifyInviteApproved({
invite: inv,
}).catch(console.warn);
queryClient.invalidateQueries({ queryKey: ['invites'] });
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
success(
'Invitation Approved',
'Partner user has been notified via In-App and Email messages with link to Partner Portal (https://partner.ifri.khongisa.co.za/).'
);
};
const { data: organisations = [] } = useQuery({
queryKey: ['organisations'],
@@ -280,14 +292,136 @@ export const SettingsPage: React.FC = () => {
queryFn: () => getContracts(1, 50),
});
const { data: projects = [] } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(1, 100),
});
// Only awards that are Approved AND have an Approved/Active contract are eligible for invitations
const eligibleAwards = useMemo(() => {
return awards.filter((aw) => {
const isApprovedAward = Number(aw.status) === ApprovalStatus.Approved;
if (!isApprovedAward) return false;
const hasApprovedContract = contracts.some(
(c) =>
String(c.awardId) === String(aw.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
return hasApprovedContract;
});
}, [awards, contracts]);
const handleSelectAward = (awardId: string | number) => {
setInviteAwardId(awardId);
const selected = eligibleAwards.find((a) => String(a.id) === String(awardId));
if (selected) {
setInvitedOrgId(selected.organisationId);
const matchingContract = contracts.find(
(c) =>
String(c.awardId) === String(selected.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
if (matchingContract) {
setInviteContractId(matchingContract.id);
}
setInviteProjectId(selected.projectId || '');
}
};
React.useEffect(() => {
if (isSendInviteModalOpen) {
if (eligibleAwards.length > 0) {
const currentAward =
eligibleAwards.find((a) => String(a.id) === String(inviteAwardId)) || eligibleAwards[0];
setInviteAwardId(currentAward.id);
setInvitedOrgId(currentAward.organisationId);
const matchingContract = contracts.find(
(c) =>
String(c.awardId) === String(currentAward.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
if (matchingContract) {
setInviteContractId(matchingContract.id);
}
setInviteProjectId(currentAward.projectId || '');
} else {
setInviteAwardId('');
setInvitedOrgId('');
setInviteContractId('');
setInviteProjectId('');
}
}
}, [isSendInviteModalOpen, eligibleAwards, contracts]);
// Invites filtering & search
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
const {
data: invitesList = [],
isLoading: loadingInvites,
isFetching: isFetchingInvites,
refetch: refetchInvites,
} = useQuery({
queryKey: ['invites'],
queryFn: () => getInvites(1, 100),
});
const getStoredInvitePlatform = (inviteId?: string | number): NotificationPlatform | undefined => {
if (!inviteId) return undefined;
try {
const raw = localStorage.getItem(`pfm_invite_platform_${inviteId}`);
return raw !== null ? (Number(raw) as NotificationPlatform) : undefined;
} catch {
return undefined;
}
};
const storeInvitePlatform = (inviteId: string | number, platform: NotificationPlatform) => {
try {
localStorage.setItem(`pfm_invite_platform_${inviteId}`, String(platform));
} catch {}
};
const resolveUserEmail = (userIdOrUuid?: string | null): string => {
if (!userIdOrUuid) return '—';
const u = usersDirectory.find((usr) => String(usr.id).toLowerCase() === String(userIdOrUuid).toLowerCase());
const email = u?.email || getUserEmail(userIdOrUuid);
return email && email !== '—' ? email : String(userIdOrUuid);
};
const sendInviteMutation = useMutation({
mutationFn: (payload: InviteRequest) => sendInvite(payload),
onSuccess: () => {
onSuccess: (data, variables) => {
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
queryClient.invalidateQueries({ queryKey: ['invites'] });
setIsSendInviteModalOpen(false);
setInviteRecipientUuid('');
setInviteMessage('');
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
storeInvitePlatform(invId, variables.platform);
if (inviteAutoApprove) {
markInviteAsApprovedInStorage(invId, currentTenantId);
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
notifyInviteApproved({
invite: {
id: invId,
recipient: variables.recipient,
invitedOrganisationId: variables.invitedOrganisationId,
organisationId: variables.organisationId,
awardId: variables.awardId,
contractId: variables.contractId,
projectId: variables.projectId,
message: variables.message,
},
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
}).catch(console.warn);
}
},
onError: (err: unknown) => {
toastError('Dispatch Error', err);
@@ -366,6 +500,38 @@ export const SettingsPage: React.FC = () => {
handleSort: handleNotifSort,
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
const resolveInvitePlatform = (inv: Invite): NotificationPlatform => {
if (inv.platform !== undefined && inv.platform !== null) {
return Number(inv.platform) as NotificationPlatform;
}
const stored = getStoredInvitePlatform(inv.id);
if (stored !== undefined) return stored;
const linkedNotif = fetchedNotifications.find(
(n) =>
(inv.recipient && n.recipient?.toLowerCase() === inv.recipient?.toLowerCase()) ||
(inv.invitedOrganisationId && String(n.organisationId) === String(inv.invitedOrganisationId))
);
if (linkedNotif?.platform !== undefined && linkedNotif.platform !== null) {
return Number(linkedNotif.platform) as NotificationPlatform;
}
return NotificationPlatform.Email;
};
const filteredInvites = useMemo(() => {
return invitesList.filter((inv) => {
const recipientEmail = resolveUserEmail(inv.recipient).toLowerCase();
const matchSearch =
!inviteSearchTerm.trim() ||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
recipientEmail.includes(inviteSearchTerm.toLowerCase());
const resolvedPlatform = resolveInvitePlatform(inv);
const matchPlatform =
invitePlatformFilter === 'all' || Number(resolvedPlatform) === Number(invitePlatformFilter);
return matchSearch && matchPlatform;
});
}, [invitesList, inviteSearchTerm, invitePlatformFilter, fetchedNotifications, usersDirectory]);
// ==========================================
// 4. Audit Log Ledger State
// ==========================================
@@ -434,9 +600,22 @@ export const SettingsPage: React.FC = () => {
{
header: 'Changes / Arguments',
key: 'changes',
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
format: (val) => {
if (!val) return '—';
let str = Array.isArray(val) ? val.join('; ') : typeof val === 'object' ? JSON.stringify(val) : String(val);
str = str.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
},
},
{
header: 'Error Details',
key: 'error',
format: (val) => {
if (!val) return '—';
let str = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
},
},
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
];
const uniqueAuditEntities = Array.from(
@@ -506,13 +685,25 @@ export const SettingsPage: React.FC = () => {
];
const inviteExportColumns: ExportColumn<Invite>[] = [
{ header: 'Recipient (UUID)', key: 'recipient' },
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
{ header: 'Invitation Message', key: 'message' },
{
header: 'Recipient Email',
key: 'recipient',
format: (val) => resolveUserEmail(val),
},
{
header: 'Channel / Platform',
key: 'platform',
format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
},
{
header: 'Target Organisation / Grant',
key: 'invitedOrganisationId',
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
format: (val, inv) => {
const orgId = val || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
const orgName = org?.name || `Organisation #${orgId || '—'}`;
return `${orgName} • Award #${inv.awardId || '—'}`;
},
},
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
];
@@ -538,7 +729,15 @@ export const SettingsPage: React.FC = () => {
sortColumn: inviteSortCol,
sortDirection: inviteSortDir,
handleSort: handleInviteSort,
} = useTableSort(filteredInvites, 'createdAt', 'desc');
} = useTableSort(filteredInvites, 'createdAt', 'desc', {
recipient: (inv: Invite) => resolveUserEmail(inv.recipient),
platform: (inv: Invite) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
invitedOrganisationId: (inv: Invite) => {
const orgId = inv.invitedOrganisationId || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
return org?.name || `Organisation #${orgId || ''}`;
},
});
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
@@ -1125,61 +1324,125 @@ export const SettingsPage: React.FC = () => {
</div>
</div>
<div className="overflow-x-auto min-w-0 w-full">
<table className="w-full text-left text-xs border-collapse min-w-[850px]">
<table className="w-full text-left text-xs border-collapse min-w-[950px]">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
Recipient (UUID)
Recipient Email
</SortableTh>
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Channel
</SortableTh>
<SortableTh column="message" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Message
</SortableTh>
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Target Org / Grant
</SortableTh>
<th className="py-3.5 px-4 font-semibold text-slate-400 uppercase tracking-wider">
Status
</th>
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
Created Date
</SortableTh>
<th className="py-3.5 px-5 text-right font-semibold text-slate-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingInvites ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
<td colSpan={6} className="py-8 text-center text-slate-500">
Loading invitations ledger...
</td>
</tr>
) : sortedInvites.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
<td colSpan={6} className="py-8 text-center text-slate-500">
No invitations recorded yet. Use &ldquo;Dispatch Invitation&rdquo; to send one.
</td>
</tr>
) : (
sortedInvites.map((inv, idx) => (
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
{inv.recipient}
</td>
<td className="py-3.5 px-4">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
{getNotificationPlatformLabel(inv.platform)}
</span>
</td>
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
{inv.message}
</td>
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
Org #{inv.invitedOrganisationId || inv.organisationId} &bull; Award #{inv.awardId}
</td>
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
</td>
</tr>
))
sortedInvites.map((inv, idx) => {
const isApproved =
approvedInviteIds.includes(String(inv.id)) ||
approvedInviteIds.includes(`inv_${idx}`) ||
approvedInviteIds.includes(String(idx));
const resolvedPlatform = resolveInvitePlatform(inv);
const userEmail = resolveUserEmail(inv.recipient);
const orgId = inv.invitedOrganisationId || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
const orgName = org?.name || `Organisation #${orgId || '—'}`;
const isCreator = isMaker(inv.createdBy);
return (
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 text-slate-200 text-xs">
<div className="font-medium text-white flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{userEmail}</span>
</div>
<div className="font-mono text-slate-500 text-[10px] truncate max-w-xs mt-0.5">
{inv.recipient}
</div>
</td>
<td className="py-3.5 px-4">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
{getNotificationPlatformLabel(resolvedPlatform)}
</span>
</td>
<td className="py-3.5 px-4 text-slate-300 text-xs">
<div className="font-medium text-white">{orgName}</div>
<div className="text-slate-400 text-[11px] mt-0.5">
Award #{inv.awardId || '—'}
</div>
</td>
<td className="py-3.5 px-4">
{isApproved ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle className="w-3 h-3" />
Approved
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
<RotateCcw className="w-3 h-3" />
Pending Approval
</span>
)}
</td>
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
</td>
<td className="py-3.5 px-5 text-right">
{isApproved ? (
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400/90 font-medium">
<Check className="w-3 h-3" />
Partner Notified
</span>
) : canApproveRecord('settings_invites', inv.createdBy) ? (
<button
type="button"
onClick={() => handleApproveInvite(inv, idx)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 border border-emerald-500/30 transition shadow-xs cursor-pointer"
title="Approve this invitation and issue InApp and Email notifications to the partner user"
>
<Check className="w-3 h-3" />
<span>Approve</span>
</button>
) : isCreator ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20"
title="Maker-Checker: You created this invite and cannot approve it"
>
Maker (Creator)
</span>
) : (
<span className="text-[11px] text-slate-500">
Pending Review
</span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
@@ -2395,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
<form
onSubmit={(e) => {
e.preventDefault();
if (!inviteAwardId) {
toastError('Policy Violation', 'An approved award with an approved contract must be selected.');
return;
}
if (!inviteRecipientUuid || !inviteMessage) {
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
toastError('Validation Error', 'Recipient identifier and message content are mandatory.');
return;
}
sendInviteMutation.mutate({
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
awardId: inviteAwardId,
contractId: inviteContractId,
projectId: inviteProjectId || 0,
recipient: inviteRecipientUuid,
message: inviteMessage,
platform: invitePlatform,
@@ -2411,18 +2679,51 @@ export const SettingsPage: React.FC = () => {
}}
className="space-y-4"
>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Recipient User UUID *
</label>
<HelpTooltip
title="Recipient UUID"
content="Unique directory user identifier or email for targeted delivery."
size="sm"
placement="right"
/>
{eligibleAwards.length === 0 ? (
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start gap-2.5">
<RotateCcw className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
<div>
<span className="font-semibold block text-white">No Approved Awards Eligible for Invitation</span>
<span className="text-[11px] text-amber-300/90 leading-relaxed block mt-0.5">
System governance policy strictly requires that invitations may only be dispatched for awards that have been formally <strong>Approved</strong> and whose associated contract is <strong>Approved &amp; Active</strong>.
</span>
</div>
</div>
) : null}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300">
Recipient User *
</label>
<HelpTooltip
title="Recipient Directory Identifier"
content="Select a registered recipient user from directory or enter their unique user UUID."
size="sm"
placement="right"
/>
</div>
{usersDirectory.length > 0 && (
<span className="text-[10px] text-slate-400">Quick-select from registered users</span>
)}
</div>
{usersDirectory.length > 0 && (
<select
value={usersDirectory.some((u) => String(u.id).toLowerCase() === inviteRecipientUuid.toLowerCase()) ? inviteRecipientUuid : ''}
onChange={(e) => {
if (e.target.value) setInviteRecipientUuid(e.target.value);
}}
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 [&>option]:bg-slate-900 [&>option]:text-white mb-1"
>
<option value="">-- Choose from Registered Users --</option>
{usersDirectory.map((u) => (
<option key={u.id} value={u.id}>
{u.name ? `${u.name} — ` : ''}{u.email} ({u.id})
</option>
))}
</select>
)}
<input
type="text"
value={inviteRecipientUuid}
@@ -2433,6 +2734,98 @@ export const SettingsPage: React.FC = () => {
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Award *
</label>
<HelpTooltip
title="Approved Award Required"
content="Only awards with status Approved and an Active/Approved contract are eligible for invitations."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => handleSelectAward(e.target.value)}
disabled={eligibleAwards.length === 0}
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 [&>option]:bg-slate-900 [&>option]:text-white disabled:opacity-50"
required
>
{eligibleAwards.length === 0 ? (
<option value="">No approved awards available</option>
) : (
eligibleAwards.map((aw) => {
const recipientOrg = organisations.find((o) => String(o.id) === String(aw.organisationId));
return (
<option key={aw.id} value={aw.id}>
Award #{aw.id} — {recipientOrg?.name || `Org #${aw.organisationId}`}
</option>
);
})
)}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Contract *
</label>
<HelpTooltip
title="Associated Approved Contract"
content="The active/approved contract linked to the selected award."
size="sm"
placement="top"
/>
</div>
<input
type="text"
value={inviteContractId ? `Contract #${inviteContractId} (Approved & Active)` : 'No active contract'}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 font-mono focus:outline-none cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Target Organisation (Recipient Org)
</label>
</div>
<input
type="text"
value={(() => {
const o = organisations.find((org) => String(org.id) === String(invitedOrgId));
return o ? `${o.name} (#${o.id})` : invitedOrgId ? `Org #${invitedOrgId}` : '—';
})()}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Project
</label>
</div>
<input
type="text"
value={(() => {
const prj = projects.find((p) => String(p.id) === String(inviteProjectId));
return prj ? `${prj.name || 'Project'} (#${prj.id})` : inviteProjectId ? `Project #${inviteProjectId}` : 'Initial Award Project';
})()}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
@@ -2462,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Originating Organisation
Originating Authority
</label>
<HelpTooltip
title="Inviting Authority"
@@ -2485,58 +2878,6 @@ export const SettingsPage: React.FC = () => {
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Award
</label>
<HelpTooltip
title="Linked Grant Award"
content="Optional award attachment to link recipient permissions directly to grant administration."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => setInviteAwardId(e.target.value)}
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 [&>option]:bg-slate-900 [&>option]:text-white"
>
{awards.map((aw) => (
<option key={aw.id} value={aw.id}>
Award #{aw.id}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Contract
</label>
<HelpTooltip
title="Linked Contract"
content="Optional bilateral agreement linking the recipient to contract execution."
size="sm"
placement="top"
/>
</div>
<select
value={inviteContractId}
onChange={(e) => setInviteContractId(e.target.value)}
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 [&>option]:bg-slate-900 [&>option]:text-white"
>
{contracts.map((ctr) => (
<option key={ctr.id} value={ctr.id}>
Contract #{ctr.id}
</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
@@ -2559,6 +2900,22 @@ export const SettingsPage: React.FC = () => {
/>
</div>
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-blue-950/20 border border-blue-900/40">
<input
type="checkbox"
id="inviteAutoApprove"
checked={inviteAutoApprove}
onChange={(e) => setInviteAutoApprove(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500 cursor-pointer"
/>
<label htmlFor="inviteAutoApprove" className="text-xs text-slate-300 cursor-pointer select-none">
<span className="font-semibold text-white block">Formally Approve Invitation &amp; Notify Partner</span>
<span className="text-[11px] text-slate-400 leading-relaxed block mt-0.5">
Automatically marks invite as Approved and issues In-App and Email notifications directing the partner user to the Partner Portal (<strong className="text-blue-400 font-mono">https://partner.ifri.khongisa.co.za/</strong>).
</span>
</label>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
@@ -2569,7 +2926,7 @@ export const SettingsPage: React.FC = () => {
</button>
<button
type="submit"
disabled={sendInviteMutation.isPending}
disabled={sendInviteMutation.isPending || eligibleAwards.length === 0}
className="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-lg shadow-blue-500/20 transition disabled:opacity-50"
>
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
title="Approved site visits are immutable."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Verified (Immutable - Cannot Be Changed)</span>
<span>Approved &amp; Verified</span>
</div>
) : (
<select
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
} = useQuery<TenantOrganisation[]>({
queryKey: ['tenant-organisations', tenantId],
queryFn: () => getTenantOrganisations(tenantId),
staleTime: 30000,
});
// 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(() => {
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId),
staleTime: 30000,
});
const { canAdd } = useRbac();
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
} = useQuery<Tenant[]>({
queryKey: ['tenants-list'],
queryFn: () => getTenants(),
staleTime: 30000,
});
// Forms
@@ -962,6 +958,7 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title="Provision New Tenant Partition"
subtitle="Define an isolated multi-tenant partition for grants, portfolios, and users."
size="md"
>
<form
@@ -970,17 +967,6 @@ export const TenantManagementPage: React.FC = () => {
)}
className="space-y-4"
>
<div className="p-3.5 rounded-xl bg-blue-950/20 border border-blue-800/30 text-xs text-blue-200 flex items-start gap-2.5">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<div className="space-y-1 leading-relaxed">
<p className="font-semibold text-white">Multi-Tenant Partition Boundary</p>
<p>
Creating a new tenant defines an isolated partition boundary for grants, portfolios, projects, and users.
New tenants are automatically initialized in the Active state.
</p>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Tenant Organization Name <span className="text-rose-400">*</span>
@@ -1007,7 +993,7 @@ export const TenantManagementPage: React.FC = () => {
<button
type="submit"
disabled={createMutation.isPending}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
>
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
@@ -1021,6 +1007,7 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isUpdateModalOpen}
onClose={() => setIsUpdateModalOpen(false)}
title={`Update Tenant: ${selectedTenant?.name || ''}`}
subtitle="Modify tenant partition name and operational access status."
size="md"
>
{selectedTenant && (
@@ -1064,13 +1051,12 @@ export const TenantManagementPage: React.FC = () => {
{/* Warning when selecting Inactive or Disabled */}
{Number(updateForm.watch('status')) !== ActivityStatus.Active && (
<div className="p-3 rounded-xl bg-amber-950/30 border border-amber-800/40 text-xs text-amber-200 flex items-start gap-2.5">
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-200 flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-semibold text-white">Multitenancy Lockout Warning</p>
<p className="text-[11px] leading-relaxed text-amber-300/90">
Changing this tenant's status to Inactive or Disabled immediately locks out all users assigned to this partition.
The backend API will deny requests until the tenant is restored to Active status.
<div className="space-y-0.5">
<p className="font-semibold text-amber-300">Lockout Notice</p>
<p className="text-[11px] text-amber-200/90 leading-relaxed">
Setting this tenant to Inactive or Disabled immediately locks out assigned users from accessing the API.
</p>
</div>
</div>
@@ -1087,7 +1073,7 @@ export const TenantManagementPage: React.FC = () => {
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
>
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
@@ -1102,66 +1088,47 @@ export const TenantManagementPage: React.FC = () => {
isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)}
title={`Tenant Details: ${selectedTenant?.name || ''}`}
subtitle="Partition boundary information, creation audit, and operational status."
size="lg"
>
{selectedTenant && (
<div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-slate-950/60 p-4 rounded-xl border border-slate-800">
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 bg-slate-950/60 p-3.5 rounded-xl border border-slate-800 text-xs">
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Numeric ID</p>
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant ID</p>
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
</div>
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p>
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p>
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant Name</p>
<p className="text-xs font-semibold text-slate-200 mt-0.5 truncate">{selectedTenant.name || '—'}</p>
</div>
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Operational Status</p>
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Operational Status</p>
<div className="mt-1">
<ActivityStatusBadge status={selectedTenant.status} />
</div>
</div>
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created Date</p>
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created Date</p>
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
</div>
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created By</p>
<p className="text-xs font-mono text-slate-300 mt-0.5">
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created By</p>
<p className="text-xs font-mono text-slate-300 mt-0.5 truncate">
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
</p>
</div>
{selectedTenant.updatedAt && (
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Last Updated</p>
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Last Updated</p>
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
</div>
)}
{selectedTenant.updatedBy && (
<div>
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Updated By</p>
<p className="text-xs font-mono text-slate-300 mt-0.5">
{getUserEmail(selectedTenant.updatedBy) || selectedTenant.updatedBy}
</p>
</div>
)}
</div>
<div className="p-3.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs text-slate-300 space-y-2">
<p className="font-semibold text-white flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-blue-400" />
Partition Scope &amp; Authentication
</p>
<p className="text-[11px] text-slate-400 leading-relaxed">
When users authenticate, their security token contains claims identifying their tenant partition.
The system automatically scopes data queries to this tenant and enforces operational lockout if the status is not Active.
</p>
</div>
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
@@ -1171,7 +1138,7 @@ export const TenantManagementPage: React.FC = () => {
setIsDetailModalOpen(false);
handleOpenUpdate(selectedTenant);
}}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
<span>Edit Tenant Status</span>
+191 -299
View File
@@ -1,6 +1,19 @@
import axios, { AxiosError } from 'axios';
import { apiClient } from './httpClient';
import { toolsService } from './toolsService';
import { toolsService } from '../utils/hashids';
import {
notifyTenantManagersAndExecutivesOnRecordStatusChange,
notifyInviteApproved,
notifyNewUserCreated,
isApprovedOrActiveStatus,
} from './notificationRulesService';
export {
notifyTenantManagersAndExecutivesOnRecordStatusChange,
notifyInviteApproved,
notifyNewUserCreated,
isApprovedOrActiveStatus,
};
import {
getCurrentUserSub,
getCurrentUserTenantId,
@@ -64,6 +77,7 @@ import {
AuditLog,
ProblemDetails,
ActivityStatus,
ApprovalStatus,
Tenant,
TenantOrganisation,
TenantUser,
@@ -239,16 +253,25 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
};
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
const orgStatus = payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active;
const res = await apiClient.put('/v1/api/governance/organisations', {
organisationId: toIntegerId(payload.organisationId),
name: payload.name,
registrationNo: payload.registrationNo,
email: payload.email,
idNumber: payload.idNumber,
idNumber: payload.idNumber || null,
organisationUser: payload.organisationUser,
type: Number(payload.type),
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
status: orgStatus,
});
if (isApprovedOrActiveStatus(orgStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Organisation',
recordId: payload.organisationId,
newStatus: orgStatus,
title: payload.name,
}).catch(console.warn);
}
return res.data;
};
@@ -419,84 +442,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
}
};
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
if (!raw) return;
const local: Instrument[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
export const getStoredInstructions = (): Instrument[] => [];
const apiIds = new Set(apiItems.map((i) => String(i.id)));
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored instruction duplicates', e);
}
};
export const getStoredInstructions = (): Instrument[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredInstruction = (inst: Instrument): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredInstructions();
const idx = current.findIndex(
(i) => String(i.id) === String(inst.id) ||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
} else {
current.unshift(inst);
}
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored instruction', e);
}
};
export const saveStoredInstruction = (_inst?: Instrument): void => {};
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
let apiItems: Instrument[] = [];
let apiSucceeded = false;
try {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
if (Array.isArray(res.data)) {
apiItems = res.data;
apiSucceeded = true;
}
return Array.isArray(res.data) ? res.data : [];
} catch (err) {
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
return [];
}
if (apiSucceeded) {
purgeStoredInstructionDuplicates(apiItems);
return apiItems;
}
// Fallback to local items only when API failed
const localItems = getStoredInstructions().filter(
(item) => String(item.programmeId) === String(programmeId)
);
return localItems;
};
export const getInstructions = getInstruments;
@@ -512,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
}
const map = new Map<string, Instrument>();
let hasApiData = false;
if (progList && progList.length > 0) {
const results = await Promise.allSettled(
progList.map((pr) => getInstruments(pr.id))
);
results.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
hasApiData = true;
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
res.value.forEach((inst) => map.set(String(inst.id), inst));
}
});
}
if (hasApiData || map.size > 0) {
const allApiInstruments = Array.from(map.values());
purgeStoredInstructionDuplicates(allApiInstruments);
return allApiInstruments;
}
return getStoredInstructions();
return Array.from(map.values());
};
export const getAllInstructions = getAllInstruments;
@@ -546,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
description: payload.description,
};
try {
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
purgeStoredInstructionDuplicates([res.data]);
return res.data;
}
} catch (err: any) {
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackInstruction: Instrument = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
saveStoredInstruction(fallbackInstruction);
return fallbackInstruction;
}
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
return res.data;
};
export const createInstruction = createInstrument;
@@ -596,33 +514,16 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
status: Number(payload.status),
};
try {
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
return res.data;
}
} catch (err) {
console.warn('[updateInstrument] API update failed:', err);
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Funding Instrument',
recordId: targetId!,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
const updatedInstrument: Instrument = {
id: targetId!,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
code: payload.code || existing?.code || null,
name: payload.name,
description: payload.description,
status: Number(payload.status),
createdBy: existing?.createdBy || 'system',
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId || 'system',
updatedAt: new Date().toISOString(),
};
if (existing) {
saveStoredInstruction(updatedInstrument);
}
return updatedInstrument;
return res.data;
};
export const updateInstruction = updateInstrument;
@@ -667,12 +568,21 @@ export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const progStatus = Number(payload.status) || 1;
const res = await apiClient.put('/v1/api/projects/programme', {
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
status: Number(payload.status) || 1,
status: progStatus,
});
if (isApprovedOrActiveStatus(progStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Programme',
recordId: payload.programmeId,
newStatus: progStatus,
title: payload.name,
}).catch(console.warn);
}
return res.data;
};
@@ -725,6 +635,13 @@ export const updateProjectStatus = async (payload: UpdateProjectStatusRequest):
projectId: toIntegerId(payload.projectId),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Project',
recordId: payload.projectId,
newStatus: payload.status,
}).catch(console.warn);
}
};
export const getActivities = async (projectId: string | number): Promise<Activity[]> => {
@@ -826,6 +743,14 @@ export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<
priority: Number(payload.priority) || 1,
status: Number(payload.status) || 1,
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Milestone',
recordId: payload.milestoneId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
};
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
@@ -884,6 +809,13 @@ export const updateAward = async (payload: UpdateAwardRequest): Promise<void> =>
amount: Number(payload.amount),
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
});
if (payload.status === undefined || isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Award',
recordId: payload.awardId,
newStatus: payload.status !== undefined ? payload.status : ApprovalStatus.Approved,
}).catch(console.warn);
}
};
export const fetchHashId = async (id64: number | string): Promise<string> => {
@@ -1018,6 +950,14 @@ export const updateContract = async (payload: UpdateContractRequest): Promise<vo
contractId: toIntegerId(payload.contractId),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Contract',
recordId: payload.contractId,
newStatus: payload.status,
}).catch(console.warn);
}
};
export const uploadContractFile = async (
@@ -1078,6 +1018,13 @@ export const updateBudget = async (payload: UpdateBudgetRequest): Promise<void>
budgetId: toIntegerId(payload.budgetId),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Budget',
recordId: payload.budgetId,
newStatus: payload.status,
}).catch(console.warn);
}
};
// ==========================================
@@ -1116,6 +1063,11 @@ export const updateDisbursement = async (payload: UpdateDisbursementRequest): Pr
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
const hashedDisbId = await toolsService.encodeId(disbursementId);
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Disbursement',
recordId: disbursementId,
newStatus: ApprovalStatus.Approved,
}).catch(console.warn);
};
/**
@@ -1277,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
try {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
return res.data || [];
} catch (err: any) {
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
try {
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
return rawRes.data || [];
} catch (fallbackErr: any) {
if (fallbackErr?.response?.status === 404) {
@@ -1316,6 +1268,13 @@ export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<vo
evidenceType: Number(payload.evidenceType),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Evidence',
recordId: payload.evidenceId,
newStatus: payload.status,
}).catch(console.warn);
}
};
// ==========================================
@@ -1555,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
awardId: toSafeInt64String(payload.awardId),
contractId: toSafeInt64String(payload.contractId),
projectId: toSafeInt64String(payload.projectId || 0),
recipient: payload.recipient,
message: payload.message,
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
@@ -1642,7 +1602,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined ? payload.status : undefined,
status: payload.status !== undefined
? payload.status
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
});
};
@@ -1664,6 +1626,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
};
// ==========================================
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
await updateNotification({
notificationId: toIntegerId(notificationId),
status: NotificationStatus.Archived,
});
};
// 9. Governance: Tenants APIs
// ==========================================
export const getTenants = async (): Promise<Tenant[]> => {
@@ -1689,6 +1658,14 @@ export const updateTenant = async (payload: UpdateTenantRequest): Promise<void>
name: payload.name,
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Tenant',
recordId: payload.tenantId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
};
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
@@ -1854,6 +1831,14 @@ export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void
description: payload.description,
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Outcome',
recordId: payload.outcomeId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
};
// ==========================================
@@ -1915,6 +1900,14 @@ export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise
revisedBudget: Number(payload.revisedBudget),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Change Request',
recordId: payload.changeRequestId,
newStatus: payload.status,
title: payload.title,
}).catch(console.warn);
}
};
// ==========================================
@@ -1975,6 +1968,14 @@ export const updateComplianceItem = async (payload: UpdateComplianceItemRequest)
status: Number(payload.status),
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Compliance Item',
recordId: payload.complianceItemId,
newStatus: payload.status,
title: payload.title,
}).catch(console.warn);
}
};
// ==========================================
@@ -2042,6 +2043,15 @@ export const updateIssue = async (payload: UpdateIssueRequest): Promise<void> =>
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
assignedTo: payload.assignedTo,
});
const issueStatus = payload.status !== undefined ? payload.status : ActivityStatus.Active;
if (isApprovedOrActiveStatus(issueStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Issue',
recordId: payload.issueId,
newStatus: issueStatus,
title: payload.title,
}).catch(console.warn);
}
};
// ==========================================
@@ -2106,77 +2116,30 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
status: Number(payload.status),
visitedAt: visitedAtIso,
});
};
const LOCAL_RULES_KEY = 'pfm_local_rules';
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
if (!raw) return;
const local: Rule[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
const apiIds = new Set(apiRules.map((r) => String(r.id)));
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored rule duplicates', e);
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Site Visit',
recordId: payload.siteVisitId,
newStatus: payload.status,
title: payload.inspectorName,
}).catch(console.warn);
}
};
export const getStoredRules = (): Rule[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
export const saveStoredRule = (rule: Rule): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredRules();
const idx = current.findIndex(
(r) => String(r.id) === String(rule.id) ||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
} else {
current.unshift(rule);
}
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored rule', e);
}
};
export const getStoredRules = (): Rule[] => [];
export const saveStoredRule = (_rule?: Rule): void => {};
// ==========================================
// 15. Execution: Programme Rules APIs
// ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => {
try {
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
if (res.data) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
if (localRule) return localRule;
throw new Error('Rule not found');
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
if (res.data) {
return res.data;
}
throw new Error('Rule not found');
};
@@ -2194,38 +2157,16 @@ export const getRules = async (
pagination: Pagination = { page: 1, pageSize: 100 },
programmeId?: string | number
): Promise<Rule[]> => {
let apiRules: Rule[] = [];
let apiSucceeded = false;
try {
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
if (Array.isArray(res.data)) {
apiRules = res.data;
apiSucceeded = true;
}
} catch (err) {
console.warn('[getRules] Error fetching from backend API:', err);
}
if (apiSucceeded) {
purgeStoredRuleDuplicates(apiRules);
let result = apiRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return result;
}
// Fallback to local storage only if API call failed
const localRules = getStoredRules();
let combined = localRules;
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
let result = Array.isArray(res.data) ? res.data : [];
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return combined;
return result;
};
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
@@ -2237,70 +2178,21 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
description: payload.description,
};
try {
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
if (res.data && res.data.id) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch (err) {
console.warn('[createRule] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackRule: Rule = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
saveStoredRule(fallbackRule);
return fallbackRule;
}
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
return res.data;
};
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
const validUserId = toValidUuid(payload.userId);
try {
await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId),
userId: validUserId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
} catch (err) {
console.warn('[updateRule] API update failed:', err);
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
createdBy: existing?.createdBy || validUserId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: validUserId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
}
await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId),
userId: validUserId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
};
// ==========================================
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
},
timeout: 15000,
});
@@ -81,34 +83,16 @@ apiClient.interceptors.request.use(
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
// Always attach freshness timestamp and mutation epoch to internal GET requests
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
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();
}
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
config.params.set('_v', String(getMutationEpoch()));
} 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();
}
}
config.params = config.params || {};
config.params._t = Date.now();
config.params._v = getMutationEpoch();
}
}
@@ -0,0 +1,416 @@
import {
NotificationPlatform,
NotificationStatus,
ApprovalStatus,
ActivityStatus,
ContractStatus,
ComplianceStatus,
User,
Invite,
CreateNotificationRequest,
} from '../types';
import { apiClient } from './httpClient';
import {
getCurrentUserSub,
getCurrentUserClaims,
getCurrentUserTenantId,
} from '../auth/AuthProvider';
export const CONSOLE_PORTAL_URL = 'https://console.ifri.khongisa.co.za/';
export const PARTNER_PORTAL_URL = 'https://partner.ifri.khongisa.co.za/';
/**
* Dispatches a notification request directly via apiClient
*/
export async function dispatchNotification(payload: CreateNotificationRequest): Promise<void> {
const tenantId = getCurrentUserTenantId();
const orgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? Number(payload.organisationId)
: (tenantId !== null ? tenantId : 1);
await apiClient.post('/v1/api/communications/notifications', {
organisationId: orgId,
recipient: payload.recipient,
platform: payload.platform,
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined ? payload.status : undefined,
});
}
/**
* Retrieves users from directory directly via apiClient
*/
export async function fetchDirectoryUsers(page = 1, pageSize = 200): Promise<User[]> {
try {
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, {});
return res.data || [];
} catch (err) {
console.warn('[NotificationRules] Failed to fetch users directory:', err);
return [];
}
}
/**
* Validates whether a given status corresponds to "Approved" or "Active".
*/
export function isApprovedOrActiveStatus(status: number | string | undefined | null): boolean {
if (status === undefined || status === null) return false;
// Numeric enum matching
const numStatus = Number(status);
if (
numStatus === ApprovalStatus.Approved || // 3
numStatus === ActivityStatus.Active || // 1
numStatus === ContractStatus.Active || // 3
numStatus === ContractStatus.Executed || // 3
numStatus === ComplianceStatus.Met || // 2
numStatus === ComplianceStatus.Exceeded // 3
) {
return true;
}
// String matching
const strStatus = String(status).toLowerCase().trim();
return (
strStatus === 'approved' ||
strStatus === 'active' ||
strStatus === 'executed' ||
strStatus === 'met' ||
strStatus === 'exceeded' ||
strStatus === 'verified'
);
}
/**
* Formats a friendly label for the approved or active status.
*/
export function getApprovedOrActiveStatusLabel(status: number | string | undefined | null): string {
if (status === undefined || status === null) return 'Active';
const numStatus = Number(status);
if (numStatus === ApprovalStatus.Approved) return 'Approved';
if (numStatus === ActivityStatus.Active) return 'Active';
if (numStatus === ContractStatus.Active) return 'Active';
if (numStatus === ComplianceStatus.Met) return 'Compliant (Met)';
const str = String(status);
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Retrieves Tenant Managers and Executives within the active tenant context.
* Falls back to the currently logged in user if directory lookup is restricted or empty.
*/
export async function getTenantManagersAndExecutives(): Promise<User[]> {
try {
const tenantId = getCurrentUserTenantId();
const users = await fetchDirectoryUsers(1, 200);
const managersAndExecutives = users.filter((u) => {
const roleStr = String(u.role || '').toLowerCase();
const isManagerOrExec =
roleStr.includes('manager') ||
roleStr.includes('executive') ||
roleStr.includes('director') ||
roleStr === '2' || // Role ID 2 = Executive
roleStr === '4'; // Role ID 4 = Manager
const matchesTenant =
!tenantId || !u.tenant_id || Number(u.tenant_id) === Number(tenantId);
return isManagerOrExec && matchesTenant;
});
if (managersAndExecutives.length > 0) {
return managersAndExecutives;
}
// Secondary fallback: Any user with admin or supervisor if no direct manager/executive found
const elevatedUsers = users.filter((u) => {
const roleStr = String(u.role || '').toLowerCase();
return (
roleStr.includes('admin') ||
roleStr.includes('supervisor') ||
roleStr === '1' ||
roleStr === '3'
);
});
if (elevatedUsers.length > 0) {
return elevatedUsers;
}
} catch (err) {
console.warn('[NotificationRules] Directory lookup failed, using authenticated identity fallback:', err);
}
// Graceful fallback to authenticated user identity so telemetry notification is recorded
const currentSub = getCurrentUserSub();
const currentClaims = getCurrentUserClaims();
if (currentSub) {
return [
{
id: currentSub,
email: (currentClaims.email as string) || (currentClaims.preferred_username as string) || 'manager@pfm.org.za',
status: ActivityStatus.Active,
lastLoginAt: null,
role: 'Manager',
name: (currentClaims.name as string) || 'Tenant Manager',
},
];
}
return [];
}
export interface RecordStatusChangeNotificationParams {
recordType: string;
recordId: string | number;
newStatus: number | string;
title?: string;
previousStatus?: number | string;
}
/**
* RULE 1:
* "Whenever a record change status to Approved, Active (for contract or similar records) exclusding users;
* the system must issue an InApp and Email message to Tenant Managers and Executives."
*/
export async function notifyTenantManagersAndExecutivesOnRecordStatusChange(
params: RecordStatusChangeNotificationParams
): Promise<void> {
const { recordType, recordId, newStatus, title } = params;
// Rule constraint: Strictly exclude users
if (recordType.toLowerCase() === 'user' || recordType.toLowerCase() === 'users') {
return;
}
// Only trigger when the target status is Approved or Active
if (!isApprovedOrActiveStatus(newStatus)) {
return;
}
const statusLabel = getApprovedOrActiveStatusLabel(newStatus);
const orgId = getCurrentUserTenantId() || 1;
const managersAndExecutives = await getTenantManagersAndExecutives();
if (managersAndExecutives.length === 0) {
return;
}
const recordDescriptor = title
? `${recordType} "${title}" (#${recordId})`
: `${recordType} #${recordId}`;
const inAppSubject = `[${statusLabel}] ${recordType} #${recordId} is now ${statusLabel}`;
const inAppMessage = `### Statutory Record Update\n\nThe record **${recordDescriptor}** has officially transitioned to **${statusLabel}** status.\n\n- **Record Category:** ${recordType}\n- **Identifier:** #${recordId}\n- **Updated Status:** ${statusLabel}\n- **Timestamp:** ${new Date().toLocaleString()}\n- **Portal Reference:** [Open PFM Console](${CONSOLE_PORTAL_URL})\n\nPlease review the updated portfolio commitments accordingly.`;
const emailSubject = `[PFM Governance Alert] ${recordType} #${recordId} Status Transition: ${statusLabel}`;
const emailMessage = `### Formal Statutory Notification\n\nThis is an automated notification to Tenant Managers and Executives regarding an approved record status update.\n\n**Record Details:**\n- **Category:** ${recordType}\n- **Reference Identifier:** #${recordId}\n${title ? `- **Title:** ${title}\n` : ''}- **New Lifecycle Status:** **${statusLabel}**\n- **Effective Timestamp:** ${new Date().toUTCString()}\n\nYou can inspect this record in the PFM Management Console: [Access Management Console](${CONSOLE_PORTAL_URL}).`;
// Dispatch both InApp and Email notifications to each Tenant Manager and Executive
for (const recipient of managersAndExecutives) {
if (!recipient.id) continue;
const recipientEmail = recipient.email || 'manager@pfm.org.za';
// 1. In-App Notification
try {
const inAppReq: CreateNotificationRequest = {
organisationId: orgId,
recipient: recipient.id,
platform: NotificationPlatform.InApp,
subject: inAppSubject,
destination: recipientEmail,
message: inAppMessage,
status: NotificationStatus.Delivered,
};
await dispatchNotification(inAppReq);
} catch (err) {
console.warn(`[NotificationRules] Failed to send InApp message to ${recipient.id}:`, err);
}
// 2. Email Notification
try {
const emailReq: CreateNotificationRequest = {
organisationId: orgId,
recipient: recipient.id,
platform: NotificationPlatform.Email,
subject: emailSubject,
destination: `To: ${recipientEmail} | From: PFM Automated Governance System`,
message: emailMessage,
status: NotificationStatus.Queued,
};
await dispatchNotification(emailReq);
} catch (err) {
console.warn(`[NotificationRules] Failed to send Email message to ${recipientEmail}:`, err);
}
}
}
export interface InviteApprovedNotificationParams {
invite: Partial<Invite> & {
id: number | string;
recipient: string;
invitedOrganisationId?: number | string;
organisationId?: number | string;
awardId?: number | string;
contractId?: number | string;
message?: string;
};
partnerUser?: User | null;
partnerEmail?: string;
}
/**
* RULE 2:
* "When an Invite is Approved, the Partner user must be notified via Email and InApp message as well."
*/
export async function notifyInviteApproved(
params: InviteApprovedNotificationParams
): Promise<void> {
const { invite, partnerUser, partnerEmail } = params;
if (!invite.recipient) return;
const orgId = invite.invitedOrganisationId || invite.organisationId || getCurrentUserTenantId() || 1;
const targetEmail = partnerEmail || partnerUser?.email || invite.recipient;
const inAppSubject = 'Onboarding Invitation Approved: Access to Partner Portal Granted';
const inAppMessage = `### Statutory Invitation Approved\n\nYour statutory onboarding invitation for **Organisation #${invite.invitedOrganisationId || invite.organisationId}** (Funding Award #${invite.awardId || 'N/A'}, Contract #${invite.contractId || 'N/A'}) has been formally **Approved**.\n\nYou may now access the **PFM Partner Portal** to review contractual agreements, complete KYC compliance, and submit milestone deliverables:\n\n👉 [Open Partner Portal](${PARTNER_PORTAL_URL})`;
const emailSubject = '[PFM Partner Portal] Statutory Invitation Approved - Welcome Onboard';
const emailMessage = `### Formal Statutory Onboarding Approval\n\nDear Grantee / Project Partner,\n\nWe are pleased to inform you that your statutory onboarding invitation has been formally **Approved** by the funding authority.\n\n**Invitation Summary:**\n- **Recipient Key:** \`${invite.recipient}\`\n- **Target Organisation:** #${invite.invitedOrganisationId || invite.organisationId}\n- **Funding Award ID:** #${invite.awardId || 'N/A'}\n- **Contract Reference:** #${invite.contractId || 'N/A'}\n- **Authorisation Status:** **Approved & Active**\n\n**Next Steps:**\nPlease log in to the PFM Partner Portal at: **${PARTNER_PORTAL_URL}**\n\nOnce logged in, please complete your statutory recipient onboarding, verify bank details, and review milestone schedules.`;
// 1. In-App Notification to Partner
try {
const inAppReq: CreateNotificationRequest = {
organisationId: orgId,
recipient: invite.recipient,
platform: NotificationPlatform.InApp,
subject: inAppSubject,
destination: targetEmail,
message: inAppMessage,
status: NotificationStatus.Delivered,
};
await dispatchNotification(inAppReq);
} catch (err) {
console.warn('[NotificationRules] Failed to issue InApp invite approval notification:', err);
}
// 2. Email Notification to Partner
try {
const emailReq: CreateNotificationRequest = {
organisationId: orgId,
recipient: invite.recipient,
platform: NotificationPlatform.Email,
subject: emailSubject,
destination: `To: ${targetEmail} | From: PFM Statutory Communications`,
message: emailMessage,
status: NotificationStatus.Queued,
};
await dispatchNotification(emailReq);
} catch (err) {
console.warn('[NotificationRules] Failed to issue Email invite approval notification:', err);
}
}
export interface NewUserCreatedNotificationParams {
userId: string;
email: string;
isPartner: boolean;
roleName?: string;
tenantId?: number | string | null;
}
/**
* RULE 3:
* "When a new user is created, an InApp and Email message must be issued to them notifying them of this
* and directing them to login (https://console.ifri.khongisa.co.za/),
* PartnerUsers (users with Partner role) should be instructed to navigate to the partner portal (https://partner.ifri.khongisa.co.za/)"
*/
export async function notifyNewUserCreated(
params: NewUserCreatedNotificationParams
): Promise<void> {
const { userId, email, isPartner, roleName, tenantId } = params;
if (!userId || !email) return;
const orgId = tenantId ? Number(tenantId) : (getCurrentUserTenantId() || 1);
const targetUrl = isPartner ? PARTNER_PORTAL_URL : CONSOLE_PORTAL_URL;
const targetPortalName = isPartner ? 'PFM Partner Portal' : 'PFM Management Console';
const roleDisplay = roleName || (isPartner ? 'Partner' : 'Console User');
const inAppSubject = `Welcome to PFM - Your Account Has Been Created`;
const inAppMessage = isPartner
? `### Welcome to the Post-Fund Management (PFM) Platform\n\nYour user account has been successfully provisioned with the **${roleDisplay}** role.\n\nAs a designated project partner, please navigate to the **Partner Portal** to complete statutory recipient onboarding, verify banking details, and collaborate on project deliverables:\n\n👉 [Open Partner Portal](${PARTNER_PORTAL_URL})\n\n**Registered Email:** \`${email}\``
: `### Welcome to the Post-Fund Management (PFM) Platform\n\nYour user account has been successfully provisioned with the **${roleDisplay}** role.\n\nPlease log in to the **Management Console** using your registered corporate credentials to access your administrative dashboard and governance responsibilities:\n\n👉 [Open Management Console](${CONSOLE_PORTAL_URL})\n\n**Registered Email:** \`${email}\``;
const emailSubject = `Welcome to PFM - Account Provisioned & Login Instructions`;
const emailMessage = isPartner
? `### Welcome to the PFM Platform - Partner Account Activation\n\nDear Partner,\n\nYour account on the Post-Fund Management (PFM) Platform has been successfully created with the **Partner** role.\n\n**Your Access Details:**\n- **Registered Email:** ${email}\n- **Assigned Role:** ${roleDisplay}\n- **Dedicated Portal URL:** **${PARTNER_PORTAL_URL}**\n\n**Instructions:**\n1. Navigate to the Partner Portal: [${PARTNER_PORTAL_URL}](${PARTNER_PORTAL_URL})\n2. Sign in using your registered email address.\n3. Complete your organization's statutory onboarding checklist and review assigned grant awards.`
: `### Welcome to the PFM Platform - User Account Provisioned\n\nDear Colleague,\n\nYour account on the Post-Fund Management (PFM) Platform has been successfully created with the **${roleDisplay}** role.\n\n**Your Access Details:**\n- **Registered Email:** ${email}\n- **Assigned Role:** ${roleDisplay}\n- **Management Console URL:** **${CONSOLE_PORTAL_URL}**\n\n**Instructions:**\n1. Navigate to the Management Console: [${CONSOLE_PORTAL_URL}](${CONSOLE_PORTAL_URL})\n2. Sign in using your registered credentials.\n3. Access your assigned programmes, portfolios, and operational tools.`;
// 1. In-App Message
try {
const inAppReq: CreateNotificationRequest = {
organisationId: orgId,
recipient: userId,
platform: NotificationPlatform.InApp,
subject: inAppSubject,
destination: email,
message: inAppMessage,
status: NotificationStatus.Delivered,
};
await dispatchNotification(inAppReq);
} catch (err) {
console.warn('[NotificationRules] Failed to send InApp message for new user:', err);
}
// 2. Email Message
try {
const emailReq: CreateNotificationRequest = {
organisationId: orgId,
recipient: userId,
platform: NotificationPlatform.Email,
subject: emailSubject,
destination: `To: ${email} | From: PFM Security & Identity Administration`,
message: emailMessage,
status: NotificationStatus.Queued,
};
await dispatchNotification(emailReq);
} catch (err) {
console.warn('[NotificationRules] Failed to send Email message for new user:', err);
}
}
/**
* Local storage persistence helper for approved invitations ledger state.
*/
const APPROVED_INVITES_STORAGE_KEY_PREFIX = 'pfm_approved_invites_';
export function getApprovedInviteIds(tenantId?: number | string | null): string[] {
if (typeof window === 'undefined') return [];
try {
const key = `${APPROVED_INVITES_STORAGE_KEY_PREFIX}${tenantId || 'global'}`;
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
export function markInviteAsApprovedInStorage(
inviteId: string | number,
tenantId?: number | string | null
): void {
if (typeof window === 'undefined') return;
try {
const key = `${APPROVED_INVITES_STORAGE_KEY_PREFIX}${tenantId || 'global'}`;
const current = getApprovedInviteIds(tenantId);
const strId = String(inviteId);
if (!current.includes(strId)) {
const updated = [...current, strId];
localStorage.setItem(key, JSON.stringify(updated));
}
} catch {}
}
@@ -520,7 +520,7 @@ export async function resolveUserRolesAndClaims(
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
const roles = await fetchAndCacheSystemRoles();
await Promise.allSettled(
roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
roles.map((role) => fetchAndCacheRoleClaims(role.id || role.Id || '', role.name || role.Name || ''))
);
}
@@ -563,7 +563,7 @@ export async function getSystemClaims(): Promise<Claim[]> {
try {
const roles = await fetchAndCacheSystemRoles();
for (const r of roles) {
const claims = await fetchAndCacheRoleClaims(r.id, r.name);
const claims = await fetchAndCacheRoleClaims(r.id || r.Id || '', r.name || r.Name || '');
claims.forEach((c) => {
const norm = normalizeClaim(c);
if (norm.claimType && !claimsMap.has(norm.claimType)) {
@@ -1,6 +1,7 @@
import { getTenant } from './api';
import { getTenant, getTenants } from './api';
import { ActivityStatus, Tenant } from '../types';
import { setActivationPending } from './userSyncService';
import { encodeHashIdSync, isAlreadyHashId } from '../utils/hashids';
export interface TenantLockoutResult {
isActive: boolean;
@@ -9,21 +10,10 @@ 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;
// Caching removed to guarantee fresh tenant state on every verification
}
/**
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
*/
export function isStatusActive(status: any): boolean {
if (status === undefined || status === null || status === '') {
return false;
@@ -40,9 +30,6 @@ export function isStatusActive(status: any): boolean {
return false;
}
/**
* Formats a human-readable status label from numeric or string status
*/
export function getStatusLabel(status: any): string {
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
return 'Active';
@@ -65,76 +52,110 @@ export function getStatusLabel(status: any): string {
return 'Not Specified';
}
/**
* Calls the GetTenant endpoint for the provided tenant identifier.
*
* Rules:
* - If nothing is returned (empty response, 404, or failure) OR
* - There is a response object and its Status is not Active
* Then lockout is triggered and the existing lockout modal is shown.
*/
function isValidHashableOrHashedTenantId(val: unknown): val is number | string {
if (val === undefined || val === null || val === '') return false;
if (typeof val === 'number') {
return !isNaN(val) && isFinite(val) && val > 0;
}
if (typeof val === 'string') {
const trimmed = val.trim();
if (!trimmed) return false;
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10) > 0;
}
return isAlreadyHashId(trimmed);
}
return false;
}
export async function verifyTenantLockout(
tenantId?: number | string | null,
tenantClaim?: string | null,
forceRefresh = false
): Promise<TenantLockoutResult> {
const primaryId =
tenantId !== undefined && tenantId !== null && tenantId !== ''
? tenantId
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== ''
? tenantClaim
: null;
if (primaryId === null || primaryId === undefined || primaryId === '') {
const errorMsg =
'Tenant identification claims (tenant / tenant_id) not found in credentials. Access is restricted.';
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
};
// Resolve a valid numeric ID or HashId to avoid passing plain-text tenant names to /v1/api/governance/tenants/{id}
let candidateId: number | string | null = null;
if (isValidHashableOrHashedTenantId(tenantId)) {
candidateId = tenantId;
} else if (isValidHashableOrHashedTenantId(tenantClaim)) {
candidateId = tenantClaim;
}
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;
}
}
// Fresh check: query endpoint directly without cache
let tenantRes: Tenant | null = null;
let lastError: any = null;
try {
tenantRes = await getTenant(primaryId);
} catch (err: any) {
lastError = err;
tenantRes = null;
}
// If primary identifier returned nothing, and tenantClaim provides a different candidate, attempt fallback
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) {
// 1. If we have a valid numeric tenantId or HashId, encode it via HashIds and call GET /v1/api/governance/tenants/{hashedId}
if (candidateId !== null) {
try {
tenantRes = await getTenant(tenantClaim);
lastError = null;
const hashedId = encodeHashIdSync(candidateId);
tenantRes = await getTenant(hashedId || candidateId);
} catch (err: any) {
lastError = err;
tenantRes = null;
}
}
// Condition 1: If nothing is returned
// 2. If tenant lookup by ID wasn't possible or didn't resolve, resolve via getTenants() (matching tenantClaim name if provided)
if (!tenantRes) {
try {
const allTenants = await getTenants();
if (Array.isArray(allTenants) && allTenants.length > 0) {
const claimStr = tenantClaim ? String(tenantClaim).trim().toLowerCase() : '';
const matched = claimStr
? allTenants.find(
(t) =>
(t.name && t.name.trim().toLowerCase() === claimStr) ||
String(t.id).toLowerCase() === claimStr ||
encodeHashIdSync(t.id).toLowerCase() === claimStr
)
: null;
const targetTenant = matched || allTenants.find((t) => isStatusActive((t as any).status ?? (t as any).Status)) || allTenants[0];
if (targetTenant && targetTenant.id !== undefined && targetTenant.id !== null) {
try {
const hashedTargetId = encodeHashIdSync(targetTenant.id);
tenantRes = await getTenant(hashedTargetId || targetTenant.id);
lastError = null;
} catch {
tenantRes = targetTenant;
lastError = null;
}
}
}
} catch (err: any) {
if (!lastError) lastError = err;
}
}
// 3. Final fallback: query default primary tenant ID 1 encoded as a HashId (matching ConsoleUI)
if (!tenantRes && candidateId !== 1 && candidateId !== '1') {
try {
const hashedDefaultId = encodeHashIdSync(1);
tenantRes = await getTenant(hashedDefaultId);
lastError = null;
} catch (err: any) {
if (!lastError) lastError = err;
tenantRes = null;
}
}
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
const serverMsg =
let serverMsg =
lastError?.response?.data?.detail ||
lastError?.response?.data?.message ||
lastError?.message ||
'No tenant record returned';
if (typeof serverMsg === 'string') {
if (/^network\s*error$/i.test(serverMsg.trim())) {
serverMsg = 'Permission Check';
} else {
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
}
}
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
@@ -145,10 +166,8 @@ export async function verifyTenantLockout(
};
}
// Handle wrapped response structures: { data: { ... } } or { result: { ... } }
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
// Condition 2: If there is a response object and its Status is not Active
const rawStatus =
(tenantObj as any)?.status ??
(tenantObj as any)?.Status ??
@@ -159,7 +178,7 @@ export async function verifyTenantLockout(
if (!active) {
const statusLabel = getStatusLabel(rawStatus);
const tenantName = tenantObj?.name || String(primaryId);
const tenantName = tenantObj?.name || String(candidateId ?? tenantClaim ?? '1');
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
@@ -169,15 +188,12 @@ export async function verifyTenantLockout(
};
}
// Success: Response object returned and Status is Active
setActivationPending(false);
const successResult: TenantLockoutResult = {
isActive: true,
tenant: tenantObj,
};
cachedTenantLockout = {
key: cacheKey,
result: successResult,
timestamp: Date.now(),
};
// Direct return without caching
return successResult;
}
@@ -1,249 +0,0 @@
import Hashids from 'hashids';
import { useQuery } from '@tanstack/react-query';
const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
/**
* Deterministic Hashids configuration matching pfm-api HasherSettings.
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
*/
export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt:
(env.VITE_HASHER_SALT as string) ||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
};
// Singleton Hashids instance initialized with salt & minHashLength
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
/**
* Dynamically reconfigures the deterministic Hashids instance from runtime configuration.
*/
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
let updated = false;
if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim();
updated = true;
}
if (minLength !== undefined && minLength !== null && minLength !== '') {
const parsed = Number(minLength);
if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed;
updated = true;
}
}
if (updated) {
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
}
}
/**
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
* Prevents double-encoding already-hashed route parameters or IDs.
*/
function isAlreadyHashId(candidate: string): boolean {
if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
return false;
}
try {
const decoded = hasher.decode(candidate);
return Array.isArray(decoded) && decoded.length > 0;
} catch {
return false;
}
}
/**
* Deterministically encodes a numeric ID or numeric string into a HashID string.
* Synchronous and does not perform HTTP network round trips.
*/
export function encodeHashIdSync(id: number | string | null | undefined): string {
if (id === undefined || id === null || id === '') {
return '';
}
// If already a number
if (typeof id === 'number') {
if (isNaN(id) || !isFinite(id) || id < 0) return '';
return hasher.encode(id);
}
const str = String(id).trim();
if (!str) return '';
// Check if string is already a valid HashID
if (isAlreadyHashId(str)) {
return str;
}
// Check if string is pure digits (e.g. "123")
if (/^\d+$/.test(str)) {
try {
const num = parseInt(str, 10);
if (!isNaN(num) && num >= 0) {
return hasher.encode(num);
}
} catch {
// Fall through
}
}
// Non-numeric identifiers (UUIDs, codes) returned as-is
return str;
}
/**
* Deterministically decodes a HashID string back to original numeric ID or string.
* Synchronous and does not perform HTTP network round trips.
*/
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId;
const str = String(hashId).trim();
if (!str) return '';
// If already pure integer string
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {
// If decoding fails, return raw string
}
return str;
}
/**
* Decodes a HashID or numeric value strictly to a canonical integer.
* Useful for request payloads expecting int64 integers.
*/
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? 0 : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {}
const fallback = Number(str);
return isNaN(fallback) ? 0 : Math.trunc(fallback);
}
export const toolsService = {
/**
* Synchronous encoding
*/
encodeIdSync(id: number | string | null | undefined): string {
return encodeHashIdSync(id);
},
/**
* Synchronous decoding
*/
decodeIdSync(hashId: string | number | null | undefined): number | string {
return decodeHashIdSync(hashId);
},
/**
* Synchronous decoding strictly to canonical integer
*/
decodeToNumberSync(hashId: number | string | null | undefined): number {
return decodeToNumberSync(hashId);
},
/**
* Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers)
*/
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
return encodeHashIdSync(id);
},
/**
* Decodes a HashID string back to original identifier
*/
async decodeId(hashId: string | null | undefined, _context = 'general'): Promise<number | string> {
return decodeHashIdSync(hashId);
},
/**
* Domain-specific convenience resolvers for entities
*/
async resolveProjectId(projectId: number | string): Promise<string> {
return encodeHashIdSync(projectId);
},
async resolveOrganisationId(organisationId: number | string): Promise<string> {
return encodeHashIdSync(organisationId);
},
async resolveProgrammeId(programmeId: number | string): Promise<string> {
return encodeHashIdSync(programmeId);
},
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
return encodeHashIdSync(portfolioId);
},
async resolveAwardId(awardId: number | string): Promise<string> {
return encodeHashIdSync(awardId);
},
async resolveContractId(contractId: number | string): Promise<string> {
return encodeHashIdSync(contractId);
},
/**
* Resolves query parameters or path variables encoded as HashIDs before performing API calls
*/
async resolveHashedParams<T extends Record<string, any>>(
params: T,
keysToHash: (keyof T)[]
): Promise<T> {
const resolved: any = { ...params };
for (const key of keysToHash) {
const val = params[key];
if (val !== undefined && val !== null) {
resolved[key] = encodeHashIdSync(val);
}
}
return resolved;
},
/**
* Compatibility cache primer (now a no-op as Hashids calculation is instantaneous client-side)
*/
async primeCache(_items: { id: number | string; context: string }[]): Promise<void> {
// No-op - instant deterministic computation
},
};
/**
* React Hook for consuming HashIDs with TanStack Query caching in components
*/
export function useHashId(id?: number | string | null, context = 'general') {
return useQuery({
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
enabled: id !== undefined && id !== null && id !== '',
staleTime: Infinity,
});
}
@@ -252,47 +252,22 @@ async function executeBackendSync(
organisationId: resolvedOrgId,
};
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
let lastError: any = null;
for (const endpoint of candidateEndpoints) {
try {
let res;
if (endpoint === '/v1/api/integration/sync') {
try {
res = await apiClient.post(endpoint, payload);
} catch (postErr: any) {
const errStatus = postErr?.response?.status;
if (errStatus === 400 || errStatus === 415 || errStatus === 422) {
res = await apiClient.post(endpoint, {});
} else {
throw postErr;
}
}
} else {
res = await apiClient.post(endpoint, payload);
}
return { endpoint, data: res?.data };
} catch (err: any) {
lastError = err;
const status = err?.response?.status;
// Fail fast on token expiration/invalid
if (status === 401) {
const endpoint = '/v1/api/integration/sync';
try {
const res = await apiClient.post(endpoint, payload);
return { endpoint, data: res?.data };
} catch (err: any) {
const status = err?.response?.status;
if (status === 400 || status === 415 || status === 422) {
try {
const resFallback = await apiClient.post(endpoint, {});
return { endpoint, data: resFallback?.data };
} catch (fallbackErr) {
throw err;
}
// User blocked or pending activation
if (status === 403 || status === 423) {
throw err;
}
// If 404 or 405, continue to next fallback candidate
}
throw err;
}
throw lastError || new Error('Failed to reach user sync endpoint');
}
/**
@@ -353,13 +328,21 @@ export async function syncUserAccount(
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
clearSuccessfulSyncRecord();
const serverMsg =
let serverMsg =
err?.response?.data?.detail ||
err?.response?.data?.message ||
err?.response?.data?.error ||
err?.message ||
'Account is not yet enabled by the administrator.';
if (typeof serverMsg === 'string') {
if (/^network\s*error$/i.test(serverMsg.trim())) {
serverMsg = 'Permission Check';
} else {
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
}
}
setActivationPending(true, serverMsg);
return {
@@ -29,6 +29,7 @@ export enum NotificationStatus {
Cancelled = 8,
Bounced = 9,
Draft = 10,
Archived = 11,
}
export enum NotificationPlatform {
@@ -685,11 +686,13 @@ export interface CreateContractRequest {
export interface UpdateContractRequest {
contractId: number | string;
projectId?: number | string;
status: ContractStatus;
}
export interface ExecuteContractRequest {
contractId: number | string;
projectId?: number | string;
externalSignatureId: string;
signedDocumentUrl: string;
}
@@ -838,6 +841,7 @@ export interface Invite {
invitedOrganisationId: number | string;
awardId: number | string;
contractId: number | string;
projectId?: number | string;
recipient: string;
createdBy: string;
updatedBy?: string | null;
@@ -852,6 +856,7 @@ export interface InviteRequest {
invitedOrganisationId: number | string;
awardId: number | string;
contractId: number | string;
projectId?: number | string;
recipient: string;
message: string;
platform: NotificationPlatform;
@@ -146,6 +146,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
} else {
formatted = rawVal ?? '';
}
if (typeof formatted === 'string') {
// Strip out line breaks and carriage returns that distort Excel row heights and layout
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
}
rowObj[col.header] = formatted;
});
return rowObj;
@@ -166,9 +170,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
} else {
formatted = String(rawVal ?? '');
}
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return Math.max(max, formatted.length);
}, headerLen);
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 45) };
});
worksheet['!cols'] = colWidths;
@@ -4,11 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
export const DEFAULT_HASHER_SALT =
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
export const DEFAULT_MIN_HASH_LENGTH = 11;
export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt:
(env.VITE_HASHER_SALT as string) ||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
};
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
@@ -35,14 +37,27 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
* Validates if candidate is already a HashID generated by this hasher.
*/
export function isHashId(candidate: string | null | undefined): boolean {
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
if (
!candidate ||
typeof candidate !== 'string' ||
candidate.length < HASHER_SETTINGS.minHashLength ||
/^\d+$/.test(candidate)
) {
return false;
}
try {
const decodedNumbers = hasher.decode(candidate);
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) {
if (hasher.encode(decodedNumbers as number[]) === candidate) {
return true;
}
}
const decodedHex = hasher.decodeHex(candidate);
if (decodedHex && decodedHex.length > 0) return true;
if (decodedHex && decodedHex.length > 0) {
if (hasher.encodeHex(decodedHex) === candidate) {
return true;
}
}
} catch {
return false;
}
@@ -194,3 +209,132 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
return str;
}
/**
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
* Prevents double-encoding already-hashed route parameters or IDs.
*/
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
return isHashId(candidate);
}
/**
* Deterministically encodes a numeric ID or numeric string into a HashID string.
* Synchronous and does not perform HTTP network round trips.
*/
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
return encodeEntityId(id);
}
/**
* Deterministically decodes a HashID string back to original numeric ID or string.
* Synchronous and does not perform HTTP network round trips.
*/
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId;
const str = String(hashId).trim();
if (!str) return '';
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
const decodedHex = hasher.decodeHex(str);
if (decodedHex) {
const num = Number(BigInt('0x' + decodedHex));
if (!isNaN(num)) return Math.trunc(num);
}
} catch {}
return str;
}
/**
* Decodes a HashID or numeric value strictly to a canonical integer.
* Useful for request payloads expecting int64 integers.
*/
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? 0 : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
const decodedHex = hasher.decodeHex(str);
if (decodedHex) {
const num = Number(BigInt('0x' + decodedHex));
if (!isNaN(num)) return Math.trunc(num);
}
} catch {}
const fallback = Number(str);
return isNaN(fallback) ? 0 : Math.trunc(fallback);
}
/**
* Legacy service resolver facade for compatibility with API endpoints and monitoring tables.
*/
export const toolsService = {
encodeIdSync(id: number | string | bigint | null | undefined): string {
return encodeHashIdSync(id);
},
decodeIdSync(hashId: string | number | null | undefined): number | string {
return decodeHashIdSync(hashId);
},
decodeToNumberSync(hashId: number | string | null | undefined): number {
return decodeToNumberSync(hashId);
},
async encodeId(id: number | string | bigint | null | undefined): Promise<string> {
return encodeHashIdSync(id);
},
async decodeId(hashId: string | number | null | undefined): Promise<number | string> {
return decodeHashIdSync(hashId);
},
async resolveProjectId(projectId: number | string): Promise<string> {
return encodeHashIdSync(projectId);
},
async resolveOrganisationId(organisationId: number | string): Promise<string> {
return encodeHashIdSync(organisationId);
},
async resolveProgrammeId(programmeId: number | string): Promise<string> {
return encodeHashIdSync(programmeId);
},
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
return encodeHashIdSync(portfolioId);
},
async resolveAwardId(awardId: number | string): Promise<string> {
return encodeHashIdSync(awardId);
},
async resolveContractId(contractId: number | string): Promise<string> {
return encodeHashIdSync(contractId);
},
async resolveHashedParams<T extends Record<string, any>>(
params: T,
keysToHash: (keyof T)[]
): Promise<T> {
const resolved: any = { ...params };
for (const key of keysToHash) {
const val = params[key];
if (val !== undefined && val !== null) {
resolved[key] = encodeHashIdSync(val);
}
}
return resolved;
},
async primeCache(): Promise<void> {},
};
+2 -1
View File
@@ -4,8 +4,9 @@ interface ImportMetaEnv {
readonly VITE_OIDC_AUTHORITY?: string;
readonly VITE_OIDC_AUDIENCE?: string;
readonly VITE_OIDC_CLIENT_ID?: string;
readonly VITE_OIDC_CLIENT_SECRET?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_HASHER_SALT?: string;
readonly VITE_HASHER_MIN_LENGTH?: string;
}
interface ImportMeta {
+2 -2
View File
@@ -50,7 +50,7 @@ export default defineConfig(async ({ command }) => {
build: {
chunkSizeWarningLimit: 600,
rollupOptions: {
onwarn(warning, defaultHandler) {
onwarn(warning: any, defaultHandler: any) {
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
if (
warning.code === 'INVALID_ANNOTATION' &&
@@ -61,7 +61,7 @@ export default defineConfig(async ({ command }) => {
defaultHandler(warning);
},
output: {
manualChunks(id) {
manualChunks(id: string) {
if (id.includes('node_modules')) {
// 1. Heavy export and PDF document generation
if (id.includes('xlsx')) {
@@ -9,4 +9,6 @@ public sealed class SmtpSettings
public string? Host { get; set; }
public bool UseSsl { get; set; }
public string? ProcessorJobCron { get; set; }
}
@@ -33,8 +33,8 @@ public sealed class EvidenceConfiguration : IEntityTypeConfiguration<Evidence>
builder.Property(f => f.IndicatorId).IsRequired();
builder.Property(f => f.ActivityId).IsRequired();
builder.Property(f => f.CreatedBy).IsRequired();
builder.Property(f => f.UpdatedBy).IsRequired();
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
builder.Property(f => f.UpdatedBy).IsRequired(false);
builder.Property(f => f.UpdatedAt).IsRequired(false);
builder.Property(f => f.Version).IsRequired().HasDefaultValue(1);
builder.Property(f => f.DocumentUrl).IsRequired();
+1
View File
@@ -24,6 +24,7 @@ public enum NotificationStatus : int
Cancelled = 8,
Bounced = 9,
Draft = 10,
Archived = 11,
}
public enum NotificationPlatform : int
@@ -27,5 +27,5 @@ public static class Constants
public const int ShortLabelLength = 100;
public const int TextLength = 1024;
public const int MediumTextLength = 2048;
public const int LargeTextLength = 4096;
public const int LargeTextLength = 1_048_576; // 1 MB — accommodates branded HTML emails with embedded logo
}
@@ -11,8 +11,8 @@ public sealed class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHea
if (scheduler == null)
return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found.");
if (scheduler.Status != SchedulerStatus.Running)
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not running");
if (scheduler.Status is not (SchedulerStatus.Running or SchedulerStatus.Standby or SchedulerStatus.Created))
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not available (Status: {scheduler.Status})");
await scheduler.Exists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PostFundManagement.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class IncreasedLargeTextLengthTo1MB : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Findings",
table: "SiteVisits",
type: "character varying(1048576)",
maxLength: 1048576,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(4096)",
oldMaxLength: 4096,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Message",
table: "Notifications",
type: "character varying(1048576)",
maxLength: 1048576,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(4096)",
oldMaxLength: 4096);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Findings",
table: "SiteVisits",
type: "character varying(4096)",
maxLength: 4096,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(1048576)",
oldMaxLength: 1048576,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Message",
table: "Notifications",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(1048576)",
oldMaxLength: 1048576);
}
}
}
@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PostFundManagement.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class UpdatedByIsOptionalOnEvidence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Guid>(
name: "UpdatedBy",
table: "Evidences",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Guid>(
name: "UpdatedBy",
table: "Evidences",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
}
}
}
@@ -604,7 +604,7 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UpdatedBy")
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid");
b.Property<int>("Version")
@@ -958,8 +958,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
.HasMaxLength(1048576)
.HasColumnType("character varying(1048576)");
b.Property<long>("OrganisationId")
.HasColumnType("bigint");
@@ -1405,8 +1405,8 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
.HasColumnType("uuid");
b.Property<string>("Findings")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
.HasMaxLength(1048576)
.HasColumnType("character varying(1048576)");
b.Property<string>("InspectorName")
.IsRequired()
@@ -0,0 +1,16 @@
using PostFundManagement.Domain.Abstractions;
namespace PostFundManagement.Infrastructure.Database.Services;
public sealed class HeadlessTenantContextService : ITenantContextService
{
public string? TenantName => "Ifri Technologies";
public long TenantId => 1;
public bool ByPassTenantQueryFilter { get; set; } = true;
public bool IsAdmin => true;
public bool HasTenantFilter => true;
}
+40
View File
@@ -0,0 +1,40 @@
# Stage 1: Runtime base
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
# Stage 2: Build & Restore
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy props and csproj files for layer caching
COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
COPY ["PostFundManagement.snk", "./"]
COPY ["LICENSE", "./"]
COPY ["PostFundManagement.Jobs/PostFundManagement.Jobs.csproj", "PostFundManagement.Jobs/"]
COPY ["PostFundManagement.Application/PostFundManagement.Application.csproj", "PostFundManagement.Application/"]
COPY ["PostFundManagement.Domain/PostFundManagement.Domain.csproj", "PostFundManagement.Domain/"]
COPY ["PostFundManagement.Infrastructure/PostFundManagement.Infrastructure.csproj", "PostFundManagement.Infrastructure/"]
# Restore packages
RUN dotnet restore "PostFundManagement.Jobs/PostFundManagement.Jobs.csproj"
# Copy full source code
COPY . .
# Build from the project folder using relative filename
WORKDIR "/src/PostFundManagement.Jobs"
RUN dotnet build "PostFundManagement.Jobs.csproj" -c Release -o /app/build
# Stage 3: Publish
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "PostFundManagement.Jobs.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 4: Final Runtime
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PostFundManagement.Jobs.dll"]
+21
View File
@@ -0,0 +1,21 @@
using PostFundManagement.Domain.Abstractions;
namespace PostFundManagement.Jobs;
public class JobsWorker(IJobOrchestrator jobOrchestrator, IFeatureManager featureManager,
ILogger<JobsWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if(!await featureManager.IsEnabledAsync(nameof(JobsWorker)))
{
logger.LogInformation("JobsWorker is disabled.");
return;
}
// TODO: scheduler reccuring jobs
logger.LogInformation("JobsWorker is stopping.");
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-PostFundManagement.Jobs-97b98845-fddb-4212-b877-8dac9f7f86e3</UserSecretsId>
</PropertyGroup>
<!-- File Nesting -->
<ItemGroup>
<ProjectCapability Include="ConfigurableFileNesting" />
<ProjectCapability Include="ConfigurableFileNestingFeatureEnabled" />
</ItemGroup>
<!-- CQRS Source Generator -->
<ItemGroup>
<PackageReference Include="Mediator.SourceGenerator">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PostFundManagement.Application\PostFundManagement.Application.csproj" />
</ItemGroup>
</Project>
+40
View File
@@ -0,0 +1,40 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Mediator;
using PostFundManagement.Infrastructure.Database.Services;
using PostFundManagement.Jobs;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddFeatureManagement();
builder.Services.AddSharedServices();
builder.Services.AddHostedService<JobsWorker>();
builder.Services.AddRegisterServicesWithCaching(builder.Configuration);
builder.Services.AddEndpointAndServiceCaching(builder.Configuration);
builder.Services.AddTransient<ITenantContextService, HeadlessTenantContextService>();
builder.Services.AddSkorubaApiService(builder.Configuration);
builder.Services.AddHttpContextAccessor();
builder.Services.AddMediator();
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
builder.Services.AddEmailServices(builder.Configuration);
builder.Services.AddHashServices(builder.Configuration);
builder.Services.AddGarageS3(builder.Configuration);
builder.Services.AddHttpClient();
builder.Services.AddDataProtectionDatabase(builder.Configuration);
builder.Services.AddApplicationDbContext(builder.Configuration);
var host = builder.Build();
var schedulerFactory = host.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler();
if (scheduler!.Status != SchedulerStatus.Running)
await scheduler.Start();
host.Run();
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"PostFundManagement.Jobs": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"FeatureManagement": {
"JobsWorker": true,
"EmailProcessorJobEnabled": true,
"ServiceCaching": false
},
"GeneralS3Settings": {
"ServiceUrl": "https://s3.garage.khongisa.co.za",
"Region": "garage",
"BucketName": "pfm",
"CdnBaseUrl": "https://pfm.cdn.khongisa.co.za"
},
"MachineIdentitySettings": {
"Authority": "https://sts.security.khongisa.co.za",
"Scope": "skoruba_identity_admin_api"
},
"Monitoring": {
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
"ServiceName": "Pfm.Jobs.DEV"
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Quartz": "Error",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database": "Error",
"Microsoft.EntityFrameworkCore.Update": "Error"
}
},
"Email": {
"ProcessorJobCron": "0 */5 * * * ?",
"SmtpServer": "smtp.example.com",
"SmtpPort": 587,
"SenderEmail": "noreply@example.com",
"SenderName": "Example"
},
"HasherSettings": {
"MinHashLength": 11
},
"AllowedHosts": "*"
}
+4 -4
View File
@@ -1,9 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# HasherSettings for HashIds compatibility with backend pfm-api
VITE_HASHER_MIN_LENGTH="11"
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
@@ -26,22 +26,18 @@ OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audien
# 4. OIDC Client ID
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
# 5. OIDC Client Secret
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
# 6. OIDC Scope
# 5. OIDC Scope
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 7. Hasher Settings
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-}}}}}"
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
# 6. Hasher Settings (Fix: Exactly 4 closing braces matching the 4 open variable expansions)
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
if [ -d "$TARGET_DIR" ]; then
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
# Public SPA clients: salt secrets and client secrets must NEVER be injected or exposed in public static files.
cat <<EOF > "$TARGET_DIR/env-config.js"
// Generated dynamically at container startup from deployment environment variables
window.__RUNTIME_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL}",
VITE_API_BASE_URL: "${API_BASE_URL}",
@@ -51,12 +47,8 @@ window.__RUNTIME_CONFIG__ = {
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
VITE_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
OIDC_SCOPE: "${OIDC_SCOPE}",
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
HASHER_SALT: "${HASHER_SALT}",
VITE_HASHER_SALT: "${HASHER_SALT}",
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
ApiSettings: {
@@ -66,11 +58,9 @@ window.__RUNTIME_CONFIG__ = {
Authority: "${OIDC_AUTHORITY}",
Audience: "${OIDC_AUDIENCE}",
ClientId: "${OIDC_CLIENT_ID}",
ClientSecret: "${OIDC_CLIENT_SECRET}",
Scope: "${OIDC_SCOPE}"
},
HasherSettings: {
Salt: "${HASHER_SALT}",
MinHashLength: "${HASHER_MIN_LENGTH}"
}
};
@@ -86,11 +76,9 @@ EOF
"Authority": "${OIDC_AUTHORITY}",
"Audience": "${OIDC_AUDIENCE}",
"ClientId": "${OIDC_CLIENT_ID}",
"ClientSecret": "${OIDC_CLIENT_SECRET}",
"Scope": "${OIDC_SCOPE}"
},
"HasherSettings": {
"Salt": "${HASHER_SALT}",
"MinHashLength": "${HASHER_MIN_LENGTH}"
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" class="h-full bg-slate-950 text-slate-100">
<html lang="en" class="dark h-full bg-slate-950 text-slate-100">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -10,8 +10,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body class="h-full bg-slate-950 font-sans text-slate-100 antialiased selection:bg-emerald-500/20 selection:text-emerald-300">
<div id="root" class="min-h-full flex flex-col"></div>
<body class="dark h-full bg-slate-950 font-sans text-slate-100 antialiased selection:bg-emerald-500/20 selection:text-emerald-300">
<div id="root" class="min-h-full flex flex-col bg-slate-950 text-slate-100"></div>
<script src="/env-config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
@@ -1,16 +1,15 @@
{
"ApiSettings": {
"BaseUrl": "https://api.ifri.khongisa.co.za/"
},
"_comment": "Runtime configuration for Docker/Kubernetes deployments. Leave values empty in source control so local .env (VITE_*) variables are used during development. Container entrypoint scripts can populate these fields at container startup.",
"SecuritySettings": {
"Authority": "https://sts.security.khongisa.co.za",
"Audience": "pfm-api-dev",
"ClientId": "pfm-scalar-dev",
"ClientSecret": "secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27",
"Scope": "openid profile roles email address pfm-api-dev"
"Authority": "",
"Audience": "",
"ClientId": "",
"Scope": ""
},
"ApiSettings": {
"BaseUrl": ""
},
"HasherSettings": {
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
"MinHashLength": 11
"MinHashLength": "11"
}
}
@@ -1 +1,9 @@
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
// Runtime configuration for development or container environments
// Note: Secret values (like hashing salt or client secrets) are not exposed here.
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
HASHER_MIN_LENGTH: "11",
VITE_HASHER_MIN_LENGTH: "11",
HasherSettings: {
MinHashLength: "11"
}
};
+13 -32
View File
@@ -2,29 +2,10 @@ import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
import { AuthProvider, useAuth } from './auth/AuthProvider';
import { AuthProvider, ProtectedRoute, useAuth } from './auth/AuthProvider';
import { ToastProvider } from './components/ui/Toast';
import { LandingPage } from './components/landing/LandingPage';
import { DashboardPage } from './components/dashboard/DashboardPage';
import { LaserDivider } from './components/layout/LaserDivider';
function AuthenticatedLayout() {
return (
<div className="h-screen w-screen overflow-hidden bg-slate-950 text-slate-100 flex flex-row select-none">
{/* Fixed-width vertical navigation pane on the left */}
<aside className="w-64 sm:w-72 md:w-80 shrink-0 h-full relative flex flex-col">
{/* Navigation placeholder / empty clean pane */}
</aside>
{/* Laser beam divider with central light source */}
<LaserDivider />
{/* Content pane occupying the rest of the screen */}
<main className="flex-1 h-full min-w-0 relative flex flex-col overflow-y-auto">
<DashboardPage />
</main>
</div>
);
}
function AppRoutes() {
const { isAuthenticated, isLoading, login } = useAuth();
@@ -55,16 +36,14 @@ function AppRoutes() {
<Route
path="/dashboard"
element={
isAuthenticated ? (
<AuthenticatedLayout />
) : (
<LandingPage onLogin={login} isLoading={isLoading} />
)
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="*"
element={<Navigate to={isAuthenticated ? "/dashboard" : "/"} replace />}
element={<Navigate to={isAuthenticated ? '/dashboard' : '/'} replace />}
/>
</Routes>
);
@@ -73,11 +52,13 @@ function AppRoutes() {
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
<ToastProvider>
<AuthProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
</ToastProvider>
</QueryClientProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,323 @@
import React, { useMemo } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthProvider';
export type RoleClaim =
| 'dashboard'
| 'governance'
| 'projects_milestones'
| 'grant_financials'
| 'evidence_risks'
| 'beneficiaries'
| 'change_management'
| 'site_visits'
| 'compliance'
| 'settings_invites'
| 'settings_users'
| 'settings_auditlog'
| 'settings_notifications'
| 'tenant_self_service'
| 'tenant_management'
| 'unrestricted';
export type ClaimAction = 'view' | 'add' | 'update' | 'export' | 'all';
export interface RoleClaimDefinition {
claim: RoleClaim;
label: string;
description: string;
}
export const ROLE_CLAIM_DEFINITIONS: RoleClaimDefinition[] = [
{ claim: 'dashboard', label: 'Partner Dashboard', description: 'Overview of ongoing projects, funding awards, and inbox' },
{ claim: 'governance', label: 'Governance & Structure', description: 'Holding entities, investment portfolios, and statutory programmes' },
{ claim: 'projects_milestones', label: 'Projects & Milestones', description: 'Work breakdown structure, scheduled milestones, and progress' },
{ claim: 'grant_financials', label: 'Grant & Financials', description: 'Funding awards, budget line allocations, and disbursements' },
{ claim: 'evidence_risks', label: 'M&E, Evidence & Risks', description: 'Evidence submission and risk registers' },
{ claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' },
{ claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' },
{ claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' },
{ claim: 'compliance', label: 'Compliance', description: 'Statutory compliance, outcome fulfillment, and risk monitoring' },
{ claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' },
{ claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' },
{ claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' },
{ claim: 'settings_notifications', label: 'Notifications & Messages', description: 'In-app and email communications with the funding organisation' },
{ claim: 'tenant_self_service', label: 'Tenant Self-Service', description: 'Tenant configuration parameters and self-managed sub-users' },
{ claim: 'tenant_management', label: 'Tenant Management', description: 'Multi-tenant partition directory and cross-tenant isolation' },
{ claim: 'unrestricted', label: 'Unrestricted Access', description: 'Full system-wide control' },
];
export const CLAIM_ACTION_OPTIONS: { action: ClaimAction; label: string; description: string }[] = [
{ action: 'view', label: 'view', description: 'Can view records based on role' },
{ action: 'add', label: 'add', description: 'Can create records or give feedback; change non-approve statuses' },
{ action: 'update', label: 'update', description: 'Can use approve status change in dropdowns' },
{ action: 'export', label: 'export', description: 'Can click export buttons (Spreadsheet/PDF)' },
{ action: 'all', label: 'all', description: 'Can do everything' },
];
/**
* RBAC on PartnerUI ONLY functions for Partner roles.
* No other roles (Admin, Executive, Supervisor, Manager, Contributor) are permitted on this app.
*/
export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
partner: [
'dashboard',
'projects_milestones',
'grant_financials',
'evidence_risks',
'beneficiaries',
'change_management',
'site_visits',
'compliance',
'settings_notifications',
],
};
/**
* Checks whether the user's roles include the mandatory 'Partner' role.
* On PartnerUI, ONLY users with the 'Partner' role are permitted.
*/
export function isPartnerRoleOnlyPermitted(roles: string[] = []): boolean {
return Array.isArray(roles) && roles.some((r) => r.toLowerCase().trim() === 'partner');
}
/**
* Resolves action capability set given a claim value string for a Partner user.
*/
export function resolveClaimActionSet(
claimValue: string | null | undefined,
roleName?: string
): Set<ClaimAction> {
const actions = new Set<ClaimAction>();
if (!claimValue && !roleName) return actions;
const rawVal = (claimValue || '').toLowerCase().trim();
const role = (roleName || '').toLowerCase().trim();
// Only Partner role is recognized in PartnerUI
if (role && role !== 'partner') {
return actions;
}
if (rawVal) {
if (rawVal === 'all' || rawVal === 'unrestricted' || rawVal === 'true' || rawVal === '1') {
actions.add('all');
actions.add('view');
actions.add('add');
actions.add('update');
actions.add('export');
return actions;
}
if (rawVal === 'none' || rawVal === 'restricted' || rawVal === 'false' || rawVal === '0') {
return actions;
}
const tokens = rawVal.split(',').map((t) => t.trim());
for (const val of tokens) {
if (val === 'all') {
actions.add('all');
actions.add('view');
actions.add('add');
actions.add('update');
actions.add('export');
} else if (val === 'view' || val === 'read') {
actions.add('view');
} else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') {
actions.add('view');
actions.add('add');
} else if (val === 'update' || val === 'approve' || val === 'verify') {
actions.add('view');
actions.add('add');
actions.add('update');
} else if (val === 'export') {
actions.add('view');
actions.add('export');
}
}
return actions;
}
// Default fallback for Partner role: view, add (submit evidence, log activities, send messages, execute contract), export
if (role === 'partner') {
actions.add('view');
actions.add('add');
actions.add('export');
}
return actions;
}
/**
* Helper to extract explicit claim value from varied dictionary structures
*/
export function extractExplicitClaimValue(
claims: Record<string, any> = {},
claim: RoleClaim
): string | null {
if (!claims || typeof claims !== 'object') return null;
if (claims[claim] !== undefined && claims[claim] !== null) {
return String(claims[claim]).trim();
}
if (claims[`role_claim:${claim}`] !== undefined) {
return String(claims[`role_claim:${claim}`]).trim();
}
if (claims[`claim:${claim}`] !== undefined) {
return String(claims[`claim:${claim}`]).trim();
}
const noUnderscore = claim.replace(/_/g, '');
if (claims[noUnderscore] !== undefined) {
return String(claims[noUnderscore]).trim();
}
const arrayCandidates = [
claims.claims,
claims.UserClaims,
claims.user_claims,
claims.role_claims,
];
for (const arr of arrayCandidates) {
if (Array.isArray(arr)) {
for (const item of arr) {
if (typeof item === 'object' && item !== null) {
const type = (item.type || item.claimType || item.name || '').toLowerCase().trim();
if (type === claim || type === noUnderscore) {
return String(item.value || item.claimValue || 'all').trim();
}
} else if (typeof item === 'string') {
const [type, val] = item.split(':').map((s) => s.trim().toLowerCase());
if (type === claim || type === noUnderscore) {
return val || 'all';
}
}
}
}
}
return null;
}
/**
* Evaluates whether a user with given roles and token claims has permission for a specific claim and action.
*
* CRITICAL RULE FOR PARTNER UI:
* RBAC on PartnerUI ONLY functions for Partner roles. No other roles are permitted on this app.
*/
export function evaluateUserPermission(
roles: string[] = [],
claims: Record<string, any> = {},
claim: RoleClaim,
action: ClaimAction = 'view'
): boolean {
// Security Gate: Only users with the 'Partner' role are permitted on PartnerUI.
const isPartner = isPartnerRoleOnlyPermitted(roles);
if (!isPartner) return false;
// 1. Check if Partner user has explicit claim in claims dictionary
const rawClaimValue = extractExplicitClaimValue(claims, claim);
if (rawClaimValue !== null && rawClaimValue !== '') {
const actionSet = resolveClaimActionSet(rawClaimValue, 'partner');
return actionSet.has('all') || actionSet.has(action);
}
// 2. Check Partner base role mappings
const allowedClaims = ROLE_CLAIM_MAP['partner'];
if (allowedClaims && allowedClaims.includes(claim)) {
const actionSet = resolveClaimActionSet(undefined, 'partner');
if (actionSet.has('all') || actionSet.has(action)) {
return true;
}
}
return false;
}
/**
* Evaluates Maker-Checker separation of duties constraint.
*/
export function isRecordMaker(
createdBy?: string | null,
currentUserId?: string | null,
currentUserSub?: string | null,
currentUserEmail?: string | null
): boolean {
if (!createdBy) return false;
const creator = createdBy.trim().toLowerCase();
if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true;
if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true;
if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true;
return false;
}
/**
* React hook exposing RBAC evaluation helpers for PartnerUI components.
* Enforces that only the Partner role has permissions in this application.
*/
export function useRbac() {
const { roles, claims, user, sub } = useAuth();
const permissions = useMemo(() => {
const isPartner = isPartnerRoleOnlyPermitted(roles);
const canAccess = (claim: RoleClaim, action: ClaimAction = 'view'): boolean => {
if (!isPartner) return false;
return evaluateUserPermission(roles, claims, claim, action);
};
const canView = (claim: RoleClaim): boolean => canAccess(claim, 'view');
const canAdd = (claim: RoleClaim): boolean => canAccess(claim, 'add');
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
const isMaker = (createdBy?: string | null): boolean => {
return isRecordMaker(createdBy, user?.id, sub, user?.email);
};
const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => {
if (!canApprove(claim)) return false;
return !isMaker(createdBy);
};
const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => {
if (!isPartner) return 'Partner role required';
if (!canApprove(claim)) return 'Requires approval permission from the funding organisation';
if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records';
return null;
};
return {
isPartner,
isUnrestricted: false,
canAccess,
canView,
canAdd,
canApprove,
canExport,
isMaker,
canApproveRecord,
getApprovalRestrictionReason,
};
}, [roles, claims, user, sub]);
return permissions;
}
/**
* Route protection component for claim-restricted screens in PartnerUI.
*/
export const ClaimProtectedRoute: React.FC<{
claim: RoleClaim;
action?: ClaimAction;
children: React.ReactNode;
}> = ({ claim, action = 'view', children }) => {
const { canAccess } = useRbac();
const allowed = canAccess(claim, action);
if (!allowed) {
return React.createElement(Navigate, { to: '/dashboard', replace: true });
}
return React.createElement(React.Fragment, null, children);
};
@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import {
RefreshCw,
LogOut,
Building2,
Clock,
ArrowLeft,
Sparkles,
} from 'lucide-react';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
interface AccountActivationModalProps {
user: AuthUserProfile | null;
claims: UserClaims;
sub: string;
onSync: (force?: boolean) => Promise<{ success: boolean; isUnauthorized?: boolean; errorMessage?: string }>;
onLogout: () => Promise<void>;
initialError?: string | null;
}
const formatLockoutMessage = (msg?: string | null): string | null => {
if (!msg) return null;
const trimmed = msg.trim();
if (/^network\s*error$/i.test(trimmed)) {
return 'Permission Check';
}
return trimmed.replace(/network\s*error/gi, 'Permission Check');
};
export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
user,
claims,
onSync,
onLogout,
initialError,
}) => {
const navigate = useNavigate();
const [isSyncing, setIsSyncing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(formatLockoutMessage(initialError) || null);
useEffect(() => {
if (initialError !== undefined) {
setErrorMessage(formatLockoutMessage(initialError));
}
}, [initialError]);
useEffect(() => {
cancelSessionExpiration();
dismissSubordinateModals();
}, []);
const displayName =
user?.name ||
(claims.name as string) ||
(claims.preferred_username as string) ||
'Partner';
const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Partner Organisation';
const handleSyncClick = async () => {
if (isSyncing) return;
setIsSyncing(true);
setErrorMessage(null);
try {
const result = await onSync(true);
if (result.isUnauthorized) {
await onLogout();
navigate('/', { replace: true });
return;
}
if (!result.success) {
setErrorMessage(
formatLockoutMessage(
result.errorMessage ||
'Activation is still being reviewed by your funding administrator. Please check back shortly.'
)
);
}
} catch (err: unknown) {
const errObj = err as { response?: { status?: number }; message?: string };
if (errObj?.response?.status === 401) {
await onLogout();
navigate('/', { replace: true });
return;
}
setErrorMessage('Permission Check: Unable to verify activation right now. Please try again in a few moments.');
} finally {
setIsSyncing(false);
}
};
const handleSignOut = async () => {
await onLogout();
navigate('/', { replace: true });
};
const handleNavigateLanding = () => {
navigate('/');
};
const modalContent = (
<div
role="dialog"
aria-modal="true"
aria-labelledby="activation-modal-title"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/80 backdrop-blur-2xl animate-in fade-in duration-300 select-none overflow-y-auto"
>
{/* Background ambient lighting matching LandingPage */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-gradient-to-tr from-emerald-500/15 via-teal-500/10 to-cyan-500/15 blur-[120px] rounded-full" />
</div>
<div
className="relative w-full max-w-md rounded-3xl border border-white/10 bg-slate-900/85 backdrop-blur-xl shadow-[0_25px_60px_rgba(0,0,0,0.85),inset_0_1px_1px_rgba(255,255,255,0.15)] overflow-hidden flex flex-col items-center text-center p-8 sm:p-10 my-auto space-y-6"
onClick={(e) => e.stopPropagation()}
>
{/* Glowing Brand Icon Lockup */}
<div className="relative flex items-center justify-center pt-2">
<div className="absolute -inset-3 rounded-full bg-emerald-500/20 blur-xl animate-pulse" />
<PfmPartnerLogo className="h-16 w-16 drop-shadow-[0_8px_24px_rgba(16,185,129,0.35)] relative z-10" />
</div>
{/* Title & Organization Pill */}
<div className="space-y-2">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-[11px] font-semibold text-emerald-300">
<Clock className="w-3.5 h-3.5 text-emerald-400" />
<span>Activation Pending</span>
</div>
<h2 id="activation-modal-title" className="text-2xl font-bold tracking-tight text-white">
Welcome,{' '}
<span className="bg-gradient-to-r from-emerald-300 via-teal-200 to-cyan-300 bg-clip-text text-transparent">
{displayName}
</span>
</h2>
<div className="inline-flex items-center gap-1.5 text-xs text-slate-400 font-medium">
<Building2 className="w-3.5 h-3.5 text-teal-400 shrink-0" />
<span>{tenant}</span>
</div>
</div>
{/* Core Friendly Message */}
<p className="text-sm text-slate-300 leading-relaxed max-w-sm">
Your credentials have been authenticated. Your funding administrator is finalizing your portfolio access.
</p>
{/* Notice badge if check returned not yet ready */}
{errorMessage && (
<div className="w-full rounded-2xl bg-amber-500/10 border border-amber-500/20 p-3 text-xs text-amber-300/90 leading-relaxed text-center flex items-center justify-center gap-2">
<Sparkles className="w-4 h-4 text-amber-400 shrink-0" />
<span>{formatLockoutMessage(errorMessage)}</span>
</div>
)}
{/* Action Button matching Specular / Landing styling */}
<div className="w-full space-y-3 pt-2">
<button
onClick={handleSyncClick}
disabled={isSyncing}
type="button"
id="activation-sync-btn"
className="w-full py-3.5 px-6 rounded-2xl bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 hover:from-emerald-500 hover:to-teal-500 active:scale-[0.99] disabled:opacity-60 disabled:pointer-events-none text-white font-bold text-sm tracking-wide shadow-[0_12px_30px_rgba(16,185,129,0.35),inset_0_1px_1px_rgba(255,255,255,0.3)] hover:shadow-[0_16px_36px_rgba(16,185,129,0.5)] transition-all flex items-center justify-center gap-2 cursor-pointer"
>
<RefreshCw className={`w-4 h-4 text-emerald-100 ${isSyncing ? 'animate-spin' : ''}`} />
<span>{isSyncing ? 'Checking Status...' : 'Check Account Status'}</span>
</button>
{/* Minimal Secondary Actions */}
<div className="flex items-center justify-center gap-4 text-xs pt-1">
<button
onClick={handleNavigateLanding}
type="button"
className="inline-flex items-center gap-1.5 text-slate-400 hover:text-slate-200 transition-colors py-1 cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Landing Page</span>
</button>
<span className="text-slate-700">•</span>
<button
onClick={handleSignOut}
type="button"
className="inline-flex items-center gap-1.5 text-slate-400 hover:text-rose-400 transition-colors py-1 cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
</button>
</div>
</div>
</div>
</div>
);
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
};
@@ -0,0 +1,111 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { ShieldCheck, LogOut, ArrowLeft, Lock } from 'lucide-react';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
interface NonPartnerAccessDeniedModalProps {
user: AuthUserProfile | null;
claims: UserClaims;
sub: string;
roles: string[];
onLogout: () => Promise<void>;
}
export const NonPartnerAccessDeniedModal: React.FC<NonPartnerAccessDeniedModalProps> = ({
user,
claims,
roles,
onLogout,
}) => {
const navigate = useNavigate();
useEffect(() => {
cancelSessionExpiration();
dismissSubordinateModals();
}, []);
const displayEmail =
user?.email ||
(claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
'Authenticated User';
const assignedRolesDisplay = roles.length > 0 ? roles.join(', ') : user?.role || (claims.role as string) || 'Standard User';
const handleLogoutClick = async () => {
await onLogout();
navigate('/', { replace: true });
};
const handleLandingClick = () => {
navigate('/');
};
const modalContent = (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="non-partner-denied-title"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-2xl animate-in fade-in duration-300 overflow-y-auto"
>
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[450px] h-[450px] bg-rose-500/10 blur-[120px] rounded-full" />
</div>
<div
className="relative w-full max-w-md rounded-3xl border border-white/10 bg-slate-900/85 backdrop-blur-xl shadow-[0_25px_60px_rgba(0,0,0,0.85),inset_0_1px_1px_rgba(255,255,255,0.15)] overflow-hidden flex flex-col items-center text-center p-8 sm:p-10 my-auto space-y-6"
onClick={(e) => e.stopPropagation()}
>
<div className="relative flex items-center justify-center pt-2">
<div className="absolute -inset-3 rounded-full bg-rose-500/20 blur-xl" />
<PfmPartnerLogo className="h-16 w-16 drop-shadow-[0_8px_24px_rgba(244,63,94,0.35)] relative z-10" />
</div>
<div className="space-y-2">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-rose-500/10 border border-rose-500/20 text-[11px] font-semibold text-rose-300">
<Lock className="w-3.5 h-3.5 text-rose-400" />
<span>Partner Access Only</span>
</div>
<h2 id="non-partner-denied-title" className="text-2xl font-bold tracking-tight text-white">
Role Authorization Required
</h2>
</div>
<p className="text-sm text-slate-300 leading-relaxed max-w-sm">
This portal is reserved for registered Grantee and Partner accounts. Your current profile (<strong className="text-rose-300">{assignedRolesDisplay}</strong>) does not have access.
</p>
<div className="w-full rounded-2xl bg-slate-950/50 border border-white/5 p-3 text-xs text-slate-400 font-mono truncate">
Signed in as {displayEmail}
</div>
<div className="w-full space-y-3 pt-2">
<button
onClick={handleLogoutClick}
type="button"
className="w-full py-3.5 px-6 rounded-2xl bg-gradient-to-r from-rose-600 via-rose-500 to-amber-600 hover:from-rose-500 hover:to-amber-500 text-white font-bold text-sm tracking-wide shadow-[0_12px_30px_rgba(244,63,94,0.35),inset_0_1px_1px_rgba(255,255,255,0.3)] hover:shadow-[0_16px_36px_rgba(244,63,94,0.5)] transition-all flex items-center justify-center gap-2 cursor-pointer"
>
<LogOut className="w-4 h-4 text-rose-100" />
<span>Switch Account / Sign Out</span>
</button>
<button
onClick={handleLandingClick}
type="button"
className="inline-flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors py-1 cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Return to Landing Page</span>
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
};
@@ -0,0 +1,153 @@
import React, { useEffect, useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Clock, LogOut, RefreshCw } from 'lucide-react';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration';
interface SessionExpiredModalProps {
isOpen: boolean;
onDismiss?: () => void;
reason?: string | null;
countdownSeconds?: number;
}
export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
isOpen,
onDismiss,
reason,
countdownSeconds = 8,
}) => {
const [timeLeftMs, setTimeLeftMs] = useState(countdownSeconds * 1000);
const [isPaused, setIsPaused] = useState(false);
const totalDurationMs = countdownSeconds * 1000;
const isClearingRef = useRef(false);
useEffect(() => {
if (isOpen) {
dismissSubordinateModals();
}
}, [isOpen]);
useEffect(() => {
const handleCancelled = () => {
setTimeLeftMs(countdownSeconds * 1000);
setIsPaused(false);
isClearingRef.current = false;
};
window.addEventListener('pfm:session-expired-cancelled', handleCancelled);
return () => {
window.removeEventListener('pfm:session-expired-cancelled', handleCancelled);
};
}, [countdownSeconds]);
useEffect(() => {
if (!isOpen) {
setTimeLeftMs(countdownSeconds * 1000);
setIsPaused(false);
isClearingRef.current = false;
return;
}
const intervalMs = 50;
const interval = setInterval(() => {
setTimeLeftMs((prev) => {
if (isPaused) return prev;
const next = prev - intervalMs;
if (next <= 0) {
clearInterval(interval);
handleAction();
return 0;
}
return next;
});
}, intervalMs);
return () => clearInterval(interval);
}, [isOpen, isPaused, countdownSeconds]);
const handleAction = () => {
if (isClearingRef.current) return;
isClearingRef.current = true;
if (onDismiss) {
onDismiss();
} else {
clearSessionAndRedirectToLanding();
}
};
if (!isOpen) return null;
const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000));
const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100));
const modalContent = (
<div
id="session-expired-modal-backdrop"
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-2xl animate-in fade-in duration-300 overflow-y-auto"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
role="dialog"
aria-modal="true"
aria-labelledby="session-expired-title"
>
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[450px] h-[450px] bg-amber-500/10 blur-[120px] rounded-full" />
</div>
<div
id="session-expired-modal-content"
className="relative w-full max-w-md rounded-3xl border border-white/10 bg-slate-900/85 backdrop-blur-xl shadow-[0_25px_60px_rgba(0,0,0,0.85),inset_0_1px_1px_rgba(255,255,255,0.15)] overflow-hidden flex flex-col items-center text-center p-8 sm:p-10 my-auto space-y-6"
>
<div className="relative flex items-center justify-center pt-2">
<div className="absolute -inset-3 rounded-full bg-amber-500/20 blur-xl" />
<PfmPartnerLogo className="h-16 w-16 drop-shadow-[0_8px_24px_rgba(245,158,11,0.35)] relative z-10" />
</div>
<div className="space-y-2">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-[11px] font-semibold text-amber-300">
<Clock className="w-3.5 h-3.5 text-amber-400" />
<span>Session Expired</span>
</div>
<h2 id="session-expired-title" className="text-2xl font-bold tracking-tight text-white">
Security Session Ended
</h2>
</div>
<p className="text-sm text-slate-300 leading-relaxed max-w-sm">
{reason
? reason.replace(/network\s*error/gi, 'Permission Check')
: 'Your security credentials have expired for your protection. Please sign in again to continue.'}
</p>
{/* Smooth Countdown Bar */}
<div className="w-full space-y-2">
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all duration-75 ease-linear rounded-full"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[11px] text-slate-400 font-mono">
Redirecting in {secondsRemaining}s
</span>
</div>
<div className="w-full pt-1">
<button
onClick={handleAction}
type="button"
className="w-full py-3.5 px-6 rounded-2xl bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 hover:from-emerald-500 hover:to-teal-500 text-white font-bold text-sm tracking-wide shadow-[0_12px_30px_rgba(16,185,129,0.35),inset_0_1px_1px_rgba(255,255,255,0.3)] hover:shadow-[0_16px_36px_rgba(16,185,129,0.5)] transition-all flex items-center justify-center gap-2 cursor-pointer"
>
<RefreshCw className="w-4 h-4 text-emerald-100" />
<span>Sign In Again Now</span>
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent;
};
File diff suppressed because it is too large Load Diff
@@ -1,23 +1,33 @@
import React, { useState } from 'react';
import { api, extractApiErrors } from '../../services/api';
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
import {
X,
UploadCloud,
FileText,
CheckCircle2,
AlertTriangle,
Loader2,
Plus,
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import {
uploadEvidenceFile,
extractApiErrors,
toIntegerId,
getProjectIndicators,
recordIndicatorActual,
} from '../../services/api';
import { Activity, Milestone, Project, Indicator } from '../../services/api/models';
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
import {
X,
UploadCloud,
FileText,
CheckCircle2,
AlertTriangle,
Loader2,
Plus,
Sparkles,
Link as LinkIcon
Target,
BarChart3,
} from 'lucide-react';
interface EvidenceUploadModalProps {
isOpen: boolean;
onClose: () => void;
project: Project;
milestone?: Milestone;
milestone?: Milestone | null;
activity: Activity;
onEvidenceUploaded: () => void;
}
@@ -32,14 +42,45 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [documentUrl, setDocumentUrl] = useState('');
const [evidenceType, setEvidenceType] = useState<EvidenceType>(1); // Invoice / Document
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
const [description, setDescription] = useState('');
const [selectedIndicatorId, setSelectedIndicatorId] = useState<string>('');
const [actualAmount, setActualAmount] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessages, setErrorMessages] = useState<string[]>([]);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
if (!isOpen) return null;
const projectId = project?.id || 0;
// Requirement #4: Fetch linked indicators for project & activity
const { data: projectIndicators = [], isLoading: isLoadingIndicators } = useQuery<Indicator[]>({
queryKey: ['evidence-project-indicators', String(projectId)],
queryFn: () => getProjectIndicators(projectId),
enabled: isOpen && Boolean(projectId),
});
// Prioritize indicators directly linked to this activity, then project indicators
const availableIndicators = React.useMemo(() => {
if (!projectIndicators || projectIndicators.length === 0) return [];
const directMatches = projectIndicators.filter(
ind => ind.activityId && String(ind.activityId) === String(activity.id)
);
if (directMatches.length > 0) return directMatches;
return projectIndicators;
}, [projectIndicators, activity.id]);
// Auto-select first indicator if available
useEffect(() => {
if (availableIndicators.length > 0 && !selectedIndicatorId) {
const firstId = String(availableIndicators[0].id);
setSelectedIndicatorId(firstId);
if (availableIndicators[0].actualAmount !== undefined) {
setActualAmount(String(availableIndicators[0].actualAmount || ''));
}
}
}, [availableIndicators, selectedIndicatorId]);
if (!isOpen || typeof document === 'undefined') return null;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
@@ -51,66 +92,94 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
}
};
const selectedIndicatorObj = availableIndicators.find(
i => String(i.id) === String(selectedIndicatorId)
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessages([]);
setSuccessMessage(null);
if (!title.trim() && !selectedFile && !documentUrl.trim()) {
setErrorMessages(['Please provide an evidence title, file upload, or document URL.']);
if (!selectedFile) {
setErrorMessages(['A supporting document file is required. Please choose a file to upload.']);
return;
}
const numProjectId = toIntegerId(project.id);
const numActivityId = toIntegerId(activity.id);
const numMilestoneId = toIntegerId(milestone?.id || activity.milestoneId || 0);
const numIndicatorId = toIntegerId(selectedIndicatorId);
// Requirement #4: IndicatorId must not be 0 to maintain referential integrity in database
if (!numIndicatorId || numIndicatorId <= 0) {
setErrorMessages([
'A valid linked Indicator is mandatory for evidence verification. Please select an indicator from the list.',
]);
return;
}
if (!actualAmount.trim() || isNaN(Number(actualAmount))) {
setErrorMessages([
'Recording an Actual amount/metric against the selected indicator is mandatory. Please provide a numeric value.',
]);
return;
}
if (!numProjectId || !numActivityId) {
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
return;
}
setIsSubmitting(true);
try {
// Build upload payload
// 1. Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
// Requirement #7: When uploading, IForm does not need the userId field in it; API detects userId from JWT sub
const formData = new FormData();
if (selectedFile) {
formData.append('file', selectedFile);
}
formData.append('title', title.trim() || selectedFile?.name || 'Evidence Deliverable');
formData.append('projectId', String(project.id || ''));
if (milestone?.id) formData.append('milestoneId', String(milestone.id));
if (activity?.id) formData.append('activityId', String(activity.id));
formData.append('type', String(evidenceType));
if (documentUrl.trim()) formData.append('documentUrl', documentUrl.trim());
if (description.trim()) formData.append('description', description.trim());
// Also support JSON payload fallback if server expects JSON body
try {
await api.evidence.createEvidenceFile(formData);
} catch (err: any) {
// Fallback: Attempt JSON payload if multipart failed or route expects JSON
const jsonPayload = {
projectId: project.id,
milestoneId: milestone?.id,
activityId: activity.id,
title: title.trim() || selectedFile?.name || 'Evidence Deliverable',
type: evidenceType,
documentUrl: documentUrl.trim() || (selectedFile ? `s3://evidence/${selectedFile.name}` : undefined),
description: description.trim(),
};
await api.evidence.createEvidenceFile(jsonPayload);
formData.append('file', selectedFile);
formData.append('projectId', String(numProjectId));
formData.append('activityId', String(numActivityId));
formData.append('milestoneId', String(numMilestoneId));
formData.append('indicatorId', String(numIndicatorId));
formData.append('type', String(Number(evidenceType) || 4));
formData.append('title', title.trim() || selectedFile.name);
if (description.trim()) {
formData.append('description', description.trim());
}
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
// 2. Upload evidence file
await uploadEvidenceFile(formData);
// 3. Requirement #4: Record Actual against linked indicator
const parsedActual = Number(actualAmount);
if (!isNaN(parsedActual)) {
try {
await recordIndicatorActual(numIndicatorId, parsedActual);
} catch (indErr) {
console.warn('[Record Indicator Actual Warning]:', indErr);
}
}
setSuccessMessage('Evidence successfully uploaded and linked to activity with recorded indicator actual!');
setTimeout(() => {
onEvidenceUploaded();
onClose();
}, 1000);
}, 800);
} catch (err: any) {
console.error('[Evidence Upload Error]:', err);
const errors = extractApiErrors(err);
setErrorMessages(errors);
setErrorMessages(
errors.length > 0 ? errors : ['Failed to upload evidence file. Please verify parameters and try again.']
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden">
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden my-auto">
{/* Ambient background glow */}
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
@@ -124,6 +193,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<h3 className="text-base font-bold text-white">Upload Activity Deliverable</h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
>
@@ -146,12 +216,12 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
</div>
</div>
{/* Error Alerts (Comma-delimited error list from ProblemDetails) */}
{/* Error Alerts */}
{errorMessages.length > 0 && (
<div className="mb-4 p-3.5 rounded-2xl bg-rose-950/60 border border-rose-500/30 text-rose-200 text-xs space-y-1">
<div className="flex items-center gap-1.5 font-bold text-rose-300">
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0" />
<span>Validation Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
<span>Upload Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
</div>
<ul className="list-disc pl-5 space-y-0.5 text-[11px] text-rose-200/90 font-mono">
{errorMessages.map((msg, idx) => (
@@ -173,10 +243,13 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
{/* File Dropzone */}
<div>
<label className="block text-slate-300 font-medium mb-1.5">Evidence File (Document, Receipt, Report)</label>
<label className="block text-slate-300 font-medium mb-1.5">
Evidence File (Document, Invoice, Certificate, Photo) <span className="text-emerald-400">*</span>
</label>
<div className="relative border-2 border-dashed border-white/15 hover:border-emerald-500/50 rounded-2xl p-4 text-center bg-white/[0.02] hover:bg-emerald-500/[0.02] transition-colors cursor-pointer">
<input
type="file"
required
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
@@ -193,23 +266,88 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
</div>
) : (
<div>
<span className="font-medium text-slate-300">Click to upload file</span>
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Max 25MB)</p>
<span className="font-medium text-slate-300">Click to choose document</span>
<p className="text-[11px] text-slate-500 mt-0.5">
PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)
</p>
</div>
)}
</div>
</div>
</div>
{/* Requirement #4: Mandatory Indicator Selection & Actual Amount Recording */}
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-emerald-500/30 space-y-3">
<div className="flex items-center gap-2 text-emerald-400 font-semibold text-xs">
<Target className="w-4 h-4" />
<span>Linked Indicator & Performance Actual (Mandatory)</span>
</div>
<div>
<label className="block text-slate-300 font-medium mb-1">
Select Linked Indicator <span className="text-emerald-400">*</span>
</label>
{isLoadingIndicators ? (
<div className="text-[11px] text-slate-400 py-1">Loading indicators...</div>
) : availableIndicators.length === 0 ? (
<div className="text-[11px] text-amber-300 bg-amber-950/40 border border-amber-500/30 p-2.5 rounded-xl">
No indicators currently recorded for this project. Please contact programme management or select standard output.
</div>
) : (
<select
required
value={selectedIndicatorId}
onChange={e => setSelectedIndicatorId(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value="">-- Choose Indicator for this Deliverable --</option>
{availableIndicators.map(ind => (
<option key={ind.id} value={ind.id} className="bg-slate-900 text-slate-100">
{ind.name || `Indicator #${ind.id}`} (Target: {ind.targetAmount} • Baseline: {ind.baselineAmount})
</option>
))}
</select>
)}
</div>
<div>
<label className="block text-slate-300 font-medium mb-1">
Actual Amount / Metric Recorded <span className="text-emerald-400">*</span>
</label>
<div className="relative">
<input
type="number"
step="any"
min="0"
required
placeholder={
selectedIndicatorObj
? `Target is ${selectedIndicatorObj.targetAmount}`
: 'e.g. 10 or 1500'
}
value={actualAmount}
onChange={e => setActualAmount(e.target.value)}
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
<BarChart3 className="w-4 h-4 text-emerald-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
{selectedIndicatorObj && (
<p className="text-[11px] text-slate-400 mt-1">
Baseline: {selectedIndicatorObj.baselineAmount} • Target: {selectedIndicatorObj.targetAmount} • Current Actual: {selectedIndicatorObj.actualAmount}
</p>
)}
</div>
</div>
{/* Evidence Title & Type */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-medium mb-1">Evidence Title</label>
<input
type="text"
placeholder="e.g. Audit Signoff Dossier"
placeholder="e.g. Activity Verification Proof"
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={e => setTitle(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
</div>
@@ -217,42 +355,26 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
<select
value={evidenceType}
onChange={(e) => setEvidenceType(Number(e.target.value) as EvidenceType)}
onChange={e => setEvidenceType(Number(e.target.value))}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value={0}>Document</option>
<option value={1}>Invoice</option>
<option value={2}>Receipt</option>
<option value={3}>Site Photo</option>
<option value={4}>Verification Report</option>
<option value={5}>Other Deliverable</option>
{EVIDENCE_TYPE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* External Secure URL (Optional) */}
<div>
<label className="block text-slate-300 font-medium mb-1">Document Link / S3 URI (Optional)</label>
<div className="relative">
<LinkIcon className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="https://... or s3://..."
value={documentUrl}
onChange={(e) => setDocumentUrl(e.target.value)}
className="w-full pl-8 pr-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
</div>
</div>
{/* Description / Notes */}
<div>
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes</label>
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes (Optional)</label>
<textarea
rows={2}
placeholder="Provide verification notes or audit references..."
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 resize-none"
/>
</div>
@@ -280,13 +402,14 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
) : (
<>
<Plus className="w-4 h-4" />
<span>Upload & Attach Evidence</span>
<span>Upload & Record Actual</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>,
document.body
);
};
@@ -0,0 +1,483 @@
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import {
X,
ShieldAlert,
ShieldCheck,
AlertTriangle,
Scale,
FileCheck,
Info,
Calendar,
User,
Users,
CheckCircle2,
Clock,
Layers,
Sparkles,
Filter,
MapPin,
Target,
} from 'lucide-react';
import {
Project,
Milestone,
Activity,
ComplianceItem,
Risk,
Rule,
Beneficiary,
ComplianceStatus,
} from '../../services/api/models';
import {
getProjectComplianceItems,
getProjectBeneficiaries,
getProjectRisks,
getRules,
} from '../../services/api';
import { formatDate } from '../../lib/formatters';
interface GovernanceInfoModalProps {
isOpen: boolean;
onClose: () => void;
project: Project;
milestone?: Milestone | null;
activity?: Activity | null;
initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules';
}
export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
isOpen,
onClose,
project,
milestone,
activity,
initialTab = 'compliance',
}) => {
const [activeTab, setActiveTab] = useState<'compliance' | 'beneficiaries' | 'risks' | 'rules'>(initialTab);
const [selectedBeneficiaryFilter, setSelectedBeneficiaryFilter] = useState<string>('ALL');
const projectId = project?.id || 0;
// 1. Fetch compliance items for project
const { data: complianceItems = [], isLoading: isLoadingCompliance } = useQuery<ComplianceItem[]>({
queryKey: ['governance-compliance', String(projectId)],
queryFn: () => getProjectComplianceItems(projectId),
enabled: isOpen && Boolean(projectId),
});
// 2. Fetch beneficiaries for project (Requirement: explore compliance items by beneficiary)
const { data: projectBeneficiaries = [], isLoading: isLoadingBeneficiaries } = useQuery<Beneficiary[]>({
queryKey: ['governance-beneficiaries', String(projectId)],
queryFn: () => getProjectBeneficiaries(projectId),
enabled: isOpen && Boolean(projectId),
});
// 3. Fetch risks for project
const { data: projectRisks = [], isLoading: isLoadingRisks } = useQuery<Risk[]>({
queryKey: ['governance-risks', String(projectId)],
queryFn: () => getProjectRisks(projectId),
enabled: isOpen && Boolean(projectId),
});
// 4. Fetch associated governance rules
const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery<Rule[]>({
queryKey: ['governance-rules'],
queryFn: () => getRules({ page: 1, pageSize: 100 }),
enabled: isOpen,
});
// Map of beneficiary by ID for fast lookup
const beneficiaryMap = useMemo(() => {
const map = new Map<string, Beneficiary>();
for (const b of projectBeneficiaries) {
if (b && b.id !== undefined && b.id !== null) {
map.set(String(b.id), b);
}
}
return map;
}, [projectBeneficiaries]);
// Filter compliance items by selected beneficiary
const filteredComplianceItems = useMemo(() => {
if (selectedBeneficiaryFilter === 'ALL') {
return complianceItems;
}
if (selectedBeneficiaryFilter === 'PROJECT_WIDE') {
return complianceItems.filter(
item => !item.beneficiaryId || Number(item.beneficiaryId) === 0
);
}
return complianceItems.filter(
item => String(item.beneficiaryId) === selectedBeneficiaryFilter
);
}, [complianceItems, selectedBeneficiaryFilter]);
if (!isOpen || typeof document === 'undefined') return null;
const renderComplianceStatus = (status?: number) => {
const s = Number(status);
if (s === ComplianceStatus.Met || s === ComplianceStatus.Exceeded) {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
<span>{s === ComplianceStatus.Exceeded ? 'Exceeded' : 'Compliant'}</span>
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
<AlertTriangle className="w-3 h-3 text-rose-400" />
<span>Action Required (Not Met)</span>
</span>
);
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-4xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-8 space-y-6 my-auto text-slate-100 max-h-[90vh] flex flex-col">
{/* Modal Header */}
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 shrink-0">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<ShieldAlert className="w-3 h-3 text-emerald-400" />
<span>Governance & Compliance Guidance</span>
</span>
{activity ? (
<span className="text-[11px] text-slate-400 font-mono">Activity: {activity.title}</span>
) : milestone ? (
<span className="text-[11px] text-slate-400 font-mono">Milestone: {milestone.name}</span>
) : (
<span className="text-[11px] text-slate-400 font-mono">Project: {project?.name || (project as any)?.title || `#${project?.id || 'PFM'}`}</span>
)}
</div>
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
Project Compliance Items, Beneficiaries & Associated Rules
</h3>
<p className="text-xs text-slate-400">
Contractual obligations, beneficiary-linked compliance criteria, risk registers, and institutional rules.
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<div className="flex items-center gap-2 border-b border-white/[0.08] pb-3 shrink-0 flex-wrap">
<button
type="button"
onClick={() => setActiveTab('compliance')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'compliance'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<FileCheck className="w-4 h-4" />
<span>Compliance Items ({complianceItems.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('beneficiaries')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'beneficiaries'
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<Users className="w-4 h-4" />
<span>Beneficiaries ({projectBeneficiaries.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('risks')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'risks'
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<AlertTriangle className="w-4 h-4" />
<span>Risk Register ({projectRisks.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('rules')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'rules'
? 'bg-teal-500/20 text-teal-300 border border-teal-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<Scale className="w-4 h-4" />
<span>Associated Rules ({governanceRules.length})</span>
</button>
</div>
{/* Tab Content Panels */}
<div className="flex-1 overflow-y-auto space-y-4 pr-1 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
{/* TAB 1: COMPLIANCE ITEMS (With Beneficiary Explorer) */}
{activeTab === 'compliance' && (
<div className="space-y-4">
{/* Beneficiary Filter Bar */}
<div className="p-3.5 rounded-2xl bg-slate-950/70 border border-white/[0.08] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs text-slate-300">
<Filter className="w-4 h-4 text-emerald-400 shrink-0" />
<span className="font-semibold">Explore by Beneficiary:</span>
</div>
<div className="flex items-center gap-2">
<select
value={selectedBeneficiaryFilter}
onChange={e => setSelectedBeneficiaryFilter(e.target.value)}
className="px-3 py-1.5 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value="ALL">All Compliance Items ({complianceItems.length})</option>
<option value="PROJECT_WIDE">
Project-Wide General Items (
{
complianceItems.filter(
i => !i.beneficiaryId || Number(i.beneficiaryId) === 0
).length
}
)
</option>
{projectBeneficiaries.map(b => {
const count = complianceItems.filter(
i => String(i.beneficiaryId) === String(b.id)
).length;
return (
<option key={b.id} value={String(b.id)}>
Beneficiary: {b.name || `Beneficiary #${b.id}`} ({count} items)
</option>
);
})}
</select>
</div>
</div>
{isLoadingCompliance ? (
<div className="p-8 text-center text-xs text-slate-400">Loading compliance requirements...</div>
) : filteredComplianceItems.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<FileCheck className="w-8 h-8 text-slate-500 mx-auto" />
<p>No compliance items found matching this beneficiary filter.</p>
<p className="text-[11px] text-slate-500">
Switch to &quot;All Compliance Items&quot; to inspect project-wide statutory deliverables.
</p>
</div>
) : (
filteredComplianceItems.map((item, idx) => {
const linkedBeneficiary = item.beneficiaryId
? beneficiaryMap.get(String(item.beneficiaryId))
: null;
return (
<div
key={item.id || idx}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm text-slate-100">
{item.title || `Compliance Item #${idx + 1}`}
</span>
{renderComplianceStatus(item.status)}
{linkedBeneficiary ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-cyan-950/80 border border-cyan-500/30 text-cyan-300">
<Users className="w-3 h-3 text-cyan-400" />
<span>Beneficiary: {linkedBeneficiary.name}</span>
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-slate-900 border border-white/10 text-slate-400">
<span>Project-Wide General</span>
</span>
)}
</div>
<div className="text-[11px] text-slate-400 flex items-center gap-1 font-mono">
<Calendar className="w-3.5 h-3.5 text-slate-500" />
<span>Created: {formatDate(item.createdAt)}</span>
</div>
</div>
{item.requirements && (
<p className="text-xs text-slate-300 leading-relaxed">{item.requirements}</p>
)}
</div>
);
})
)}
</div>
)}
{/* TAB 2: BENEFICIARIES LISTING & EXPLORATION */}
{activeTab === 'beneficiaries' && (
<div className="space-y-3">
{isLoadingBeneficiaries ? (
<div className="p-8 text-center text-xs text-slate-400">Loading project beneficiaries...</div>
) : projectBeneficiaries.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<Users className="w-8 h-8 text-slate-500 mx-auto" />
<p>No individual beneficiary cohorts registered for this project.</p>
<p className="text-[11px] text-slate-500">
Beneficiaries are captured during project execution and onboarding.
</p>
</div>
) : (
projectBeneficiaries.map(ben => {
const linkedItems = complianceItems.filter(
ci => String(ci.beneficiaryId) === String(ben.id)
);
return (
<div
key={ben.id}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2.5 flex-wrap">
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
<Users className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-bold text-slate-100">{ben.name || 'Unnamed Beneficiary'}</h4>
<p className="text-[11px] text-slate-400 font-mono">
Category: {ben.category || 'Direct Participant'}
{ben.location && ` • Location: ${ben.location}`}
</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs">
<div className="text-right">
<span className="text-[10px] text-slate-500 block uppercase font-bold">Target / Reached</span>
<span className="text-emerald-400 font-mono font-semibold">
{ben.reachedCount ?? 0} / {ben.targetCount ?? 0}
</span>
</div>
<button
type="button"
onClick={() => {
setSelectedBeneficiaryFilter(String(ben.id));
setActiveTab('compliance');
}}
className="px-3 py-1.5 rounded-xl bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-cyan-300 text-[11px] font-semibold transition-colors cursor-pointer"
>
Explore Compliance ({linkedItems.length})
</button>
</div>
</div>
</div>
);
})
)}
</div>
)}
{/* TAB 3: RISK REGISTER */}
{activeTab === 'risks' && (
<div className="space-y-3">
{isLoadingRisks ? (
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
) : projectRisks.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto" />
<p>No high-severity risks recorded for this project.</p>
<p className="text-[11px] text-slate-500">
Maintain milestone delivery cadence to preserve low-risk standing.
</p>
</div>
) : (
projectRisks.map((risk, idx) => (
<div
key={risk.id || idx}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h4 className="font-semibold text-sm text-slate-100">
{risk.title || `Risk Item #${idx + 1}`}
</h4>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-300 border border-amber-500/30">
Likelihood: {risk.likelihood ?? 'Medium'}
</span>
<span className="px-2 py-0.5 rounded-md bg-rose-500/15 text-rose-300 border border-rose-500/30">
Impact: {risk.impact ?? 'Moderate'}
</span>
</div>
</div>
{risk.description && (
<p className="text-xs text-slate-300 leading-relaxed">{risk.description}</p>
)}
{risk.mitigation && (
<div className="text-[11px] text-teal-300 bg-teal-950/40 border border-teal-500/20 p-2.5 rounded-xl">
<strong>Mitigation Strategy:</strong> {risk.mitigation}
</div>
)}
</div>
))
)}
</div>
)}
{/* TAB 4: ASSOCIATED GOVERNANCE RULES */}
{activeTab === 'rules' && (
<div className="space-y-3">
{isLoadingRules ? (
<div className="p-8 text-center text-xs text-slate-400">Loading institutional rules...</div>
) : governanceRules.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<Scale className="w-8 h-8 text-slate-500 mx-auto" />
<p>Standard PFM Funding Portfolio Governance Rules apply.</p>
<p className="text-[11px] text-slate-500">
All disbursements gate against verified milestone deliverables and evidence.
</p>
</div>
) : (
governanceRules.map((rule, idx) => (
<div
key={rule.id || idx}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2"
>
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-sm text-slate-100">
{rule.name || `Rule #${idx + 1}`}
</h4>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-slate-800 text-slate-300 border border-white/10">
v{rule.version || 1}
</span>
</div>
{rule.description && (
<p className="text-xs text-slate-300 leading-relaxed">{rule.description}</p>
)}
</div>
))
)}
</div>
)}
</div>
{/* Modal Footer */}
<div className="flex items-center justify-between pt-3 border-t border-white/[0.08] shrink-0">
<span className="text-[11px] text-slate-500 font-mono">
PFM Governance Architecture • Beneficiary & Compliance Tracking
</span>
<button
type="button"
onClick={onClose}
className="px-5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors cursor-pointer"
>
Close
</button>
</div>
</div>
</div>,
document.body
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
import React, { useState, useMemo } from 'react';
import { Copy, Check } from 'lucide-react';
import { NotificationPlatform } from '../../types';
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
export interface MessageViewerProps {
content?: string;
message?: string;
platform?: NotificationPlatform | number | string;
subject?: string;
className?: string;
showFormatHeader?: boolean;
maxHeightClass?: string;
variant?: string;
createdDate?: string;
}
export const MessageViewer: React.FC<MessageViewerProps> = ({
content,
message,
platform,
subject,
className = '',
maxHeightClass = 'max-h-[65vh]',
}) => {
const rawContent = (content !== undefined && content !== null ? content : message) ?? '';
const [copied, setCopied] = useState(false);
// Determine whether this message represents an Email (HTML) or In-App (Markdown)
const isEmail = useMemo(() => {
if (
platform === NotificationPlatform.Email ||
platform === 1 ||
platform === '1' ||
String(platform).toLowerCase() === 'email'
) {
return true;
}
if (
platform !== NotificationPlatform.InApp &&
platform !== 5 &&
platform !== '5' &&
String(platform).toLowerCase() !== 'inapp' &&
isHtmlContent(rawContent)
) {
return true;
}
return false;
}, [platform, rawContent]);
// Requirement #1: In-App message contents encoded as markdown, rendered for user; Email in HTML
// Requirement #8: User is none the wiser of raw form or format in any Messages UI
const renderedHtml = useMemo(() => {
if (!rawContent || !rawContent.trim()) {
return '<p class="text-slate-400 italic">No message content</p>';
}
if (isEmail) {
if (isHtmlContent(rawContent)) {
return rawContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
}
return parseMarkdownToHtml(rawContent);
}
// In-App: parsed from Markdown to styled HTML
return parseMarkdownToHtml(rawContent);
}, [rawContent, isEmail]);
const handleCopy = () => {
const textToCopy = stripMarkdown(rawContent) || rawContent;
navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className={`flex flex-col rounded-2xl bg-slate-900/60 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
{/* Top action toolbar - clean without technical format metadata */}
<div className="px-5 py-3 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
<span className="text-xs font-semibold text-slate-200 truncate">
{subject || 'Message Content'}
</span>
{/* Copy Clean Text */}
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-medium transition cursor-pointer border border-white/10"
title="Copy message content"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-emerald-300">Copied</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-slate-400" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Rendered Body - Prose & Markdown styling */}
<div
className={`w-full max-w-full p-5 sm:p-7 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}
>
<div
className="prose prose-invert max-w-full w-full break-words [word-break:break-word] text-xs sm:text-sm leading-relaxed text-slate-200 [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_table]:w-full [&_table]:max-w-full [&_table]:border-collapse [&_table]:my-3 [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2.5 [&_th]:break-words [&_td]:border [&_td]:border-slate-800 [&_td]:p-2.5 [&_td]:break-words [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-emerald-300 [&_code]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_img]:max-w-full [&_img]:h-auto [&_iframe]:max-w-full"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
</div>
);
};
@@ -0,0 +1,287 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
X,
Send,
Bold,
Italic,
Underline,
Heading2,
List,
ListOrdered,
Quote,
Minus,
MessageSquare,
Sparkles,
Eraser,
} from 'lucide-react';
import { htmlToMarkdown } from '../../lib/markdownUtils';
interface PartnerMessageComposerModalProps {
isOpen: boolean;
onClose: () => void;
recipientEmail: string;
recipientName?: string;
organisationName?: string;
initialSubject?: string;
onSend: (subject: string, markdownMessage: string) => Promise<void>;
isSending?: boolean;
}
export const PartnerMessageComposerModal: React.FC<PartnerMessageComposerModalProps> = ({
isOpen,
onClose,
recipientEmail,
recipientName,
organisationName,
initialSubject = '',
onSend,
isSending = false,
}) => {
const [subject, setSubject] = useState(initialSubject);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSubject(initialSubject);
setErrorMessage(null);
}, [initialSubject, isOpen]);
if (!isOpen || typeof document === 'undefined') return null;
const applyFormat = (command: string, value: string | undefined = undefined) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
document.execCommand(command, false, value);
};
const handleClearFormat = () => {
applyFormat('removeFormat');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage(null);
if (!subject.trim()) {
setErrorMessage('Please enter a message subject.');
return;
}
const htmlContent = editorRef.current ? editorRef.current.innerHTML : '';
const textContent = editorRef.current ? editorRef.current.innerText.trim() : '';
if (!textContent && !htmlContent.trim()) {
setErrorMessage('Please write your message in the composer space.');
return;
}
// Requirement #1: In-App messages are encoded as Markdown
// Requirement #8: User is none the wiser of raw format
const markdown = htmlToMarkdown(htmlContent);
try {
await onSend(subject.trim(), markdown);
onClose();
} catch (err: any) {
setErrorMessage(err?.message || 'Failed to dispatch message. Please try again.');
}
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-2xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-7 space-y-5 my-auto text-slate-100">
{/* Modal Header */}
<div className="flex items-center justify-between border-b border-white/[0.08] pb-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-2xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400">
<MessageSquare className="w-5 h-5" />
</div>
<div>
<h3 className="text-base sm:text-lg font-bold text-white tracking-tight">
{initialSubject.startsWith('Re:') ? 'Reply Message' : 'Compose Message'}
</h3>
<p className="text-xs text-slate-400">
Direct transmission to your programme funding administration
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{errorMessage && (
<div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
{errorMessage}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Recipient Field */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
To (Recipient Support Email)
</label>
<input
type="text"
disabled
value={recipientEmail || 'support@ifri.khongisa.co.za'}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs font-mono text-emerald-400 select-none"
/>
{(organisationName || recipientName) && (
<p className="text-[11px] text-slate-400 mt-1">
Organisation: <span className="text-slate-200">{organisationName || recipientName}</span>
</p>
)}
</div>
{/* Subject Field */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Subject</label>
<input
type="text"
required
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="e.g. Milestone 2 Deliverable Verification Enquiry"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
</div>
{/* WYSIWYG Message Composition Space (Requirement #2 & #8) */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Message Composition Space
</label>
<div className="rounded-2xl border border-white/10 bg-slate-950 overflow-hidden shadow-inner focus-within:border-emerald-500/50 transition">
{/* WYSIWYG Toolbar */}
<div className="px-3 py-2 border-b border-white/[0.08] bg-slate-900/80 flex flex-wrap items-center gap-1 text-slate-300">
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('bold')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Bold"
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('italic')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Italic"
>
<Italic className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('underline')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Underline"
>
<Underline className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'H3')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Heading"
>
<Heading2 className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-white/10 mx-1" />
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertUnorderedList')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Bullet List"
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertOrderedList')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Numbered List"
>
<ListOrdered className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Quote Block"
>
<Quote className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertHorizontalRule')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Horizontal Divider"
>
<Minus className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={handleClearFormat}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer ml-auto"
title="Clear Formatting"
>
<Eraser className="w-4 h-4 text-slate-400" />
</button>
</div>
{/* Rich ContentEditable Area */}
<div
ref={editorRef}
contentEditable
className="w-full min-h-[160px] max-h-[300px] p-4 text-xs sm:text-sm text-slate-100 leading-relaxed overflow-y-auto focus:outline-none [scrollbar-width:thin] [scrollbar-color:#334155_transparent] prose prose-invert max-w-none [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5"
data-placeholder="Write your message here..."
tabIndex={0}
/>
</div>
</div>
{/* Modal Actions */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/[0.08]">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isSending}
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
</button>
</div>
</form>
</div>
</div>,
document.body
);
};
@@ -0,0 +1,33 @@
import React from 'react';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
interface BrandLogoProps {
size?: number;
className?: string;
showGlow?: boolean;
animateOnHover?: boolean;
}
export const BrandLogo: React.FC<BrandLogoProps> = ({
size = 32,
className = '',
showGlow = false,
animateOnHover = true,
}) => {
return (
<div
className={`inline-flex items-center justify-center relative ${
animateOnHover ? 'transition-transform duration-300 hover:scale-105' : ''
} ${className}`}
>
{showGlow && (
<div
className="absolute inset-0 rounded-full bg-emerald-500/25 blur-md pointer-events-none"
style={{ width: size, height: size }}
/>
)}
<PfmPartnerLogo size={size} className="relative z-10" />
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More