Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc62696017 | ||
|
|
d4598e0c6c | ||
|
|
43656e848a | ||
|
|
3a3245b643 | ||
|
|
fdaf12a183 | ||
|
|
6cdd03a02f | ||
|
|
1d1060d031 | ||
|
|
b0c89362d0 | ||
|
|
dee9ccf0b1 | ||
|
|
d9b3e80f7c | ||
|
|
8688c752d6 | ||
|
|
937a5595d8 | ||
|
|
58ce606410 | ||
|
|
a98bfa4308 | ||
|
|
6e56a4c223 | ||
|
|
e38edd24a0 | ||
|
|
1ac0018926 | ||
|
|
5ad59f6569 | ||
|
|
71a0abf0b2 | ||
|
|
627885e36b | ||
|
|
447ffb366e | ||
|
|
a9bb319eb1 | ||
|
|
bbba869cdd | ||
|
|
5ae2622454 | ||
|
|
bd0f03344d | ||
|
|
1af860fdc1 | ||
|
|
c04ae9a933 | ||
|
|
0e853811a9 | ||
|
|
3a3d5daefc | ||
|
|
fe9b9052e9 | ||
|
|
d25545ddad | ||
|
|
7effc32498 | ||
|
|
8909bcf5c4 | ||
|
|
411e603f51 | ||
|
|
138ef84973 | ||
|
|
312721bf51 | ||
|
|
dd59294911 | ||
|
|
28a3a17a15 | ||
|
|
4c2b0cd7d5 | ||
|
|
275fd109e6 | ||
|
|
248cc2fa2d | ||
|
|
761d14beb9 | ||
|
|
931e1a8698 | ||
|
|
aff58caeb0 | ||
|
|
1c5080bf04 | ||
|
|
1fb157aa0a | ||
|
|
0adfae1616 | ||
|
|
4856b6e6f4 | ||
|
|
0ba47269aa | ||
|
|
5e25fd7a11 | ||
|
|
f8fff04065 | ||
|
|
aca5882ad3 | ||
|
|
6b027193d1 | ||
|
|
81702a4bf8 | ||
|
|
5eeb1cbfc7 | ||
|
|
fb00986f59 | ||
|
|
0338737385 | ||
|
|
82c0936b4c | ||
|
|
5d2a886f6f | ||
|
|
dc4047ca6d | ||
|
|
46fe14d9ef | ||
|
|
f17368d939 | ||
|
|
7aea3fa8de | ||
|
|
d43eb17aa3 | ||
|
|
01547d8c3f | ||
|
|
18dd9131cb | ||
|
|
f82a5cef76 | ||
|
|
614730caab | ||
|
|
9823cd7ea6 | ||
|
|
a7a68fc290 | ||
|
|
4dbc74e488 | ||
|
|
d658497545 | ||
|
|
b947959724 | ||
|
|
58126e7573 | ||
|
|
2fede443cc | ||
|
|
80f220fcae | ||
|
|
06e2c3ee6a | ||
|
|
3c0fd8844c | ||
|
|
b7c135f0fc | ||
|
|
db053e3e5f | ||
|
|
d7177e1a72 | ||
|
|
c67041faea | ||
|
|
0e8a5e1169 | ||
|
|
41fdb067fe | ||
|
|
b4b687dfcc | ||
|
|
f200ccbf60 | ||
|
|
27f57150e1 | ||
|
|
79c63107e9 | ||
|
|
d945731252 | ||
|
|
c979a4632e | ||
|
|
9eed3cff6b | ||
|
|
6db1a2f367 | ||
|
|
06d9952cd7 | ||
|
|
92d481f75d | ||
|
|
1c36602995 | ||
|
|
5ae0c500ac | ||
|
|
db04f800e9 | ||
|
|
e7a19611f0 | ||
|
|
5ad24da7cf | ||
|
|
156dae40b7 | ||
|
|
1b67d9cf8b | ||
|
|
1bd05e64e5 | ||
|
|
303dba3c6f | ||
|
|
1dd4f54a1d | ||
|
|
fae1e080c4 | ||
|
|
4de23d27b3 | ||
|
|
d4bce12dbb | ||
|
|
299b876e1c | ||
|
|
dde1dbe056 | ||
|
|
b81e6feb45 | ||
|
|
4138a1941f | ||
|
|
17af31d810 | ||
|
|
b85cf6a1cb | ||
|
|
81ddc11746 | ||
|
|
5b94672ff5 | ||
|
|
c535392ef1 | ||
|
|
0607de1802 | ||
|
|
4c1e52e452 | ||
|
|
2414d74d1c | ||
|
|
93d9661a7f | ||
|
|
dba401fe94 | ||
|
|
42d73fe6cb | ||
|
|
1475887a7f | ||
|
|
730664d9c4 | ||
|
|
f38e569a34 | ||
|
|
7e79171f97 | ||
|
|
d7b868f2c1 | ||
|
|
d43a09b371 | ||
|
|
0dc0de1502 | ||
|
|
de47dd4a4f | ||
|
|
2810cc669b | ||
|
|
734af8c86f | ||
|
|
a754c42d14 | ||
|
|
bd7ca9372f | ||
|
|
0d01d91ab4 | ||
|
|
2c817a8818 | ||
|
|
4c3a96917b | ||
|
|
e5c3a75795 | ||
|
|
1bc7e65960 | ||
|
|
094126d84b | ||
|
|
2506241a25 | ||
|
|
cfe3530639 | ||
|
|
985557bee4 | ||
|
|
f0b8b3051a | ||
|
|
6c88b9e533 | ||
|
|
7d67408d2c | ||
|
|
d1d58a6183 | ||
|
|
60ad154841 | ||
|
|
2c13170859 | ||
|
|
d87c8aa3c9 | ||
|
|
6af7815ad1 | ||
|
|
a169f6c3f6 | ||
|
|
65ea753c19 | ||
|
|
e6d2663265 | ||
|
|
91b5814b60 | ||
|
|
0c4c848568 | ||
|
|
fb05150ebf | ||
|
|
2f9c20020b | ||
|
|
a771957db3 | ||
|
|
46267a2a34 | ||
|
|
a7f360cc2a | ||
|
|
67c08b40ca |
@@ -1,4 +1,4 @@
|
|||||||
using PostFundManagement.Application.Abstractions.Services;
|
using PostFundManagement.Application.Abstractions.Services;
|
||||||
using PostFundManagement.Application.Extensions;
|
using PostFundManagement.Application.Extensions;
|
||||||
using PostFundManagement.Domain.Abstractions;
|
using PostFundManagement.Domain.Abstractions;
|
||||||
using PostFundManagement.Domain.Errors;
|
using PostFundManagement.Domain.Errors;
|
||||||
@@ -13,29 +13,22 @@ public sealed class SetUserLockoutEndpoint : IEndpoint
|
|||||||
{
|
{
|
||||||
builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context,
|
builder.MapPut("api/users/{userId:Guid}/session/lockout/{disable:bool}", async (Guid userId, bool disable, HttpContext context,
|
||||||
IUserService service, CancellationToken cancellationToken = default) =>
|
IUserService service, CancellationToken cancellationToken = default) =>
|
||||||
{
|
await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
|
||||||
var (_, role) = context.User.GetUserRole();
|
{
|
||||||
if (role != null && role.Equals("Partner", StringComparison.OrdinalIgnoreCase))
|
{ IsSuccess: true } => Results.Ok(),
|
||||||
return Results.Forbid();
|
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||||
|
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||||
return await service.SetUserLockoutAsync(userId, context.GetUserId(), disable, cancellationToken) switch
|
var r => Results.InternalServerError(r.Errors.ToProblems())
|
||||||
{
|
})
|
||||||
{ IsSuccess: true } => Results.Ok(),
|
.WithDescription("Lock user session")
|
||||||
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
.RequireAuthorization(Constants.MainScope)
|
||||||
var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
|
.WithName(typeof(SetUserLockoutEndpoint).ToEndpointName())
|
||||||
var r => Results.InternalServerError(r.Errors.ToProblems())
|
.MapToApiVersion(new ApiVersion(1))
|
||||||
};
|
.Produces(StatusCodes.Status200OK)
|
||||||
})
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.WithDescription("Lock user session")
|
.Produces<Problems>(StatusCodes.Status404NotFound)
|
||||||
.RequireAuthorization(Constants.MainScope)
|
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||||
.WithName(typeof(SetUserLockoutEndpoint).ToEndpointName())
|
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||||
.MapToApiVersion(new ApiVersion(1))
|
.WithTags(EndpointTags.Users);
|
||||||
.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)
|
public void Map(IEndpointRouteBuilder builder)
|
||||||
{
|
{
|
||||||
builder.MapPost("api/integration/sync", async (HttpContext context, IUserService service,
|
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator,
|
||||||
CancellationToken cancellationToken = default) =>
|
HttpContext context, IUserService service, CancellationToken cancellationToken = default) =>
|
||||||
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
|
await service.SyncUserAsync(context.GetUserId(), context.GetUserEmail(), context.GetUserRole(), cancellationToken) switch
|
||||||
{
|
{
|
||||||
{ IsSuccess: true } => Results.Ok(),
|
{ IsSuccess: true } => Results.Ok(),
|
||||||
|
|||||||
@@ -116,30 +116,7 @@ builder.Services.AddCors(options =>
|
|||||||
{
|
{
|
||||||
options.AddPolicy(CorsPolicy, policy =>
|
options.AddPolicy(CorsPolicy, policy =>
|
||||||
{
|
{
|
||||||
policy.SetIsOriginAllowed(origin =>
|
policy.WithOrigins(allowedOrigins)
|
||||||
{
|
|
||||||
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()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowCredentials();
|
.AllowCredentials();
|
||||||
@@ -154,7 +131,6 @@ var scheduler = await schedulerFactory.GetScheduler();
|
|||||||
if (scheduler!.Status != SchedulerStatus.Running)
|
if (scheduler!.Status != SchedulerStatus.Running)
|
||||||
await scheduler.Start();
|
await scheduler.Start();
|
||||||
|
|
||||||
app.UseCors(CorsPolicy);
|
|
||||||
app.UseHsts();
|
app.UseHsts();
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"EnableServiceCaching": true,
|
"EnableServiceCaching": true,
|
||||||
"CorsOrigins": [
|
"CorsOrigins": [
|
||||||
"https://partner.ifri.khongisa.co.za",
|
|
||||||
"https://console.ifri.khongisa.co.za",
|
"https://console.ifri.khongisa.co.za",
|
||||||
"https://localhost:7208"
|
"https://localhost:7208"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ APP_URL="MY_APP_URL"
|
|||||||
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
|
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
|
||||||
VITE_OIDC_AUDIENCE="pfm-api-dev"
|
VITE_OIDC_AUDIENCE="pfm-api-dev"
|
||||||
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
|
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
|
||||||
|
VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27"
|
||||||
|
|
||||||
# PFM Backend API Base URL
|
# PFM Backend API Base URL
|
||||||
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
|
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
|
||||||
|
|||||||
@@ -26,12 +26,15 @@ OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audien
|
|||||||
# 4. OIDC Client ID
|
# 4. OIDC Client ID
|
||||||
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
||||||
|
|
||||||
# 5. OIDC Scope
|
# 5. OIDC Client Secret
|
||||||
|
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
|
||||||
|
|
||||||
|
# 6. OIDC Scope
|
||||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||||
|
|
||||||
# 6. Hasher Settings
|
# 7. 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:-}}}}}"
|
||||||
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
|
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
||||||
|
|
||||||
if [ -d "$TARGET_DIR" ]; then
|
if [ -d "$TARGET_DIR" ]; then
|
||||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||||
@@ -48,12 +51,14 @@ window.__RUNTIME_CONFIG__ = {
|
|||||||
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
||||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||||
VITE_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}",
|
OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
|
||||||
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
|
||||||
HASHER_SALT: "${HASHER_SALT}",
|
HASHER_SALT: "${HASHER_SALT}",
|
||||||
VITE_HASHER_SALT: "${HASHER_SALT}",
|
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||||
|
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
|
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
ApiSettings: {
|
ApiSettings: {
|
||||||
BaseUrl: "${API_BASE_URL}"
|
BaseUrl: "${API_BASE_URL}"
|
||||||
},
|
},
|
||||||
@@ -61,6 +66,7 @@ window.__RUNTIME_CONFIG__ = {
|
|||||||
Authority: "${OIDC_AUTHORITY}",
|
Authority: "${OIDC_AUTHORITY}",
|
||||||
Audience: "${OIDC_AUDIENCE}",
|
Audience: "${OIDC_AUDIENCE}",
|
||||||
ClientId: "${OIDC_CLIENT_ID}",
|
ClientId: "${OIDC_CLIENT_ID}",
|
||||||
|
ClientSecret: "${OIDC_CLIENT_SECRET}",
|
||||||
Scope: "${OIDC_SCOPE}"
|
Scope: "${OIDC_SCOPE}"
|
||||||
},
|
},
|
||||||
HasherSettings: {
|
HasherSettings: {
|
||||||
@@ -80,6 +86,7 @@ EOF
|
|||||||
"Authority": "${OIDC_AUTHORITY}",
|
"Authority": "${OIDC_AUTHORITY}",
|
||||||
"Audience": "${OIDC_AUDIENCE}",
|
"Audience": "${OIDC_AUDIENCE}",
|
||||||
"ClientId": "${OIDC_CLIENT_ID}",
|
"ClientId": "${OIDC_CLIENT_ID}",
|
||||||
|
"ClientSecret": "${OIDC_CLIENT_SECRET}",
|
||||||
"Scope": "${OIDC_SCOPE}"
|
"Scope": "${OIDC_SCOPE}"
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
"Authority": "",
|
"Authority": "",
|
||||||
"Audience": "",
|
"Audience": "",
|
||||||
"ClientId": "",
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
"Scope": ""
|
"Scope": ""
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
|
"Salt": "",
|
||||||
"MinHashLength": "11"
|
"MinHashLength": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
// Development and fallback runtime configuration.
|
// Development and fallback runtime configuration.
|
||||||
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
||||||
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
// 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,14 +10,13 @@ import {
|
|||||||
isActivationPending,
|
isActivationPending,
|
||||||
getActivationPendingReason,
|
getActivationPendingReason,
|
||||||
clearUserSyncInBrowser,
|
clearUserSyncInBrowser,
|
||||||
setActivationPending,
|
|
||||||
UserSyncResult,
|
UserSyncResult,
|
||||||
} from '../services/userSyncService';
|
} from '../services/userSyncService';
|
||||||
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
||||||
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
|
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
|
||||||
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
||||||
import { encodeHashIdSync } from '../utils/hashids';
|
import { encodeHashIdSync } from '../services/toolsService';
|
||||||
import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
|
import { verifyTenantLockout } from '../services/tenantLockoutService';
|
||||||
import {
|
import {
|
||||||
onSessionExpired,
|
onSessionExpired,
|
||||||
triggerSessionExpired,
|
triggerSessionExpired,
|
||||||
@@ -308,6 +307,11 @@ export function resetUserManager(): void {
|
|||||||
userManagerInstance = null;
|
userManagerInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatically reset UserManager when runtime configuration is updated
|
||||||
|
onConfigChanged(() => {
|
||||||
|
resetUserManager();
|
||||||
|
});
|
||||||
|
|
||||||
export function getUserManager(): UserManager {
|
export function getUserManager(): UserManager {
|
||||||
if (!userManagerInstance && typeof window !== 'undefined') {
|
if (!userManagerInstance && typeof window !== 'undefined') {
|
||||||
const sec = getSecuritySettings();
|
const sec = getSecuritySettings();
|
||||||
@@ -316,7 +320,7 @@ export function getUserManager(): UserManager {
|
|||||||
userManagerInstance = new UserManager({
|
userManagerInstance = new UserManager({
|
||||||
authority: sec.Authority,
|
authority: sec.Authority,
|
||||||
client_id: sec.ClientId,
|
client_id: sec.ClientId,
|
||||||
...(sec.ClientSecret ? { client_secret: sec.ClientSecret } : {}),
|
client_secret: sec.ClientSecret,
|
||||||
redirect_uri: `${origin}/dashboard`,
|
redirect_uri: `${origin}/dashboard`,
|
||||||
post_logout_redirect_uri: `${origin}/`,
|
post_logout_redirect_uri: `${origin}/`,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
@@ -433,8 +437,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
||||||
|
|
||||||
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
|
const tenantClaimValues = extractTenantClaimValues(mergedClaims, token);
|
||||||
let tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
|
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||||
let parsedTenantId = extractTenantId(mergedClaims, token);
|
const parsedTenantId = extractTenantId(mergedClaims, token);
|
||||||
setTenantId(parsedTenantId);
|
setTenantId(parsedTenantId);
|
||||||
|
|
||||||
const profile: AuthUserProfile = {
|
const profile: AuthUserProfile = {
|
||||||
@@ -454,7 +458,29 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
// Clean up code and state from URL
|
// Clean up code and state from URL
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
|
||||||
// Step 1: Synchronize authenticated user profile with backend directory FIRST (/v1/api/integration/sync)
|
// 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
|
||||||
try {
|
try {
|
||||||
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
||||||
if (syncResult.isUnauthorized) {
|
if (syncResult.isUnauthorized) {
|
||||||
@@ -462,51 +488,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
window.location.replace('/');
|
window.location.replace('/');
|
||||||
return;
|
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) {
|
if (syncResult.isActivationPending) {
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
@@ -515,6 +496,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setIsPendingActivation(false);
|
setIsPendingActivation(false);
|
||||||
setIsSynced(true);
|
setIsSynced(true);
|
||||||
setActivationError(null);
|
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) {
|
} catch (syncErr: any) {
|
||||||
if (syncErr?.response?.status === 401) {
|
if (syncErr?.response?.status === 401) {
|
||||||
@@ -524,9 +525,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
const rawMsg = syncErr?.message || 'Account activation pending';
|
setActivationError(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);
|
setIsLoading(false);
|
||||||
@@ -562,10 +561,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setSub(storedSub);
|
setSub(storedSub);
|
||||||
}
|
}
|
||||||
|
|
||||||
let storedTenantId = extractTenantId(parsedClaims, storedToken);
|
const storedTenantId = extractTenantId(parsedClaims, storedToken);
|
||||||
setTenantId(storedTenantId);
|
setTenantId(storedTenantId);
|
||||||
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
|
const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken);
|
||||||
let storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
|
const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined;
|
||||||
|
|
||||||
let resolvedUser: AuthUserProfile | null = null;
|
let resolvedUser: AuthUserProfile | null = null;
|
||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
@@ -589,53 +588,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
// Initial fast unblock: valid persisted session found, unblock UI immediately
|
// Initial fast unblock: valid persisted session found, unblock UI immediately
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
// Asynchronous background revalidation (User Sync, Role Claims, Tenant Lockout)
|
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims)
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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(
|
const tenantLockoutResult = await verifyTenantLockout(
|
||||||
storedTenantId ?? tenantClaimValues.tenantId,
|
storedTenantId ?? tenantClaimValues.tenantId,
|
||||||
storedTenantClaim
|
storedTenantClaim
|
||||||
@@ -657,6 +612,22 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setTenantName(tenantLockoutResult.tenant.name);
|
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) {
|
if (syncResult.isActivationPending) {
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
@@ -668,6 +639,27 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setIsPendingActivation(false);
|
setIsPendingActivation(false);
|
||||||
setIsSynced(true);
|
setIsSynced(true);
|
||||||
setActivationError(null);
|
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) {
|
} catch (syncErr: any) {
|
||||||
if (syncErr?.response?.status === 401) {
|
if (syncErr?.response?.status === 401) {
|
||||||
@@ -676,9 +668,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
const rawMsg = syncErr?.message || 'Account activation pending';
|
setActivationError(syncErr?.message || 'Account activation pending');
|
||||||
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
|
||||||
setActivationError(cleanMsg);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -697,65 +687,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
|
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
|
||||||
try {
|
try {
|
||||||
await loadRuntimeConfig();
|
// Re-verify tenant status via GetTenant as part of the lockout resolution
|
||||||
clearTenantLockoutCache();
|
|
||||||
|
|
||||||
// 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 tenantClaimValues = extractTenantClaimValues(claims, accessToken);
|
||||||
const effectiveTenantId =
|
const currentTenantId = tenantId ?? tenantClaimValues.tenantId;
|
||||||
currentTenantId ??
|
const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined;
|
||||||
result.data?.tenantId ??
|
|
||||||
result.data?.organisationId ??
|
|
||||||
tenantClaimValues.tenantId;
|
|
||||||
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
|
|
||||||
|
|
||||||
const tenantLockoutResult = await verifyTenantLockout(
|
const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim);
|
||||||
effectiveTenantId,
|
|
||||||
effectiveTenantClaim,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
if (!tenantLockoutResult.isActive) {
|
if (!tenantLockoutResult.isActive) {
|
||||||
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
|
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
@@ -768,22 +705,28 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tenantLockoutResult.tenant?.name) {
|
// Tenant is verified Active: proceed with user sync
|
||||||
setTenantName(tenantLockoutResult.tenant.name);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
setActivationPending(false);
|
|
||||||
setIsPendingActivation(false);
|
|
||||||
setIsSynced(true);
|
|
||||||
setActivationError(null);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.response?.status === 401) {
|
if (err?.response?.status === 401) {
|
||||||
await logout();
|
await logout();
|
||||||
return { success: false, isUnauthorized: true };
|
return { success: false, isUnauthorized: true };
|
||||||
}
|
}
|
||||||
const rawMsg = err?.message || 'Sync failed';
|
const msg = err?.message || 'Sync failed';
|
||||||
const msg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
setActivationError(msg);
|
setActivationError(msg);
|
||||||
|
|||||||
@@ -115,94 +115,141 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTenantIssue = !!errorMessage?.toLowerCase().includes('tenant');
|
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="activation-modal-title"
|
aria-labelledby="activation-modal-title"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
|
{/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */}
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Subtle accent top border */}
|
{/* Amber accent top bar */}
|
||||||
<div className={`h-1 w-full ${isTenantIssue ? 'bg-amber-500' : 'bg-blue-600'}`} />
|
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-yellow-500" />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Modal Header */}
|
||||||
<div className="p-5 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
<div className="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3.5">
|
||||||
<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'}`}>
|
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400">
|
||||||
<ShieldAlert className="w-5 h-5" />
|
<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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="activation-modal-title" className="text-base font-bold text-white tracking-tight">
|
<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">
|
||||||
{isTenantIssue ? 'Tenant Access Restricted' : 'Account Pending Activation'}
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BrandLogo size={32} showGlow={false} animateOnHover={false} />
|
<BrandLogo size={36} showGlow={false} animateOnHover={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Modal Body */}
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]">
|
||||||
{/* User Profile Summary */}
|
{/* Status notification block */}
|
||||||
<div className="rounded-xl border border-slate-800/80 bg-slate-900/60 p-3.5 space-y-2 text-xs">
|
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5">
|
||||||
<div className="grid grid-cols-2 gap-2.5">
|
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
|
||||||
<div>
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">User</span>
|
<span>Identity Verified & Signed In Successfully</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>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2.5 border-t border-slate-800/60 pt-2">
|
<p className="text-xs text-slate-300 leading-relaxed">
|
||||||
<div>
|
You are a registered user and have successfully authenticated via Enterprise Single Sign-On.
|
||||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Tenant</span>
|
However, your account has not yet been enabled in the system directory by a system administrator.
|
||||||
<span className="text-slate-300 truncate block">{tenant}</span>
|
</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>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="text-[10px] uppercase font-semibold text-slate-500 block">Role</span>
|
<div className="flex items-center gap-2.5 bg-slate-900/60 p-2.5 rounded-lg border border-slate-800/60">
|
||||||
<span className="text-blue-400 font-medium truncate block">{role}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feedback notice if sync failed */}
|
{/* Sync Attempt Feedback / Error Notice */}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<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">
|
<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-amber-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
|
||||||
<div className="min-w-0">
|
<div className="space-y-1">
|
||||||
<span className="font-semibold text-amber-300 block">Notice:</span>
|
<p className="text-xs font-semibold text-rose-300">
|
||||||
<p className="text-[11px] text-amber-200/90 leading-relaxed mt-0.5">{errorMessage}</p>
|
{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>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Modal 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="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={handleNavigateLanding}
|
onClick={handleNavigateLanding}
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
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."
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
<span>Landing Page</span>
|
<span>Landing Page</span>
|
||||||
@@ -211,7 +258,8 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
@@ -223,10 +271,10 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
disabled={isSyncing}
|
disabled={isSyncing}
|
||||||
type="button"
|
type="button"
|
||||||
id="activation-sync-btn"
|
id="activation-sync-btn"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||||
<span>{isSyncing ? 'Checking...' : 'Sync Account'}</span>
|
<span>{isSyncing ? 'Verifying Activation...' : 'Sync Account'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,57 +61,114 @@ export const PartnerAccessDeniedModal: React.FC<PartnerAccessDeniedModalProps> =
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="partner-denied-title"
|
aria-labelledby="partner-denied-title"
|
||||||
aria-describedby="partner-denied-description"
|
aria-describedby="partner-denied-description"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<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="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">
|
||||||
<div className="h-1 w-full bg-rose-600" />
|
{/* Glow Header Accent */}
|
||||||
|
<div className="h-2 w-full bg-linear-to-r from-rose-600 via-red-500 to-amber-500" />
|
||||||
|
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-6 sm:p-8 space-y-6">
|
||||||
<div className="flex items-start gap-3">
|
{/* Header */}
|
||||||
<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">
|
<div className="flex items-start gap-4">
|
||||||
<Ban className="h-5 w-5" />
|
<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>
|
</div>
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<h2 id="partner-denied-title" className="text-base font-bold text-white tracking-tight">
|
<div className="flex items-center gap-2">
|
||||||
Partner Portal Required
|
<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
|
||||||
</h2>
|
</h2>
|
||||||
<p id="partner-denied-description" className="text-xs text-slate-400 mt-0.5">
|
<p id="partner-denied-description" className="text-xs text-slate-400 leading-relaxed">
|
||||||
Accounts with the Partner role must access the Partner Portal.
|
Users assigned the <strong className="text-rose-300">Partner</strong> role by security are not permitted to access the PFM Backoffice Console.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-3.5 space-y-2 text-xs">
|
{/* User & Identity Summary Card */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs">
|
||||||
<span className="text-slate-500 text-[11px] uppercase font-semibold">User</span>
|
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||||
<span className="text-white font-medium">{displayName}</span>
|
<span className="text-slate-400 font-medium">Account Name</span>
|
||||||
|
<span className="text-white font-semibold">{displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayEmail && (
|
{displayEmail && (
|
||||||
<div className="flex items-center justify-between border-t border-slate-800/60 pt-2">
|
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||||
<span className="text-slate-500 text-[11px] uppercase font-semibold">Email</span>
|
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||||
<span className="text-slate-300 font-mono text-[11px]">{displayEmail}</span>
|
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-200 font-mono">{displayEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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>
|
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||||
<span className="text-rose-400 font-medium">{role}</span>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-slate-400 text-center leading-relaxed">
|
{/* Security Policy Information */}
|
||||||
The Console is reserved for statutory administrators and programme managers.
|
<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>
|
<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>
|
||||||
|
|
||||||
<div className="pt-1">
|
{/* Actions */}
|
||||||
|
<div className="pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogoutClick}
|
onClick={handleLogoutClick}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4 text-rose-400" />
|
||||||
<span>Sign Out & Return Home</span>
|
<span>Sign Out & Return to Home</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
|||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
id="session-expired-modal-backdrop"
|
id="session-expired-modal-backdrop"
|
||||||
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"
|
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"
|
||||||
onMouseEnter={() => setIsPaused(true)}
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
onMouseLeave={() => setIsPaused(false)}
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -96,20 +96,27 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
id="session-expired-modal-content"
|
id="session-expired-modal-content"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-amber-500" />
|
{/* 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" />
|
||||||
|
|
||||||
|
{/* Top bar with close button */}
|
||||||
<div className="flex items-start justify-between pt-1">
|
<div className="flex items-start justify-between pt-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<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">
|
<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-5 h-5" />
|
<ShieldAlert className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
|
<div className="flex items-center gap-2">
|
||||||
Session Expired
|
<h3 id="session-expired-title" className="text-base font-bold text-white tracking-tight">
|
||||||
</h3>
|
Session Expired
|
||||||
<p className="text-xs text-slate-400">Authorization credentials expired</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,46 +125,60 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleAction}
|
onClick={handleAction}
|
||||||
aria-label="Dismiss and return to landing page"
|
aria-label="Dismiss and return to landing page"
|
||||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-colors cursor-pointer"
|
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800/80 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
{/* 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">
|
||||||
<p>
|
<p>
|
||||||
Your active session has ended to protect account security. Please sign in again to continue working.
|
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.
|
||||||
</p>
|
</p>
|
||||||
{reason && (
|
{reason && (
|
||||||
<div className="flex items-start gap-1.5 pt-1 text-[11px] text-amber-300/90 font-mono">
|
<div className="flex items-start gap-2 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" />
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-400" />
|
||||||
<span className="break-words">{reason}</span>
|
<span className="break-words">{reason}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
{/* Progress Bar Countdown */}
|
||||||
<span className="flex items-center gap-1.5">
|
<div className="space-y-2">
|
||||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
<div className="flex items-center justify-between text-xs">
|
||||||
Redirecting in <strong className="text-white font-mono">{secondsRemaining}s</strong>
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
</span>
|
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
{isPaused && (
|
Returning to landing page in{' '}
|
||||||
<span className="text-[10px] text-amber-400/80 px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20">
|
<strong className="text-white font-mono">{secondsRemaining}s</strong>
|
||||||
Paused
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="pt-1">
|
{/* Actions */}
|
||||||
|
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
|
||||||
<button
|
<button
|
||||||
id="session-expired-return-btn"
|
id="session-expired-return-btn"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAction}
|
onClick={handleAction}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Return to Landing Page</span>
|
<span>Return to Landing Page Now</span>
|
||||||
<ArrowRight className="w-3.5 h-3.5" />
|
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
getAllProgrammes,
|
getAllProgrammes,
|
||||||
getProjects,
|
getProjects,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { toolsService } from '../../utils/hashids';
|
import { toolsService } from '../../services/toolsService';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
||||||
|
|||||||
@@ -237,26 +237,16 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
onError: (err: unknown) => toastError('Logout Failed', err),
|
onError: (err: unknown) => toastError('Logout Failed', err),
|
||||||
});
|
});
|
||||||
|
|
||||||
const lockUserMutation = useMutation({
|
const lockoutMutation = useMutation({
|
||||||
mutationFn: () => setUserLockout(userId, true),
|
mutationFn: (disableLock: boolean) => setUserLockout(userId, disableLock),
|
||||||
onSuccess: () => {
|
onSuccess: (_, disableLock) => {
|
||||||
success('Account Locked', 'User account lockout enabled (disable = true).');
|
success(
|
||||||
|
disableLock ? 'Account Unlocked' : 'Account Locked',
|
||||||
|
`User account lockout status successfully modified.`
|
||||||
|
);
|
||||||
refetchUser();
|
refetchUser();
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['user-detail', userId] });
|
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => toastError('Lock User Failed', err),
|
onError: (err: unknown) => toastError('Lockout Update 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({
|
const statusMutation = useMutation({
|
||||||
@@ -290,18 +280,18 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={`User Security: ${effectiveUser.email || effectiveUser.id}`}
|
title={`User Administration: ${effectiveUser.email || effectiveUser.id}`}
|
||||||
subtitle="Manage account status, role assignment, permissions, and active sessions."
|
subtitle="Direct tenant-level user, role, claims, and session administration without database intervention."
|
||||||
>
|
>
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<div className="flex gap-1.5 border-b border-slate-800 pb-2">
|
<div className="flex gap-2 border-b border-slate-800 pb-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('overview')}
|
onClick={() => setActiveTab('overview')}
|
||||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer ${
|
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer ${
|
||||||
activeTab === 'overview'
|
activeTab === 'overview'
|
||||||
? 'bg-blue-600 text-white font-semibold'
|
? 'bg-blue-600 text-white'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -310,21 +300,21 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('roles')}
|
onClick={() => setActiveTab('roles')}
|
||||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
activeTab === 'roles'
|
activeTab === 'roles'
|
||||||
? 'bg-blue-600 text-white font-semibold'
|
? 'bg-blue-600 text-white'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Shield className="w-3.5 h-3.5" />
|
<Shield className="w-3.5 h-3.5" />
|
||||||
<span>Role ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
|
<span>Roles ({(Array.isArray(userRoles) ? userRoles : []).length})</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('claims')}
|
onClick={() => setActiveTab('claims')}
|
||||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
activeTab === 'claims'
|
activeTab === 'claims'
|
||||||
? 'bg-blue-600 text-white font-semibold'
|
? 'bg-blue-600 text-white'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -334,9 +324,9 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('sessions')}
|
onClick={() => setActiveTab('sessions')}
|
||||||
className={`px-3 py-1.5 rounded-lg font-medium transition cursor-pointer flex items-center gap-1.5 ${
|
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
activeTab === 'sessions'
|
activeTab === 'sessions'
|
||||||
? 'bg-blue-600 text-white font-semibold'
|
? 'bg-blue-600 text-white'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -602,66 +592,70 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 4: Sessions & Security Interventions */}
|
{/* TAB 4: Sessions & Direct Account Interventions */}
|
||||||
{activeTab === 'sessions' && (
|
{activeTab === 'sessions' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
|
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
|
||||||
{/* Force Logout Action */}
|
{/* Force Logout Action */}
|
||||||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3 gap-3">
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||||
<LogOut className="w-4 h-4 text-amber-400" />
|
<LogOut className="w-4 h-4 text-amber-400" />
|
||||||
<span>Revoke Active User Session</span>
|
<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>
|
</h4>
|
||||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
Immediately invalidates active JWT tokens and logs the user out.
|
Terminates all active JWT tokens and sessions immediately without direct database intervention.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => logoutMutation.mutate()}
|
onClick={() => logoutMutation.mutate()}
|
||||||
disabled={logoutMutation.isPending || !canManageSessions}
|
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 shrink-0"
|
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"
|
||||||
>
|
>
|
||||||
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
|
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lock / Unlock Account */}
|
{/* Lock / Unlock Account */}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||||
<Lock className="w-4 h-4 text-rose-400" />
|
<Lock className="w-4 h-4 text-rose-400" />
|
||||||
<span>Account Lockout</span>
|
<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"
|
||||||
|
/>
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
Control tenant login authorization for this account.
|
Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={() => lockoutMutation.mutate(false)}
|
||||||
e.preventDefault();
|
disabled={lockoutMutation.isPending || !canManageSessions}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{lockUserMutation.isPending ? 'Locking...' : 'Lock User'}
|
Lock User
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={() => lockoutMutation.mutate(true)}
|
||||||
e.preventDefault();
|
disabled={lockoutMutation.isPending || !canManageSessions}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{unlockUserMutation.isPending ? 'Unlocking...' : 'Unlock User'}
|
Unlock User
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { updateHasherSettings } from './utils/hashids';
|
import { updateHasherSettings } from './services/toolsService';
|
||||||
|
|
||||||
export interface SecuritySettings {
|
export interface SecuritySettings {
|
||||||
Authority: string;
|
Authority: string;
|
||||||
Audience: string;
|
Audience: string;
|
||||||
ClientId: string;
|
ClientId: string;
|
||||||
ClientSecret?: string;
|
ClientSecret: string;
|
||||||
Scope?: string;
|
Scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ export interface ApiSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HasherSettings {
|
export interface HasherSettings {
|
||||||
Salt?: string;
|
Salt: string;
|
||||||
MinHashLength: number;
|
MinHashLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export const defaultSecuritySettings: SecuritySettings = {
|
|||||||
Authority: 'https://sts.security.khongisa.co.za',
|
Authority: 'https://sts.security.khongisa.co.za',
|
||||||
Audience: 'pfm-api-dev',
|
Audience: 'pfm-api-dev',
|
||||||
ClientId: 'pfm-console-dev',
|
ClientId: 'pfm-console-dev',
|
||||||
|
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||||
Scope: 'openid profile roles email address pfm-api-dev',
|
Scope: 'openid profile roles email address pfm-api-dev',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,10 +49,6 @@ export const defaultHasherSettings: HasherSettings = {
|
|||||||
MinHashLength: 11,
|
MinHashLength: 11,
|
||||||
};
|
};
|
||||||
|
|
||||||
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
|
|
||||||
updateHasherSettings(salt, minHashLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts first non-empty, trimmed string from candidate values.
|
* Extracts first non-empty, trimmed string from candidate values.
|
||||||
*/
|
*/
|
||||||
@@ -258,8 +255,9 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
const clientSecret = pickFirstNonEmpty(
|
const clientSecret = pickFirstNonEmpty(
|
||||||
windowConfig.clientSecret,
|
windowConfig.clientSecret,
|
||||||
fetchedConfig.clientSecret,
|
fetchedConfig.clientSecret,
|
||||||
buildEnvConfig.clientSecret
|
buildEnvConfig.clientSecret,
|
||||||
);
|
defaultSecuritySettings.ClientSecret
|
||||||
|
)!;
|
||||||
|
|
||||||
const scope = pickFirstNonEmpty(
|
const scope = pickFirstNonEmpty(
|
||||||
windowConfig.scope,
|
windowConfig.scope,
|
||||||
@@ -280,7 +278,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
fetchedConfig.hasherSalt,
|
fetchedConfig.hasherSalt,
|
||||||
buildEnvConfig.hasherSalt,
|
buildEnvConfig.hasherSalt,
|
||||||
defaultHasherSettings.Salt
|
defaultHasherSettings.Salt
|
||||||
);
|
)!;
|
||||||
|
|
||||||
const minHashLength =
|
const minHashLength =
|
||||||
windowConfig.hasherMinLength ||
|
windowConfig.hasherMinLength ||
|
||||||
@@ -293,14 +291,14 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
Authority: normalizeAuthorityUrl(authority),
|
Authority: normalizeAuthorityUrl(authority),
|
||||||
Audience: audience,
|
Audience: audience,
|
||||||
ClientId: clientId,
|
ClientId: clientId,
|
||||||
...(clientSecret ? { ClientSecret: clientSecret } : {}),
|
ClientSecret: clientSecret,
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
},
|
},
|
||||||
ApiSettings: {
|
ApiSettings: {
|
||||||
BaseUrl: normalizeBaseUrl(baseUrl),
|
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||||
},
|
},
|
||||||
HasherSettings: {
|
HasherSettings: {
|
||||||
Salt: salt || defaultHasherSettings.Salt,
|
Salt: salt,
|
||||||
MinHashLength: minHashLength,
|
MinHashLength: minHashLength,
|
||||||
},
|
},
|
||||||
ConfigSource: source,
|
ConfigSource: source,
|
||||||
@@ -312,7 +310,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
let runtimeConfig: AppConfig = resolveConfig();
|
let runtimeConfig: AppConfig = resolveConfig();
|
||||||
|
|
||||||
// Immediately apply deterministic hasher settings
|
// Immediately apply deterministic hasher settings
|
||||||
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||||
|
|
||||||
const configListeners: Array<(config: AppConfig) => void> = [];
|
const configListeners: Array<(config: AppConfig) => void> = [];
|
||||||
|
|
||||||
@@ -362,7 +360,7 @@ export async function loadRuntimeConfig(): Promise<AppConfig> {
|
|||||||
runtimeConfig = resolveConfig(fetchedJson);
|
runtimeConfig = resolveConfig(fetchedJson);
|
||||||
|
|
||||||
// Synchronize deterministic hasher settings
|
// Synchronize deterministic hasher settings
|
||||||
syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||||
|
|
||||||
console.info(
|
console.info(
|
||||||
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
||||||
|
|||||||
@@ -962,7 +962,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
title="Provision New Tenant Partition"
|
title="Provision New Tenant Partition"
|
||||||
subtitle="Define an isolated multi-tenant partition for grants, portfolios, and users."
|
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
@@ -971,6 +970,17 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
className="space-y-4"
|
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>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||||
Tenant Organization Name <span className="text-rose-400">*</span>
|
Tenant Organization Name <span className="text-rose-400">*</span>
|
||||||
@@ -997,7 +1007,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||||
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
||||||
@@ -1011,7 +1021,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
isOpen={isUpdateModalOpen}
|
isOpen={isUpdateModalOpen}
|
||||||
onClose={() => setIsUpdateModalOpen(false)}
|
onClose={() => setIsUpdateModalOpen(false)}
|
||||||
title={`Update Tenant: ${selectedTenant?.name || ''}`}
|
title={`Update Tenant: ${selectedTenant?.name || ''}`}
|
||||||
subtitle="Modify tenant partition name and operational access status."
|
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
{selectedTenant && (
|
{selectedTenant && (
|
||||||
@@ -1055,12 +1064,13 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Warning when selecting Inactive or Disabled */}
|
{/* Warning when selecting Inactive or Disabled */}
|
||||||
{Number(updateForm.watch('status')) !== ActivityStatus.Active && (
|
{Number(updateForm.watch('status')) !== ActivityStatus.Active && (
|
||||||
<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">
|
<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">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-1">
|
||||||
<p className="font-semibold text-amber-300">Lockout Notice</p>
|
<p className="font-semibold text-white">Multitenancy Lockout Warning</p>
|
||||||
<p className="text-[11px] text-amber-200/90 leading-relaxed">
|
<p className="text-[11px] leading-relaxed text-amber-300/90">
|
||||||
Setting this tenant to Inactive or Disabled immediately locks out assigned users from accessing the API.
|
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1077,7 +1087,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||||
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
|
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
|
||||||
@@ -1092,47 +1102,66 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
isOpen={isDetailModalOpen}
|
isOpen={isDetailModalOpen}
|
||||||
onClose={() => setIsDetailModalOpen(false)}
|
onClose={() => setIsDetailModalOpen(false)}
|
||||||
title={`Tenant Details: ${selectedTenant?.name || ''}`}
|
title={`Tenant Details: ${selectedTenant?.name || ''}`}
|
||||||
subtitle="Partition boundary information, creation audit, and operational status."
|
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{selectedTenant && (
|
{selectedTenant && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-slate-950/60 p-4 rounded-xl border border-slate-800">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant ID</p>
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Numeric ID</p>
|
||||||
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
|
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Tenant Name</p>
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p>
|
||||||
<p className="text-xs font-semibold text-slate-200 mt-0.5 truncate">{selectedTenant.name || '—'}</p>
|
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Operational Status</p>
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Operational Status</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<ActivityStatusBadge status={selectedTenant.status} />
|
<ActivityStatusBadge status={selectedTenant.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created Date</p>
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created Date</p>
|
||||||
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
|
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Created By</p>
|
<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 truncate">
|
<p className="text-xs font-mono text-slate-300 mt-0.5">
|
||||||
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
|
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTenant.updatedAt && (
|
{selectedTenant.updatedAt && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Last Updated</p>
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Last Updated</p>
|
||||||
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
|
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
||||||
@@ -1142,7 +1171,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
setIsDetailModalOpen(false);
|
setIsDetailModalOpen(false);
|
||||||
handleOpenUpdate(selectedTenant);
|
handleOpenUpdate(selectedTenant);
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
<span>Edit Tenant Status</span>
|
<span>Edit Tenant Status</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import { apiClient } from './httpClient';
|
import { apiClient } from './httpClient';
|
||||||
import { toolsService } from '../utils/hashids';
|
import { toolsService } from './toolsService';
|
||||||
import {
|
import {
|
||||||
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
||||||
notifyInviteApproved,
|
notifyInviteApproved,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { getTenant, getTenants } from './api';
|
import { getTenant } from './api';
|
||||||
import { ActivityStatus, Tenant } from '../types';
|
import { ActivityStatus, Tenant } from '../types';
|
||||||
import { setActivationPending } from './userSyncService';
|
import { setActivationPending } from './userSyncService';
|
||||||
import { encodeHashIdSync, isAlreadyHashId } from '../utils/hashids';
|
|
||||||
|
|
||||||
export interface TenantLockoutResult {
|
export interface TenantLockoutResult {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -10,17 +9,21 @@ export interface TenantLockoutResult {
|
|||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations)
|
||||||
let cachedTenantLockout: {
|
let cachedTenantLockout: {
|
||||||
key: string;
|
key: string;
|
||||||
result: TenantLockoutResult;
|
result: TenantLockoutResult;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
|
const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
||||||
|
|
||||||
export function clearTenantLockoutCache(): void {
|
export function clearTenantLockoutCache(): void {
|
||||||
cachedTenantLockout = null;
|
cachedTenantLockout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
|
||||||
|
*/
|
||||||
export function isStatusActive(status: any): boolean {
|
export function isStatusActive(status: any): boolean {
|
||||||
if (status === undefined || status === null || status === '') {
|
if (status === undefined || status === null || status === '') {
|
||||||
return false;
|
return false;
|
||||||
@@ -37,6 +40,9 @@ export function isStatusActive(status: any): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a human-readable status label from numeric or string status
|
||||||
|
*/
|
||||||
export function getStatusLabel(status: any): string {
|
export function getStatusLabel(status: any): string {
|
||||||
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
|
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
|
||||||
return 'Active';
|
return 'Active';
|
||||||
@@ -59,36 +65,38 @@ export function getStatusLabel(status: any): string {
|
|||||||
return 'Not Specified';
|
return 'Not Specified';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidHashableOrHashedTenantId(val: unknown): val is number | string {
|
/**
|
||||||
if (val === undefined || val === null || val === '') return false;
|
* Calls the GetTenant endpoint for the provided tenant identifier.
|
||||||
if (typeof val === 'number') {
|
*
|
||||||
return !isNaN(val) && isFinite(val) && val > 0;
|
* Rules:
|
||||||
}
|
* - If nothing is returned (empty response, 404, or failure) OR
|
||||||
if (typeof val === 'string') {
|
* - There is a response object and its Status is not Active
|
||||||
const trimmed = val.trim();
|
* Then lockout is triggered and the existing lockout modal is shown.
|
||||||
if (!trimmed) return false;
|
*/
|
||||||
if (/^\d+$/.test(trimmed)) {
|
|
||||||
return parseInt(trimmed, 10) > 0;
|
|
||||||
}
|
|
||||||
return isAlreadyHashId(trimmed);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyTenantLockout(
|
export async function verifyTenantLockout(
|
||||||
tenantId?: number | string | null,
|
tenantId?: number | string | null,
|
||||||
tenantClaim?: string | null,
|
tenantClaim?: string | null,
|
||||||
forceRefresh = false
|
forceRefresh = false
|
||||||
): Promise<TenantLockoutResult> {
|
): Promise<TenantLockoutResult> {
|
||||||
// Resolve a valid numeric ID or HashId to avoid passing plain-text tenant names to /v1/api/governance/tenants/{id}
|
const primaryId =
|
||||||
let candidateId: number | string | null = null;
|
tenantId !== undefined && tenantId !== null && tenantId !== ''
|
||||||
if (isValidHashableOrHashedTenantId(tenantId)) {
|
? tenantId
|
||||||
candidateId = tenantId;
|
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== ''
|
||||||
} else if (isValidHashableOrHashedTenantId(tenantClaim)) {
|
? tenantClaim
|
||||||
candidateId = 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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
|
const cacheKey = String(primaryId).trim().toLowerCase();
|
||||||
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
|
||||||
if (
|
if (
|
||||||
cachedTenantLockout.result.isActive &&
|
cachedTenantLockout.result.isActive &&
|
||||||
@@ -101,76 +109,32 @@ export async function verifyTenantLockout(
|
|||||||
let tenantRes: Tenant | null = null;
|
let tenantRes: Tenant | null = null;
|
||||||
let lastError: any = 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}
|
try {
|
||||||
if (candidateId !== null) {
|
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)) {
|
||||||
try {
|
try {
|
||||||
const hashedId = encodeHashIdSync(candidateId);
|
tenantRes = await getTenant(tenantClaim);
|
||||||
tenantRes = await getTenant(hashedId || candidateId);
|
lastError = null;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
tenantRes = null;
|
tenantRes = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. If tenant lookup by ID wasn't possible or didn't resolve, resolve via getTenants() (matching tenantClaim name if provided)
|
// Condition 1: If nothing is returned
|
||||||
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)) {
|
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
|
||||||
let serverMsg =
|
const serverMsg =
|
||||||
lastError?.response?.data?.detail ||
|
lastError?.response?.data?.detail ||
|
||||||
lastError?.response?.data?.message ||
|
lastError?.response?.data?.message ||
|
||||||
lastError?.message ||
|
lastError?.message ||
|
||||||
'No tenant record returned';
|
'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.`;
|
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
|
||||||
setActivationPending(true, errorMsg);
|
setActivationPending(true, errorMsg);
|
||||||
return {
|
return {
|
||||||
@@ -181,8 +145,10 @@ export async function verifyTenantLockout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle wrapped response structures: { data: { ... } } or { result: { ... } }
|
||||||
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
|
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 =
|
const rawStatus =
|
||||||
(tenantObj as any)?.status ??
|
(tenantObj as any)?.status ??
|
||||||
(tenantObj as any)?.Status ??
|
(tenantObj as any)?.Status ??
|
||||||
@@ -193,7 +159,7 @@ export async function verifyTenantLockout(
|
|||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
const statusLabel = getStatusLabel(rawStatus);
|
const statusLabel = getStatusLabel(rawStatus);
|
||||||
const tenantName = tenantObj?.name || String(candidateId ?? tenantClaim ?? '1');
|
const tenantName = tenantObj?.name || String(primaryId);
|
||||||
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
|
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
|
||||||
setActivationPending(true, errorMsg);
|
setActivationPending(true, errorMsg);
|
||||||
return {
|
return {
|
||||||
@@ -203,8 +169,7 @@ export async function verifyTenantLockout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setActivationPending(false);
|
// Success: Response object returned and Status is Active
|
||||||
|
|
||||||
const successResult: TenantLockoutResult = {
|
const successResult: TenantLockoutResult = {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
tenant: tenantObj,
|
tenant: tenantObj,
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
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,22 +252,47 @@ async function executeBackendSync(
|
|||||||
organisationId: resolvedOrgId,
|
organisationId: resolvedOrgId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoint = '/v1/api/integration/sync';
|
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
|
||||||
try {
|
|
||||||
const res = await apiClient.post(endpoint, payload);
|
let lastError: any = null;
|
||||||
return { endpoint, data: res?.data };
|
|
||||||
} catch (err: any) {
|
for (const endpoint of candidateEndpoints) {
|
||||||
const status = err?.response?.status;
|
try {
|
||||||
if (status === 400 || status === 415 || status === 422) {
|
let res;
|
||||||
try {
|
if (endpoint === '/v1/api/integration/sync') {
|
||||||
const resFallback = await apiClient.post(endpoint, {});
|
try {
|
||||||
return { endpoint, data: resFallback?.data };
|
res = await apiClient.post(endpoint, payload);
|
||||||
} catch (fallbackErr) {
|
} 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) {
|
||||||
throw err;
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -328,21 +353,13 @@ export async function syncUserAccount(
|
|||||||
|
|
||||||
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
|
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
|
||||||
clearSuccessfulSyncRecord();
|
clearSuccessfulSyncRecord();
|
||||||
let serverMsg =
|
const serverMsg =
|
||||||
err?.response?.data?.detail ||
|
err?.response?.data?.detail ||
|
||||||
err?.response?.data?.message ||
|
err?.response?.data?.message ||
|
||||||
err?.response?.data?.error ||
|
err?.response?.data?.error ||
|
||||||
err?.message ||
|
err?.message ||
|
||||||
'Account is not yet enabled by the administrator.';
|
'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);
|
setActivationPending(true, serverMsg);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|||||||
? import.meta.env
|
? import.meta.env
|
||||||
: {}) as Record<string, string | undefined>;
|
: {}) as Record<string, string | undefined>;
|
||||||
|
|
||||||
export const DEFAULT_HASHER_SALT =
|
|
||||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
|
||||||
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
|
||||||
|
|
||||||
export const HASHER_SETTINGS = {
|
export const HASHER_SETTINGS = {
|
||||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||||
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
salt:
|
||||||
|
(env.VITE_HASHER_SALT as string) ||
|
||||||
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||||
};
|
};
|
||||||
|
|
||||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
@@ -37,27 +35,14 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
|
|||||||
* Validates if candidate is already a HashID generated by this hasher.
|
* Validates if candidate is already a HashID generated by this hasher.
|
||||||
*/
|
*/
|
||||||
export function isHashId(candidate: string | null | undefined): boolean {
|
export function isHashId(candidate: string | null | undefined): boolean {
|
||||||
if (
|
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
||||||
!candidate ||
|
|
||||||
typeof candidate !== 'string' ||
|
|
||||||
candidate.length < HASHER_SETTINGS.minHashLength ||
|
|
||||||
/^\d+$/.test(candidate)
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const decodedNumbers = hasher.decode(candidate);
|
const decodedNumbers = hasher.decode(candidate);
|
||||||
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) {
|
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
|
||||||
if (hasher.encode(decodedNumbers as number[]) === candidate) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const decodedHex = hasher.decodeHex(candidate);
|
const decodedHex = hasher.decodeHex(candidate);
|
||||||
if (decodedHex && decodedHex.length > 0) {
|
if (decodedHex && decodedHex.length > 0) return true;
|
||||||
if (hasher.encodeHex(decodedHex) === candidate) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -209,132 +194,3 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
|
|||||||
|
|
||||||
return str;
|
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> {},
|
|
||||||
};
|
|
||||||
|
|||||||
+1
-2
@@ -4,9 +4,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OIDC_AUTHORITY?: string;
|
readonly VITE_OIDC_AUTHORITY?: string;
|
||||||
readonly VITE_OIDC_AUDIENCE?: string;
|
readonly VITE_OIDC_AUDIENCE?: string;
|
||||||
readonly VITE_OIDC_CLIENT_ID?: string;
|
readonly VITE_OIDC_CLIENT_ID?: string;
|
||||||
|
readonly VITE_OIDC_CLIENT_SECRET?: string;
|
||||||
readonly VITE_API_BASE_URL?: string;
|
readonly VITE_API_BASE_URL?: string;
|
||||||
readonly VITE_HASHER_SALT?: string;
|
|
||||||
readonly VITE_HASHER_MIN_LENGTH?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -26,20 +26,22 @@ OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audien
|
|||||||
# 4. OIDC Client ID
|
# 4. OIDC Client ID
|
||||||
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
||||||
|
|
||||||
# 5. OIDC Scope
|
# 5. OIDC Client Secret
|
||||||
|
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
|
||||||
|
|
||||||
|
# 6. OIDC Scope
|
||||||
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||||
|
|
||||||
# 6. Hasher Settings
|
# 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:-}}}}}"
|
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
||||||
|
|
||||||
if [ -d "$TARGET_DIR" ]; then
|
if [ -d "$TARGET_DIR" ]; then
|
||||||
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
|
||||||
|
|
||||||
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
|
# 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"
|
cat <<EOF > "$TARGET_DIR/env-config.js"
|
||||||
// Generated dynamically at container startup from deployment environment variables
|
// 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__ = {
|
window.__RUNTIME_CONFIG__ = {
|
||||||
API_BASE_URL: "${API_BASE_URL}",
|
API_BASE_URL: "${API_BASE_URL}",
|
||||||
VITE_API_BASE_URL: "${API_BASE_URL}",
|
VITE_API_BASE_URL: "${API_BASE_URL}",
|
||||||
@@ -49,8 +51,12 @@ window.__RUNTIME_CONFIG__ = {
|
|||||||
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
||||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||||
VITE_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}",
|
OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
|
HASHER_SALT: "${HASHER_SALT}",
|
||||||
|
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||||
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
ApiSettings: {
|
ApiSettings: {
|
||||||
@@ -60,9 +66,11 @@ window.__RUNTIME_CONFIG__ = {
|
|||||||
Authority: "${OIDC_AUTHORITY}",
|
Authority: "${OIDC_AUTHORITY}",
|
||||||
Audience: "${OIDC_AUDIENCE}",
|
Audience: "${OIDC_AUDIENCE}",
|
||||||
ClientId: "${OIDC_CLIENT_ID}",
|
ClientId: "${OIDC_CLIENT_ID}",
|
||||||
|
ClientSecret: "${OIDC_CLIENT_SECRET}",
|
||||||
Scope: "${OIDC_SCOPE}"
|
Scope: "${OIDC_SCOPE}"
|
||||||
},
|
},
|
||||||
HasherSettings: {
|
HasherSettings: {
|
||||||
|
Salt: "${HASHER_SALT}",
|
||||||
MinHashLength: "${HASHER_MIN_LENGTH}"
|
MinHashLength: "${HASHER_MIN_LENGTH}"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -78,9 +86,11 @@ EOF
|
|||||||
"Authority": "${OIDC_AUTHORITY}",
|
"Authority": "${OIDC_AUTHORITY}",
|
||||||
"Audience": "${OIDC_AUDIENCE}",
|
"Audience": "${OIDC_AUDIENCE}",
|
||||||
"ClientId": "${OIDC_CLIENT_ID}",
|
"ClientId": "${OIDC_CLIENT_ID}",
|
||||||
|
"ClientSecret": "${OIDC_CLIENT_SECRET}",
|
||||||
"Scope": "${OIDC_SCOPE}"
|
"Scope": "${OIDC_SCOPE}"
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
|
"Salt": "${HASHER_SALT}",
|
||||||
"MinHashLength": "${HASHER_MIN_LENGTH}"
|
"MinHashLength": "${HASHER_MIN_LENGTH}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" class="dark h-full bg-slate-950 text-slate-100">
|
<html lang="en" class="h-full bg-slate-950 text-slate-100">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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 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" />
|
<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>
|
</head>
|
||||||
<body class="dark h-full bg-slate-950 font-sans text-slate-100 antialiased selection:bg-emerald-500/20 selection:text-emerald-300">
|
<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 bg-slate-950 text-slate-100"></div>
|
<div id="root" class="min-h-full flex flex-col"></div>
|
||||||
<script src="/env-config.js"></script>
|
<script src="/env-config.js"></script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
"Authority": "",
|
"Authority": "",
|
||||||
"Audience": "",
|
"Audience": "",
|
||||||
"ClientId": "",
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
"Scope": ""
|
"Scope": ""
|
||||||
},
|
},
|
||||||
"ApiSettings": {
|
"ApiSettings": {
|
||||||
"BaseUrl": ""
|
"BaseUrl": ""
|
||||||
},
|
},
|
||||||
"HasherSettings": {
|
"HasherSettings": {
|
||||||
|
"Salt": "",
|
||||||
"MinHashLength": ""
|
"MinHashLength": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AccountActivationModal } from '../components/auth/AccountActivationModa
|
|||||||
import { NonPartnerAccessDeniedModal } from '../components/auth/NonPartnerAccessDeniedModal';
|
import { NonPartnerAccessDeniedModal } from '../components/auth/NonPartnerAccessDeniedModal';
|
||||||
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
||||||
import { encodeHashIdSync } from '../services/toolsService';
|
import { encodeHashIdSync } from '../services/toolsService';
|
||||||
|
import { verifyTenantLockout, clearTenantLockoutCache } from '../services/tenantLockoutService';
|
||||||
import {
|
import {
|
||||||
onSessionExpired,
|
onSessionExpired,
|
||||||
triggerSessionExpired,
|
triggerSessionExpired,
|
||||||
@@ -322,7 +323,7 @@ export function getUserManager(): UserManager {
|
|||||||
userManagerInstance = new UserManager({
|
userManagerInstance = new UserManager({
|
||||||
authority: sec.Authority,
|
authority: sec.Authority,
|
||||||
client_id: sec.ClientId,
|
client_id: sec.ClientId,
|
||||||
...(sec.ClientSecret ? { client_secret: sec.ClientSecret } : {}),
|
client_secret: sec.ClientSecret,
|
||||||
redirect_uri: `${origin}/dashboard`,
|
redirect_uri: `${origin}/dashboard`,
|
||||||
post_logout_redirect_uri: `${origin}/`,
|
post_logout_redirect_uri: `${origin}/`,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
@@ -509,6 +510,35 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 3: Verify tenant lockout using the HashId-encoded tenant ID
|
||||||
|
const effectiveTenantId =
|
||||||
|
parsedTenantId ??
|
||||||
|
syncResult.data?.tenantId ??
|
||||||
|
syncResult.data?.organisationId ??
|
||||||
|
tenantClaimValues.tenantId;
|
||||||
|
|
||||||
|
const tenantLockoutResult = await verifyTenantLockout(effectiveTenantId, 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);
|
||||||
|
}
|
||||||
|
if (tenantLockoutResult.tenant?.id !== undefined && !profile.tenant_id) {
|
||||||
|
const numericTid = Number(tenantLockoutResult.tenant.id);
|
||||||
|
if (!isNaN(numericTid) && numericTid > 0) {
|
||||||
|
profile.tenant_id = numericTid;
|
||||||
|
setTenantId(numericTid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setUser({ ...profile });
|
setUser({ ...profile });
|
||||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||||
|
|
||||||
@@ -524,9 +554,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
const rawMsg = syncErr?.message || 'Account activation pending';
|
setActivationError(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);
|
setIsLoading(false);
|
||||||
@@ -641,6 +669,39 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 3: Verify tenant lockout using HashId-encoded tenant ID
|
||||||
|
const effectiveTenantId =
|
||||||
|
storedTenantId ??
|
||||||
|
syncResult.data?.tenantId ??
|
||||||
|
syncResult.data?.organisationId ??
|
||||||
|
tenantClaimValues.tenantId;
|
||||||
|
|
||||||
|
const tenantLockoutResult = await verifyTenantLockout(
|
||||||
|
effectiveTenantId,
|
||||||
|
storedTenantClaim,
|
||||||
|
hadPendingActivation
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tenantLockoutResult.isActive) {
|
||||||
|
setIsPendingActivation(true);
|
||||||
|
setIsSynced(false);
|
||||||
|
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenantLockoutResult.tenant?.name) {
|
||||||
|
if (resolvedUser) {
|
||||||
|
resolvedUser.tenant = tenantLockoutResult.tenant.name;
|
||||||
|
}
|
||||||
|
setTenantName(tenantLockoutResult.tenant.name);
|
||||||
|
}
|
||||||
|
if (tenantLockoutResult.tenant?.id !== undefined && resolvedUser && !resolvedUser.tenant_id) {
|
||||||
|
const numericTid = Number(tenantLockoutResult.tenant.id);
|
||||||
|
if (!isNaN(numericTid) && numericTid > 0) {
|
||||||
|
resolvedUser.tenant_id = numericTid;
|
||||||
|
setTenantId(numericTid);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (resolvedUser) {
|
if (resolvedUser) {
|
||||||
setUser({ ...resolvedUser });
|
setUser({ ...resolvedUser });
|
||||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||||
@@ -657,9 +718,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
const rawMsg = syncErr?.message || 'Account activation pending';
|
setActivationError(syncErr?.message || 'Account activation pending');
|
||||||
const cleanMsg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
|
||||||
setActivationError(cleanMsg);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -687,7 +746,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
|
const syncUserSession = async (force = true): Promise<UserSyncResult> => {
|
||||||
try {
|
try {
|
||||||
await loadRuntimeConfig();
|
await loadRuntimeConfig();
|
||||||
if (sub) {
|
clearTenantLockoutCache();
|
||||||
|
if (sub) {
|
||||||
clearCachedUserRbac(sub);
|
clearCachedUserRbac(sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +793,41 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Partner user verified successfully
|
const tenantClaimValues = extractTenantClaimValues(claims, accessToken);
|
||||||
|
const effectiveTenantId =
|
||||||
|
currentTenantId ??
|
||||||
|
result.data?.tenantId ??
|
||||||
|
result.data?.organisationId ??
|
||||||
|
tenantClaimValues.tenantId;
|
||||||
|
const effectiveTenantClaim = currentTenantClaim || tenantClaimValues.tenant || undefined;
|
||||||
|
|
||||||
|
// Step 3: Verify tenant lockout using HashId-encoded tenant ID
|
||||||
|
const tenantLockoutResult = await verifyTenantLockout(
|
||||||
|
effectiveTenantId,
|
||||||
|
effectiveTenantClaim,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if (!tenantLockoutResult.isActive) {
|
||||||
|
const msg = tenantLockoutResult.errorMessage || 'Tenant is not Active. Lockout active.';
|
||||||
|
setIsPendingActivation(true);
|
||||||
|
setIsSynced(false);
|
||||||
|
setActivationError(msg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
isActivationPending: true,
|
||||||
|
errorMessage: msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenantLockoutResult.tenant?.name) {
|
||||||
|
setTenantName(tenantLockoutResult.tenant.name);
|
||||||
|
}
|
||||||
|
if (tenantLockoutResult.tenant?.id !== undefined && !tenantId) {
|
||||||
|
const numericTid = Number(tenantLockoutResult.tenant.id);
|
||||||
|
if (!isNaN(numericTid) && numericTid > 0) {
|
||||||
|
setTenantId(numericTid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setActivationPending(false);
|
setActivationPending(false);
|
||||||
setIsPendingActivation(false);
|
setIsPendingActivation(false);
|
||||||
@@ -745,8 +839,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
await logout();
|
await logout();
|
||||||
return { success: false, isUnauthorized: true };
|
return { success: false, isUnauthorized: true };
|
||||||
}
|
}
|
||||||
const rawMsg = err?.message || 'Sync failed';
|
const msg = err?.message || 'Sync failed';
|
||||||
const msg = /network\s*error/i.test(rawMsg) ? rawMsg.replace(/network\s*error/gi, 'Permission Check') : rawMsg;
|
|
||||||
setIsPendingActivation(true);
|
setIsPendingActivation(true);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
setActivationError(msg);
|
setActivationError(msg);
|
||||||
@@ -780,7 +873,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
|
localStorage.removeItem(LOCAL_STORAGE_SUB_KEY);
|
||||||
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
|
localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY);
|
||||||
clearUserSyncInBrowser();
|
clearUserSyncInBrowser();
|
||||||
setUser(null);
|
clearTenantLockoutCache();
|
||||||
|
setUser(null);
|
||||||
setSub('');
|
setSub('');
|
||||||
setClaims({});
|
setClaims({});
|
||||||
setTenantId(null);
|
setTenantId(null);
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
ShieldAlert,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
LogOut,
|
LogOut,
|
||||||
Building2,
|
UserCheck,
|
||||||
Clock,
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Sparkles,
|
Mail,
|
||||||
|
Building2,
|
||||||
|
Fingerprint,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
import { BrandLogo } from '../ui/BrandLogo';
|
||||||
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
|
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
|
||||||
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
|
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
|
||||||
|
|
||||||
@@ -22,29 +26,22 @@ interface AccountActivationModalProps {
|
|||||||
initialError?: string | null;
|
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> = ({
|
export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
||||||
user,
|
user,
|
||||||
claims,
|
claims,
|
||||||
|
sub,
|
||||||
onSync,
|
onSync,
|
||||||
onLogout,
|
onLogout,
|
||||||
initialError,
|
initialError,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(formatLockoutMessage(initialError) || null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(initialError || null);
|
||||||
|
const [syncAttemptCount, setSyncAttemptCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialError !== undefined) {
|
if (initialError !== undefined) {
|
||||||
setErrorMessage(formatLockoutMessage(initialError));
|
setErrorMessage(initialError);
|
||||||
}
|
}
|
||||||
}, [initialError]);
|
}, [initialError]);
|
||||||
|
|
||||||
@@ -57,9 +54,16 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
user?.name ||
|
user?.name ||
|
||||||
(claims.name as string) ||
|
(claims.name as string) ||
|
||||||
(claims.preferred_username as string) ||
|
(claims.preferred_username as string) ||
|
||||||
'Partner';
|
'Registered Partner';
|
||||||
|
|
||||||
|
const displayEmail =
|
||||||
|
user?.email ||
|
||||||
|
(claims.email as string) ||
|
||||||
|
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
|
||||||
|
'';
|
||||||
|
|
||||||
const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Partner Organisation';
|
const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Partner Organisation';
|
||||||
|
const role = user?.role || (claims.role as string) || 'Partner';
|
||||||
|
|
||||||
const handleSyncClick = async () => {
|
const handleSyncClick = async () => {
|
||||||
if (isSyncing) return;
|
if (isSyncing) return;
|
||||||
@@ -76,11 +80,10 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
setSyncAttemptCount((prev) => prev + 1);
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
formatLockoutMessage(
|
result.errorMessage ||
|
||||||
result.errorMessage ||
|
'Your partner account is not yet enabled. Please ask your funding administrator to activate your account.'
|
||||||
'Activation is still being reviewed by your funding administrator. Please check back shortly.'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -90,7 +93,10 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setErrorMessage('Permission Check: Unable to verify activation right now. Please try again in a few moments.');
|
setErrorMessage(
|
||||||
|
errObj?.message ||
|
||||||
|
'Account activation check encountered an error. Please confirm with your administrator and try again.'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
}
|
}
|
||||||
@@ -110,87 +116,147 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="activation-modal-title"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{/* 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
|
<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"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Glowing Brand Icon Lockup */}
|
<div className="h-1.5 w-full bg-gradient-to-r from-amber-500 via-amber-400 to-emerald-500" />
|
||||||
<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="p-6 pb-4 border-b border-slate-800/80 bg-[#0f172a]/60">
|
||||||
<div className="space-y-2">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<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">
|
<div className="flex items-center gap-3.5">
|
||||||
<Clock className="w-3.5 h-3.5 text-emerald-400" />
|
<div className="relative p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400">
|
||||||
<span>Activation Pending</span>
|
<ShieldAlert className="w-6 h-6" />
|
||||||
</div>
|
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
|
||||||
<h2 id="activation-modal-title" className="text-2xl font-bold tracking-tight text-white">
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||||
Welcome,{' '}
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
|
||||||
<span className="bg-gradient-to-r from-emerald-300 via-teal-200 to-cyan-300 bg-clip-text text-transparent">
|
</span>
|
||||||
{displayName}
|
</div>
|
||||||
</span>
|
<div>
|
||||||
</h2>
|
<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">
|
||||||
<div className="inline-flex items-center gap-1.5 text-xs text-slate-400 font-medium">
|
Access Restricted • Activation Pending
|
||||||
<Building2 className="w-3.5 h-3.5 text-teal-400 shrink-0" />
|
</span>
|
||||||
<span>{tenant}</span>
|
<h2 id="activation-modal-title" className="text-lg sm:text-xl font-bold text-white tracking-tight">
|
||||||
|
{errorMessage?.toLowerCase().includes('tenant')
|
||||||
|
? 'Organisation Account Inactive'
|
||||||
|
: 'Partner Account Pending Activation'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BrandLogo size={36} showGlow={false} animateOnHover={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Core Friendly Message */}
|
<div className="p-6 space-y-5 overflow-y-auto max-h-[70vh]">
|
||||||
<p className="text-sm text-slate-300 leading-relaxed max-w-sm">
|
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-4 space-y-2.5">
|
||||||
Your credentials have been authenticated. Your funding administrator is finalizing your portfolio access.
|
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
|
||||||
</p>
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span>Signed In Successfully</span>
|
||||||
{/* Notice badge if check returned not yet ready */}
|
</div>
|
||||||
{errorMessage && (
|
<p className="text-xs text-slate-300 leading-relaxed">
|
||||||
<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">
|
You have signed in successfully, but your partner account is waiting to be activated by the funding administrator.
|
||||||
<Sparkles className="w-4 h-4 text-amber-400 shrink-0" />
|
</p>
|
||||||
<span>{formatLockoutMessage(errorMessage)}</span>
|
<p className="text-xs text-slate-400 leading-relaxed border-t border-slate-800/80 pt-2 font-medium">
|
||||||
|
Once your administrator confirms your account is active, click{' '}
|
||||||
|
<strong className="text-white">Check Account Status</strong> below to open your dashboard.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Button matching Specular / Landing styling */}
|
<div className="rounded-xl border border-slate-800 bg-[#090e1a]/60 p-4 space-y-3">
|
||||||
<div className="w-full space-y-3 pt-2">
|
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center justify-between">
|
||||||
<button
|
<span>Your Partner Profile</span>
|
||||||
onClick={handleSyncClick}
|
<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">
|
||||||
disabled={isSyncing}
|
Pending Activation
|
||||||
type="button"
|
</span>
|
||||||
id="activation-sync-btn"
|
</div>
|
||||||
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="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||||
<div className="flex items-center justify-center gap-4 text-xs pt-1">
|
<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-emerald-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>
|
||||||
|
|
||||||
|
<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-teal-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-cyan-400 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[10px] text-slate-500 block">Organisation / 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">Account ID</span>
|
||||||
|
<span className="font-mono text-[11px] text-slate-400 truncate block">{sub || 'SSO-Principal'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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">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">
|
||||||
|
Checked #{syncAttemptCount} at {new Date().toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 border-t border-slate-800/80 bg-[#0a0f1d] flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={handleNavigateLanding}
|
onClick={handleNavigateLanding}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 text-slate-400 hover:text-slate-200 transition-colors py-1 cursor-pointer"
|
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"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
<span>Landing Page</span>
|
<span>Home Page</span>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-slate-700">•</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 text-slate-400 hover:text-rose-400 transition-colors py-1 cursor-pointer"
|
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"
|
||||||
>
|
>
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSyncClick}
|
||||||
|
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-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:pointer-events-none rounded-xl shadow-lg shadow-emerald-600/30 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{isSyncing ? 'Checking Status...' : 'Check Account Status'}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,3 +266,4 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
|
|||||||
? createPortal(modalContent, document.body)
|
? createPortal(modalContent, document.body)
|
||||||
: modalContent;
|
: modalContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ShieldCheck, LogOut, ArrowLeft, Lock } from 'lucide-react';
|
import {
|
||||||
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
ShieldAlert,
|
||||||
|
LogOut,
|
||||||
|
Ban,
|
||||||
|
Mail,
|
||||||
|
Building2,
|
||||||
|
Fingerprint,
|
||||||
|
Lock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { BrandLogo } from '../ui/BrandLogo';
|
||||||
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
|
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
|
||||||
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
|
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
|
||||||
|
|
||||||
@@ -14,9 +22,14 @@ interface NonPartnerAccessDeniedModalProps {
|
|||||||
onLogout: () => Promise<void>;
|
onLogout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks any authenticated user who does NOT hold the 'Partner' role.
|
||||||
|
* RBAC on PartnerUI strictly permits only Partner roles; no other roles are allowed.
|
||||||
|
*/
|
||||||
export const NonPartnerAccessDeniedModal: React.FC<NonPartnerAccessDeniedModalProps> = ({
|
export const NonPartnerAccessDeniedModal: React.FC<NonPartnerAccessDeniedModalProps> = ({
|
||||||
user,
|
user,
|
||||||
claims,
|
claims,
|
||||||
|
sub,
|
||||||
roles,
|
roles,
|
||||||
onLogout,
|
onLogout,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -27,79 +40,133 @@ export const NonPartnerAccessDeniedModal: React.FC<NonPartnerAccessDeniedModalPr
|
|||||||
dismissSubordinateModals();
|
dismissSubordinateModals();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
user?.name ||
|
||||||
|
(claims.name as string) ||
|
||||||
|
(claims.preferred_username as string) ||
|
||||||
|
'Authenticated User';
|
||||||
|
|
||||||
const displayEmail =
|
const displayEmail =
|
||||||
user?.email ||
|
user?.email ||
|
||||||
(claims.email as string) ||
|
(claims.email as string) ||
|
||||||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] 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 tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Statutory Enterprise';
|
||||||
|
const assignedRolesDisplay = roles.length > 0 ? roles.join(', ') : user?.role || (claims.role as string) || 'Unassigned';
|
||||||
|
|
||||||
const handleLogoutClick = async () => {
|
const handleLogoutClick = async () => {
|
||||||
await onLogout();
|
await onLogout();
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLandingClick = () => {
|
|
||||||
navigate('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="non-partner-denied-title"
|
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"
|
aria-describedby="non-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"
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
<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">
|
||||||
<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 className="h-2 w-full bg-gradient-to-r from-rose-600 via-red-500 to-amber-500" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div className="p-6 sm:p-8 space-y-6">
|
||||||
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="flex items-start gap-4">
|
||||||
onClick={(e) => e.stopPropagation()}
|
<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="relative flex items-center justify-center pt-2">
|
</div>
|
||||||
<div className="absolute -inset-3 rounded-full bg-rose-500/20 blur-xl" />
|
<div className="space-y-1">
|
||||||
<PfmPartnerLogo className="h-16 w-16 drop-shadow-[0_8px_24px_rgba(244,63,94,0.35)] relative z-10" />
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<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" />
|
||||||
<div className="space-y-2">
|
Partner Role Required
|
||||||
<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">
|
</span>
|
||||||
<Lock className="w-3.5 h-3.5 text-rose-400" />
|
<span className="text-xs text-slate-500">Security Policy</span>
|
||||||
<span>Partner Access Only</span>
|
</div>
|
||||||
|
<h2 id="non-partner-denied-title" className="text-lg font-bold text-white tracking-tight">
|
||||||
|
Access Denied: Partner Accounts Only
|
||||||
|
</h2>
|
||||||
|
<p id="non-partner-denied-description" className="text-xs text-slate-400 leading-relaxed">
|
||||||
|
This application is exclusively reserved for users assigned the <strong className="text-emerald-300">Partner</strong> role. Your account role (<strong className="text-rose-300">{assignedRolesDisplay}</strong>) is not permitted on the Partner Portal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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(s)
|
||||||
|
</span>
|
||||||
|
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
|
||||||
|
{assignedRolesDisplay}
|
||||||
|
</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" />
|
||||||
|
Organisation / Tenant
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
Portal Separation Notice
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-rose-300/90">
|
||||||
|
Internal staff, executives, programme managers, and administrators must sign in through the PFM Management Console. Only external grant recipients and project partners with the <strong>Partner</strong> role are permitted to use this portal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 text-rose-400" />
|
||||||
|
<span>Sign Out & Return to Home</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="non-partner-denied-title" className="text-2xl font-bold tracking-tight text-white">
|
|
||||||
Role Authorization Required
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-slate-300 leading-relaxed max-w-sm">
|
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
This portal is reserved for registered Grantee and Partner accounts. Your current profile (<strong className="text-rose-300">{assignedRolesDisplay}</strong>) does not have access.
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
|
||||||
|
<span>PFM Partner Portal</span>
|
||||||
<div className="w-full rounded-2xl bg-slate-950/50 border border-white/5 p-3 text-xs text-slate-400 font-mono truncate">
|
</div>
|
||||||
Signed in as {displayEmail}
|
<span>Role Security Gate</span>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,3 +176,4 @@ export const NonPartnerAccessDeniedModal: React.FC<NonPartnerAccessDeniedModalPr
|
|||||||
? createPortal(modalContent, document.body)
|
? createPortal(modalContent, document.body)
|
||||||
: modalContent;
|
: modalContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Clock, LogOut, RefreshCw } from 'lucide-react';
|
import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react';
|
||||||
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
|
|
||||||
import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration';
|
import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration';
|
||||||
|
|
||||||
interface SessionExpiredModalProps {
|
interface SessionExpiredModalProps {
|
||||||
@@ -84,63 +83,92 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
|||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
id="session-expired-modal-backdrop"
|
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"
|
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"
|
||||||
onMouseEnter={() => setIsPaused(true)}
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
onMouseLeave={() => setIsPaused(false)}
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="session-expired-title"
|
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
|
<div
|
||||||
id="session-expired-modal-content"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center justify-center pt-2">
|
<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 -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 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>
|
||||||
|
<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">Please sign in again to continue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="session-expired-close-btn"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<p>
|
||||||
|
Your active working session has ended for security. To protect your information, please return to the home page and sign in again.
|
||||||
|
</p>
|
||||||
|
{reason && (
|
||||||
|
<div className="flex items-start gap-2 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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
<span>Session Expired</span>
|
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
Returning to home page in{' '}
|
||||||
|
<strong className="text-white font-mono">{secondsRemaining}s</strong>
|
||||||
|
</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>
|
</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">
|
<div className="w-full h-2 bg-slate-800/90 rounded-full overflow-hidden border border-slate-700/60 p-0.5">
|
||||||
{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
|
<div
|
||||||
className="h-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all duration-75 ease-linear rounded-full"
|
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}%` }}
|
style={{ width: `${progressPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-slate-400 font-mono">
|
|
||||||
Redirecting in {secondsRemaining}s
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full pt-1">
|
<div className="pt-2 flex flex-col sm:flex-row items-center gap-2.5">
|
||||||
<button
|
<button
|
||||||
onClick={handleAction}
|
id="session-expired-return-btn"
|
||||||
type="button"
|
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"
|
onClick={handleAction}
|
||||||
|
className="w-full py-2.5 px-4 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-500/20 transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 text-emerald-100" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Sign In Again Now</span>
|
<span>Return to Home Page Now</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 ml-0.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,3 +179,4 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
|
|||||||
? createPortal(modalContent, document.body)
|
? createPortal(modalContent, document.body)
|
||||||
: modalContent;
|
: modalContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,188 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ export interface SecuritySettings {
|
|||||||
Authority: string;
|
Authority: string;
|
||||||
Audience: string;
|
Audience: string;
|
||||||
ClientId: string;
|
ClientId: string;
|
||||||
ClientSecret?: string;
|
ClientSecret: string;
|
||||||
Scope?: string;
|
Scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export interface ApiSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HasherSettings {
|
export interface HasherSettings {
|
||||||
Salt?: string;
|
Salt: string;
|
||||||
MinHashLength: number;
|
MinHashLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ export const defaultSecuritySettings: SecuritySettings = {
|
|||||||
Authority: 'https://sts.security.khongisa.co.za',
|
Authority: 'https://sts.security.khongisa.co.za',
|
||||||
Audience: 'pfm-api-dev',
|
Audience: 'pfm-api-dev',
|
||||||
ClientId: 'pfm-console-dev',
|
ClientId: 'pfm-console-dev',
|
||||||
|
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||||
Scope: 'openid profile roles email address pfm-api-dev',
|
Scope: 'openid profile roles email address pfm-api-dev',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,11 +41,11 @@ export const defaultApiSettings: ApiSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const defaultHasherSettings: HasherSettings = {
|
export const defaultHasherSettings: HasherSettings = {
|
||||||
Salt: '',
|
Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||||
MinHashLength: 11,
|
MinHashLength: 11,
|
||||||
};
|
};
|
||||||
|
|
||||||
function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void {
|
function syncAllHasherSettings(salt: string, minHashLength: number): void {
|
||||||
updateToolsHasherSettings(salt, minHashLength);
|
updateToolsHasherSettings(salt, minHashLength);
|
||||||
updateUtilsHasherSettings(salt, minHashLength);
|
updateUtilsHasherSettings(salt, minHashLength);
|
||||||
}
|
}
|
||||||
@@ -217,8 +218,9 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
const clientSecret = pickFirstNonEmpty(
|
const clientSecret = pickFirstNonEmpty(
|
||||||
winCfg.clientSecret,
|
winCfg.clientSecret,
|
||||||
fetchCfg.clientSecret,
|
fetchCfg.clientSecret,
|
||||||
envCfg.clientSecret
|
envCfg.clientSecret,
|
||||||
);
|
defaultSecuritySettings.ClientSecret
|
||||||
|
)!;
|
||||||
const scope = pickFirstNonEmpty(
|
const scope = pickFirstNonEmpty(
|
||||||
winCfg.scope,
|
winCfg.scope,
|
||||||
fetchCfg.scope,
|
fetchCfg.scope,
|
||||||
@@ -233,7 +235,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
fetchCfg.hasherSalt,
|
fetchCfg.hasherSalt,
|
||||||
envCfg.hasherSalt,
|
envCfg.hasherSalt,
|
||||||
defaultHasherSettings.Salt
|
defaultHasherSettings.Salt
|
||||||
);
|
)!;
|
||||||
const hasherMinLength =
|
const hasherMinLength =
|
||||||
winCfg.hasherMinLength ||
|
winCfg.hasherMinLength ||
|
||||||
fetchCfg.hasherMinLength ||
|
fetchCfg.hasherMinLength ||
|
||||||
@@ -245,14 +247,14 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
|||||||
Authority: authority,
|
Authority: authority,
|
||||||
Audience: audience,
|
Audience: audience,
|
||||||
ClientId: clientId,
|
ClientId: clientId,
|
||||||
...(clientSecret ? { ClientSecret: clientSecret } : {}),
|
ClientSecret: clientSecret,
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
},
|
},
|
||||||
ApiSettings: {
|
ApiSettings: {
|
||||||
BaseUrl: baseUrl,
|
BaseUrl: baseUrl,
|
||||||
},
|
},
|
||||||
HasherSettings: {
|
HasherSettings: {
|
||||||
Salt: hasherSalt || '',
|
Salt: hasherSalt,
|
||||||
MinHashLength: hasherMinLength,
|
MinHashLength: hasherMinLength,
|
||||||
},
|
},
|
||||||
ConfigSource: source,
|
ConfigSource: source,
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html, body {
|
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;
|
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,62 +62,11 @@ export function parseMarkdownToHtml(markdown: string): string {
|
|||||||
return `<code style="background:#1e293b;color:#34d399;padding:2px 6px;border-radius:6px;font-family:monospace;font-size:12px;">${escaped}</code>`;
|
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, '<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, '<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, '<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>');
|
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><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, '<strong style="color:#ffffff;">$1</strong>');
|
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||||
|
|||||||
@@ -1585,7 +1585,13 @@ export const deleteUserClaim = async (targetUserId: string, claimId: string | nu
|
|||||||
await apiClient.delete(`/v1/api/users/${targetUserId}/claims/${claimId}`);
|
await apiClient.delete(`/v1/api/users/${targetUserId}/claims/${claimId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Administrative functions (forceLogoutUser, setUserLockout) removed from PartnerUI
|
export const forceLogoutUser = async (userId: string): Promise<void> => {
|
||||||
|
await apiClient.put(`/v1/api/users/${userId}/session/logout`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setUserLockout = async (userId: string, disable: boolean): Promise<void> => {
|
||||||
|
await apiClient.put(`/v1/api/users/${userId}/session/lockout/${disable}`);
|
||||||
|
};
|
||||||
|
|
||||||
import {
|
import {
|
||||||
syncUserAccount,
|
syncUserAccount,
|
||||||
|
|||||||
@@ -157,20 +157,12 @@ export async function verifyTenantLockout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
|
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
|
||||||
let serverMsg =
|
const serverMsg =
|
||||||
lastError?.response?.data?.detail ||
|
lastError?.response?.data?.detail ||
|
||||||
lastError?.response?.data?.message ||
|
lastError?.response?.data?.message ||
|
||||||
lastError?.message ||
|
lastError?.message ||
|
||||||
'No tenant record returned';
|
'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.`;
|
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
|
||||||
setActivationPending(true, errorMsg);
|
setActivationPending(true, errorMsg);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|||||||
*/
|
*/
|
||||||
export const HASHER_SETTINGS = {
|
export const HASHER_SETTINGS = {
|
||||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
salt:
|
||||||
|
(env.VITE_HASHER_SALT as string) ||
|
||||||
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||||
};
|
};
|
||||||
|
|
||||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
|
|||||||
@@ -189,7 +189,19 @@ async function executeBackendSync(
|
|||||||
userSub?: string,
|
userSub?: string,
|
||||||
profile?: UserSyncProfile
|
profile?: UserSyncProfile
|
||||||
): Promise<{ endpoint: string; data?: any }> {
|
): Promise<{ endpoint: string; data?: any }> {
|
||||||
const endpoint = '/v1/api/integration/sync';
|
const rawTenantId = profile?.tenant_id ?? (profile as any)?.tenantId;
|
||||||
|
const parsedTenantId =
|
||||||
|
rawTenantId !== undefined && rawTenantId !== null && rawTenantId !== ''
|
||||||
|
? Number(rawTenantId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const resolvedOrgId =
|
||||||
|
parsedTenantId !== undefined
|
||||||
|
? parsedTenantId
|
||||||
|
: profile?.organisationId !== undefined && profile?.organisationId !== null && profile?.organisationId !== ''
|
||||||
|
? Number(profile.organisationId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
id: userSub || profile?.id || profile?.sub,
|
id: userSub || profile?.id || profile?.sub,
|
||||||
sub: userSub || profile?.sub,
|
sub: userSub || profile?.sub,
|
||||||
@@ -197,25 +209,40 @@ async function executeBackendSync(
|
|||||||
name: profile?.name || undefined,
|
name: profile?.name || undefined,
|
||||||
role: profile?.role || undefined,
|
role: profile?.role || undefined,
|
||||||
tenant: profile?.tenant || undefined,
|
tenant: profile?.tenant || undefined,
|
||||||
tenant_id: profile?.tenant_id,
|
tenant_id: parsedTenantId,
|
||||||
organisationId: profile?.organisationId,
|
organisationId: resolvedOrgId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
let lastError: any = null;
|
||||||
const res = await apiClient.post(endpoint, payload);
|
|
||||||
return { endpoint, data: res?.data };
|
for (const endpoint of USER_SYNC_CANDIDATE_ENDPOINTS) {
|
||||||
} catch (err: any) {
|
try {
|
||||||
const status = err?.response?.status;
|
let res;
|
||||||
if (status === 400 || status === 415 || status === 422) {
|
if (endpoint === '/v1/api/integration/sync') {
|
||||||
try {
|
try {
|
||||||
const resFallback = await apiClient.post(endpoint, {});
|
res = await apiClient.post(endpoint, payload);
|
||||||
return { endpoint, data: resFallback?.data };
|
} catch (postErr: any) {
|
||||||
} catch (fallbackErr) {
|
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;
|
||||||
|
if (status === 401 || status === 403 || status === 423) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Failed to reach user sync endpoint');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncUserAccount(
|
export async function syncUserAccount(
|
||||||
@@ -263,21 +290,13 @@ export async function syncUserAccount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSuccessfulSyncRecord();
|
clearSuccessfulSyncRecord();
|
||||||
let serverMsg =
|
const serverMsg =
|
||||||
err?.response?.data?.detail ||
|
err?.response?.data?.detail ||
|
||||||
err?.response?.data?.message ||
|
err?.response?.data?.message ||
|
||||||
err?.response?.data?.error ||
|
err?.response?.data?.error ||
|
||||||
err?.message ||
|
err?.message ||
|
||||||
'Account is not yet enabled by the administrator.';
|
'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);
|
setActivationPending(true, serverMsg);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|||||||
|
|
||||||
export const HASHER_SETTINGS = {
|
export const HASHER_SETTINGS = {
|
||||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||||
salt: (env.VITE_HASHER_SALT as string) || '',
|
salt:
|
||||||
|
(env.VITE_HASHER_SALT as string) ||
|
||||||
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||||
};
|
};
|
||||||
|
|
||||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ services:
|
|||||||
- CorsOrigins__0=${CORS_ORIGIN_CONSOLE:-https://console.ifri.khongisa.co.za}
|
- CorsOrigins__0=${CORS_ORIGIN_CONSOLE:-https://console.ifri.khongisa.co.za}
|
||||||
- CorsOrigins__1=${CORS_ORIGIN_API:-https://api.ifri.khongisa.co.za}
|
- CorsOrigins__1=${CORS_ORIGIN_API:-https://api.ifri.khongisa.co.za}
|
||||||
- CorsOrigins__2=${CORS_ORIGIN_LOCAL:-https://localhost:7208}
|
- CorsOrigins__2=${CORS_ORIGIN_LOCAL:-https://localhost:7208}
|
||||||
- CorsOrigins__3=${CORS_ORIGIN_PARTNER:-https://partner.ifri.khongisa.co.za}
|
|
||||||
|
|
||||||
# Postgres Database Overrides
|
# 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:-};
|
- 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