Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48edfa1cbb | ||
|
|
3e1d8bc1cc | ||
|
|
17a4149a73 | ||
|
|
25e251aa79 | ||
|
|
a731c979bb | ||
|
|
17a99ff084 | ||
|
|
29a11f0e0b | ||
|
|
385c76510b | ||
|
|
631a0b7a9a | ||
|
|
1233f15b91 | ||
|
|
c43e18f184 | ||
|
|
d912c5c6c3 | ||
|
|
20bdd8b209 | ||
|
|
bf384d7e6a | ||
|
|
957d2210a9 | ||
|
|
16fb6b9434 | ||
|
|
7220700bac | ||
|
|
3316fc0275 | ||
|
|
8f58e15f0f | ||
|
|
9ed210613e | ||
|
|
5050c901bc | ||
|
|
46c6ddcc80 | ||
|
|
7071c404f0 |
@@ -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(),
|
||||
|
||||
@@ -116,7 +116,30 @@ 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
|
||||
{
|
||||
// ignore parse failure
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
@@ -131,6 +154,7 @@ var scheduler = await schedulerFactory.GetScheduler();
|
||||
if (scheduler!.Status != SchedulerStatus.Running)
|
||||
await scheduler.Start();
|
||||
|
||||
app.UseCors(CorsPolicy);
|
||||
app.UseHsts();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseRouting();
|
||||
@@ -218,4 +242,4 @@ app.MapGet("/", () => Results.Redirect($"/openapi/{latestVersionGroup}"))
|
||||
if (!app.Environment.IsDevelopment())
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
|
||||
app.Run();
|
||||
app.Run();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"EnableServiceCaching": true,
|
||||
"CorsOrigins": [
|
||||
"https://partner.ifri.khongisa.co.za",
|
||||
"https://console.ifri.khongisa.co.za",
|
||||
"https://localhost:7208"
|
||||
],
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+209
-119
@@ -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(
|
||||
`\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>>({});
|
||||
@@ -57,10 +61,17 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
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(() => {
|
||||
@@ -69,7 +80,7 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
const filtered = activeUsers.filter(u => {
|
||||
if (!term) return true;
|
||||
const email = (u.email || '').toLowerCase();
|
||||
const role = (u.role || '').toLowerCase();
|
||||
const role = String(u.role ?? '').toLowerCase();
|
||||
return email.includes(term) || role.includes(term);
|
||||
});
|
||||
|
||||
@@ -80,7 +91,7 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
const otherUsers: User[] = [];
|
||||
|
||||
filtered.forEach(u => {
|
||||
const r = (u.role || '').toLowerCase();
|
||||
const r = String(u.role ?? '').toLowerCase();
|
||||
if (r.includes('admin') || r.includes('executive') || r.includes('director')) {
|
||||
adminUsers.push(u);
|
||||
} else if (r.includes('programme') || r.includes('project') || r.includes('manager')) {
|
||||
@@ -172,87 +183,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: u.role || 'User',
|
||||
});
|
||||
setIsDropdownOpen(false);
|
||||
} else {
|
||||
onSelectUser({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: formattedName,
|
||||
role: 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">
|
||||
<{singleSelectedUser.email}>
|
||||
</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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -237,16 +237,26 @@ 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({
|
||||
@@ -280,18 +290,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 +310,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 +334,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'
|
||||
}`}
|
||||
>
|
||||
@@ -592,70 +602,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>
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -39,70 +39,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[]>({
|
||||
@@ -131,23 +134,16 @@ 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
|
||||
);
|
||||
}, [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 +157,19 @@ 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)));
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Mutation to mark notification as read
|
||||
const markAsReadMutation = useMutation({
|
||||
@@ -206,27 +201,61 @@ 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) => {
|
||||
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;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
// Restore from trash
|
||||
const restoreMessage = useCallback((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;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
// 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 +266,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 +333,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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -214,6 +214,11 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
return dashboard?.disbursed || 0;
|
||||
}, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]);
|
||||
|
||||
const disbursedPercentage = useMemo(() => {
|
||||
if (!portfolioValue || portfolioValue <= 0) return 0;
|
||||
return Math.min(100, Math.round((disbursed / portfolioValue) * 1000) / 10);
|
||||
}, [disbursed, portfolioValue]);
|
||||
|
||||
const onTrackPercentage = useMemo(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return 0;
|
||||
@@ -341,7 +346,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
}, [disbursements]);
|
||||
|
||||
// Projects requiring management attention based on risk profile
|
||||
const attentionProjects = useMemo(() => {
|
||||
const attentionProjects = useMemo<AttentionProject[]>(() => {
|
||||
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
|
||||
if (targetProjects.length === 0) return [];
|
||||
|
||||
@@ -356,7 +361,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
});
|
||||
|
||||
return targetProjects
|
||||
.map((p) => {
|
||||
.map((p): AttentionProject => {
|
||||
const maxScore = projectMaxRisk[String(p.id)] || 0;
|
||||
const isRejected = p.status === ApprovalStatus.Rejected;
|
||||
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
|
||||
@@ -393,7 +398,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
name: p.name || 'Unnamed Project',
|
||||
programme: p.programmeName || `Programme ${p.programmeId}`,
|
||||
status: statusLabel,
|
||||
risk: riskLevel,
|
||||
@@ -568,187 +573,328 @@ export const ExecutiveDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 8 Headline Portfolio KPI Cards with Compliance & Budget At Risk */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 xl:grid-cols-8 gap-3.5">
|
||||
{/* Card 1: Portfolio Value */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span>
|
||||
<HelpTooltip
|
||||
title="Total Portfolio Value"
|
||||
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-110 transition">
|
||||
<Coins className="w-3.5 h-3.5" />
|
||||
{/* Executive Portfolio KPIs: Structured 2-Tier Balanced Grid */}
|
||||
<div className="space-y-6">
|
||||
{/* Tier 1: Capital Commitments & Deployment Scale */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
|
||||
Capital Deployment & Scale
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||||
<span className="text-xs text-slate-500 hidden sm:inline">Portfolio commitments, disbursements & capital exposure</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-blue-400/90 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/20">
|
||||
Financial Governance
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{formatCompactZAR(portfolioValue)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(portfolioValue)}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 1: Portfolio Value */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Portfolio Value
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Total Portfolio Value"
|
||||
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-105 transition-transform">
|
||||
<Coins className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{formatCompactZAR(portfolioValue)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(portfolioValue)}>
|
||||
{formatZAR(portfolioValue)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20 shrink-0">
|
||||
Statutory Total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Disbursed */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Disbursed
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Disbursed Funds"
|
||||
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-105 transition-transform">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-teal-400 tracking-tight">
|
||||
{formatCompactZAR(disbursed)}
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-teal-400 bg-teal-500/10 px-2 py-0.5 rounded-full border border-teal-500/20 shrink-0 font-mono">
|
||||
{disbursedPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-teal-500 to-emerald-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${disbursedPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Tranches released</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">{disbursedPercentage}% deployed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Active Awards */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Active Awards
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Active Grant Awards"
|
||||
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-105 transition-transform">
|
||||
<Layers className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{activeAwards}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">Under implementation</span>
|
||||
<span className="font-medium text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 shrink-0 text-[10px]">
|
||||
{portfolios.length} Portfolio{portfolios.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Budget At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-rose-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Budget At Risk
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Total Budgeted Capital At Risk"
|
||||
content="Aggregated approved budget across all projects currently flagged as noncompliant or carrying unmitigated high risks."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-rose-500/10 text-rose-400 border border-rose-500/20 group-hover:scale-105 transition-transform">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-rose-400 tracking-tight">
|
||||
{formatCompactZAR(totalBudgetAtRisk)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 font-mono text-[11px] truncate mr-2" title={formatZAR(totalBudgetAtRisk)}>
|
||||
{formatZAR(totalBudgetAtRisk)}
|
||||
</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
|
||||
totalBudgetAtRisk > 0
|
||||
? 'text-rose-400 bg-rose-500/10 border-rose-500/20'
|
||||
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
||||
}`}>
|
||||
{totalBudgetAtRisk > 0 ? 'Exposed' : 'Clear'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Active Awards */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Active Awards</span>
|
||||
<HelpTooltip
|
||||
title="Active Grant Awards"
|
||||
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
{/* Tier 2: Operational Health, Risk & Beneficiary Impact */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
|
||||
Operational Performance & Impact
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-600" />
|
||||
<span className="text-xs text-slate-500 hidden sm:inline">Milestone pace, compliance gate health & direct beneficiaries</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-emerald-400/90 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
|
||||
Delivery Health
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">{activeAwards}</p>
|
||||
<p className="text-[10px] text-emerald-400 font-mono mt-1">
|
||||
Across {portfolios.length} portfolio{portfolios.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Disbursed */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span>
|
||||
<HelpTooltip
|
||||
title="Disbursed Funds"
|
||||
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 5: On Track */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
On Track
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="On-Track Progress Ratio"
|
||||
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
|
||||
{onTrackPercentage}%
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
|
||||
{onTrackPercentage >= 80 ? 'Optimal' : onTrackPercentage >= 60 ? 'Moderate' : 'Critical'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-teal-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, onTrackPercentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Milestones verified</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">Pace index</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-110 transition">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-teal-400 tracking-tight">{formatCompactZAR(disbursed)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">
|
||||
{portfolioValue > 0 ? `${((disbursed / portfolioValue) * 100).toFixed(1)}% of commitments` : '0.0% of commitments'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: On Track */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span>
|
||||
<HelpTooltip
|
||||
title="On-Track Progress Ratio"
|
||||
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
{/* Card 6: Compliance Health */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Compliance
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Portfolio Compliance Health"
|
||||
content="Percentage of active projects meeting 100% statutory compliance and beneficiary outcome criteria."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-105 transition-transform">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-emerald-400 tracking-tight">
|
||||
{complianceRate}%
|
||||
</p>
|
||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 shrink-0 font-mono">
|
||||
{complianceRate >= 90 ? 'Passed' : 'Gated'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800/90 rounded-full overflow-hidden mt-3">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-cyan-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, complianceRate)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2.5 pt-2 border-t border-slate-800/70 text-xs text-slate-400">
|
||||
<span className="text-[11px]">Statutory gate check</span>
|
||||
<span className="font-mono text-slate-300 text-[11px]">100% requirement</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{onTrackPercentage}%</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Milestones verified</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">At Risk</span>
|
||||
<HelpTooltip
|
||||
title="At-Risk Projects"
|
||||
content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
{/* Card 7: At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
At Risk
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="At-Risk Projects"
|
||||
content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-105 transition-transform">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-amber-400 tracking-tight">
|
||||
{atRiskCount}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">{atRiskCount === 1 ? 'Project flagged' : 'Projects flagged'}</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border shrink-0 ${
|
||||
atRiskCount > 0
|
||||
? 'text-amber-400 bg-amber-500/10 border-amber-500/20'
|
||||
: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
|
||||
}`}>
|
||||
{atRiskCount > 0 ? 'Requires PM Action' : 'Nominal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-amber-400 tracking-tight">{atRiskCount}</p>
|
||||
<p className="text-[10px] text-rose-400 font-mono mt-1">Requires PM action</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 6: Beneficiaries Reached */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
|
||||
<HelpTooltip
|
||||
title="Beneficiaries Impacted"
|
||||
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
{/* Card 8: Beneficiaries Reached */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-purple-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 truncate">
|
||||
Beneficiaries
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Beneficiaries Impacted"
|
||||
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20 group-hover:scale-105 transition-transform">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(1)}k` : beneficiariesReached}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/70 text-xs">
|
||||
<span className="text-slate-400 text-[11px]">Directly impacted</span>
|
||||
<span className="text-[10px] font-medium text-purple-400 bg-purple-500/10 px-1.5 py-0.5 rounded border border-purple-500/20 shrink-0">
|
||||
M&E Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-white tracking-tight">
|
||||
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(0)}k` : beneficiariesReached}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">Directly impacted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 7: Budget At Risk */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-rose-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Budget At Risk</span>
|
||||
<HelpTooltip
|
||||
title="Total Budgeted Capital At Risk"
|
||||
content="Aggregated approved budget across all projects currently flagged as noncompliant or carrying unmitigated high risks."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-rose-500/10 text-rose-400 border border-rose-500/20 group-hover:scale-110 transition">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-rose-400 tracking-tight">{formatCompactZAR(totalBudgetAtRisk)}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(totalBudgetAtRisk)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 8: Compliance Health */}
|
||||
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/40 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Compliance</span>
|
||||
<HelpTooltip
|
||||
title="Portfolio Compliance Health"
|
||||
content="Percentage of active projects meeting 100% statutory compliance and beneficiary outcome criteria."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{complianceRate}%</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1">100% statutory gate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
assignUserRole,
|
||||
getSystemRoles,
|
||||
getCurrentUserTenantId,
|
||||
notifyNewUserCreated,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
@@ -251,7 +252,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]);
|
||||
|
||||
@@ -274,9 +275,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 +335,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('');
|
||||
@@ -644,7 +658,7 @@ export const GovernancePage: React.FC = () => {
|
||||
programmeId: editingProgramme.id,
|
||||
name: data.name,
|
||||
portfolioId: Number(data.portfolioId),
|
||||
status: data.status !== undefined ? Number(data.status) : undefined,
|
||||
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
|
||||
ownerId: resolvedOwnerId,
|
||||
});
|
||||
}
|
||||
@@ -861,7 +875,7 @@ 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 },
|
||||
@@ -1253,8 +1267,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>
|
||||
|
||||
@@ -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,33 @@ 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),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
allMailboxNotifications,
|
||||
inboxNotifications,
|
||||
@@ -60,8 +68,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 +87,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 +104,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 +123,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(() => {
|
||||
@@ -173,15 +174,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 +226,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 +262,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 +314,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 +454,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 +488,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 +508,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 +572,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 +621,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 +649,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"
|
||||
@@ -707,7 +701,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 +719,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 +809,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 +839,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 +871,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 +889,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 +933,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,7 +59,13 @@ import {
|
||||
getContracts,
|
||||
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';
|
||||
@@ -238,6 +244,31 @@ export const SettingsPage: React.FC = () => {
|
||||
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
||||
const [inviteMessage, setInviteMessage] = useState('');
|
||||
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
||||
const [inviteAutoApprove, setInviteAutoApprove] = useState<boolean>(true);
|
||||
|
||||
// Approved invites tracking state
|
||||
const currentTenantId = getCurrentUserTenantId();
|
||||
const [approvedInviteIds, setApprovedInviteIds] = useState<string[]>(() =>
|
||||
getApprovedInviteIds(currentTenantId)
|
||||
);
|
||||
|
||||
const handleApproveInvite = async (inv: Invite, idx: number) => {
|
||||
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
|
||||
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
|
||||
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
|
||||
|
||||
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/).'
|
||||
);
|
||||
};
|
||||
|
||||
// Invites filtering & search
|
||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||
@@ -282,12 +313,33 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
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('');
|
||||
|
||||
if (inviteAutoApprove) {
|
||||
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
|
||||
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,
|
||||
message: variables.message,
|
||||
},
|
||||
}).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}).catch(console.warn);
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError('Dispatch Error', err);
|
||||
@@ -1125,7 +1177,7 @@ 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">
|
||||
@@ -1140,46 +1192,90 @@ export const SettingsPage: React.FC = () => {
|
||||
<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={7} 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={7} className="py-8 text-center text-slate-500">
|
||||
No invitations recorded yet. Use “Dispatch Invitation” 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} • 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));
|
||||
|
||||
return (
|
||||
<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} • Award #{inv.awardId}
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -2559,6 +2655,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 & 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"
|
||||
|
||||
@@ -962,6 +962,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 +971,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 +997,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 +1011,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 +1055,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 +1077,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 +1092,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 & 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 +1142,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>
|
||||
|
||||
@@ -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,6 +253,7 @@ 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,
|
||||
@@ -247,8 +262,16 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
|
||||
idNumber: payload.idNumber,
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -622,6 +645,14 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
||||
if (existing) {
|
||||
saveStoredInstruction(updatedInstrument);
|
||||
}
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Funding Instrument',
|
||||
recordId: targetId!,
|
||||
newStatus: payload.status,
|
||||
title: payload.name,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
return updatedInstrument;
|
||||
};
|
||||
|
||||
@@ -667,12 +698,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 +765,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 +873,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 +939,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 +1080,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 +1148,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 +1193,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);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1398,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);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
@@ -1689,6 +1778,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 +1951,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 +2020,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 +2088,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 +2163,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,6 +2236,14 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
|
||||
status: Number(payload.status),
|
||||
visitedAt: visitedAtIso,
|
||||
});
|
||||
if (isApprovedOrActiveStatus(payload.status)) {
|
||||
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||
recordType: 'Site Visit',
|
||||
recordId: payload.siteVisitId,
|
||||
newStatus: payload.status,
|
||||
title: payload.inspectorName,
|
||||
}).catch(console.warn);
|
||||
}
|
||||
};
|
||||
|
||||
const LOCAL_RULES_KEY = 'pfm_local_rules';
|
||||
|
||||
@@ -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,17 @@ 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
|
||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
export function clearTenantLockoutCache(): void {
|
||||
cachedTenantLockout = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +37,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,38 +59,36 @@ 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();
|
||||
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
|
||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
||||
if (
|
||||
cachedTenantLockout.result.isActive &&
|
||||
@@ -109,32 +101,76 @@ export async function verifyTenantLockout(
|
||||
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 +181,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 +193,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,7 +203,8 @@ export async function verifyTenantLockout(
|
||||
};
|
||||
}
|
||||
|
||||
// Success: Response object returned and Status is Active
|
||||
setActivationPending(false);
|
||||
|
||||
const successResult: TenantLockoutResult = {
|
||||
isActive: true,
|
||||
tenant: tenantObj,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+2519
File diff suppressed because it is too large
Load Diff
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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()
|
||||
|
||||
@@ -26,22 +26,20 @@ 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:-}}}}}"
|
||||
# 6. Hasher Settings
|
||||
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
||||
|
||||
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 use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here.
|
||||
cat <<EOF > "$TARGET_DIR/env-config.js"
|
||||
// Generated dynamically at container startup from deployment environment variables
|
||||
// Note: Secrets such as client secrets, private keys, or hashing salts must NEVER be exposed here.
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
API_BASE_URL: "${API_BASE_URL}",
|
||||
VITE_API_BASE_URL: "${API_BASE_URL}",
|
||||
@@ -51,12 +49,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 +60,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 +78,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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -138,9 +138,9 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
#{hashedProjectId}
|
||||
</span>
|
||||
)}
|
||||
{project.code && (
|
||||
{(project as any).code && (
|
||||
<span className="font-mono text-xs text-slate-300 bg-white/5 border border-white/10 px-2 py-0.5 rounded-md">
|
||||
{project.code}
|
||||
{(project as any).code}
|
||||
</span>
|
||||
)}
|
||||
{renderStatusBadge(project.status, 'In Progress')}
|
||||
@@ -162,7 +162,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
<div className="bg-slate-950/60 border border-white/10 px-4 py-2 rounded-2xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-wider font-semibold">Total Budget</div>
|
||||
<div className="text-sm font-bold text-emerald-300 font-mono">
|
||||
{formatCurrency(project.budget || totalAwardsAmount)}
|
||||
{formatCurrency((project as any).budget || totalAwardsAmount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,7 +390,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span className="text-slate-200 font-medium truncate">
|
||||
{ev.title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
|
||||
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
|
||||
</span>
|
||||
</div>
|
||||
{ev.documentUrl && (
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Code2,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { NotificationPlatform } from '../../types';
|
||||
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
|
||||
|
||||
export interface MessageViewerProps {
|
||||
content: string;
|
||||
platform?: NotificationPlatform | number | string;
|
||||
subject?: string;
|
||||
className?: string;
|
||||
showFormatHeader?: boolean;
|
||||
maxHeightClass?: string;
|
||||
}
|
||||
|
||||
export const MessageViewer: React.FC<MessageViewerProps> = ({
|
||||
content,
|
||||
platform,
|
||||
subject,
|
||||
className = '',
|
||||
showFormatHeader = true,
|
||||
maxHeightClass = 'max-h-[60vh]',
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
// Determine whether this message represents an Email (HTML) or In-App (Markup)
|
||||
const isEmail = useMemo(() => {
|
||||
if (
|
||||
platform === NotificationPlatform.Email ||
|
||||
platform === 1 ||
|
||||
platform === '1' ||
|
||||
String(platform).toLowerCase() === 'email'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// If platform is not explicitly InApp, detect if content is structured HTML
|
||||
if (
|
||||
platform !== NotificationPlatform.InApp &&
|
||||
platform !== 5 &&
|
||||
platform !== '5' &&
|
||||
String(platform).toLowerCase() !== 'inapp' &&
|
||||
isHtmlContent(content)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [platform, content]);
|
||||
|
||||
const isInApp = useMemo(() => {
|
||||
if (
|
||||
platform === NotificationPlatform.InApp ||
|
||||
platform === 5 ||
|
||||
platform === '5' ||
|
||||
String(platform).toLowerCase() === 'inapp'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !isEmail;
|
||||
}, [platform, isEmail]);
|
||||
|
||||
// Convert the message to its respective format:
|
||||
// - Email: HTML rendered
|
||||
// - In-App: Markup (Markdown) parsed to HTML
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!content) return '<p class="text-slate-400 italic">No message content</p>';
|
||||
|
||||
// Format 1: Email => HTML
|
||||
if (isEmail) {
|
||||
if (isHtmlContent(content)) {
|
||||
return content.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
}
|
||||
return parseMarkdownToHtml(content);
|
||||
}
|
||||
|
||||
// Format 2: In-App => Markup (Markdown)
|
||||
return parseMarkdownToHtml(content);
|
||||
}, [content, isEmail]);
|
||||
|
||||
const handleCopy = () => {
|
||||
const textToCopy = showRaw ? content : stripMarkdown(content) || content;
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/50 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
|
||||
{/* Format Header & Controls Bar */}
|
||||
{showFormatHeader && (
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isEmail ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-300 font-semibold text-[11px]">
|
||||
<Mail className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Email (HTML)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-300 font-semibold text-[11px]">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>In-App (Markup)</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[11px] text-slate-400 hidden sm:inline">
|
||||
{isEmail ? 'Rendered as HTML Email' : 'Converted from Markup to Formatted View'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* View Mode Toggle: Formatted vs Raw */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
||||
title={showRaw ? 'Switch to Formatted view' : 'View Raw message content'}
|
||||
>
|
||||
{showRaw ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-emerald-400" />
|
||||
<span>Formatted</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code2 className="w-3 h-3" />
|
||||
<span>Raw</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
|
||||
title="Copy message content"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-emerald-300">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Body Content - with explicit max height, max width, word wrapping, and automatic scrollbars */}
|
||||
<div className={`w-full max-w-full p-4 sm:p-6 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}>
|
||||
{showRaw ? (
|
||||
<pre className="font-mono text-xs text-slate-300 whitespace-pre-wrap break-words [word-break:break-word] bg-[#080d18] p-4 rounded-xl border border-white/5 max-w-full overflow-x-auto [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* Footer Info Stamp */}
|
||||
<div className="px-5 py-2.5 border-t border-white/[0.08] bg-[#090e1a] flex items-center justify-between text-[11px] text-slate-400 shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>PFM Communications • {isEmail ? 'Email HTML Format' : 'In-App Markup Format'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
{stripMarkdown(content).length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Info,
|
||||
X,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { parseFriendlyError, ErrorCategory } from '../../utils/apiErrorParser';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration: number; // in milliseconds
|
||||
createdAt: number;
|
||||
statusCode?: number;
|
||||
category?: ErrorCategory;
|
||||
categoryLabel?: string;
|
||||
technicalDetails?: string;
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
errorsList?: string[];
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
duration?: number;
|
||||
statusCode?: number;
|
||||
category?: ErrorCategory;
|
||||
categoryLabel?: string;
|
||||
technicalDetails?: string;
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
errorsList?: string[];
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => void;
|
||||
success: (title: string, message?: string, options?: ToastOptions) => void;
|
||||
error: (titleOrError: unknown, messageOrError?: unknown, options?: ToastOptions) => void;
|
||||
info: (title: string, message?: string, options?: ToastOptions) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
// Default durations (in ms):
|
||||
// Errors linger significantly longer (14s) so users have sufficient time to read and note the failure.
|
||||
const DEFAULT_DURATIONS: Record<ToastType, number> = {
|
||||
success: 4500,
|
||||
info: 6000,
|
||||
error: 14000,
|
||||
};
|
||||
|
||||
const ToastCard: React.FC<{
|
||||
toast: ToastItem;
|
||||
onRemove: (id: string) => void;
|
||||
}> = ({ toast, onRemove }) => {
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [remainingTime, setRemainingTime] = useState(toast.duration);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showTechnical, setShowTechnical] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const step = 100;
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemainingTime((prev) => {
|
||||
if (prev <= step) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
onRemove(toast.id);
|
||||
return 0;
|
||||
}
|
||||
return prev - step;
|
||||
});
|
||||
}, step);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isPaused, toast.id, onRemove]);
|
||||
|
||||
const handleCopy = () => {
|
||||
const textToCopy = [
|
||||
`PFM Partner Portal Notice: ${toast.title}`,
|
||||
toast.message ? `Details: ${toast.message}` : '',
|
||||
toast.statusCode ? `Status Code: ${toast.statusCode}` : '',
|
||||
toast.technicalDetails ? `Technical Info: ${toast.technicalDetails}` : '',
|
||||
`Timestamp: ${new Date(toast.createdAt).toISOString()}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const progressPercent = Math.max(0, Math.min(100, (remainingTime / toast.duration) * 100));
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
className={`pointer-events-auto relative flex flex-col rounded-2xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#0d1322] backdrop-blur-none ${
|
||||
toast.type === 'success'
|
||||
? 'border-emerald-500/60 text-gray-100 shadow-emerald-950/40 ring-1 ring-emerald-500/20'
|
||||
: toast.type === 'error'
|
||||
? 'border-rose-500/70 text-gray-100 shadow-rose-950/60 ring-1 ring-rose-500/30'
|
||||
: 'border-teal-500/60 text-gray-100 shadow-teal-950/40 ring-1 ring-teal-500/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
{toast.type === 'success' && <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />}
|
||||
{toast.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />}
|
||||
{toast.type === 'info' && <Info className="w-5 h-5 text-teal-400 shrink-0 mt-0.5" />}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<h4 className="text-sm font-semibold text-white tracking-tight leading-snug">{toast.title}</h4>
|
||||
{toast.categoryLabel && (
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${
|
||||
toast.category === 'validation'
|
||||
? 'bg-amber-950/60 text-amber-300 border-amber-800/60'
|
||||
: toast.category === 'creation'
|
||||
? 'bg-emerald-950/60 text-emerald-300 border-emerald-800/60'
|
||||
: toast.category === 'update'
|
||||
? 'bg-teal-950/60 text-teal-300 border-teal-800/60'
|
||||
: toast.category === 'security'
|
||||
? 'bg-rose-950/60 text-rose-300 border-rose-800/60'
|
||||
: toast.category === 'notFound'
|
||||
? 'bg-slate-800 text-slate-300 border-slate-700'
|
||||
: toast.category === 'conflict'
|
||||
? 'bg-purple-950/60 text-purple-300 border-purple-800/60'
|
||||
: 'bg-slate-800 text-slate-300 border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{toast.categoryLabel}
|
||||
</span>
|
||||
)}
|
||||
{toast.statusCode && (
|
||||
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
|
||||
HTTP {toast.statusCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toast.errorsList && toast.errorsList.length > 0 ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<ul className="space-y-1.5">
|
||||
{toast.errorsList.map((err, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="text-xs text-rose-200/95 flex items-start gap-2 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
|
||||
>
|
||||
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
|
||||
<span className="leading-snug break-words font-medium">{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : toast.message ? (
|
||||
<div className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
|
||||
{toast.message.includes('\n') ? (
|
||||
<ul className="space-y-1.5 mt-1">
|
||||
{toast.message
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex items-start gap-2 text-rose-200/95 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
|
||||
>
|
||||
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
|
||||
<span className="leading-snug break-words font-medium">{line.replace(/^[•\-*]\s*/, '')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{toast.technicalDetails && (
|
||||
<div className="mt-2.5 pt-2 border-t border-slate-800/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTechnical(!showTechnical)}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
>
|
||||
<span>{showTechnical ? 'Hide Technical Reference' : 'Show Technical Reference'}</span>
|
||||
{showTechnical ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{showTechnical && (
|
||||
<div className="mt-1.5 p-2 rounded bg-[#090d16] border border-slate-800 font-mono text-[10px] text-slate-400 break-all select-all">
|
||||
{toast.technicalDetails}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast.type === 'error' && (
|
||||
<div className="mt-3 flex items-center justify-between gap-2 pt-2 border-t border-slate-800/60 text-[11px] text-slate-400">
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{isPaused ? 'Timer paused (hovering)' : 'Notification lingers for review'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors cursor-pointer"
|
||||
title="Copy details to clipboard for support or administrator"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||||
<span>{copied ? 'Copied' : 'Copy Details'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
|
||||
title="Dismiss notification"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-slate-800/40 h-1 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-100 ease-linear ${
|
||||
toast.type === 'success'
|
||||
? 'bg-emerald-500'
|
||||
: toast.type === 'error'
|
||||
? 'bg-rose-500'
|
||||
: 'bg-teal-500'
|
||||
}`}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const addToast = (
|
||||
type: ToastType,
|
||||
title: string,
|
||||
message?: string,
|
||||
options?: ToastOptions
|
||||
) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const duration = options?.duration || DEFAULT_DURATIONS[type];
|
||||
|
||||
setToasts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
duration,
|
||||
createdAt: Date.now(),
|
||||
statusCode: options?.statusCode,
|
||||
category: options?.category,
|
||||
categoryLabel: options?.categoryLabel,
|
||||
technicalDetails: options?.technicalDetails,
|
||||
fieldErrors: options?.fieldErrors,
|
||||
errorsList: options?.errorsList,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const success = (title: string, message?: string, options?: ToastOptions) =>
|
||||
addToast('success', title, message, options);
|
||||
|
||||
const info = (title: string, message?: string, options?: ToastOptions) =>
|
||||
addToast('info', title, message, options);
|
||||
|
||||
const error = (
|
||||
titleOrError: unknown,
|
||||
messageOrError?: unknown,
|
||||
options?: ToastOptions
|
||||
) => {
|
||||
let rawError: unknown = null;
|
||||
let actionTitle = 'Action Failed';
|
||||
|
||||
if (typeof titleOrError === 'string') {
|
||||
actionTitle = titleOrError;
|
||||
rawError = messageOrError;
|
||||
} else {
|
||||
rawError = titleOrError;
|
||||
if (typeof messageOrError === 'string') {
|
||||
actionTitle = messageOrError;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseFriendlyError(rawError, actionTitle);
|
||||
|
||||
let finalMessage = parsed.message;
|
||||
if (
|
||||
typeof messageOrError === 'string' &&
|
||||
messageOrError.trim() &&
|
||||
!messageOrError.includes('Request failed with status code') &&
|
||||
(!parsed.hasReturnObject || !parsed.errorsList?.length)
|
||||
) {
|
||||
finalMessage = messageOrError.trim();
|
||||
}
|
||||
|
||||
let finalErrorsList = parsed.errorsList || options?.errorsList;
|
||||
if (!finalErrorsList && typeof finalMessage === 'string' && finalMessage.includes(',')) {
|
||||
const parts = finalMessage.split(',').map((p) => p.trim()).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
finalErrorsList = parts;
|
||||
}
|
||||
}
|
||||
|
||||
addToast('error', parsed.title, finalMessage, {
|
||||
duration: options?.duration || DEFAULT_DURATIONS.error,
|
||||
statusCode: parsed.statusCode || options?.statusCode,
|
||||
category: options?.category || parsed.category,
|
||||
categoryLabel: options?.categoryLabel || parsed.categoryLabel,
|
||||
technicalDetails: parsed.technicalDetails || options?.technicalDetails,
|
||||
fieldErrors: parsed.fieldErrors || options?.fieldErrors,
|
||||
errorsList: finalErrorsList,
|
||||
});
|
||||
};
|
||||
|
||||
const toastRegion = (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="System Notifications"
|
||||
className="fixed bottom-5 right-5 z-[999999] flex flex-col gap-3 max-w-md sm:max-w-lg w-full pointer-events-none p-2"
|
||||
style={{ zIndex: 999999 }}
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
|
||||
{children}
|
||||
{typeof document !== 'undefined' ? createPortal(toastRegion, document.body) : null}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { updateHasherSettings } from './services/toolsService';
|
||||
import { updateHasherSettings as updateToolsHasherSettings } from './services/toolsService';
|
||||
import { updateHasherSettings as updateUtilsHasherSettings } from './utils/hashids';
|
||||
|
||||
export interface SecuritySettings {
|
||||
Authority: string;
|
||||
Audience: string;
|
||||
ClientId: string;
|
||||
ClientSecret: string;
|
||||
ClientSecret?: string;
|
||||
Scope?: string;
|
||||
}
|
||||
|
||||
@@ -13,7 +14,7 @@ export interface ApiSettings {
|
||||
}
|
||||
|
||||
export interface HasherSettings {
|
||||
Salt: string;
|
||||
Salt?: string;
|
||||
MinHashLength: number;
|
||||
}
|
||||
|
||||
@@ -30,8 +31,7 @@ export interface AppConfig {
|
||||
export const defaultSecuritySettings: SecuritySettings = {
|
||||
Authority: 'https://sts.security.khongisa.co.za',
|
||||
Audience: 'pfm-api-dev',
|
||||
ClientId: 'pfm-scalar-dev',
|
||||
ClientSecret: 'secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27',
|
||||
ClientId: 'pfm-console-dev',
|
||||
Scope: 'openid profile roles email address pfm-api-dev',
|
||||
};
|
||||
|
||||
@@ -40,15 +40,22 @@ export const defaultApiSettings: ApiSettings = {
|
||||
};
|
||||
|
||||
export const defaultHasherSettings: HasherSettings = {
|
||||
Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
Salt: '',
|
||||
MinHashLength: 11,
|
||||
};
|
||||
|
||||
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
|
||||
updateToolsHasherSettings(salt, minHashLength);
|
||||
updateUtilsHasherSettings(salt, minHashLength);
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(...candidates: any[]): string | undefined {
|
||||
for (const c of candidates) {
|
||||
if (c !== undefined && c !== null) {
|
||||
const s = String(c).trim();
|
||||
if (s !== '' && s !== 'undefined' && s !== 'null') return s;
|
||||
if (s !== '' && s !== 'undefined' && s !== 'null') {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -56,13 +63,17 @@ function pickFirstNonEmpty(...candidates: any[]): string | undefined {
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
let trimmed = url.trim();
|
||||
if (!/^https?:\/\//i.test(trimmed)) trimmed = `https://${trimmed}`;
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
trimmed = `https://${trimmed}`;
|
||||
}
|
||||
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
}
|
||||
|
||||
function normalizeAuthorityUrl(url: string): string {
|
||||
let trimmed = url.trim();
|
||||
if (!/^https?:\/\//i.test(trimmed)) trimmed = `https://${trimmed}`;
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
trimmed = `https://${trimmed}`;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
@@ -72,44 +83,177 @@ function getWindowRuntimeConfig(): Record<string, any> {
|
||||
return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {};
|
||||
}
|
||||
|
||||
function extractSourceConfig(src: Record<string, any>) {
|
||||
function extractSourceConfig(src: Record<string, any>): {
|
||||
authority?: string;
|
||||
audience?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scope?: string;
|
||||
baseUrl?: string;
|
||||
hasherSalt?: string;
|
||||
hasherMinLength?: number;
|
||||
} {
|
||||
if (!src || typeof src !== 'object') return {};
|
||||
|
||||
const sec = src.SecuritySettings || src.securitySettings || {};
|
||||
const api = src.ApiSettings || src.apiSettings || {};
|
||||
const hasher = src.HasherSettings || src.hasherSettings || {};
|
||||
|
||||
const minLenRaw = pickFirstNonEmpty(
|
||||
src.VITE_HASHER_MIN_LENGTH,
|
||||
src.HASHER_MIN_LENGTH,
|
||||
src.HasherSettings__MinHashLength,
|
||||
hasher.MinHashLength,
|
||||
hasher.minHashLength,
|
||||
src.MinHashLength,
|
||||
src.minHashLength
|
||||
);
|
||||
const parsedMinLen = minLenRaw !== undefined ? Number(minLenRaw) : undefined;
|
||||
|
||||
return {
|
||||
authority: pickFirstNonEmpty(src.VITE_OIDC_AUTHORITY, src.OIDC_AUTHORITY, src.SecuritySettings?.Authority, src.SecuritySettings__Authority),
|
||||
audience: pickFirstNonEmpty(src.VITE_OIDC_AUDIENCE, src.OIDC_AUDIENCE, src.SecuritySettings?.Audience, src.SecuritySettings__Audience),
|
||||
clientId: pickFirstNonEmpty(src.VITE_OIDC_CLIENT_ID, src.OIDC_CLIENT_ID, src.SecuritySettings?.ClientId, src.SecuritySettings__ClientId),
|
||||
clientSecret: pickFirstNonEmpty(src.VITE_OIDC_CLIENT_SECRET, src.OIDC_CLIENT_SECRET, src.SecuritySettings?.ClientSecret, src.SecuritySettings__ClientSecret),
|
||||
scope: pickFirstNonEmpty(src.VITE_OIDC_SCOPE, src.OIDC_SCOPE, src.SecuritySettings?.Scope, src.SecuritySettings__Scope),
|
||||
baseUrl: pickFirstNonEmpty(src.VITE_API_BASE_URL, src.API_BASE_URL, src.ApiSettings?.BaseUrl, src.ApiSettings__BaseUrl),
|
||||
hasherSalt: pickFirstNonEmpty(src.VITE_HASHER_SALT, src.HASHER_SALT, src.HasherSettings?.Salt, src.HasherSettings__Salt),
|
||||
hasherMinLength: Number(pickFirstNonEmpty(src.VITE_HASHER_MIN_LENGTH, src.HASHER_MIN_LENGTH, src.HasherSettings?.MinHashLength)) || undefined,
|
||||
authority: pickFirstNonEmpty(
|
||||
src.VITE_OIDC_AUTHORITY,
|
||||
src.OIDC_AUTHORITY,
|
||||
src.SecuritySettings__Authority,
|
||||
sec.Authority,
|
||||
sec.authority,
|
||||
src.Authority,
|
||||
src.authority
|
||||
),
|
||||
audience: pickFirstNonEmpty(
|
||||
src.VITE_OIDC_AUDIENCE,
|
||||
src.OIDC_AUDIENCE,
|
||||
src.SecuritySettings__Audience,
|
||||
sec.Audience,
|
||||
sec.audience,
|
||||
src.Audience,
|
||||
src.audience
|
||||
),
|
||||
clientId: pickFirstNonEmpty(
|
||||
src.VITE_OIDC_CLIENT_ID,
|
||||
src.OIDC_CLIENT_ID,
|
||||
src.SecuritySettings__ClientId,
|
||||
sec.ClientId,
|
||||
sec.clientId,
|
||||
src.ClientId,
|
||||
src.clientId
|
||||
),
|
||||
clientSecret: pickFirstNonEmpty(
|
||||
src.VITE_OIDC_CLIENT_SECRET,
|
||||
src.OIDC_CLIENT_SECRET,
|
||||
src.SecuritySettings__ClientSecret,
|
||||
sec.ClientSecret,
|
||||
sec.clientSecret,
|
||||
src.ClientSecret,
|
||||
src.clientSecret
|
||||
),
|
||||
scope: pickFirstNonEmpty(
|
||||
src.VITE_OIDC_SCOPE,
|
||||
src.OIDC_SCOPE,
|
||||
src.SecuritySettings__Scope,
|
||||
sec.Scope,
|
||||
sec.scope,
|
||||
src.Scope,
|
||||
src.scope
|
||||
),
|
||||
baseUrl: pickFirstNonEmpty(
|
||||
src.VITE_API_BASE_URL,
|
||||
src.API_BASE_URL,
|
||||
src.ApiSettings__BaseUrl,
|
||||
api.BaseUrl,
|
||||
api.baseUrl,
|
||||
src.BaseUrl,
|
||||
src.baseUrl
|
||||
),
|
||||
hasherSalt: pickFirstNonEmpty(
|
||||
src.VITE_HASHER_SALT,
|
||||
src.HASHER_SALT,
|
||||
src.HasherSettings__Salt,
|
||||
hasher.Salt,
|
||||
hasher.salt,
|
||||
src.Salt,
|
||||
src.salt
|
||||
),
|
||||
hasherMinLength:
|
||||
parsedMinLen !== undefined && !isNaN(parsedMinLen) && parsedMinLen > 0
|
||||
? parsedMinLen
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
const winCfg = extractSourceConfig(getWindowRuntimeConfig());
|
||||
const fetchCfg = fetchedJson ? extractSourceConfig(fetchedJson) : {};
|
||||
const envCfg = extractSourceConfig((typeof import.meta !== 'undefined' && (import.meta as any).env ? (import.meta as any).env : {}) as Record<string, any>);
|
||||
const envCfg = extractSourceConfig(
|
||||
(typeof import.meta !== 'undefined' && (import.meta as any).env
|
||||
? (import.meta as any).env
|
||||
: {}) as Record<string, any>
|
||||
);
|
||||
|
||||
const hasWin = Object.values(winCfg).some((v) => v !== undefined);
|
||||
const hasFetch = Object.values(fetchCfg).some((v) => v !== undefined);
|
||||
const hasEnv = Object.values(envCfg).some((v) => v !== undefined);
|
||||
|
||||
let source: ConfigSource = 'fallback-defaults';
|
||||
if (Object.values(winCfg).some((v) => v !== undefined)) source = 'runtime-injected';
|
||||
else if (Object.values(fetchCfg).some((v) => v !== undefined)) source = 'runtime-fetched';
|
||||
else if (Object.values(envCfg).some((v) => v !== undefined)) source = 'build-env';
|
||||
if (hasWin) source = 'runtime-injected';
|
||||
else if (hasFetch) source = 'runtime-fetched';
|
||||
else if (hasEnv) source = 'build-env';
|
||||
|
||||
const authority = normalizeAuthorityUrl(
|
||||
pickFirstNonEmpty(winCfg.authority, fetchCfg.authority, envCfg.authority, defaultSecuritySettings.Authority)!
|
||||
);
|
||||
const audience = pickFirstNonEmpty(
|
||||
winCfg.audience,
|
||||
fetchCfg.audience,
|
||||
envCfg.audience,
|
||||
defaultSecuritySettings.Audience
|
||||
)!;
|
||||
const clientId = pickFirstNonEmpty(
|
||||
winCfg.clientId,
|
||||
fetchCfg.clientId,
|
||||
envCfg.clientId,
|
||||
defaultSecuritySettings.ClientId
|
||||
)!;
|
||||
const clientSecret = pickFirstNonEmpty(
|
||||
winCfg.clientSecret,
|
||||
fetchCfg.clientSecret,
|
||||
envCfg.clientSecret
|
||||
);
|
||||
const scope = pickFirstNonEmpty(
|
||||
winCfg.scope,
|
||||
fetchCfg.scope,
|
||||
envCfg.scope,
|
||||
defaultSecuritySettings.Scope
|
||||
);
|
||||
const baseUrl = normalizeBaseUrl(
|
||||
pickFirstNonEmpty(winCfg.baseUrl, fetchCfg.baseUrl, envCfg.baseUrl, defaultApiSettings.BaseUrl)!
|
||||
);
|
||||
const hasherSalt = pickFirstNonEmpty(
|
||||
winCfg.hasherSalt,
|
||||
fetchCfg.hasherSalt,
|
||||
envCfg.hasherSalt,
|
||||
defaultHasherSettings.Salt
|
||||
);
|
||||
const hasherMinLength =
|
||||
winCfg.hasherMinLength ||
|
||||
fetchCfg.hasherMinLength ||
|
||||
envCfg.hasherMinLength ||
|
||||
defaultHasherSettings.MinHashLength;
|
||||
|
||||
return {
|
||||
SecuritySettings: {
|
||||
Authority: normalizeAuthorityUrl(pickFirstNonEmpty(winCfg.authority, fetchCfg.authority, envCfg.authority, defaultSecuritySettings.Authority)!),
|
||||
Audience: pickFirstNonEmpty(winCfg.audience, fetchCfg.audience, envCfg.audience, defaultSecuritySettings.Audience)!,
|
||||
ClientId: pickFirstNonEmpty(winCfg.clientId, fetchCfg.clientId, envCfg.clientId, defaultSecuritySettings.ClientId)!,
|
||||
ClientSecret: pickFirstNonEmpty(winCfg.clientSecret, fetchCfg.clientSecret, envCfg.clientSecret, defaultSecuritySettings.ClientSecret)!,
|
||||
Scope: pickFirstNonEmpty(winCfg.scope, fetchCfg.scope, envCfg.scope, defaultSecuritySettings.Scope),
|
||||
Authority: authority,
|
||||
Audience: audience,
|
||||
ClientId: clientId,
|
||||
...(clientSecret ? { ClientSecret: clientSecret } : {}),
|
||||
Scope: scope,
|
||||
},
|
||||
ApiSettings: {
|
||||
BaseUrl: normalizeBaseUrl(pickFirstNonEmpty(winCfg.baseUrl, fetchCfg.baseUrl, envCfg.baseUrl, defaultApiSettings.BaseUrl)!),
|
||||
BaseUrl: baseUrl,
|
||||
},
|
||||
HasherSettings: {
|
||||
Salt: pickFirstNonEmpty(winCfg.hasherSalt, fetchCfg.hasherSalt, envCfg.hasherSalt, defaultHasherSettings.Salt)!,
|
||||
MinHashLength: winCfg.hasherMinLength || fetchCfg.hasherMinLength || envCfg.hasherMinLength || defaultHasherSettings.MinHashLength,
|
||||
Salt: hasherSalt || '',
|
||||
MinHashLength: hasherMinLength,
|
||||
},
|
||||
ConfigSource: source,
|
||||
LoadedAt: new Date().toISOString(),
|
||||
@@ -117,7 +261,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||
}
|
||||
|
||||
let runtimeConfig: AppConfig = resolveConfig();
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
|
||||
const configListeners: Array<(config: AppConfig) => void> = [];
|
||||
|
||||
@@ -129,28 +273,49 @@ export function onConfigChanged(listener: (config: AppConfig) => void): () => vo
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRuntimeConfig(): Promise<AppConfig> {
|
||||
let fetchedJson: Record<string, any> | undefined;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const res = await fetch(`/config.json?_t=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).catch(() => null);
|
||||
if (res && res.ok) {
|
||||
fetchedJson = await res.json().catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Safe fallback
|
||||
}
|
||||
let loadPromise: Promise<AppConfig> | null = null;
|
||||
|
||||
export async function loadRuntimeConfig(forceReload = false): Promise<AppConfig> {
|
||||
if (loadPromise && !forceReload) {
|
||||
return loadPromise;
|
||||
}
|
||||
runtimeConfig = resolveConfig(fetchedJson);
|
||||
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
configListeners.forEach((fn) => fn(runtimeConfig));
|
||||
return runtimeConfig;
|
||||
|
||||
loadPromise = (async () => {
|
||||
let fetchedJson: Record<string, any> | undefined;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const res = await fetch(`/config.json?_t=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).catch(() => null);
|
||||
|
||||
if (res && res.ok) {
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json') || contentType.includes('text/plain')) {
|
||||
fetchedJson = await res.json().catch(() => undefined);
|
||||
} else {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (text.trim().startsWith('{')) {
|
||||
fetchedJson = JSON.parse(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback if /config.json is unreachable
|
||||
}
|
||||
}
|
||||
|
||||
runtimeConfig = resolveConfig(fetchedJson);
|
||||
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||
configListeners.forEach((fn) => fn(runtimeConfig));
|
||||
return runtimeConfig;
|
||||
})();
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export const getAppConfig = () => runtimeConfig;
|
||||
export const getSecuritySettings = () => runtimeConfig.SecuritySettings;
|
||||
export const getApiBaseUrl = () => runtimeConfig.ApiSettings.BaseUrl;
|
||||
export const getHasherSettings = () => runtimeConfig.HasherSettings;
|
||||
export const getAppConfig = (): AppConfig => runtimeConfig;
|
||||
export const getSecuritySettings = (): SecuritySettings => runtimeConfig.SecuritySettings;
|
||||
export const getApiBaseUrl = (): string => runtimeConfig.ApiSettings.BaseUrl;
|
||||
export const getHasherSettings = (): HasherSettings => runtimeConfig.HasherSettings;
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import {
|
||||
getUserNotifications,
|
||||
getNotifications,
|
||||
createNotification,
|
||||
markNotificationAsRead,
|
||||
getCurrentUserTenantId,
|
||||
} from '../services/api';
|
||||
import {
|
||||
Notification,
|
||||
NotificationPlatform,
|
||||
NotificationStatus,
|
||||
CreateNotificationRequest,
|
||||
} from '../types';
|
||||
|
||||
export interface DraftMessage {
|
||||
id: string;
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
platform?: NotificationPlatform;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useInAppNotifications() {
|
||||
const { user, claims } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const userId = (user?.id || claims?.sub || '') as string;
|
||||
const userEmail = (user?.email || claims?.email || '') as string;
|
||||
|
||||
const draftsStorageKey = `pfm_partner_mailbox_drafts_${userId || 'default'}`;
|
||||
const starredStorageKey = `pfm_partner_mailbox_starred_${userId || 'default'}`;
|
||||
const trashStorageKey = `pfm_partner_mailbox_trash_${userId || 'default'}`;
|
||||
const sentStorageKey = `pfm_partner_mailbox_sent_${userId || 'default'}`;
|
||||
|
||||
const readStorageList = useCallback(<T,>(key: string, fallbackKey?: string): T[] => {
|
||||
try {
|
||||
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 [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [starredIds, setStarredIds] = useState<string[]>(() =>
|
||||
readStorageList<string>(starredStorageKey, 'pfm_partner_mailbox_starred_default')
|
||||
);
|
||||
|
||||
const [trashedIds, setTrashedIds] = useState<string[]>(() =>
|
||||
readStorageList<string>(trashStorageKey, 'pfm_partner_mailbox_trash_default')
|
||||
);
|
||||
|
||||
const [drafts, setDrafts] = useState<DraftMessage[]>(() =>
|
||||
readStorageList<DraftMessage>(draftsStorageKey, 'pfm_partner_mailbox_drafts_default')
|
||||
);
|
||||
|
||||
const [localSent, setLocalSent] = useState<Notification[]>(() =>
|
||||
readStorageList<Notification>(sentStorageKey, 'pfm_partner_mailbox_sent_default')
|
||||
);
|
||||
|
||||
const syncFromStorage = useCallback(() => {
|
||||
setStarredIds(readStorageList<string>(starredStorageKey, 'pfm_partner_mailbox_starred_default'));
|
||||
setTrashedIds(readStorageList<string>(trashStorageKey, 'pfm_partner_mailbox_trash_default'));
|
||||
setDrafts(readStorageList<DraftMessage>(draftsStorageKey, 'pfm_partner_mailbox_drafts_default'));
|
||||
setLocalSent(readStorageList<Notification>(sentStorageKey, 'pfm_partner_mailbox_sent_default'));
|
||||
}, [readStorageList, starredStorageKey, trashStorageKey, draftsStorageKey, sentStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
syncFromStorage();
|
||||
}, [syncFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
||||
queryKey: ['inapp-notifications', userId],
|
||||
queryFn: async () => {
|
||||
let list = await getUserNotifications(userId || undefined);
|
||||
if (!list || list.length === 0) {
|
||||
const orgList = await getNotifications();
|
||||
if (orgList && orgList.length > 0) {
|
||||
list = orgList;
|
||||
}
|
||||
}
|
||||
return list || [];
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
const inAppNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(
|
||||
n => Number(n.platform) === NotificationPlatform.InApp || n.platform === (NotificationPlatform.InApp as any)
|
||||
);
|
||||
}, [rawNotifications]);
|
||||
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
const allMailboxNotifications = inboxNotifications;
|
||||
|
||||
const unreadNotifications = useMemo(() => {
|
||||
return inboxNotifications.filter(
|
||||
n =>
|
||||
Number(n.status) !== NotificationStatus.Read &&
|
||||
Number(n.status) !== NotificationStatus.Cancelled &&
|
||||
Number(n.status) !== NotificationStatus.Failed
|
||||
);
|
||||
}, [inboxNotifications]);
|
||||
|
||||
const unreadCount = unreadNotifications.length;
|
||||
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
||||
|
||||
const starredNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n =>
|
||||
starredIds.includes(String(n.id)) &&
|
||||
!trashedIds.includes(String(n.id))
|
||||
);
|
||||
}, [inAppNotifications, starredIds, trashedIds]);
|
||||
|
||||
const trashedNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: async (id: number | string) => {
|
||||
await markNotificationAsRead(id);
|
||||
},
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['inapp-notifications', userId] });
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === String(id) ? { ...n, status: NotificationStatus.Read } : n))
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
const unread = unreadNotifications;
|
||||
for (const item of unread) {
|
||||
try {
|
||||
await markNotificationAsRead(item.id);
|
||||
} catch {}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}, [unreadNotifications, queryClient]);
|
||||
|
||||
const toggleStar = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
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]);
|
||||
|
||||
const deleteMessage = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
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;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
const restoreMessage = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
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;
|
||||
});
|
||||
}, [trashStorageKey]);
|
||||
|
||||
const permanentDelete = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
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]);
|
||||
|
||||
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
||||
const draftId = draft.id || `draft_${Date.now()}`;
|
||||
const newDraft: DraftMessage = {
|
||||
...draft,
|
||||
id: draftId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setDrafts(prev => {
|
||||
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_partner_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 => {
|
||||
const current = readStorageList<DraftMessage>(draftsStorageKey, 'pfm_partner_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]);
|
||||
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
platform?: NotificationPlatform;
|
||||
organisationId?: number | string;
|
||||
draftId?: string;
|
||||
}) => {
|
||||
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
|
||||
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
||||
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'Partner User'}`;
|
||||
|
||||
const req: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
platform: targetPlatform,
|
||||
subject: payload.subject,
|
||||
destination: formattedDestination,
|
||||
message: payload.message,
|
||||
status: targetPlatform === NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued,
|
||||
};
|
||||
|
||||
await createNotification(req);
|
||||
|
||||
const sentItem: Notification = {
|
||||
id: `sent_${Date.now()}`,
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
platform: targetPlatform,
|
||||
status: NotificationStatus.Sent,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
destination: formattedDestination,
|
||||
createdAt: new Date().toISOString(),
|
||||
sentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLocalSent(prev => {
|
||||
const next = [sentItem, ...prev];
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(next));
|
||||
window.dispatchEvent(new Event('pfm-mailbox-sync'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (payload.draftId) {
|
||||
deleteDraft(payload.draftId);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rawNotifications,
|
||||
inAppNotifications,
|
||||
allMailboxNotifications,
|
||||
inboxNotifications,
|
||||
unreadNotifications,
|
||||
unreadCount,
|
||||
top5Unread,
|
||||
starredNotifications,
|
||||
trashedNotifications,
|
||||
sentNotifications: localSent,
|
||||
drafts,
|
||||
starredIds,
|
||||
trashedIds,
|
||||
isLoading,
|
||||
refetch,
|
||||
markAsRead: (id: string | number) => markAsReadMutation.mutate(id),
|
||||
markAllAsRead,
|
||||
toggleStar,
|
||||
deleteMessage,
|
||||
restoreMessage,
|
||||
permanentDelete,
|
||||
saveDraft,
|
||||
deleteDraft,
|
||||
sendMessage: sendMessageMutation.mutateAsync,
|
||||
isSending: sendMessageMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
html, body {
|
||||
background-color: #020617; /* slate-950 */
|
||||
color: #f8fafc; /* slate-100 */
|
||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
import {
|
||||
ApprovalStatus,
|
||||
ComplianceStatus,
|
||||
ContractStatus,
|
||||
ActivityStatus,
|
||||
Priority,
|
||||
OrganisationType,
|
||||
IndustrySector,
|
||||
ProjectType,
|
||||
UnitOfMeasure,
|
||||
EvidenceType,
|
||||
RiskLikelihood,
|
||||
RiskImpact,
|
||||
NotificationPlatform,
|
||||
NotificationStatus,
|
||||
EmailStatuses,
|
||||
UserRole,
|
||||
} from '../types';
|
||||
|
||||
export const formatZAR = (amount: number): string => {
|
||||
return new Intl.NumberFormat('en-ZA', {
|
||||
style: 'currency',
|
||||
currency: 'ZAR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount || 0);
|
||||
};
|
||||
|
||||
export const formatCompactZAR = (amount: number): string => {
|
||||
const num = Number(amount) || 0;
|
||||
if (num >= 1_000_000_000) {
|
||||
return `R ${(num / 1_000_000_000).toFixed(2)}bn`;
|
||||
}
|
||||
if (num >= 1_000_000) {
|
||||
return `R ${(num / 1_000_000).toFixed(1)}m`;
|
||||
}
|
||||
if (num >= 1_000) {
|
||||
return `R ${(num / 1_000).toFixed(0)}k`;
|
||||
}
|
||||
return formatZAR(num);
|
||||
};
|
||||
|
||||
export const formatDate = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatShortDate = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
export const EMAIL_STATUS_OPTIONS = [
|
||||
{ value: EmailStatuses.GeneralError, label: 'General Error' },
|
||||
{ value: EmailStatuses.AuthenticationError, label: 'Authentication Error' },
|
||||
{ value: EmailStatuses.ProtocolError, label: 'Protocol Error' },
|
||||
{ value: EmailStatuses.Connected, label: 'Connected' },
|
||||
{ value: EmailStatuses.Disconnected, label: 'Disconnected' },
|
||||
{ value: EmailStatuses.TooManyConnections, label: 'Too Many Connections' },
|
||||
{ value: EmailStatuses.ConnectionAborted, label: 'Connection Aborted' },
|
||||
{ value: EmailStatuses.Success, label: 'Success' },
|
||||
];
|
||||
|
||||
export const EMAIL_STATUSES_OPTIONS = EMAIL_STATUS_OPTIONS;
|
||||
|
||||
export const NOTIFICATION_STATUS_OPTIONS = [
|
||||
{ value: NotificationStatus.Pending, label: 'Pending' },
|
||||
{ value: NotificationStatus.Queued, label: 'Queued' },
|
||||
{ value: NotificationStatus.Processing, label: 'Processing' },
|
||||
{ value: NotificationStatus.Sent, label: 'Sent' },
|
||||
{ value: NotificationStatus.Delivered, label: 'Delivered' },
|
||||
{ value: NotificationStatus.Read, label: 'Read' },
|
||||
{ value: NotificationStatus.Failed, label: 'Failed' },
|
||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||
{ value: NotificationPlatform.Email, label: 'Email' },
|
||||
{ value: NotificationPlatform.SMS, label: 'SMS' },
|
||||
{ value: NotificationPlatform.WhatsApp, label: 'WhatsApp' },
|
||||
{ value: NotificationPlatform.PushNotification, label: 'Push Notification' },
|
||||
{ value: NotificationPlatform.InApp, label: 'In App' },
|
||||
{ value: NotificationPlatform.Teams, label: 'Teams' },
|
||||
{ value: NotificationPlatform.Slack, label: 'Slack' },
|
||||
{ value: NotificationPlatform.Webhook, label: 'Webhook' },
|
||||
{ value: NotificationPlatform.Other, label: 'Other' },
|
||||
];
|
||||
|
||||
export const EVIDENCE_TYPE_OPTIONS = [
|
||||
{ value: EvidenceType.AttendanceRegister, label: 'Attendance Register' },
|
||||
{ value: EvidenceType.BankStatement, label: 'Bank Statement' },
|
||||
{ value: EvidenceType.CompletionCertificate, label: 'Completion Certificate' },
|
||||
{ value: EvidenceType.FinancialInvoiceOrReceipt, label: 'Financial Invoice Or Receipt' },
|
||||
{ value: EvidenceType.GeotaggedPhoto, label: 'Geotagged Photo' },
|
||||
{ value: EvidenceType.InspectionReport, label: 'Inspection Report' },
|
||||
{ value: EvidenceType.MeetingMinutes, label: 'Meeting Minutes' },
|
||||
{ value: EvidenceType.PayrollOrStipendRegister, label: 'Payroll Or Stipend Register' },
|
||||
{ value: EvidenceType.ProcurementDocument, label: 'Procurement Document' },
|
||||
{ value: EvidenceType.TechnicalSpecificationOrDesign, label: 'Technical Specification Or Design' },
|
||||
{ value: EvidenceType.VideoRecording, label: 'Video Recording' },
|
||||
{ value: EvidenceType.Other, label: 'Other' },
|
||||
];
|
||||
|
||||
export const PROJECT_TYPE_OPTIONS = [
|
||||
{ value: ProjectType.CapacityBuildingAndTraining, label: 'Capacity Building And Training' },
|
||||
{ value: ProjectType.CapitalExpenditureAndInfrastructure, label: 'Capital Expenditure And Infrastructure' },
|
||||
{ value: ProjectType.CommercializationAndScaling, label: 'Commercialization And Scaling' },
|
||||
{ value: ProjectType.FeasibilityAndTechnicalAssistance, label: 'Feasibility And Technical Assistance' },
|
||||
{ value: ProjectType.OperationalGrantAndSubsidy, label: 'Operational Grant And Subsidy' },
|
||||
{ value: ProjectType.ProductDevelopmentAndPrototyping, label: 'Product Development And Prototyping' },
|
||||
{ value: ProjectType.ResearchAndDevelopment, label: 'Research And Development' },
|
||||
{ value: ProjectType.SocialImpactAndCommunityDevelopment, label: 'Social Impact And Community Development' },
|
||||
{ value: ProjectType.TechnologyTransferAndAdoption, label: 'Technology Transfer And Adoption' },
|
||||
{ value: ProjectType.Innovation, label: 'Innovation' },
|
||||
{ value: ProjectType.Other, label: 'Other' },
|
||||
];
|
||||
|
||||
export const INDUSTRY_SECTOR_OPTIONS = [
|
||||
{ value: IndustrySector.AgricultureAndAgroProcessing, label: 'Agriculture And Agro Processing' },
|
||||
{ value: IndustrySector.AutomotiveAndTransportEquipment, label: 'Automotive And Transport Equipment' },
|
||||
{ value: IndustrySector.ChemicalsAndPharmaceuticals, label: 'Chemicals And Pharmaceuticals' },
|
||||
{ value: IndustrySector.ConstructionAndInfrastructure, label: 'Construction And Infrastructure' },
|
||||
{ value: IndustrySector.CreativeIndustriesAndMedia, label: 'Creative Industries And Media' },
|
||||
{ value: IndustrySector.DefenseAndAerospace, label: 'Defense And Aerospace' },
|
||||
{ value: IndustrySector.EducationAndSkillsDevelopment, label: 'Education And Skills Development' },
|
||||
{ value: IndustrySector.EnergyAndUtilities, label: 'Energy And Utilities' },
|
||||
{ value: IndustrySector.FinancialServices, label: 'Financial Services' },
|
||||
{ value: IndustrySector.HealthcareAndLifeSciences, label: 'Healthcare And Life Sciences' },
|
||||
{ value: IndustrySector.InformationCommunicationTechnology, label: 'Information Communication Technology' },
|
||||
{ value: IndustrySector.ManufacturingAndIndustrial, label: 'Manufacturing And Industrial' },
|
||||
{ value: IndustrySector.MiningAndMetals, label: 'Mining And Metals' },
|
||||
{ value: IndustrySector.RealEstateAndHousing, label: 'Real Estate And Housing' },
|
||||
{ value: IndustrySector.RetailAndConsumerGoods, label: 'Retail And Consumer Goods' },
|
||||
{ value: IndustrySector.TourismAndHospitality, label: 'Tourism And Hospitality' },
|
||||
{ value: IndustrySector.WaterAndSanitation, label: 'Water And Sanitation' },
|
||||
{ value: IndustrySector.Other, label: 'Other' },
|
||||
];
|
||||
|
||||
export const ORGANISATION_TYPE_OPTIONS = [
|
||||
{ value: OrganisationType.NonGovernmentalOrganisation, label: 'Non-Governmental Organisation' },
|
||||
{ value: OrganisationType.CivilSocietyOrganisation, label: 'Civil Society Organisation' },
|
||||
{ value: OrganisationType.Trust, label: 'Trust' },
|
||||
{ value: OrganisationType.FaithBasedOrganisation, label: 'Faith-Based Organisation' },
|
||||
{ value: OrganisationType.PrivateLimitedCompany, label: 'Private Limited Company' },
|
||||
{ value: OrganisationType.PublicLimitedCompany, label: 'Public Limited Company' },
|
||||
{ value: OrganisationType.SoleProprietorship, label: 'Sole Proprietorship' },
|
||||
{ value: OrganisationType.Partnership, label: 'Partnership' },
|
||||
{ value: OrganisationType.SocialEnterprise, label: 'Social Enterprise' },
|
||||
{ value: OrganisationType.NationalGovernmentMinistry, label: 'National Government Ministry' },
|
||||
{ value: OrganisationType.ProvincialGovernmentEntity, label: 'Provincial Government Entity' },
|
||||
{ value: OrganisationType.LocalGovernmentMunicipality, label: 'Local Government Municipality' },
|
||||
{ value: OrganisationType.StateOwnedEnterprise, label: 'State-Owned Enterprise' },
|
||||
{ value: OrganisationType.PublicAgency, label: 'Public Agency' },
|
||||
{ value: OrganisationType.PublicUniversity, label: 'Public University' },
|
||||
{ value: OrganisationType.PrivateUniversity, label: 'Private University' },
|
||||
{ value: OrganisationType.ResearchInstitute, label: 'Research Institute' },
|
||||
{ value: OrganisationType.TechnicalVocationalCollege, label: 'Technical Vocational College' },
|
||||
{ value: OrganisationType.Cooperative, label: 'Cooperative' },
|
||||
{ value: OrganisationType.MicrofinanceInstitution, label: 'Microfinance Institution' },
|
||||
{ value: OrganisationType.CommercialBank, label: 'Commercial Bank' },
|
||||
{ value: OrganisationType.DevelopmentFinanceInstitution, label: 'Development Finance Institution' },
|
||||
{ value: OrganisationType.UnitedNationsAgency, label: 'United Nations Agency' },
|
||||
{ value: OrganisationType.IntergovernmentalOrganisation, label: 'Intergovernmental Organisation' },
|
||||
{ value: OrganisationType.InternationalNGO, label: 'International NGO' },
|
||||
{ value: OrganisationType.Consortium, label: 'Consortium' },
|
||||
{ value: OrganisationType.SpecialPurposeVehicle, label: 'Special Purpose Vehicle' },
|
||||
];
|
||||
|
||||
export const PRIORITY_OPTIONS = [
|
||||
{ value: Priority.Low, label: 'Low' },
|
||||
{ value: Priority.Medium, label: 'Medium' },
|
||||
{ value: Priority.High, label: 'High' },
|
||||
{ value: Priority.Critical, label: 'Critical' },
|
||||
];
|
||||
|
||||
export const UNIT_OF_MEASURE_OPTIONS = [
|
||||
{ value: UnitOfMeasure.Count, label: 'Count' },
|
||||
{ value: UnitOfMeasure.Percentage, label: 'Percentage' },
|
||||
{ value: UnitOfMeasure.Currency, label: 'Currency' },
|
||||
{ value: UnitOfMeasure.Area, label: 'Area' },
|
||||
{ value: UnitOfMeasure.Weight, label: 'Weight' },
|
||||
{ value: UnitOfMeasure.Volume, label: 'Volume' },
|
||||
{ value: UnitOfMeasure.DurationDays, label: 'Duration (Days)' },
|
||||
{ value: UnitOfMeasure.DurationMonths, label: 'Duration (Months)' },
|
||||
{ value: UnitOfMeasure.Ratio, label: 'Ratio' },
|
||||
];
|
||||
|
||||
export const CONTRACT_STATUS_OPTIONS = [
|
||||
{ value: ContractStatus.Draft, label: 'Draft' },
|
||||
{ value: ContractStatus.PendingApproval, label: 'Pending Approval' },
|
||||
{ value: ContractStatus.Active, label: 'Active' },
|
||||
{ value: ContractStatus.Expired, label: 'Expired' },
|
||||
{ value: ContractStatus.Terminated, label: 'Terminated' },
|
||||
{ value: ContractStatus.Amended, label: 'Amended' },
|
||||
];
|
||||
|
||||
export const APPROVAL_STATUS_OPTIONS = [
|
||||
{ value: ApprovalStatus.Pending, label: 'Pending' },
|
||||
{ value: ApprovalStatus.Review, label: 'Review' },
|
||||
{ value: ApprovalStatus.Approved, label: 'Approved' },
|
||||
{ value: ApprovalStatus.Rejected, label: 'Rejected' },
|
||||
{ value: ApprovalStatus.Removed, label: 'Removed' },
|
||||
];
|
||||
|
||||
export const ACTIVITY_STATUS_OPTIONS = [
|
||||
{ value: ActivityStatus.Active, label: 'Active' },
|
||||
{ value: ActivityStatus.Inactive, label: 'Inactive' },
|
||||
{ value: ActivityStatus.Disabled, label: 'Disabled' },
|
||||
];
|
||||
|
||||
export const RISK_LIKELIHOOD_OPTIONS = [
|
||||
{ value: RiskLikelihood.Rare, label: 'Rare' },
|
||||
{ value: RiskLikelihood.Unlikely, label: 'Unlikely' },
|
||||
{ value: RiskLikelihood.Possible, label: 'Possible' },
|
||||
{ value: RiskLikelihood.Likely, label: 'Likely' },
|
||||
{ value: RiskLikelihood.AlmostCertain, label: 'Almost Certain' },
|
||||
];
|
||||
|
||||
export const RISK_IMPACT_OPTIONS = [
|
||||
{ value: RiskImpact.Minor, label: 'Minor' },
|
||||
{ value: RiskImpact.Moderate, label: 'Moderate' },
|
||||
{ value: RiskImpact.Major, label: 'Major' },
|
||||
{ value: RiskImpact.Critical, label: 'Critical' },
|
||||
{ value: RiskImpact.Severe, label: 'Severe' },
|
||||
];
|
||||
|
||||
export const getApprovalStatusBadge = (status: ApprovalStatus | number) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case ApprovalStatus.Approved:
|
||||
return {
|
||||
label: 'Approved',
|
||||
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
|
||||
dot: 'bg-emerald-500',
|
||||
};
|
||||
case ApprovalStatus.Review:
|
||||
return {
|
||||
label: 'In Review',
|
||||
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
|
||||
dot: 'bg-amber-500',
|
||||
};
|
||||
case ApprovalStatus.Rejected:
|
||||
return {
|
||||
label: 'Needs Changes',
|
||||
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
|
||||
dot: 'bg-rose-500',
|
||||
};
|
||||
case ApprovalStatus.Removed:
|
||||
return {
|
||||
label: 'Removed',
|
||||
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
|
||||
dot: 'bg-slate-500',
|
||||
};
|
||||
case ApprovalStatus.Pending:
|
||||
default:
|
||||
return {
|
||||
label: 'Pending',
|
||||
className: 'bg-teal-500/10 text-teal-300 border-teal-500/30 font-semibold',
|
||||
dot: 'bg-teal-400',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getComplianceStatusBadge = (status: ComplianceStatus | number) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case ComplianceStatus.Exceeded:
|
||||
return {
|
||||
label: 'Exceeded Expectation',
|
||||
className: 'bg-purple-500/15 text-purple-300 border-purple-500/30 font-semibold',
|
||||
dot: 'bg-purple-400',
|
||||
};
|
||||
case ComplianceStatus.Met:
|
||||
return {
|
||||
label: 'Met',
|
||||
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 font-semibold',
|
||||
dot: 'bg-emerald-400',
|
||||
};
|
||||
case ComplianceStatus.NotMet:
|
||||
default:
|
||||
return {
|
||||
label: 'Not Met',
|
||||
className: 'bg-rose-500/15 text-rose-300 border-rose-500/30 font-semibold',
|
||||
dot: 'bg-rose-400',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const COMPLIANCE_STATUS_OPTIONS = [
|
||||
{ value: ComplianceStatus.NotMet, label: 'Not Met' },
|
||||
{ value: ComplianceStatus.Met, label: 'Met' },
|
||||
{ value: ComplianceStatus.Exceeded, label: 'Exceeded Expectation' },
|
||||
];
|
||||
|
||||
export const getContractStatusBadge = (status: ContractStatus | number) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case ContractStatus.Active:
|
||||
return {
|
||||
label: 'Active',
|
||||
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
|
||||
};
|
||||
case ContractStatus.PendingApproval:
|
||||
return {
|
||||
label: 'Pending Approval',
|
||||
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
|
||||
};
|
||||
case ContractStatus.Amended:
|
||||
return {
|
||||
label: 'Amended',
|
||||
className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30 font-semibold',
|
||||
};
|
||||
case ContractStatus.Expired:
|
||||
return {
|
||||
label: 'Expired',
|
||||
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
|
||||
};
|
||||
case ContractStatus.Terminated:
|
||||
return {
|
||||
label: 'Terminated',
|
||||
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
|
||||
};
|
||||
case ContractStatus.Draft:
|
||||
default:
|
||||
return {
|
||||
label: 'Draft',
|
||||
className: 'bg-slate-800/80 text-slate-300 border-slate-700/80 font-semibold',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getActivityStatusBadge = (status: ActivityStatus | number | string | boolean | null | undefined) => {
|
||||
if (status === null || status === undefined) {
|
||||
return {
|
||||
label: 'Active',
|
||||
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof status === 'boolean') {
|
||||
return status
|
||||
? { label: 'Active', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold' }
|
||||
: { label: 'Inactive', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold' };
|
||||
}
|
||||
|
||||
const str = String(status).trim().toLowerCase();
|
||||
if (str === '1' || str === 'active' || str === 'enabled') {
|
||||
return {
|
||||
label: 'Active',
|
||||
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
|
||||
};
|
||||
}
|
||||
if (str === '2' || str === 'inactive') {
|
||||
return {
|
||||
label: 'Inactive',
|
||||
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
|
||||
};
|
||||
}
|
||||
if (str === '3' || str === 'disabled' || str === 'suspended' || str === 'terminated') {
|
||||
return {
|
||||
label: str === 'suspended' ? 'Suspended' : 'Disabled',
|
||||
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
|
||||
};
|
||||
}
|
||||
if (str === 'approved') {
|
||||
return {
|
||||
label: 'Approved',
|
||||
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
|
||||
};
|
||||
}
|
||||
if (str === 'pending' || str === 'review') {
|
||||
return {
|
||||
label: 'Pending',
|
||||
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
|
||||
};
|
||||
}
|
||||
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case ActivityStatus.Active:
|
||||
return {
|
||||
label: 'Active',
|
||||
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
|
||||
};
|
||||
case ActivityStatus.Inactive:
|
||||
return {
|
||||
label: 'Inactive',
|
||||
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
|
||||
};
|
||||
case ActivityStatus.Disabled:
|
||||
return {
|
||||
label: 'Disabled',
|
||||
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: String(status),
|
||||
className: 'bg-slate-800/80 text-slate-300 border-slate-700/80 font-semibold',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrganisationStatusBadge = (statusOrOrg: any) => {
|
||||
let val: any = statusOrOrg;
|
||||
if (statusOrOrg !== null && typeof statusOrOrg === 'object') {
|
||||
val =
|
||||
statusOrOrg.status ??
|
||||
statusOrOrg.Status ??
|
||||
statusOrOrg.activityStatus ??
|
||||
statusOrOrg.ActivityStatus ??
|
||||
statusOrOrg.approvalStatus ??
|
||||
statusOrOrg.ApprovalStatus ??
|
||||
statusOrOrg.statusId ??
|
||||
statusOrOrg.StatusId ??
|
||||
statusOrOrg.state ??
|
||||
statusOrOrg.State ??
|
||||
(statusOrOrg.isActive !== undefined ? (statusOrOrg.isActive ? 1 : 2) : undefined) ??
|
||||
(statusOrOrg.IsActive !== undefined ? (statusOrOrg.IsActive ? 1 : 2) : undefined);
|
||||
}
|
||||
|
||||
return getActivityStatusBadge(val);
|
||||
};
|
||||
|
||||
export const getPriorityBadge = (priority: Priority | number) => {
|
||||
const p = Number(priority);
|
||||
switch (p) {
|
||||
case Priority.Critical:
|
||||
return { label: 'Critical', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold' };
|
||||
case Priority.High:
|
||||
return { label: 'High', className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold' };
|
||||
case Priority.Medium:
|
||||
return { label: 'Medium', className: 'bg-teal-500/10 text-teal-300 border-teal-500/30 font-semibold' };
|
||||
case Priority.Low:
|
||||
default:
|
||||
return { label: 'Low', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold' };
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrganisationTypeLabel = (type: OrganisationType | number): string => {
|
||||
const opt = ORGANISATION_TYPE_OPTIONS.find((o) => o.value === Number(type));
|
||||
return opt ? opt.label : 'Organisation';
|
||||
};
|
||||
|
||||
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => {
|
||||
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector));
|
||||
return opt ? opt.label : 'Industry Sector';
|
||||
};
|
||||
|
||||
export const getProjectTypeLabel = (type: ProjectType | number): string => {
|
||||
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type));
|
||||
return opt ? opt.label : 'Project Type';
|
||||
};
|
||||
|
||||
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
|
||||
const opt = EVIDENCE_TYPE_OPTIONS.find((o) => o.value === Number(type));
|
||||
return opt ? opt.label : 'Evidence Document';
|
||||
};
|
||||
|
||||
export const getUnitOfMeasureLabel = (uom?: UnitOfMeasure | number | null): string => {
|
||||
if (uom === null || uom === undefined) return 'Qty';
|
||||
const opt = UNIT_OF_MEASURE_OPTIONS.find((o) => o.value === Number(uom));
|
||||
return opt ? opt.label : 'Qty';
|
||||
};
|
||||
|
||||
export const getNotificationPlatformLabel = (platform?: NotificationPlatform | number | null): string => {
|
||||
const opt = NOTIFICATION_PLATFORM_OPTIONS.find((o) => o.value === Number(platform));
|
||||
return opt ? opt.label : 'Platform';
|
||||
};
|
||||
|
||||
export const getNotificationStatusLabel = (status?: NotificationStatus | number | null): string => {
|
||||
const opt = NOTIFICATION_STATUS_OPTIONS.find((o) => o.value === Number(status));
|
||||
return opt ? opt.label : 'Status';
|
||||
};
|
||||
|
||||
export const getEmailStatusLabel = (status?: EmailStatuses | number | null): string => {
|
||||
const opt = EMAIL_STATUS_OPTIONS.find((o) => o.value === Number(status));
|
||||
return opt ? opt.label : 'Status';
|
||||
};
|
||||
|
||||
export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case EmailStatuses.Connected:
|
||||
case EmailStatuses.Success:
|
||||
return { label: getEmailStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
|
||||
case EmailStatuses.Disconnected:
|
||||
return { label: getEmailStatusLabel(s), className: 'bg-slate-100 text-slate-700 border-slate-300 font-semibold' };
|
||||
default:
|
||||
return { label: getEmailStatusLabel(s), className: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
|
||||
}
|
||||
};
|
||||
|
||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case NotificationStatus.Draft:
|
||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||
case NotificationStatus.Delivered:
|
||||
case NotificationStatus.Read:
|
||||
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
|
||||
case NotificationStatus.Sent:
|
||||
case NotificationStatus.Queued:
|
||||
case NotificationStatus.Processing:
|
||||
return { label: getNotificationStatusLabel(s), className: 'bg-sky-50 text-sky-800 border-sky-300 font-semibold' };
|
||||
case NotificationStatus.Pending:
|
||||
return { label: getNotificationStatusLabel(s), className: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' };
|
||||
case NotificationStatus.Failed:
|
||||
case NotificationStatus.Cancelled:
|
||||
case NotificationStatus.Bounced:
|
||||
default:
|
||||
return { label: getNotificationStatusLabel(s), className: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
|
||||
}
|
||||
};
|
||||
|
||||
export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: RiskImpact | number) => {
|
||||
const score = (Number(likelihood) || 1) * (Number(impact) || 1);
|
||||
if (score >= 15) {
|
||||
return { level: 'Critical', color: 'bg-rose-600 text-white', badge: 'bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold' };
|
||||
}
|
||||
if (score >= 10) {
|
||||
return { level: 'High', color: 'bg-orange-500 text-white', badge: 'bg-orange-500/20 text-orange-300 border border-orange-500/40 font-bold' };
|
||||
}
|
||||
if (score >= 5) {
|
||||
return { level: 'Moderate', color: 'bg-amber-500 text-white', badge: 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold' };
|
||||
}
|
||||
return { level: 'Low', color: 'bg-emerald-600 text-white', badge: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-semibold' };
|
||||
};
|
||||
|
||||
const USER_EMAIL_CACHE: Record<string, string> = {};
|
||||
const EMAIL_TO_USER_ID_CACHE: Record<string, string> = {};
|
||||
|
||||
export const registerUserEmail = (userId: string | number, email: string) => {
|
||||
if (userId && email && typeof email === 'string' && email.trim()) {
|
||||
const cleanId = String(userId).trim();
|
||||
const cleanEmail = email.trim();
|
||||
USER_EMAIL_CACHE[cleanId] = cleanEmail;
|
||||
USER_EMAIL_CACHE[cleanId.toLowerCase()] = cleanEmail;
|
||||
EMAIL_TO_USER_ID_CACHE[cleanEmail.toLowerCase()] = cleanId;
|
||||
}
|
||||
};
|
||||
|
||||
export const registerUserEmails = (users: Array<{ id: string | number; email?: string | null }>) => {
|
||||
if (!Array.isArray(users)) return;
|
||||
for (const u of users) {
|
||||
if (u && u.id && u.email) {
|
||||
registerUserEmail(u.id, u.email);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserIdByEmail = (email?: string | null): string | undefined => {
|
||||
if (!email || typeof email !== 'string') return undefined;
|
||||
return EMAIL_TO_USER_ID_CACHE[email.trim().toLowerCase()];
|
||||
};
|
||||
|
||||
export const getUserEmail = (userIdOrEmail?: string | number | null): string => {
|
||||
if (!userIdOrEmail) return '—';
|
||||
const str = String(userIdOrEmail).trim();
|
||||
if (!str) return '—';
|
||||
if (str.includes('@')) return str;
|
||||
if (USER_EMAIL_CACHE[str]) return USER_EMAIL_CACHE[str];
|
||||
if (USER_EMAIL_CACHE[str.toLowerCase()]) return USER_EMAIL_CACHE[str.toLowerCase()];
|
||||
|
||||
try {
|
||||
const storedUserJson = localStorage.getItem('pfm_auth_user');
|
||||
const storedSub = localStorage.getItem('pfm_user_sub');
|
||||
if (storedUserJson) {
|
||||
const parsed = JSON.parse(storedUserJson);
|
||||
if (parsed?.email) {
|
||||
const email = String(parsed.email).trim();
|
||||
if (
|
||||
(parsed.id && String(parsed.id).toLowerCase() === str.toLowerCase()) ||
|
||||
(parsed.sub && String(parsed.sub).toLowerCase() === str.toLowerCase()) ||
|
||||
(storedSub && String(storedSub).toLowerCase() === str.toLowerCase())
|
||||
) {
|
||||
registerUserEmail(str, email);
|
||||
return email;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
export const USER_ROLE_OPTIONS = [
|
||||
{ value: 'Partner', label: 'Partner' },
|
||||
{ value: 'Admin', label: 'Admin' },
|
||||
{ value: 'Executive', label: 'Executive' },
|
||||
{ value: 'Supervisor', label: 'Supervisor' },
|
||||
{ value: 'Manager', label: 'Manager' },
|
||||
{ value: 'Contributor', label: 'Contributor' },
|
||||
{ value: 'Unassigned', label: 'Unassigned' },
|
||||
];
|
||||
|
||||
export const getUserRoleBadge = (role?: string | number | UserRole | null) => {
|
||||
if (role === null || role === undefined || role === '' || String(role).trim().toLowerCase() === 'null' || String(role).trim().toLowerCase() === 'unassigned') {
|
||||
return {
|
||||
label: 'Unassigned',
|
||||
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 shadow-xs',
|
||||
dot: 'bg-slate-500',
|
||||
};
|
||||
}
|
||||
|
||||
const roleStr = String(role).trim().toLowerCase();
|
||||
|
||||
if (roleStr === 'partner') {
|
||||
return {
|
||||
label: 'Partner',
|
||||
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 shadow-xs shadow-emerald-500/10 font-semibold',
|
||||
dot: 'bg-emerald-400',
|
||||
};
|
||||
}
|
||||
|
||||
if (roleStr === 'admin' || roleStr === 'superadmin' || roleStr === '1') {
|
||||
return {
|
||||
label: 'Admin',
|
||||
className: 'bg-rose-500/15 text-rose-300 border-rose-500/30 shadow-xs shadow-rose-500/10 font-semibold',
|
||||
dot: 'bg-rose-400',
|
||||
};
|
||||
}
|
||||
|
||||
if (roleStr === 'executive' || roleStr === '2') {
|
||||
return {
|
||||
label: 'Executive',
|
||||
className: 'bg-amber-500/15 text-amber-300 border-amber-500/30 shadow-xs shadow-amber-500/10 font-semibold',
|
||||
dot: 'bg-amber-400',
|
||||
};
|
||||
}
|
||||
|
||||
if (roleStr === 'supervisor' || roleStr === '3') {
|
||||
return {
|
||||
label: 'Supervisor',
|
||||
className: 'bg-purple-500/15 text-purple-300 border-purple-500/30 shadow-xs shadow-purple-500/10 font-semibold',
|
||||
dot: 'bg-purple-400',
|
||||
};
|
||||
}
|
||||
|
||||
if (roleStr === 'manager' || roleStr === 'programmemanager' || roleStr === 'projectmanager' || roleStr === '4') {
|
||||
return {
|
||||
label: 'Manager',
|
||||
className: 'bg-blue-500/15 text-blue-300 border-blue-500/30 shadow-xs shadow-blue-500/10 font-semibold',
|
||||
dot: 'bg-blue-400',
|
||||
};
|
||||
}
|
||||
|
||||
if (roleStr === 'contributor' || roleStr === 'viewer' || roleStr === 'recipientviewer' || roleStr === '5') {
|
||||
return {
|
||||
label: 'Contributor',
|
||||
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30 shadow-xs shadow-emerald-500/10 font-semibold',
|
||||
dot: 'bg-emerald-400',
|
||||
};
|
||||
}
|
||||
|
||||
const formatted = roleStr.charAt(0).toUpperCase() + roleStr.slice(1);
|
||||
return {
|
||||
label: formatted,
|
||||
className: 'bg-cyan-500/15 text-cyan-300 border-cyan-500/30',
|
||||
dot: 'bg-cyan-400',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Markdown & Branded HTML Formatting Utilities for PostFundManagement (PFM)
|
||||
*/
|
||||
|
||||
export function isHtmlContent(content: string): boolean {
|
||||
if (!content) return false;
|
||||
const trimmed = content.trim();
|
||||
return (
|
||||
trimmed.startsWith('<!DOCTYPE') ||
|
||||
trimmed.startsWith('<html') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
export function stripMarkdown(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/#{1,6}\s+/g, '')
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/`{1,3}.*?`{1,3}/gs, '')
|
||||
.replace(/^>\s+/gm, '')
|
||||
.replace(/^[-*+]\s+/gm, '')
|
||||
.replace(/^\d+\.\s+/gm, '')
|
||||
.replace(/\|/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseMarkdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
let html = markdown
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/\r\n/g, '\n');
|
||||
|
||||
html = html.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<pre style="background:#0b1120;border:1px solid #1e293b;border-radius:12px;padding:14px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;color:#e2e8f0;margin:12px 0;"><code>${escaped}</code></pre>`;
|
||||
});
|
||||
|
||||
html = html.replace(/`([^`]+)`/g, (_match, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<code style="background:#1e293b;color:#34d399;padding:2px 6px;border-radius:6px;font-family:monospace;font-size:12px;">${escaped}</code>`;
|
||||
});
|
||||
|
||||
// Tables (| A | B |\n|---|---|\n| C | D |)
|
||||
html = html.replace(/((?:\|[^\n]+\|\n?)+)/g, (match) => {
|
||||
const rows = match.trim().split('\n');
|
||||
if (rows.length < 2) return match;
|
||||
|
||||
const hasSeparator = rows[1] && rows[1].includes('---');
|
||||
if (!hasSeparator) return match;
|
||||
|
||||
const parseCells = (row: string) =>
|
||||
row
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
|
||||
const headers = parseCells(rows[0]);
|
||||
const bodyRows = rows.slice(2);
|
||||
|
||||
let tableHtml = `<div style="overflow-x:auto;margin:14px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;border-radius:8px;font-size:12px;text-align:left;">`;
|
||||
tableHtml += `<thead><tr style="background:#1e293b;color:#f8fafc;">`;
|
||||
headers.forEach((h) => {
|
||||
tableHtml += `<th style="padding:8px 12px;border:1px solid #334155;font-weight:600;">${h}</th>`;
|
||||
});
|
||||
tableHtml += `</tr></thead><tbody>`;
|
||||
|
||||
bodyRows.forEach((row, i) => {
|
||||
const cells = parseCells(row);
|
||||
const bg = i % 2 === 0 ? '#0f172a' : '#1e293b/40';
|
||||
tableHtml += `<tr style="background:${bg};color:#cbd5e1;">`;
|
||||
cells.forEach((c) => {
|
||||
tableHtml += `<td style="padding:8px 12px;border:1px solid #334155;">${c}</td>`;
|
||||
});
|
||||
tableHtml += `</tr>`;
|
||||
});
|
||||
|
||||
tableHtml += `</tbody></table></div>`;
|
||||
return tableHtml;
|
||||
});
|
||||
|
||||
html = html.replace(/^#### (.*?)$/gm, '<h4 style="font-size:14px;font-weight:700;color:#f8fafc;margin:12px 0 6px 0;">$1</h4>');
|
||||
html = html.replace(/^### (.*?)$/gm, '<h3 style="font-size:16px;font-weight:700;color:#f8fafc;margin:14px 0 8px 0;">$1</h3>');
|
||||
html = html.replace(/^## (.*?)$/gm, '<h2 style="font-size:18px;font-weight:800;color:#ffffff;margin:16px 0 8px 0;border-bottom:1px solid #1e293b;padding-bottom:6px;">$1</h2>');
|
||||
html = html.replace(/^# (.*?)$/gm, '<h1 style="font-size:22px;font-weight:800;color:#ffffff;margin:18px 0 10px 0;border-bottom:1px solid #334155;padding-bottom:8px;">$1</h1>');
|
||||
|
||||
// Blockquotes and GitHub alerts
|
||||
html = html.replace(/^>\s*\[!NOTE\]\s*(.*?)$/gim, '<div style="background:#0c4a6e22;border-left:4px solid #0284c7;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>NOTE:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!IMPORTANT\]\s*(.*?)$/gim, '<div style="background:#83184322;border-left:4px solid #db2777;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fbcfe8;font-size:12px;"><strong>IMPORTANT:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!WARNING\]\s*(.*?)$/gim, '<div style="background:#713f1222;border-left:4px solid #d97706;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fef08a;font-size:12px;"><strong>WARNING:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!TIP\]\s*(.*?)$/gim, '<div style="background:#064e3b22;border-left:4px solid #059669;padding:10px 14px;border-radius:8px;margin:10px 0;color:#a7f3d0;font-size:12px;"><strong>TIP:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*(.*?)$/gm, '<blockquote style="border-left:3px solid #10b981;padding-left:12px;margin:8px 0;color:#94a3b8;font-style:italic;">$1</blockquote>');
|
||||
|
||||
// Horizontal Rules (---)
|
||||
html = html.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr style="border:none;border-top:1px solid #334155;margin:16px 0;" />');
|
||||
|
||||
// Strikethrough
|
||||
html = html.replace(/~~(.*?)~~/g, '<del style="color:#64748b;">$1</del>');
|
||||
|
||||
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em style="color:#cbd5e1;">$1</em>');
|
||||
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#34d399;text-decoration:underline;">$1</a>');
|
||||
html = html.replace(/^[-*+]\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:disc;color:#cbd5e1;padding:2px 0;">$1</li>');
|
||||
html = html.replace(/^\d+\.\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:decimal;color:#cbd5e1;padding:2px 0;">$1</li>');
|
||||
|
||||
const paragraphs = html.split(/\n{2,}/);
|
||||
html = paragraphs
|
||||
.map((p) => {
|
||||
const trimmed = p.trim();
|
||||
if (!trimmed) return '';
|
||||
if (
|
||||
trimmed.startsWith('<h') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
trimmed.startsWith('<pre') ||
|
||||
trimmed.startsWith('<blockquote') ||
|
||||
trimmed.startsWith('<hr') ||
|
||||
trimmed.startsWith('<li')
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
return `<p style="margin:8px 0;line-height:1.6;color:#cbd5e1;">${trimmed.replace(/\n/g, '<br />')}</p>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { QueryClient, InvalidateQueryFilters } from '@tanstack/react-query';
|
||||
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,
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Invalidates queries while simultaneously bumping the mutation epoch.
|
||||
*/
|
||||
export async function invalidateAndBustCache(
|
||||
client: QueryClient,
|
||||
filters?: InvalidateQueryFilters
|
||||
): Promise<void> {
|
||||
bumpMutationEpoch();
|
||||
await client.invalidateQueries(filters);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { loadRuntimeConfig } from './config';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
// Load external runtime configuration (/config.json or window.__RUNTIME_CONFIG__)
|
||||
// before mounting the React application so OIDC, API BaseUrl, and HashIds use runtime values.
|
||||
loadRuntimeConfig().finally(() => {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ export function hashParam(id: number | string | null | undefined): string {
|
||||
// Users & Integration Endpoints
|
||||
// ----------------------------------------------------------------------
|
||||
export const usersApi = {
|
||||
getUser: (userId: string | number) =>
|
||||
apiClient.get<Models.User>(`/v1/api/users/${hashParam(userId)}`),
|
||||
getUser: (userId: string) =>
|
||||
apiClient.get<Models.User>(`/v1/api/users/${userId}`),
|
||||
|
||||
getUsersPaged: (page: number, pageSize: number, filter?: Models.UserFilterRequest) =>
|
||||
apiClient.post<Models.User[]>(`/v1/api/users/${page}/${pageSize}`, filter || {}),
|
||||
@@ -23,8 +23,8 @@ export const usersApi = {
|
||||
syncIntegration: (payload: any) =>
|
||||
apiClient.post<any>('/v1/api/integration/sync', payload),
|
||||
|
||||
updateUserStatus: (userId: string | number, status: string | number) =>
|
||||
apiClient.get<any>(`/v1/api/users/${hashParam(userId)}/status/${status}`),
|
||||
updateUserStatus: (userId: string, status: string | number) =>
|
||||
apiClient.get<any>(`/v1/api/users/${userId}/status/${status}`),
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
@@ -72,7 +72,7 @@ export const projectsApi = {
|
||||
|
||||
// Outcomes
|
||||
createOutcome: (payload: Models.CreateOutcomeRequest) =>
|
||||
apiClient.post<Models.Outcome>('/v1/api/projects/beneficiaris/outcomes', payload),
|
||||
apiClient.post<Models.Outcome>('/v1/api/projects/beneficiaries/outcomes', payload),
|
||||
|
||||
updateOutcome: (payload: Models.UpdateOutcomeRequest) =>
|
||||
apiClient.put<Models.Outcome>('/v1/api/projects/outcomes', payload),
|
||||
@@ -403,14 +403,17 @@ export const communicationsApi = {
|
||||
getInvitesPaged: (page: number, pageSize: number) =>
|
||||
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${page}/${pageSize}`),
|
||||
|
||||
getApplicantInvites: (applicantUserId: string | number) =>
|
||||
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${hashParam(applicantUserId)}`),
|
||||
getApplicantData: (applicantUserId: string) =>
|
||||
apiClient.get<Models.ApplicantData[]>(`/v1/api/communications/applicants/${applicantUserId}/all`),
|
||||
|
||||
getApplicantInvite: (applicantUserId: string | number) =>
|
||||
apiClient.get<Models.Invite>(`/v1/api/communications/invites/applicants/${hashParam(applicantUserId)}`),
|
||||
getApplicantInvites: (applicantUserId: string) =>
|
||||
apiClient.get<Models.Invite[]>(`/v1/api/communications/invites/${applicantUserId}`),
|
||||
|
||||
getApplicantInvite: (applicantUserId: string) =>
|
||||
apiClient.get<Models.Invite>(`/v1/api/communications/invites/applicants/${applicantUserId}`),
|
||||
|
||||
getNotifications: (organisationId: string | number) =>
|
||||
apiClient.get<Models.Notification[]>(`/v1/api/communications/norifications/${hashParam(organisationId)}`),
|
||||
apiClient.get<Models.Notification[]>(`/v1/api/communications/notifications/${hashParam(organisationId)}`),
|
||||
};
|
||||
|
||||
// Consolidated export
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,694 +1 @@
|
||||
/**
|
||||
* OpenAPI TypeScript Models for PostFundManagement.Api v1
|
||||
* Synchronized from https://api.ifri.khongisa.co.za/openapi/v1/
|
||||
*/
|
||||
|
||||
export interface ProblemDetails {
|
||||
type?: string | null;
|
||||
title?: string | null;
|
||||
status?: number | string | null;
|
||||
detail?: string | null;
|
||||
instance?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type ActivityStatus = 0 | 1 | 2 | 3 | 4 | 'Planned' | 'InProgress' | 'Completed' | 'Delayed' | 'Cancelled' | number | string;
|
||||
export type ApprovalStatus = 0 | 1 | 2 | 3 | 'Draft' | 'PendingApproval' | 'Approved' | 'Rejected' | number | string;
|
||||
export type ContractStatus = 0 | 1 | 2 | 3 | 4 | 'Draft' | 'Active' | 'Executed' | 'Terminated' | 'Expired' | number | string;
|
||||
export type EvidenceType = 0 | 1 | 2 | 3 | 4 | 5 | 'Document' | 'Invoice' | 'Receipt' | 'SitePhoto' | 'Report' | 'Other' | number | string;
|
||||
export type IndustrySector = number | string;
|
||||
export type NotificationPlatform = 0 | 1 | 2 | 'Email' | 'SMS' | 'InApp' | number | string;
|
||||
export type NotificationStatus = 0 | 1 | 2 | 3 | 'Queued' | 'Sent' | 'Failed' | 'Delivered' | number | string;
|
||||
export type OrganisationType = 0 | 1 | 2 | 3 | 'Grantee' | 'Funder' | 'Partner' | 'Auditor' | number | string;
|
||||
export type Priority = 0 | 1 | 2 | 3 | 'Low' | 'Medium' | 'High' | 'Critical' | number | string;
|
||||
export type ProjectType = 0 | 1 | 2 | 3 | 'Grant' | 'Loan' | 'Equity' | 'Hybrid' | number | string;
|
||||
export type RiskImpact = 0 | 1 | 2 | 3 | 'Low' | 'Medium' | 'High' | 'Critical' | number | string;
|
||||
export type RiskLikelihood = 0 | 1 | 2 | 3 | 'Rare' | 'Unlikely' | 'Possible' | 'Likely' | number | string;
|
||||
export type UnitOfMeasure = number | string;
|
||||
|
||||
export interface Pagination {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
totalRecords?: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id?: string;
|
||||
sub?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
tenant?: string;
|
||||
tenant_id?: number | string;
|
||||
status?: string | number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface UserFilterRequest {
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: ActivityStatus;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
progressPercentage?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateActivityRequest {
|
||||
projectId: string | number;
|
||||
title: string;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: ActivityStatus;
|
||||
}
|
||||
|
||||
export interface Beneficiary {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
name?: string;
|
||||
identificationNumber?: string;
|
||||
contactNumber?: string;
|
||||
email?: string;
|
||||
location?: string;
|
||||
gender?: string;
|
||||
age?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateBeneficiaryRequest {
|
||||
projectId: string | number;
|
||||
name: string;
|
||||
identificationNumber?: string;
|
||||
contactNumber?: string;
|
||||
email?: string;
|
||||
location?: string;
|
||||
gender?: string;
|
||||
age?: number;
|
||||
}
|
||||
|
||||
export interface UpdateBeneficiaryRequest {
|
||||
id: string | number;
|
||||
projectId?: string | number;
|
||||
name?: string;
|
||||
identificationNumber?: string;
|
||||
contactNumber?: string;
|
||||
email?: string;
|
||||
location?: string;
|
||||
gender?: string;
|
||||
age?: number;
|
||||
}
|
||||
|
||||
export interface Outcome {
|
||||
id?: string | number;
|
||||
beneficiaryId?: string | number;
|
||||
projectId?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
targetMetric?: string;
|
||||
achievedMetric?: string;
|
||||
dateRecorded?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateOutcomeRequest {
|
||||
beneficiaryId?: string | number;
|
||||
projectId?: string | number;
|
||||
title: string;
|
||||
description?: string;
|
||||
targetMetric?: string;
|
||||
achievedMetric?: string;
|
||||
dateRecorded?: string;
|
||||
}
|
||||
|
||||
export interface UpdateOutcomeRequest {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
targetMetric?: string;
|
||||
achievedMetric?: string;
|
||||
}
|
||||
|
||||
export interface ChangeRequest {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
reason?: string;
|
||||
proposedChanges?: string;
|
||||
requestedBy?: string;
|
||||
status?: ApprovalStatus;
|
||||
requestDate?: string;
|
||||
approvalDate?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateChangeRequest {
|
||||
projectId: string | number;
|
||||
reason: string;
|
||||
proposedChanges: string;
|
||||
}
|
||||
|
||||
export interface UpdateChangeRequest {
|
||||
id: string | number;
|
||||
reason?: string;
|
||||
proposedChanges?: string;
|
||||
status?: ApprovalStatus;
|
||||
}
|
||||
|
||||
export interface ComplianceItem {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
isCompliant?: boolean;
|
||||
dueDate?: string;
|
||||
verifiedAt?: string;
|
||||
verifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateComplianceItemRequest {
|
||||
projectId: string | number;
|
||||
title: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateComplianceItemRequest {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
isCompliant?: boolean;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
priority?: Priority;
|
||||
status?: string;
|
||||
reportedBy?: string;
|
||||
resolvedAt?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateIssueRequest {
|
||||
projectId: string | number;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: Priority;
|
||||
}
|
||||
|
||||
export interface UpdateIssueRequest {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
priority?: Priority;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface Milestone {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
amount?: number;
|
||||
status?: ApprovalStatus;
|
||||
completionPercentage?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateMilestoneRequest {
|
||||
projectId: string | number;
|
||||
title: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface UpdateMilestoneRequest {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
amount?: number;
|
||||
status?: ApprovalStatus;
|
||||
completionPercentage?: number;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id?: string | number;
|
||||
programmeId?: string | number;
|
||||
organisationId?: string | number;
|
||||
name?: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
type?: ProjectType;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
budget?: number;
|
||||
status?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateProjectRequest {
|
||||
programmeId: string | number;
|
||||
organisationId: string | number;
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
type?: ProjectType;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface UpdateProjectRequest {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
budget?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectStatusRequest {
|
||||
projectId: string | number;
|
||||
status: string | number;
|
||||
}
|
||||
|
||||
export interface SiteVisit {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
visitDate?: string;
|
||||
visitorName?: string;
|
||||
findings?: string;
|
||||
recommendations?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateSiteVisitRequest {
|
||||
projectId: string | number;
|
||||
visitDate: string;
|
||||
visitorName: string;
|
||||
findings?: string;
|
||||
recommendations?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSiteVisitRequest {
|
||||
id: string | number;
|
||||
visitDate?: string;
|
||||
visitorName?: string;
|
||||
findings?: string;
|
||||
recommendations?: string;
|
||||
}
|
||||
|
||||
export interface Award {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
amount?: number;
|
||||
awardDate?: string;
|
||||
conditions?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateAwardRequest {
|
||||
projectId: string | number;
|
||||
amount: number;
|
||||
awardDate?: string;
|
||||
conditions?: string;
|
||||
}
|
||||
|
||||
export interface Budget {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
category?: string;
|
||||
allocatedAmount?: number;
|
||||
spentAmount?: number;
|
||||
notes?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateBudgetRequest {
|
||||
projectId: string | number;
|
||||
category: string;
|
||||
allocatedAmount: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBudgetRequest {
|
||||
id: string | number;
|
||||
category?: string;
|
||||
allocatedAmount?: number;
|
||||
spentAmount?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Contract {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
title?: string;
|
||||
contractNumber?: string;
|
||||
status?: ContractStatus;
|
||||
signedDate?: string;
|
||||
expiryDate?: string;
|
||||
value?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateContractRequest {
|
||||
projectId: string | number;
|
||||
title: string;
|
||||
contractNumber?: string;
|
||||
signedDate?: string;
|
||||
expiryDate?: string;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface UpdateContractRequest {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
status?: ContractStatus;
|
||||
expiryDate?: string;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface ExecuteContractRequest {
|
||||
contractId: string | number;
|
||||
executionDate: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Instrument {
|
||||
id?: string | number;
|
||||
programmeId?: string | number;
|
||||
name?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
terms?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateInstrumentRequest {
|
||||
programmeId: string | number;
|
||||
name: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
terms?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInstrumentRequest {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
terms?: string;
|
||||
}
|
||||
|
||||
export interface Organisation {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
registrationNumber?: string;
|
||||
type?: OrganisationType;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateOrganisationRequest {
|
||||
name: string;
|
||||
registrationNumber?: string;
|
||||
type?: OrganisationType;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface UpdateOrganisationRequest {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
registrationNumber?: string;
|
||||
type?: OrganisationType;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
id?: string | number;
|
||||
organisationId?: string | number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
totalValue?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreatePortfolioRequest {
|
||||
organisationId: string | number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePortfolioRequest {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Programme {
|
||||
id?: string | number;
|
||||
portfolioId?: string | number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
budget?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateProgrammeRequest {
|
||||
portfolioId: string | number;
|
||||
name: string;
|
||||
description?: string;
|
||||
budget?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProgrammeRequest {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
code?: string;
|
||||
isActive?: boolean;
|
||||
createdDate?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateTenantRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTenantRequest {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
code?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
totalProjects?: number;
|
||||
totalCommittedCapital?: number;
|
||||
totalDisbursedCapital?: number;
|
||||
activeMilestonesCount?: number;
|
||||
complianceRate?: number;
|
||||
recentActivities?: any[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id?: string | number;
|
||||
entityName?: string;
|
||||
entityId?: string | number;
|
||||
action?: string;
|
||||
timestamp?: string;
|
||||
performedBy?: string;
|
||||
details?: string;
|
||||
error?: string | null;
|
||||
isError?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Disbursement {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
milestoneId?: string | number;
|
||||
amount?: number;
|
||||
disbursementDate?: string;
|
||||
status?: ApprovalStatus;
|
||||
referenceNumber?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateDisbursementRequest {
|
||||
projectId: string | number;
|
||||
milestoneId?: string | number;
|
||||
amount: number;
|
||||
referenceNumber?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDisbursementRequest {
|
||||
id: string | number;
|
||||
amount?: number;
|
||||
status?: ApprovalStatus;
|
||||
referenceNumber?: string;
|
||||
}
|
||||
|
||||
export interface Risk {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
likelihood?: RiskLikelihood;
|
||||
impact?: RiskImpact;
|
||||
mitigationPlan?: string;
|
||||
status?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateRiskRequest {
|
||||
projectId: string | number;
|
||||
title: string;
|
||||
description?: string;
|
||||
likelihood?: RiskLikelihood;
|
||||
impact?: RiskImpact;
|
||||
mitigationPlan?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRiskRequest {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
likelihood?: RiskLikelihood;
|
||||
impact?: RiskImpact;
|
||||
mitigationPlan?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id?: string | number;
|
||||
programmeId?: string | number;
|
||||
name?: string;
|
||||
expression?: string;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateRuleRequest {
|
||||
programmeId: string | number;
|
||||
name: string;
|
||||
expression: string;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateRuleRequest {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
expression?: string;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface Evidence {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
milestoneId?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: EvidenceType;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
uploadedAt?: string;
|
||||
verified?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface UpdateEvidenceRequest {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: EvidenceType;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
export interface Indicator {
|
||||
id?: string | number;
|
||||
projectId?: string | number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
actualValue?: number;
|
||||
unit?: UnitOfMeasure;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateIndicatorRequest {
|
||||
projectId: string | number;
|
||||
name: string;
|
||||
description?: string;
|
||||
targetValue: number;
|
||||
unit?: UnitOfMeasure;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
id?: string | number;
|
||||
email?: string;
|
||||
role?: string;
|
||||
organisationId?: string | number;
|
||||
status?: string;
|
||||
expiresAt?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface InviteRequest {
|
||||
email: string;
|
||||
role?: string;
|
||||
organisationId?: string | number;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id?: string | number;
|
||||
organisationId?: string | number;
|
||||
platform?: NotificationPlatform;
|
||||
status?: NotificationStatus;
|
||||
message?: string;
|
||||
recipient?: string;
|
||||
sentAt?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
export * from '../../types';
|
||||
|
||||
@@ -3,29 +3,95 @@ import { getApiBaseUrl } from '../config';
|
||||
import { triggerSessionExpired, isUserSyncEndpoint, isTenantEndpoint } from './sessionExpiration';
|
||||
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
|
||||
|
||||
export const BASE_API_URL = getApiBaseUrl();
|
||||
|
||||
const MUTATION_EPOCH_STORAGE_KEY = 'pfm_mutation_epoch';
|
||||
|
||||
let activeMutationEpoch: number = (() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(MUTATION_EPOCH_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = Number(stored);
|
||||
if (!isNaN(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return Date.now();
|
||||
})();
|
||||
|
||||
export function getMutationEpoch(): number {
|
||||
return activeMutationEpoch;
|
||||
}
|
||||
|
||||
export function bumpMutationEpoch(): number {
|
||||
activeMutationEpoch = Date.now();
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
sessionStorage.setItem(MUTATION_EPOCH_STORAGE_KEY, String(activeMutationEpoch));
|
||||
} catch {}
|
||||
}
|
||||
return activeMutationEpoch;
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
baseURL: BASE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; version=1',
|
||||
Accept: 'application/json; version=1',
|
||||
},
|
||||
timeout: 20000,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
config.baseURL = getApiBaseUrl();
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null;
|
||||
const token = localStorage.getItem('pfm_access_token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
|
||||
const isExternal =
|
||||
config.url?.startsWith('http://') ||
|
||||
config.url?.startsWith('https://') ||
|
||||
config.url?.includes('amazonaws.com') ||
|
||||
config.url?.includes('garage');
|
||||
|
||||
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) {
|
||||
if (config.params instanceof URLSearchParams) {
|
||||
config.params.set('_t', String(Date.now()));
|
||||
} else {
|
||||
config.params = config.params || {};
|
||||
config.params._t = Date.now();
|
||||
}
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (token && !isLifecycleRequest) {
|
||||
if (isActivationPending()) {
|
||||
return Promise.reject(new Error('User account is pending administrator activation'));
|
||||
}
|
||||
const storedSub = typeof window !== 'undefined' ? (localStorage.getItem('pfm_user_sub') || '') : '';
|
||||
|
||||
const storedSub = localStorage.getItem('pfm_user_sub') || '';
|
||||
if (!isUserSyncedInBrowser(storedSub, token)) {
|
||||
const syncRes = await syncUserAccount(storedSub, token);
|
||||
if (!syncRes.success) {
|
||||
@@ -33,20 +99,40 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
const method = response.config?.method?.toLowerCase();
|
||||
if (method && ['post', 'put', 'patch', 'delete'].includes(method)) {
|
||||
bumpMutationEpoch();
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
const requestUrl = error.config?.url;
|
||||
const isLifecycleEndpoint = isUserSyncEndpoint(requestUrl) || isTenantEndpoint(requestUrl);
|
||||
|
||||
if (!isLifecycleEndpoint) {
|
||||
const detailMsg = error?.response?.data?.detail || error?.response?.data?.message || 'Session expired (HTTP 401).';
|
||||
const detailMsg =
|
||||
error?.response?.data?.detail ||
|
||||
error?.response?.data?.message ||
|
||||
'Authentication session expired or security token invalidated (HTTP 401).';
|
||||
|
||||
triggerSessionExpired(detailMsg, requestUrl);
|
||||
} else {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('pfm_access_token');
|
||||
localStorage.removeItem('pfm_access_user');
|
||||
localStorage.removeItem('pfm_user_sync_record');
|
||||
localStorage.removeItem('pfm_user_pending_activation');
|
||||
localStorage.removeItem('pfm_user_pending_activation_reason');
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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/';
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
export function isApprovedOrActiveStatus(status: number | string | undefined | null): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
|
||||
const numStatus = Number(status);
|
||||
if (
|
||||
numStatus === ApprovalStatus.Approved ||
|
||||
numStatus === ActivityStatus.Active ||
|
||||
numStatus === ContractStatus.Active ||
|
||||
numStatus === ContractStatus.Executed ||
|
||||
numStatus === ComplianceStatus.Met ||
|
||||
numStatus === ComplianceStatus.Exceeded
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const strStatus = String(status).toLowerCase().trim();
|
||||
return (
|
||||
strStatus === 'approved' ||
|
||||
strStatus === 'active' ||
|
||||
strStatus === 'executed' ||
|
||||
strStatus === 'met' ||
|
||||
strStatus === 'exceeded' ||
|
||||
strStatus === 'verified'
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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' ||
|
||||
roleStr === '4';
|
||||
|
||||
const matchesTenant =
|
||||
!tenantId || !u.tenant_id || Number(u.tenant_id) === Number(tenantId);
|
||||
|
||||
return isManagerOrExec && matchesTenant;
|
||||
});
|
||||
|
||||
if (managersAndExecutives.length > 0) {
|
||||
return managersAndExecutives;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function notifyTenantManagersAndExecutivesOnRecordStatusChange(
|
||||
params: RecordStatusChangeNotificationParams
|
||||
): Promise<void> {
|
||||
const { recordType, recordId, newStatus, title } = params;
|
||||
|
||||
if (recordType.toLowerCase() === 'user' || recordType.toLowerCase() === 'users') {
|
||||
return;
|
||||
}
|
||||
|
||||
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()}`;
|
||||
|
||||
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()}`;
|
||||
|
||||
for (const recipient of managersAndExecutives) {
|
||||
if (!recipient.id) continue;
|
||||
const recipientEmail = recipient.email || 'manager@pfm.org.za';
|
||||
|
||||
try {
|
||||
await dispatchNotification({
|
||||
organisationId: orgId,
|
||||
recipient: recipient.id,
|
||||
platform: NotificationPlatform.InApp,
|
||||
subject: inAppSubject,
|
||||
destination: recipientEmail,
|
||||
message: inAppMessage,
|
||||
status: NotificationStatus.Delivered,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await dispatchNotification({
|
||||
organisationId: orgId,
|
||||
recipient: recipient.id,
|
||||
platform: NotificationPlatform.Email,
|
||||
subject: emailSubject,
|
||||
destination: `To: ${recipientEmail} | From: PFM Automated Governance System`,
|
||||
message: emailMessage,
|
||||
status: NotificationStatus.Queued,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
await dispatchNotification({
|
||||
organisationId: orgId,
|
||||
recipient: invite.recipient,
|
||||
platform: NotificationPlatform.InApp,
|
||||
subject: 'Onboarding Invitation Approved: Access to Partner Portal Granted',
|
||||
destination: targetEmail,
|
||||
message: `Your statutory onboarding invitation has been formally Approved. Access the Partner Portal at ${PARTNER_PORTAL_URL}.`,
|
||||
status: NotificationStatus.Delivered,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export interface NewUserCreatedNotificationParams {
|
||||
userId: string;
|
||||
email: string;
|
||||
isPartner: boolean;
|
||||
roleName?: string;
|
||||
tenantId?: number | string | null;
|
||||
}
|
||||
|
||||
export async function notifyNewUserCreated(
|
||||
params: NewUserCreatedNotificationParams
|
||||
): Promise<void> {
|
||||
const { userId, email, isPartner, tenantId } = params;
|
||||
if (!userId || !email) return;
|
||||
|
||||
const orgId = tenantId ? Number(tenantId) : (getCurrentUserTenantId() || 1);
|
||||
const targetUrl = isPartner ? PARTNER_PORTAL_URL : CONSOLE_PORTAL_URL;
|
||||
|
||||
try {
|
||||
await dispatchNotification({
|
||||
organisationId: orgId,
|
||||
recipient: userId,
|
||||
platform: NotificationPlatform.InApp,
|
||||
subject: 'Welcome to PFM - Your Account Has Been Created',
|
||||
destination: email,
|
||||
message: `Your user account has been created. Please sign in at ${targetUrl}.`,
|
||||
status: NotificationStatus.Delivered,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { apiClient } from './httpClient';
|
||||
import { Role, Claim } from '../types';
|
||||
import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac';
|
||||
|
||||
export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_partner_cache_system_roles';
|
||||
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_partner_cache_role_claims_';
|
||||
export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_partner_cache_system_claims';
|
||||
export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_partner_rbac_cache_version';
|
||||
export const CURRENT_RBAC_CACHE_VERSION = 'v2.2-partner';
|
||||
|
||||
export function ensureFreshRbacCache(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const version = localStorage.getItem(LOCAL_STORAGE_RBAC_VERSION_KEY);
|
||||
if (version !== CURRENT_RBAC_CACHE_VERSION) {
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.startsWith(LOCAL_STORAGE_ROLE_CLAIMS_PREFIX) || key === 'pfm_user_claims')) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
||||
inMemoryRoleClaims.clear();
|
||||
localStorage.setItem(LOCAL_STORAGE_RBAC_VERSION_KEY, CURRENT_RBAC_CACHE_VERSION);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let inMemorySystemRoles: Role[] | null = null;
|
||||
const inMemoryRoleClaims = new Map<string, Claim[]>();
|
||||
let inMemorySystemClaims: Claim[] | null = null;
|
||||
const inMemoryUserRoles = new Map<string, { roles: Role[]; timestamp: number }>();
|
||||
const inMemoryUserClaims = new Map<string, { claims: Claim[]; timestamp: number }>();
|
||||
const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
ensureFreshRbacCache();
|
||||
|
||||
// PartnerUI RBAC only functions for Partner roles
|
||||
export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
|
||||
partner: [
|
||||
{ claimId: 50, claimType: 'dashboard', claimValue: 'view, export' },
|
||||
{ claimId: 51, claimType: 'projects_milestones', claimValue: 'view, add, export' },
|
||||
{ claimId: 52, claimType: 'grant_financials', claimValue: 'view, add, export' },
|
||||
{ claimId: 53, claimType: 'evidence_risks', claimValue: 'view, add, export' },
|
||||
{ claimId: 54, claimType: 'beneficiaries', claimValue: 'view, add, export' },
|
||||
{ claimId: 55, claimType: 'change_management', claimValue: 'view, add, export' },
|
||||
{ claimId: 56, claimType: 'site_visits', claimValue: 'view, export' },
|
||||
{ claimId: 57, claimType: 'compliance', claimValue: 'view, add, export' },
|
||||
{ claimId: 58, claimType: 'settings_notifications', claimValue: 'view, add' },
|
||||
],
|
||||
};
|
||||
|
||||
export function normalizeClaim(c: any): Claim {
|
||||
const claimType = c.claimType || c.type || c.name || '';
|
||||
const claimValue = c.claimValue || c.value || 'all';
|
||||
const claimId = c.claimId ?? c.id ?? undefined;
|
||||
return {
|
||||
...c,
|
||||
type: claimType,
|
||||
value: claimValue,
|
||||
claimType,
|
||||
claimValue,
|
||||
claimId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCachedSystemRoles(): Role[] | null {
|
||||
if (inMemorySystemRoles && inMemorySystemRoles.length > 0) {
|
||||
return inMemorySystemRoles;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
inMemorySystemRoles = parsed;
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setCachedSystemRoles(roles: Role[]): void {
|
||||
inMemorySystemRoles = roles;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY, JSON.stringify(roles));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedRoleClaims(roleKey: string): Claim[] | null {
|
||||
const normalizedKey = roleKey.toLowerCase().trim();
|
||||
if (inMemoryRoleClaims.has(normalizedKey)) {
|
||||
return inMemoryRoleClaims.get(normalizedKey)!;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${LOCAL_STORAGE_ROLE_CLAIMS_PREFIX}${normalizedKey}`);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const normalized = parsed.map(normalizeClaim);
|
||||
inMemoryRoleClaims.set(normalizedKey, normalized);
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void {
|
||||
const normalizedKey = roleKey.toLowerCase().trim();
|
||||
const normalized = claims.map(normalizeClaim);
|
||||
inMemoryRoleClaims.set(normalizedKey, normalized);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${LOCAL_STORAGE_ROLE_CLAIMS_PREFIX}${normalizedKey}`,
|
||||
JSON.stringify(normalized)
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise<Role[]> {
|
||||
if (!forceRefresh) {
|
||||
const cached = getCachedSystemRoles();
|
||||
if (cached && cached.length > 0) return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiClient.get<Role[]>('/v1/api/users/system/roles');
|
||||
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
||||
setCachedSystemRoles(res.data);
|
||||
return res.data;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const cached = getCachedSystemRoles();
|
||||
if (cached && cached.length > 0) return cached;
|
||||
|
||||
const defaults: Role[] = [{ id: '6', name: 'Partner' }];
|
||||
setCachedSystemRoles(defaults);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
|
||||
export async function fetchAndCacheRoleClaims(
|
||||
roleId: string,
|
||||
roleName?: string,
|
||||
forceRefresh = false
|
||||
): Promise<Claim[]> {
|
||||
const roleIdKey = roleId.toLowerCase().trim();
|
||||
const roleNameKey = (roleName || '').toLowerCase().trim();
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cachedById = getCachedRoleClaims(roleIdKey);
|
||||
if (cachedById && cachedById.length > 0) return cachedById;
|
||||
|
||||
if (roleNameKey) {
|
||||
const cachedByName = getCachedRoleClaims(roleNameKey);
|
||||
if (cachedByName && cachedByName.length > 0) return cachedByName;
|
||||
}
|
||||
}
|
||||
|
||||
let effectiveGuid = GUID_REGEX.test(roleId.trim()) ? roleId.trim() : null;
|
||||
if (!effectiveGuid) {
|
||||
const cachedRoles = getCachedSystemRoles();
|
||||
if (cachedRoles && cachedRoles.length > 0) {
|
||||
const match = cachedRoles.find(
|
||||
(r) =>
|
||||
(r.name && r.name.toLowerCase() === roleNameKey) ||
|
||||
(r.name && r.name.toLowerCase() === roleIdKey) ||
|
||||
String(r.id).toLowerCase() === roleIdKey
|
||||
);
|
||||
if (match && match.id && GUID_REGEX.test(String(match.id).trim())) {
|
||||
effectiveGuid = String(match.id).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveGuid) {
|
||||
try {
|
||||
const res = await apiClient.get<Claim[]>(`/v1/api/users/system/roles/${effectiveGuid}/claims`);
|
||||
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
||||
const normalized = res.data.map(normalizeClaim);
|
||||
setCachedRoleClaims(roleIdKey, normalized);
|
||||
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
|
||||
if (effectiveGuid.toLowerCase() !== roleIdKey) {
|
||||
setCachedRoleClaims(effectiveGuid.toLowerCase(), normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const cachedById = getCachedRoleClaims(roleIdKey);
|
||||
if (cachedById && cachedById.length > 0) return cachedById;
|
||||
|
||||
if (roleNameKey) {
|
||||
const cachedByName = getCachedRoleClaims(roleNameKey);
|
||||
if (cachedByName && cachedByName.length > 0) return cachedByName;
|
||||
}
|
||||
|
||||
const fallback = (roleNameKey && DEFAULT_ROLE_CLAIMS[roleNameKey]) || DEFAULT_ROLE_CLAIMS[roleIdKey] || [];
|
||||
const normalized = fallback.map(normalizeClaim);
|
||||
setCachedRoleClaims(roleIdKey, normalized);
|
||||
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function clearCachedUserRbac(userId?: string): void {
|
||||
if (userId) {
|
||||
const key = userId.toLowerCase().trim();
|
||||
inMemoryUserRoles.delete(key);
|
||||
inMemoryUserClaims.delete(key);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(`pfm_partner_cache_user_roles_${key}`);
|
||||
localStorage.removeItem(`pfm_partner_cache_user_claims_${key}`);
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
inMemoryUserRoles.clear();
|
||||
inMemoryUserClaims.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserRolesFromApi(userId: string, forceRefresh = false): Promise<Role[]> {
|
||||
const key = userId.toLowerCase().trim();
|
||||
if (!forceRefresh) {
|
||||
const cached = inMemoryUserRoles.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
|
||||
return cached.roles;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const raw = localStorage.getItem(`pfm_partner_cache_user_roles_${key}`);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
inMemoryUserRoles.set(key, { roles: parsed, timestamp: Date.now() });
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
|
||||
const roles = res.data || [];
|
||||
if (roles.length > 0) {
|
||||
inMemoryUserRoles.set(key, { roles, timestamp: Date.now() });
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(`pfm_partner_cache_user_roles_${key}`, JSON.stringify(roles));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
} catch {
|
||||
return inMemoryUserRoles.get(key)?.roles || [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserClaimsFromApi(userId: string, forceRefresh = false): Promise<Claim[]> {
|
||||
const key = userId.toLowerCase().trim();
|
||||
if (!forceRefresh) {
|
||||
const cached = inMemoryUserClaims.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
|
||||
return cached.claims;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const raw = localStorage.getItem(`pfm_partner_cache_user_claims_${key}`);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
const normalized = parsed.map(normalizeClaim);
|
||||
inMemoryUserClaims.set(key, { claims: normalized, timestamp: Date.now() });
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
|
||||
const claims = (res.data || []).map(normalizeClaim);
|
||||
if (claims.length > 0) {
|
||||
inMemoryUserClaims.set(key, { claims, timestamp: Date.now() });
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(`pfm_partner_cache_user_claims_${key}`, JSON.stringify(claims));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return claims;
|
||||
} catch {
|
||||
return inMemoryUserClaims.get(key)?.claims || [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveUserRolesAndClaims(
|
||||
userSub: string,
|
||||
initialClaims: Record<string, any> = {},
|
||||
currentProfile: { role?: string; tenant?: string; tenant_id?: number } = {}
|
||||
): Promise<{
|
||||
roles: string[];
|
||||
claims: Record<string, any>;
|
||||
primaryRole: string;
|
||||
tenant?: string;
|
||||
tenant_id?: number;
|
||||
}> {
|
||||
ensureFreshRbacCache();
|
||||
const mergedClaims: Record<string, any> = { ...initialClaims };
|
||||
const resolvedRoles: string[] = [];
|
||||
let userRoleObjects: Role[] = [];
|
||||
let primaryRole = currentProfile.role || (initialClaims.role as string) || '';
|
||||
let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined;
|
||||
let tenant_id = currentProfile.tenant_id ?? (initialClaims.tenant_id ? Number(initialClaims.tenant_id) : undefined);
|
||||
|
||||
const [rolesResult, claimsResult] = await Promise.allSettled([
|
||||
userSub ? getUserRolesFromApi(userSub) : Promise.resolve([]),
|
||||
userSub ? getUserClaimsFromApi(userSub) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value) && rolesResult.value.length > 0) {
|
||||
userRoleObjects = rolesResult.value;
|
||||
rolesResult.value.forEach((r) => {
|
||||
const rName = r.name || r.Name;
|
||||
if (rName && !resolvedRoles.includes(rName)) {
|
||||
resolvedRoles.push(rName);
|
||||
}
|
||||
});
|
||||
if (resolvedRoles.length > 0) {
|
||||
const partnerMatch = resolvedRoles.find((r) => r.toLowerCase().trim() === 'partner');
|
||||
primaryRole = partnerMatch || resolvedRoles[0];
|
||||
mergedClaims.roles = resolvedRoles;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedRoles.length === 0 && primaryRole) {
|
||||
resolvedRoles.push(primaryRole);
|
||||
mergedClaims.roles = resolvedRoles;
|
||||
}
|
||||
|
||||
const allRoleClaims: Claim[] = [];
|
||||
const roleClaimsPromises = resolvedRoles
|
||||
.filter((roleName) => roleName.toLowerCase().trim() === 'partner')
|
||||
.map(async (roleName) => {
|
||||
const matchedRoleObj = userRoleObjects.find(
|
||||
(r) => (r.name || r.Name || '').toLowerCase() === roleName.toLowerCase()
|
||||
);
|
||||
const roleIdToPass = matchedRoleObj?.id ? String(matchedRoleObj.id) : roleName;
|
||||
return fetchAndCacheRoleClaims(roleIdToPass, roleName);
|
||||
});
|
||||
|
||||
const roleClaimsResults = await Promise.allSettled(roleClaimsPromises);
|
||||
roleClaimsResults.forEach((res) => {
|
||||
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
|
||||
res.value.forEach((rc) => {
|
||||
const norm = normalizeClaim(rc);
|
||||
allRoleClaims.push(norm);
|
||||
if (norm.claimType) {
|
||||
mergedClaims[norm.claimType] = norm.claimValue || 'view, add, export';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (allRoleClaims.length > 0) {
|
||||
mergedClaims.role_claims = allRoleClaims;
|
||||
}
|
||||
|
||||
if (claimsResult.status === 'fulfilled' && Array.isArray(claimsResult.value) && claimsResult.value.length > 0) {
|
||||
claimsResult.value.forEach((c) => {
|
||||
const norm = normalizeClaim(c);
|
||||
const type = (norm.claimType || '').toLowerCase().trim();
|
||||
const val = norm.claimValue;
|
||||
if (type === 'tenant' && val) {
|
||||
tenant = String(val);
|
||||
mergedClaims.tenant = String(val);
|
||||
} else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
|
||||
const parsed = Number(val);
|
||||
if (!isNaN(parsed) && isFinite(parsed)) {
|
||||
tenant_id = parsed;
|
||||
mergedClaims.tenant_id = parsed;
|
||||
}
|
||||
}
|
||||
if (norm.claimType) {
|
||||
mergedClaims[norm.claimType] = norm.claimValue;
|
||||
}
|
||||
});
|
||||
mergedClaims.user_claims = claimsResult.value.map(normalizeClaim);
|
||||
}
|
||||
|
||||
return {
|
||||
roles: resolvedRoles,
|
||||
claims: mergedClaims,
|
||||
primaryRole,
|
||||
tenant,
|
||||
tenant_id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
|
||||
const roles = await fetchAndCacheSystemRoles();
|
||||
await Promise.allSettled(
|
||||
roles.map((role) => fetchAndCacheRoleClaims(role.id || role.Id || '', role.name || role.Name || ''))
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSystemClaims(): Promise<Claim[]> {
|
||||
if (inMemorySystemClaims && inMemorySystemClaims.length > 0) {
|
||||
return inMemorySystemClaims;
|
||||
}
|
||||
|
||||
const claimsMap = new Map<string, Claim>();
|
||||
ROLE_CLAIM_DEFINITIONS.forEach((def, index) => {
|
||||
claimsMap.set(def.claim, {
|
||||
claimId: index + 1,
|
||||
claimType: def.claim,
|
||||
claimValue: 'view, add, export',
|
||||
type: def.claim,
|
||||
value: 'view, add, export',
|
||||
});
|
||||
});
|
||||
|
||||
const allClaims = Array.from(claimsMap.values());
|
||||
inMemorySystemClaims = allClaims;
|
||||
return allClaims;
|
||||
}
|
||||
|
||||
@@ -11,36 +11,86 @@ export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_
|
||||
export function isUserSyncEndpoint(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
const clean = url.toLowerCase().split('?')[0];
|
||||
return clean.includes('/users/sync') || clean.includes('/integration/sync') || clean.includes('/user/sync');
|
||||
return (
|
||||
clean.includes('/users/sync') ||
|
||||
clean.includes('/integration/sync') ||
|
||||
clean.includes('/user/sync') ||
|
||||
clean.endsWith('/sync') ||
|
||||
clean === '/sync'
|
||||
);
|
||||
}
|
||||
|
||||
export function isTenantEndpoint(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
return url.toLowerCase().includes('/governance/tenants');
|
||||
const clean = url.toLowerCase().split('?')[0];
|
||||
return clean.includes('/governance/tenants');
|
||||
}
|
||||
|
||||
type SessionExpiredListener = (reason?: string) => void;
|
||||
|
||||
const listeners = new Set<SessionExpiredListener>();
|
||||
let hasTriggeredSessionExpired = false;
|
||||
|
||||
export function dismissSubordinateModals(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('pfm:dismiss-subordinates'));
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelSessionExpiration(): void {
|
||||
hasTriggeredSessionExpired = false;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('pfm:session-expired-cancelled'));
|
||||
}
|
||||
}
|
||||
|
||||
export function isSessionExpiredState(): boolean {
|
||||
return hasTriggeredSessionExpired;
|
||||
}
|
||||
|
||||
export function triggerSessionExpired(reason?: string, requestUrl?: string): void {
|
||||
if (requestUrl && isUserSyncEndpoint(requestUrl)) return;
|
||||
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) return;
|
||||
if (hasTriggeredSessionExpired) return;
|
||||
if (requestUrl && isUserSyncEndpoint(requestUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTriggeredSessionExpired) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExistingToken = typeof window !== 'undefined' && !!localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
|
||||
if (!hasExistingToken) return;
|
||||
if (!hasExistingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggeredSessionExpired = true;
|
||||
dismissSubordinateModals();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('pfm:session-expired', { detail: { reason: reason || 'HTTP 401 Unauthorized' } }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('pfm:session-expired', {
|
||||
detail: { reason: reason || 'HTTP 401 Unauthorized' },
|
||||
})
|
||||
);
|
||||
}
|
||||
listeners.forEach((cb) => cb(reason));
|
||||
|
||||
listeners.forEach((callback) => {
|
||||
try {
|
||||
callback(reason);
|
||||
} catch (err) {
|
||||
console.warn('[SessionExpiration] Callback execution error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function onSessionExpired(listener: SessionExpiredListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function resetSessionExpiredState(): void {
|
||||
@@ -49,6 +99,7 @@ export function resetSessionExpiredState(): void {
|
||||
|
||||
export function clearSessionAndRedirectToLanding(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY);
|
||||
localStorage.removeItem(LOCAL_STORAGE_USER_KEY);
|
||||
@@ -57,8 +108,16 @@ export function clearSessionAndRedirectToLanding(): void {
|
||||
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
|
||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
||||
} catch {}
|
||||
try { queryClient.clear(); } catch {}
|
||||
} catch (err) {
|
||||
console.warn('[SessionExpiration] Storage cleanup warning:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
queryClient.clear();
|
||||
} catch (err) {
|
||||
console.warn('[SessionExpiration] QueryClient clear warning:', err);
|
||||
}
|
||||
|
||||
resetSessionExpiredState();
|
||||
window.location.replace('/');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { getTenant, getTenants } from './api';
|
||||
import { ActivityStatus, Tenant } from '../types';
|
||||
import { setActivationPending } from './userSyncService';
|
||||
import { encodeHashIdSync, isAlreadyHashId } from './toolsService';
|
||||
|
||||
export interface TenantLockoutResult {
|
||||
isActive: boolean;
|
||||
tenant: Tenant | null;
|
||||
errorMessage?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
let cachedTenantLockout: {
|
||||
key: string;
|
||||
result: TenantLockoutResult;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
export function clearTenantLockoutCache(): void {
|
||||
cachedTenantLockout = null;
|
||||
}
|
||||
|
||||
export function isStatusActive(status: any): boolean {
|
||||
if (status === undefined || status === null || status === '') {
|
||||
return false;
|
||||
}
|
||||
if (status === ActivityStatus.Active || status === 1 || status === '1') {
|
||||
return true;
|
||||
}
|
||||
if (typeof status === 'string' && status.trim().toLowerCase() === 'active') {
|
||||
return true;
|
||||
}
|
||||
if (status === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getStatusLabel(status: any): string {
|
||||
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
|
||||
return 'Active';
|
||||
}
|
||||
if (status === ActivityStatus.Inactive || status === 2 || String(status).toLowerCase() === 'inactive') {
|
||||
return 'Inactive';
|
||||
}
|
||||
if (status === ActivityStatus.Disabled || status === 3 || String(status).toLowerCase() === 'disabled') {
|
||||
return 'Disabled';
|
||||
}
|
||||
if (String(status).toLowerCase() === 'suspended') {
|
||||
return 'Suspended';
|
||||
}
|
||||
if (status === 0 || String(status).toLowerCase() === 'pending') {
|
||||
return 'Pending';
|
||||
}
|
||||
if (status !== undefined && status !== null && status !== '') {
|
||||
return String(status);
|
||||
}
|
||||
return 'Not Specified';
|
||||
}
|
||||
|
||||
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> {
|
||||
// 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(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
|
||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
||||
if (
|
||||
cachedTenantLockout.result.isActive &&
|
||||
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
|
||||
) {
|
||||
return cachedTenantLockout.result;
|
||||
}
|
||||
}
|
||||
|
||||
let tenantRes: Tenant | null = null;
|
||||
let lastError: any = null;
|
||||
|
||||
// 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 {
|
||||
const hashedId = encodeHashIdSync(candidateId);
|
||||
tenantRes = await getTenant(hashedId || candidateId);
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
tenantRes = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
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 {
|
||||
isActive: false,
|
||||
tenant: null,
|
||||
errorMessage: errorMsg,
|
||||
statusCode: lastError?.response?.status,
|
||||
};
|
||||
}
|
||||
|
||||
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
|
||||
|
||||
const rawStatus =
|
||||
(tenantObj as any)?.status ??
|
||||
(tenantObj as any)?.Status ??
|
||||
(tenantObj as any)?.activityStatus ??
|
||||
(tenantObj as any)?.ActivityStatus;
|
||||
|
||||
const active = isStatusActive(rawStatus);
|
||||
|
||||
if (!active) {
|
||||
const statusLabel = getStatusLabel(rawStatus);
|
||||
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 {
|
||||
isActive: false,
|
||||
tenant: tenantObj,
|
||||
errorMessage: errorMsg,
|
||||
};
|
||||
}
|
||||
|
||||
setActivationPending(false);
|
||||
|
||||
const successResult: TenantLockoutResult = {
|
||||
isActive: true,
|
||||
tenant: tenantObj,
|
||||
};
|
||||
cachedTenantLockout = {
|
||||
key: cacheKey,
|
||||
result: successResult,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return successResult;
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
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: 11,
|
||||
salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
||||
};
|
||||
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
|
||||
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
||||
let updated = false;
|
||||
if (salt && salt.trim() !== '') {
|
||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||
HASHER_SETTINGS.salt = salt.trim();
|
||||
updated = true;
|
||||
}
|
||||
@@ -26,43 +34,85 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
||||
}
|
||||
}
|
||||
|
||||
export function isAlreadyHashId(candidate: string): boolean {
|
||||
if (
|
||||
typeof candidate !== 'string' ||
|
||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||
/^\d+$/.test(candidate)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decoded = hasher.decode(candidate);
|
||||
if (!Array.isArray(decoded) || decoded.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return hasher.encode(decoded as number[]) === candidate;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeHashIdSync(id: number | string | null | undefined): string {
|
||||
if (id === undefined || id === null || id === '') return '';
|
||||
if (id === undefined || id === null || id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof id === 'number') {
|
||||
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
||||
return hasher.encode(id);
|
||||
}
|
||||
|
||||
const str = String(id).trim();
|
||||
if (!str) return '';
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) return str;
|
||||
} catch {}
|
||||
if (/^\d+$/.test(str)) {
|
||||
const num = parseInt(str, 10);
|
||||
if (!isNaN(num) && num >= 0) return hasher.encode(num);
|
||||
|
||||
if (isAlreadyHashId(str)) {
|
||||
return str;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(str)) {
|
||||
try {
|
||||
const num = parseInt(str, 10);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
return hasher.encode(num);
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
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]);
|
||||
} catch {}
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return Number(decoded[0]);
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, return raw string
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
||||
if (!hashId) return 0;
|
||||
if (typeof hashId === 'number') return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
||||
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);
|
||||
@@ -70,22 +120,84 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
|
||||
}
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) return Number(decoded[0]);
|
||||
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 = {
|
||||
encodeIdSync: encodeHashIdSync,
|
||||
decodeIdSync: decodeHashIdSync,
|
||||
decodeToNumberSync,
|
||||
async encodeId(id: number | string | null | undefined) { return encodeHashIdSync(id); },
|
||||
async decodeId(hashId: string | null | undefined) { return decodeHashIdSync(hashId); },
|
||||
async resolveProjectId(id: number | string) { return encodeHashIdSync(id); },
|
||||
async resolveOrganisationId(id: number | string) { return encodeHashIdSync(id); },
|
||||
async resolveActivityId(id: number | string) { return encodeHashIdSync(id); },
|
||||
async resolveMilestoneId(id: number | string) { return encodeHashIdSync(id); },
|
||||
encodeIdSync(id: number | string | 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 | null | undefined, _context = 'general'): Promise<string> {
|
||||
return encodeHashIdSync(id);
|
||||
},
|
||||
|
||||
async decodeId(hashId: string | null | undefined, _context = 'general'): 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 resolveActivityId(activityId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(activityId);
|
||||
},
|
||||
|
||||
async resolveMilestoneId(milestoneId: number | string): Promise<string> {
|
||||
return encodeHashIdSync(milestoneId);
|
||||
},
|
||||
|
||||
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(_items: { id: number | string; context: string }[]): Promise<void> {
|
||||
// No-op - instant deterministic computation
|
||||
},
|
||||
};
|
||||
|
||||
export function useHashId(id?: number | string | null, context = 'general') {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UserSyncProfile {
|
||||
tenant?: string | null;
|
||||
tenant_id?: number | string | null;
|
||||
organisationId?: number | string | null;
|
||||
organisationName?: string | null;
|
||||
}
|
||||
|
||||
export interface UserSyncResult {
|
||||
@@ -34,41 +35,94 @@ export interface UserSyncResult {
|
||||
}
|
||||
|
||||
let inFlightSyncPromise: Promise<UserSyncResult> | null = null;
|
||||
let inMemorySyncRecord: UserSyncRecord | null = null;
|
||||
let inMemoryActivationPending: boolean | null = null;
|
||||
let inMemoryActivationReason: string | null = null;
|
||||
|
||||
export function isActivationPending(): boolean {
|
||||
try { return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true'; } catch { return false; }
|
||||
if (inMemoryActivationPending !== null) {
|
||||
return inMemoryActivationPending;
|
||||
}
|
||||
try {
|
||||
const val = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
|
||||
inMemoryActivationPending = val;
|
||||
return val;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActivationPendingReason(): string | null {
|
||||
try { return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); } catch { return null; }
|
||||
if (inMemoryActivationReason !== null) {
|
||||
return inMemoryActivationReason;
|
||||
}
|
||||
try {
|
||||
const reason = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
||||
inMemoryActivationReason = reason;
|
||||
return reason;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setActivationPending(pending: boolean, reason?: string): void {
|
||||
inMemoryActivationPending = pending;
|
||||
inMemoryActivationReason = reason || null;
|
||||
try {
|
||||
if (pending) {
|
||||
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
|
||||
if (reason) localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason);
|
||||
if (reason) {
|
||||
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason);
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
|
||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
|
||||
}
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.warn('[UserSync] Failed to update activation flag:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string | null): boolean {
|
||||
try {
|
||||
if (isActivationPending()) return false;
|
||||
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||
if (!raw) return false;
|
||||
const record: UserSyncRecord = JSON.parse(raw);
|
||||
if (isActivationPending()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!inMemorySyncRecord) {
|
||||
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||
if (!raw) return false;
|
||||
const parsed: UserSyncRecord = JSON.parse(raw);
|
||||
if (!parsed || parsed.status !== 'synced') return false;
|
||||
inMemorySyncRecord = parsed;
|
||||
}
|
||||
|
||||
const record = inMemorySyncRecord;
|
||||
if (!record || record.status !== 'synced') return false;
|
||||
if (currentSub && record.sub && record.sub !== currentSub) return false;
|
||||
if (currentToken && record.tokenSnippet && record.tokenSnippet !== currentToken.slice(-16)) return false;
|
||||
|
||||
if (currentSub && record.sub && record.sub !== currentSub) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentToken && record.tokenSnippet) {
|
||||
const snippet = currentToken.slice(-16);
|
||||
if (record.tokenSnippet !== snippet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch { return false; }
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveUserSyncInBrowser(sub: string, token: string | null | undefined, endpoint: string, userData?: any): void {
|
||||
export function saveUserSyncInBrowser(
|
||||
sub: string,
|
||||
token: string | null | undefined,
|
||||
endpoint: string,
|
||||
userData?: any
|
||||
): void {
|
||||
try {
|
||||
const record: UserSyncRecord = {
|
||||
sub: sub || 'authenticated_user',
|
||||
@@ -76,13 +130,19 @@ export function saveUserSyncInBrowser(sub: string, token: string | null | undefi
|
||||
syncedEndpoint: endpoint,
|
||||
tokenSnippet: token ? token.slice(-16) : '',
|
||||
status: 'synced',
|
||||
userData,
|
||||
userData: userData || undefined,
|
||||
};
|
||||
inMemorySyncRecord = record;
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.warn('[UserSync] Failed to write sync record to localStorage:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearUserSyncInBrowser(): void {
|
||||
inMemorySyncRecord = null;
|
||||
inMemoryActivationPending = null;
|
||||
inMemoryActivationReason = null;
|
||||
try {
|
||||
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
|
||||
@@ -90,63 +150,142 @@ export function clearUserSyncInBrowser(): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function clearSuccessfulSyncRecord(): void {
|
||||
inMemorySyncRecord = null;
|
||||
try {
|
||||
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function getUserSyncRecord(): UserSyncRecord | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as UserSyncRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const USER_SYNC_CANDIDATE_ENDPOINTS = [
|
||||
'/v1/api/integration/sync',
|
||||
'/v1/api/users/sync',
|
||||
'/v1/api/user/sync',
|
||||
];
|
||||
|
||||
async function executeBackendSync(userSub?: string, profile?: UserSyncProfile): Promise<{ endpoint: string; data?: any }> {
|
||||
export function isUserSyncEndpoint(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
const clean = url.toLowerCase().split('?')[0];
|
||||
return (
|
||||
clean.includes('/integration/sync') ||
|
||||
clean.includes('/users/sync') ||
|
||||
clean.includes('/user/sync') ||
|
||||
clean.endsWith('/sync') ||
|
||||
clean === '/sync'
|
||||
);
|
||||
}
|
||||
|
||||
async function executeBackendSync(
|
||||
userSub?: string,
|
||||
profile?: UserSyncProfile
|
||||
): Promise<{ endpoint: string; data?: any }> {
|
||||
const endpoint = '/v1/api/integration/sync';
|
||||
const payload = {
|
||||
id: userSub || profile?.id || profile?.sub,
|
||||
sub: userSub || profile?.sub,
|
||||
email: profile?.email || undefined,
|
||||
name: profile?.name || undefined,
|
||||
role: profile?.role || undefined,
|
||||
tenant_id: profile?.tenant_id ? Number(profile.tenant_id) : undefined,
|
||||
organisationId: profile?.organisationId ? Number(profile.organisationId) : undefined,
|
||||
tenant: profile?.tenant || undefined,
|
||||
tenant_id: profile?.tenant_id,
|
||||
organisationId: profile?.organisationId,
|
||||
};
|
||||
|
||||
let lastError: any = null;
|
||||
for (const endpoint of USER_SYNC_CANDIDATE_ENDPOINTS) {
|
||||
try {
|
||||
const res = await apiClient.post(endpoint, payload);
|
||||
return { endpoint, data: res?.data };
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
const status = err?.response?.status;
|
||||
if (status === 401 || status === 403 || status === 423) throw err;
|
||||
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;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw lastError || new Error('Failed to reach user sync endpoint');
|
||||
}
|
||||
|
||||
export async function syncUserAccount(userSub?: string, token?: string | null, profile?: UserSyncProfile, force = false): Promise<UserSyncResult> {
|
||||
export async function syncUserAccount(
|
||||
userSub?: string,
|
||||
token?: string | null,
|
||||
profile?: UserSyncProfile,
|
||||
force = false
|
||||
): Promise<UserSyncResult> {
|
||||
const effectiveToken = token || (typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null);
|
||||
const effectiveSub = userSub || (typeof window !== 'undefined' ? localStorage.getItem('pfm_user_sub') || '' : '');
|
||||
|
||||
if (!force && isUserSyncedInBrowser(effectiveSub, effectiveToken) && !isActivationPending()) {
|
||||
return { success: true, endpoint: '/v1/api/users/sync' };
|
||||
return {
|
||||
success: true,
|
||||
endpoint: getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync',
|
||||
data: getUserSyncRecord()?.userData,
|
||||
};
|
||||
}
|
||||
|
||||
if (inFlightSyncPromise) return inFlightSyncPromise;
|
||||
if (inFlightSyncPromise) {
|
||||
return inFlightSyncPromise;
|
||||
}
|
||||
|
||||
inFlightSyncPromise = (async (): Promise<UserSyncResult> => {
|
||||
try {
|
||||
const result = await executeBackendSync(effectiveSub, profile);
|
||||
setActivationPending(false);
|
||||
saveUserSyncInBrowser(effectiveSub, effectiveToken, result.endpoint, result.data);
|
||||
return { success: true, endpoint: result.endpoint, data: result.data };
|
||||
return {
|
||||
success: true,
|
||||
endpoint: result.endpoint,
|
||||
data: result.data,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
|
||||
if (status === 401) {
|
||||
clearUserSyncInBrowser();
|
||||
setActivationPending(false);
|
||||
return { success: false, isUnauthorized: true, errorMessage: 'Session expired (HTTP 401).' };
|
||||
return {
|
||||
success: false,
|
||||
isUnauthorized: true,
|
||||
errorMessage: 'Authentication session expired or unauthorized (HTTP 401).',
|
||||
};
|
||||
}
|
||||
const serverMsg = err?.response?.data?.detail || err?.response?.data?.message || 'Account activation pending.';
|
||||
|
||||
clearSuccessfulSyncRecord();
|
||||
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 { success: false, isUnauthorized: false, isActivationPending: true, errorMessage: serverMsg };
|
||||
|
||||
return {
|
||||
success: false,
|
||||
isUnauthorized: false,
|
||||
isActivationPending: true,
|
||||
errorMessage: serverMsg,
|
||||
};
|
||||
} finally {
|
||||
inFlightSyncPromise = null;
|
||||
}
|
||||
@@ -154,3 +293,13 @@ export async function syncUserAccount(userSub?: string, token?: string | null, p
|
||||
|
||||
return inFlightSyncPromise;
|
||||
}
|
||||
|
||||
export async function ensureUserSynced(
|
||||
userSub?: string,
|
||||
token?: string | null,
|
||||
profile?: UserSyncProfile,
|
||||
force = false
|
||||
): Promise<boolean> {
|
||||
const result = await syncUserAccount(userSub, token, profile, force);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { ProblemDetails } from '../types';
|
||||
|
||||
export type ErrorCategory =
|
||||
| 'validation'
|
||||
| 'creation'
|
||||
| 'update'
|
||||
| 'security'
|
||||
| 'notFound'
|
||||
| 'conflict'
|
||||
| 'server'
|
||||
| 'general';
|
||||
|
||||
export interface FriendlyError {
|
||||
title: string;
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
hasReturnObject: boolean;
|
||||
isProblemDetails: boolean;
|
||||
category: ErrorCategory;
|
||||
categoryLabel?: string;
|
||||
errorsList?: string[];
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
technicalDetails?: string;
|
||||
}
|
||||
|
||||
export interface ProblemDetailsResponse {
|
||||
type?: string | null;
|
||||
title?: string | null;
|
||||
status?: number | null;
|
||||
detail?: string | null;
|
||||
details?: string | null;
|
||||
Details?: string | null;
|
||||
instance?: string | null;
|
||||
errors?: Record<string, string[] | string> | any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string contains raw HTTP request/response headers or dump.
|
||||
*/
|
||||
export function isRawHttpOutput(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') return false;
|
||||
const headerPatterns = [
|
||||
/^(?:accept|authorization|content-type|content-length|origin|referer|user-agent|sec-ch-ua|sec-fetch|x-forwarded|x-real-ip|host):/im,
|
||||
/(?:authorization:\s*bearer|content-type:\s*application\/)/i,
|
||||
/HTTP\/\d(?:\.\d)?\s+\d{3}/i,
|
||||
];
|
||||
|
||||
let matches = 0;
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (/^[A-Za-z0-9-]+:\s*.+$/.test(trimmed)) {
|
||||
if (headerPatterns.some((p) => p.test(trimmed))) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches >= 2 || (matches >= 1 && /(?:authorization:\s*bearer|content-type:\s*application)/i.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts sensitive tokens and credentials from technical traces.
|
||||
*/
|
||||
export function sanitizeTechnicalDetails(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/(authorization:\s*bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
|
||||
.replace(/(bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
|
||||
.replace(/("?(?:token|access_token|password|secret|apiKey)"?\s*[:=]\s*)"[^"]+"/gi, '$1"[REDACTED]"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
|
||||
*/
|
||||
export function cleanTechnicalJargon(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
if (isRawHttpOutput(text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const cleaned = text
|
||||
.replace(/^AxiosError:\s*/i, '')
|
||||
.replace(/^Error:\s*/i, '')
|
||||
.replace(/Request failed with status code \d+/i, '')
|
||||
.replace(/https?:\/\/[^\s]+/g, '')
|
||||
.replace(/System\.[a-zA-Z0-9_.]+/g, '')
|
||||
.replace(/Microsoft\.[a-zA-Z0-9_.]+/g, '')
|
||||
.replace(/at\s+[a-zA-Z0-9_.]+\s*\(.*?\)/g, '')
|
||||
.trim();
|
||||
|
||||
if (cleaned.length < 2 || cleaned === '[object Object]' || cleaned.toLowerCase() === 'error') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely inspects whether an error contains a valid response body object.
|
||||
*/
|
||||
export function inspectErrorResponseObject(error: unknown): ProblemDetailsResponse | null {
|
||||
if (!error) return null;
|
||||
|
||||
const anyErr = error as any;
|
||||
const data = anyErr?.response?.data;
|
||||
if (data && typeof data === 'object') {
|
||||
return data as ProblemDetailsResponse;
|
||||
}
|
||||
if (typeof data === 'string' && data.trim().startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(data.trim());
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as ProblemDetailsResponse;
|
||||
}
|
||||
} catch {
|
||||
// Not a JSON object
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
if (
|
||||
'details' in anyErr ||
|
||||
'Details' in anyErr ||
|
||||
'detail' in anyErr ||
|
||||
'Detail' in anyErr ||
|
||||
'status' in anyErr ||
|
||||
'title' in anyErr ||
|
||||
'type' in anyErr ||
|
||||
'errors' in anyErr ||
|
||||
'Errors' in anyErr
|
||||
) {
|
||||
return anyErr as ProblemDetailsResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a response object conforms to RFC 7807 / RFC 9110 ProblemDetails.
|
||||
*/
|
||||
export function isProblemDetailsObject(data: any): data is ProblemDetails {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
return (
|
||||
'details' in data ||
|
||||
'Details' in data ||
|
||||
'title' in data ||
|
||||
'detail' in data ||
|
||||
'Detail' in data ||
|
||||
'status' in data ||
|
||||
'type' in data ||
|
||||
'errors' in data ||
|
||||
'Errors' in data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a comma-delimited error list from the `detail` property.
|
||||
*/
|
||||
export function parseCommaDelimitedErrors(detail?: string | null): string[] {
|
||||
if (!detail || typeof detail !== 'string') return [];
|
||||
const trimmed = detail.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const rawParts = trimmed.split(',');
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const part of rawParts) {
|
||||
const pTrim = part.trim();
|
||||
if (!pTrim) continue;
|
||||
const cleaned = cleanTechnicalJargon(pTrim);
|
||||
if (cleaned && cleaned.length > 0) {
|
||||
errors.push(cleaned);
|
||||
} else if (pTrim.length > 0 && pTrim.toLowerCase() !== 'error') {
|
||||
errors.push(pTrim);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length > 0 ? errors : [trimmed];
|
||||
}
|
||||
|
||||
function detectOperationIntent(
|
||||
method?: string,
|
||||
actionTitle?: string,
|
||||
url?: string
|
||||
): 'creation' | 'update' | 'deletion' | 'general' {
|
||||
const normMethod = (method || '').toUpperCase();
|
||||
const text = `${actionTitle || ''} ${url || ''}`.toLowerCase();
|
||||
|
||||
if (
|
||||
normMethod === 'POST' ||
|
||||
text.includes('create') ||
|
||||
text.includes('registration') ||
|
||||
text.includes('register') ||
|
||||
text.includes('dispatch') ||
|
||||
text.includes('add ') ||
|
||||
text.includes('new ') ||
|
||||
text.includes('upload') ||
|
||||
text.includes('record actual') ||
|
||||
text.includes('log risk')
|
||||
) {
|
||||
return 'creation';
|
||||
}
|
||||
|
||||
if (
|
||||
normMethod === 'PUT' ||
|
||||
normMethod === 'PATCH' ||
|
||||
text.includes('update') ||
|
||||
text.includes('edit') ||
|
||||
text.includes('save') ||
|
||||
text.includes('status') ||
|
||||
text.includes('approve') ||
|
||||
text.includes('execute') ||
|
||||
text.includes('modify')
|
||||
) {
|
||||
return 'update';
|
||||
}
|
||||
|
||||
if (normMethod === 'DELETE' || text.includes('delete') || text.includes('remove')) {
|
||||
return 'deletion';
|
||||
}
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive common error handling function across the application.
|
||||
* Handles RFC 9110 / RFC 7807 ProblemDetails and non-technical language.
|
||||
*/
|
||||
export function parseFriendlyError(
|
||||
error: unknown,
|
||||
fallbackActionTitle = 'Action Failed'
|
||||
): FriendlyError {
|
||||
let statusCode: number | undefined;
|
||||
let technicalSummary = '';
|
||||
let httpMethod = '';
|
||||
let requestUrl = '';
|
||||
|
||||
const anyErr = error as any;
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosErr = error as AxiosError<any>;
|
||||
statusCode = axiosErr.response?.status;
|
||||
httpMethod = axiosErr.config?.method?.toUpperCase() || '';
|
||||
requestUrl = axiosErr.config?.url || '';
|
||||
technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`;
|
||||
|
||||
if (!axiosErr.response) {
|
||||
if (axiosErr.code === 'ECONNABORTED' || axiosErr.message.includes('timeout')) {
|
||||
return {
|
||||
title: 'Request Timed Out',
|
||||
message: 'The server took too long to respond. Please check your network connection and try again.',
|
||||
statusCode: 408,
|
||||
hasReturnObject: false,
|
||||
isProblemDetails: false,
|
||||
category: 'server',
|
||||
categoryLabel: 'Timeout',
|
||||
technicalDetails: technicalSummary || axiosErr.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Connection Unavailable',
|
||||
message: 'Unable to reach the system service. Please verify your internet connection or try again in a few moments.',
|
||||
statusCode: 0,
|
||||
hasReturnObject: false,
|
||||
isProblemDetails: false,
|
||||
category: 'server',
|
||||
categoryLabel: 'Network',
|
||||
technicalDetails: technicalSummary || axiosErr.message,
|
||||
};
|
||||
}
|
||||
} else if (anyErr && typeof anyErr === 'object') {
|
||||
statusCode = anyErr.response?.status ?? anyErr.status;
|
||||
httpMethod = anyErr.config?.method?.toUpperCase() || '';
|
||||
requestUrl = anyErr.config?.url || '';
|
||||
if (statusCode) {
|
||||
technicalSummary = `HTTP ${statusCode}`;
|
||||
}
|
||||
}
|
||||
|
||||
const returnObject = inspectErrorResponseObject(error);
|
||||
const hasReturnObject = returnObject !== null;
|
||||
|
||||
if (returnObject) {
|
||||
if (returnObject.status && typeof returnObject.status === 'number') {
|
||||
statusCode = returnObject.status;
|
||||
}
|
||||
}
|
||||
|
||||
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
|
||||
|
||||
if (statusCode === 401) {
|
||||
const rawDetail =
|
||||
returnObject?.details ??
|
||||
returnObject?.Details ??
|
||||
returnObject?.detail ??
|
||||
returnObject?.Detail ??
|
||||
anyErr?.response?.data?.details ??
|
||||
anyErr?.response?.data?.Details ??
|
||||
anyErr?.response?.data?.detail ??
|
||||
anyErr?.details ??
|
||||
anyErr?.message;
|
||||
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
|
||||
return {
|
||||
title: 'Authentication Required',
|
||||
message: cleanDetail || 'Your authentication session has expired or is unauthorized (HTTP 401). Please sign in again.',
|
||||
statusCode: 401,
|
||||
hasReturnObject,
|
||||
isProblemDetails: hasReturnObject,
|
||||
category: 'security',
|
||||
categoryLabel: 'Unauthorized',
|
||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : 'HTTP 401 Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode === 403) {
|
||||
const rawDetail =
|
||||
returnObject?.details ??
|
||||
returnObject?.Details ??
|
||||
returnObject?.detail ??
|
||||
returnObject?.Detail ??
|
||||
anyErr?.response?.data?.details ??
|
||||
anyErr?.response?.data?.Details ??
|
||||
anyErr?.response?.data?.detail ??
|
||||
anyErr?.details ??
|
||||
anyErr?.message;
|
||||
const detailErrors = parseCommaDelimitedErrors(rawDetail);
|
||||
const friendlyMessage =
|
||||
detailErrors.length > 0
|
||||
? detailErrors.length === 1
|
||||
? detailErrors[0]
|
||||
: detailErrors.map((err) => `• ${err}`).join('\n')
|
||||
: 'You do not have permission to access this resource or perform this action (HTTP 403).';
|
||||
|
||||
return {
|
||||
title: fallbackActionTitle !== 'Action Failed' ? fallbackActionTitle : 'Access Forbidden',
|
||||
message: friendlyMessage,
|
||||
statusCode: 403,
|
||||
hasReturnObject,
|
||||
isProblemDetails: hasReturnObject,
|
||||
category: 'security',
|
||||
categoryLabel: 'Forbidden',
|
||||
errorsList: detailErrors.length > 0 ? detailErrors : undefined,
|
||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : 'HTTP 403 Forbidden',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasReturnObject && returnObject) {
|
||||
const rawDetail =
|
||||
returnObject.detail ??
|
||||
returnObject.Detail ??
|
||||
returnObject.details ??
|
||||
returnObject.Details ??
|
||||
anyErr?.response?.data?.detail ??
|
||||
anyErr?.response?.data?.Detail ??
|
||||
anyErr?.response?.data?.details ??
|
||||
anyErr?.response?.data?.Details ??
|
||||
anyErr?.detail ??
|
||||
anyErr?.Detail ??
|
||||
anyErr?.details ??
|
||||
anyErr?.Details ??
|
||||
returnObject.message ??
|
||||
returnObject.Message ??
|
||||
returnObject.error ??
|
||||
returnObject.Error;
|
||||
|
||||
const errorsList: string[] = [];
|
||||
|
||||
if (Array.isArray(rawDetail)) {
|
||||
rawDetail.forEach((e: any) => {
|
||||
const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e));
|
||||
if (cleaned) errorsList.push(cleaned);
|
||||
});
|
||||
} else if (typeof rawDetail === 'string' && rawDetail.trim()) {
|
||||
errorsList.push(...parseCommaDelimitedErrors(rawDetail));
|
||||
}
|
||||
|
||||
if (errorsList.length === 0 && returnObject.errors) {
|
||||
if (Array.isArray(returnObject.errors)) {
|
||||
returnObject.errors.forEach((e: any) => {
|
||||
const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e));
|
||||
if (cleaned) errorsList.push(cleaned);
|
||||
});
|
||||
} else if (typeof returnObject.errors === 'object') {
|
||||
for (const [key, val] of Object.entries(returnObject.errors)) {
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach((m) => {
|
||||
const cleaned = cleanTechnicalJargon(String(m));
|
||||
if (cleaned) errorsList.push(`${key}: ${cleaned}`);
|
||||
});
|
||||
} else if (typeof val === 'string' && val.trim()) {
|
||||
const cleaned = cleanTechnicalJargon(val);
|
||||
if (cleaned) errorsList.push(`${key}: ${cleaned}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let category: ErrorCategory = 'general';
|
||||
let categoryLabel: string = 'Error';
|
||||
|
||||
if (statusCode === 400) {
|
||||
if (operationIntent === 'creation') {
|
||||
category = 'creation';
|
||||
categoryLabel = 'Creation Error';
|
||||
} else if (operationIntent === 'update') {
|
||||
category = 'update';
|
||||
categoryLabel = 'Update Error';
|
||||
} else {
|
||||
category = 'validation';
|
||||
categoryLabel = 'Validation Error';
|
||||
}
|
||||
} else if (statusCode === 404) {
|
||||
category = 'notFound';
|
||||
categoryLabel = 'Not Found';
|
||||
} else if (statusCode === 409) {
|
||||
category = 'conflict';
|
||||
categoryLabel = 'Conflict';
|
||||
} else if (statusCode === 422) {
|
||||
category = 'validation';
|
||||
categoryLabel = 'Validation Error';
|
||||
} else if (statusCode && statusCode >= 500) {
|
||||
category = 'server';
|
||||
categoryLabel = 'Server Error';
|
||||
}
|
||||
|
||||
let friendlyMessage = '';
|
||||
if (errorsList.length > 1) {
|
||||
friendlyMessage = errorsList.map((err) => `• ${err}`).join('\n');
|
||||
} else if (errorsList.length === 1) {
|
||||
friendlyMessage = errorsList[0];
|
||||
} else {
|
||||
friendlyMessage =
|
||||
operationIntent === 'creation'
|
||||
? 'Failed to create record. Please verify the submitted parameters.'
|
||||
: operationIntent === 'update'
|
||||
? 'Failed to update record. Please verify the submitted parameters.'
|
||||
: 'Request could not be processed due to invalid parameters.';
|
||||
}
|
||||
|
||||
let displayTitle = fallbackActionTitle;
|
||||
if (displayTitle === 'Action Failed' && returnObject.title && returnObject.title.toLowerCase() !== 'error') {
|
||||
displayTitle = returnObject.title;
|
||||
}
|
||||
|
||||
return {
|
||||
title: displayTitle,
|
||||
message: friendlyMessage,
|
||||
statusCode,
|
||||
hasReturnObject: true,
|
||||
isProblemDetails: true,
|
||||
category,
|
||||
categoryLabel,
|
||||
errorsList: errorsList.length > 0 ? errorsList : undefined,
|
||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
let title = fallbackActionTitle || 'Action Failed';
|
||||
let message = '';
|
||||
let category: ErrorCategory = 'general';
|
||||
let categoryLabel: string = 'Error';
|
||||
|
||||
const directDetail =
|
||||
anyErr?.response?.data?.detail ??
|
||||
anyErr?.response?.data?.Detail ??
|
||||
anyErr?.response?.data?.details ??
|
||||
anyErr?.response?.data?.Details ??
|
||||
anyErr?.detail ??
|
||||
anyErr?.Detail ??
|
||||
anyErr?.details ??
|
||||
anyErr?.Details;
|
||||
|
||||
const fallbackErrorsList: string[] = [];
|
||||
|
||||
if (directDetail) {
|
||||
if (typeof directDetail === 'string' && directDetail.trim()) {
|
||||
fallbackErrorsList.push(...parseCommaDelimitedErrors(directDetail.trim()));
|
||||
message = fallbackErrorsList.join('\n');
|
||||
} else if (Array.isArray(directDetail)) {
|
||||
fallbackErrorsList.push(...directDetail.map(String));
|
||||
message = fallbackErrorsList.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation';
|
||||
categoryLabel = 'Bad Request';
|
||||
message =
|
||||
operationIntent === 'creation'
|
||||
? 'The new record could not be created. Please review the submitted parameters.'
|
||||
: operationIntent === 'update'
|
||||
? 'The record could not be updated. Please verify the submitted parameters.'
|
||||
: 'The request could not be processed due to invalid parameters.';
|
||||
break;
|
||||
|
||||
case 404:
|
||||
title = 'Record Not Found';
|
||||
category = 'notFound';
|
||||
categoryLabel = 'Not Found';
|
||||
message = 'The requested resource could not be found (HTTP 404). It may have been archived, removed, or the link is outdated.';
|
||||
break;
|
||||
|
||||
case 409:
|
||||
title = 'Resource Conflict';
|
||||
category = 'conflict';
|
||||
categoryLabel = 'Conflict';
|
||||
message = 'This operation conflicts with the current status of the resource (HTTP 409). Please refresh the page and try again.';
|
||||
break;
|
||||
|
||||
case 422:
|
||||
category = 'validation';
|
||||
categoryLabel = 'Incomplete Data';
|
||||
message = 'The submitted information could not be processed by the server (HTTP 422). Please verify all required fields.';
|
||||
break;
|
||||
|
||||
case 429:
|
||||
title = 'Rate Limit Exceeded';
|
||||
category = 'server';
|
||||
categoryLabel = 'Rate Limit';
|
||||
message = 'The system received too many requests in a short time (HTTP 429). Please pause for a moment before trying again.';
|
||||
break;
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
title = 'Service Temporarily Unavailable';
|
||||
category = 'server';
|
||||
categoryLabel = 'Service Issue';
|
||||
message = `The server encountered an error while processing your request (HTTP ${statusCode}). Please try again in a few moments.`;
|
||||
break;
|
||||
|
||||
default:
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
message = cleanTechnicalJargon(error);
|
||||
} else if (error instanceof Error && error.message) {
|
||||
message = cleanTechnicalJargon(error.message);
|
||||
}
|
||||
if (!message) {
|
||||
message = statusCode
|
||||
? `An unexpected server issue occurred (HTTP ${statusCode}). Please try again or contact support.`
|
||||
: 'An unexpected issue occurred while processing this request. Please try again.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
message,
|
||||
statusCode,
|
||||
hasReturnObject: fallbackErrorsList.length > 0,
|
||||
isProblemDetails: false,
|
||||
category,
|
||||
categoryLabel,
|
||||
errorsList: fallbackErrorsList.length > 0 ? fallbackErrorsList : undefined,
|
||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import Hashids from 'hashids';
|
||||
|
||||
const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||
? import.meta.env
|
||||
: {}) as Record<string, string | undefined>;
|
||||
|
||||
export const HASHER_SETTINGS = {
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
||||
};
|
||||
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
|
||||
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
||||
let updated = false;
|
||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||
HASHER_SETTINGS.salt = salt.trim();
|
||||
updated = true;
|
||||
}
|
||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||
const parsed = Number(minLength);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
HASHER_SETTINGS.minHashLength = parsed;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if candidate is already a HashID generated by this hasher.
|
||||
*/
|
||||
export function isHashId(candidate: string | null | undefined): boolean {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'string' ||
|
||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
||||
/^\d+$/.test(candidate)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decodedNumbers = hasher.decode(candidate);
|
||||
if (
|
||||
Array.isArray(decodedNumbers) &&
|
||||
decodedNumbers.length > 0 &&
|
||||
hasher.encode(decodedNumbers as number[]) === candidate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(candidate);
|
||||
if (
|
||||
decodedHex &&
|
||||
decodedHex.length > 0 &&
|
||||
hasher.encodeHex(decodedHex) === candidate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a user idNumber using Hashids Hex encoding.
|
||||
* Supports South African national IDs (13 digits), passports, and alphanumeric keys.
|
||||
*/
|
||||
export function encodeIdNumberHex(idNumber: string | number | null | undefined): string {
|
||||
if (idNumber === undefined || idNumber === null) return '';
|
||||
const str = String(idNumber).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (isHashId(str)) {
|
||||
return str;
|
||||
}
|
||||
|
||||
if (/^[0-9a-fA-F]+$/.test(str)) {
|
||||
try {
|
||||
const encoded = hasher.encodeHex(str.toLowerCase());
|
||||
if (encoded) return encoded;
|
||||
} catch {
|
||||
// Fallback below
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const hex = Array.from(str)
|
||||
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
const encoded = hasher.encodeHex(hex);
|
||||
return encoded || str;
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a Hashids Hex encoded string back to original idNumber.
|
||||
*/
|
||||
export function decodeIdNumberHex(hash: string | null | undefined): string {
|
||||
if (!hash || typeof hash !== 'string') return '';
|
||||
const trimmed = hash.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
try {
|
||||
const decodedHex = hasher.decodeHex(trimmed);
|
||||
if (!decodedHex) return trimmed;
|
||||
|
||||
if (/^[0-9a-fA-F]{4,}$/.test(decodedHex) && decodedHex.length % 2 === 0) {
|
||||
let isText = true;
|
||||
let text = '';
|
||||
for (let i = 0; i < decodedHex.length; i += 2) {
|
||||
const code = parseInt(decodedHex.slice(i, i + 2), 16);
|
||||
if (code < 32 || code > 126) {
|
||||
isText = false;
|
||||
break;
|
||||
}
|
||||
text += String.fromCharCode(code);
|
||||
}
|
||||
if (isText && text.length > 0) return text;
|
||||
}
|
||||
|
||||
return decodedHex;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts any numeric or string ID to a safe 64-bit integer string.
|
||||
* Preserves precision beyond 2^53 - 1 by avoiding standard float coercion.
|
||||
*/
|
||||
export function toSafeInt64String(val: string | number | bigint | null | undefined): string {
|
||||
if (val === undefined || val === null || val === '') return '';
|
||||
if (typeof val === 'bigint') return val.toString();
|
||||
if (typeof val === 'number') {
|
||||
if (isNaN(val) || !isFinite(val)) return '';
|
||||
return Math.trunc(val).toString();
|
||||
}
|
||||
const str = String(val).trim();
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
return str;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
|
||||
*/
|
||||
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
|
||||
if (id === undefined || id === null || id === '') return '';
|
||||
|
||||
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (isHashId(str)) return str;
|
||||
|
||||
if (/^\d+$/.test(str)) {
|
||||
try {
|
||||
const num = Number(str);
|
||||
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
|
||||
return hasher.encode(num);
|
||||
}
|
||||
const bigHex = BigInt(str).toString(16);
|
||||
return hasher.encodeHex(bigHex);
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically decodes a standard HashID string to its canonical ID representation as string.
|
||||
*/
|
||||
export function decodeEntityId(hashId: string | number | null | undefined): string {
|
||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||
const str = String(hashId).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (/^-?\d+$/.test(str)) return str;
|
||||
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return decoded[0].toString();
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(str);
|
||||
if (decodedHex) {
|
||||
try {
|
||||
return BigInt('0x' + decodedHex).toString();
|
||||
} catch {
|
||||
return decodedHex;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Return raw
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
- CorsOrigins__0=${CORS_ORIGIN_CONSOLE:-https://console.ifri.khongisa.co.za}
|
||||
- CorsOrigins__1=${CORS_ORIGIN_API:-https://api.ifri.khongisa.co.za}
|
||||
- CorsOrigins__2=${CORS_ORIGIN_LOCAL:-https://localhost:7208}
|
||||
- CorsOrigins__3=${CORS_ORIGIN_PARTNER:-https://partner.ifri.khongisa.co.za}
|
||||
|
||||
# Postgres Database Overrides
|
||||
- ConnectionStrings__PfmDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-pfm};User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD:-};
|
||||
|
||||
Reference in New Issue
Block a user