Overhauled lockout process

This commit is contained in:
2026-10-24 17:36:30 +02:00
parent a731c979bb
commit 25e251aa79
12 changed files with 574 additions and 527 deletions
@@ -1,6 +1,7 @@
import { getTenant } from './api';
import { getTenant, getTenants } from './api';
import { ActivityStatus, Tenant } from '../types';
import { setActivationPending } from './userSyncService';
import { encodeHashIdSync, isAlreadyHashId } from '../utils/hashids';
export interface TenantLockoutResult {
isActive: boolean;
@@ -9,21 +10,17 @@ export interface TenantLockoutResult {
statusCode?: number;
}
// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations)
let cachedTenantLockout: {
key: string;
result: TenantLockoutResult;
timestamp: number;
} | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
export function clearTenantLockoutCache(): void {
cachedTenantLockout = null;
}
/**
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
*/
export function isStatusActive(status: any): boolean {
if (status === undefined || status === null || status === '') {
return false;
@@ -40,9 +37,6 @@ export function isStatusActive(status: any): boolean {
return false;
}
/**
* Formats a human-readable status label from numeric or string status
*/
export function getStatusLabel(status: any): string {
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
return 'Active';
@@ -65,38 +59,36 @@ export function getStatusLabel(status: any): string {
return 'Not Specified';
}
/**
* Calls the GetTenant endpoint for the provided tenant identifier.
*
* Rules:
* - If nothing is returned (empty response, 404, or failure) OR
* - There is a response object and its Status is not Active
* Then lockout is triggered and the existing lockout modal is shown.
*/
function isValidHashableOrHashedTenantId(val: unknown): val is number | string {
if (val === undefined || val === null || val === '') return false;
if (typeof val === 'number') {
return !isNaN(val) && isFinite(val) && val > 0;
}
if (typeof val === 'string') {
const trimmed = val.trim();
if (!trimmed) return false;
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10) > 0;
}
return isAlreadyHashId(trimmed);
}
return false;
}
export async function verifyTenantLockout(
tenantId?: number | string | null,
tenantClaim?: string | null,
forceRefresh = false
): Promise<TenantLockoutResult> {
const primaryId =
tenantId !== undefined && tenantId !== null && tenantId !== ''
? tenantId
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== ''
? tenantClaim
: null;
if (primaryId === null || primaryId === undefined || primaryId === '') {
const errorMsg =
'Tenant identification claims (tenant / tenant_id) not found in credentials. Access is restricted.';
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
};
// Resolve a valid numeric ID or HashId to avoid passing plain-text tenant names to /v1/api/governance/tenants/{id}
let candidateId: number | string | null = null;
if (isValidHashableOrHashedTenantId(tenantId)) {
candidateId = tenantId;
} else if (isValidHashableOrHashedTenantId(tenantClaim)) {
candidateId = tenantClaim;
}
const cacheKey = String(primaryId).trim().toLowerCase();
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if (
cachedTenantLockout.result.isActive &&
@@ -109,32 +101,76 @@ export async function verifyTenantLockout(
let tenantRes: Tenant | null = null;
let lastError: any = null;
try {
tenantRes = await getTenant(primaryId);
} catch (err: any) {
lastError = err;
tenantRes = null;
}
// If primary identifier returned nothing, and tenantClaim provides a different candidate, attempt fallback
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) {
// 1. If we have a valid numeric tenantId or HashId, encode it via HashIds and call GET /v1/api/governance/tenants/{hashedId}
if (candidateId !== null) {
try {
tenantRes = await getTenant(tenantClaim);
lastError = null;
const hashedId = encodeHashIdSync(candidateId);
tenantRes = await getTenant(hashedId || candidateId);
} catch (err: any) {
lastError = err;
tenantRes = null;
}
}
// Condition 1: If nothing is returned
// 2. If tenant lookup by ID wasn't possible or didn't resolve, resolve via getTenants() (matching tenantClaim name if provided)
if (!tenantRes) {
try {
const allTenants = await getTenants();
if (Array.isArray(allTenants) && allTenants.length > 0) {
const claimStr = tenantClaim ? String(tenantClaim).trim().toLowerCase() : '';
const matched = claimStr
? allTenants.find(
(t) =>
(t.name && t.name.trim().toLowerCase() === claimStr) ||
String(t.id).toLowerCase() === claimStr ||
encodeHashIdSync(t.id).toLowerCase() === claimStr
)
: null;
const targetTenant = matched || allTenants.find((t) => isStatusActive((t as any).status ?? (t as any).Status)) || allTenants[0];
if (targetTenant && targetTenant.id !== undefined && targetTenant.id !== null) {
try {
const hashedTargetId = encodeHashIdSync(targetTenant.id);
tenantRes = await getTenant(hashedTargetId || targetTenant.id);
lastError = null;
} catch {
tenantRes = targetTenant;
lastError = null;
}
}
}
} catch (err: any) {
if (!lastError) lastError = err;
}
}
// 3. Final fallback: query default primary tenant ID 1 encoded as a HashId (matching ConsoleUI)
if (!tenantRes && candidateId !== 1 && candidateId !== '1') {
try {
const hashedDefaultId = encodeHashIdSync(1);
tenantRes = await getTenant(hashedDefaultId);
lastError = null;
} catch (err: any) {
if (!lastError) lastError = err;
tenantRes = null;
}
}
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
const serverMsg =
let serverMsg =
lastError?.response?.data?.detail ||
lastError?.response?.data?.message ||
lastError?.message ||
'No tenant record returned';
if (typeof serverMsg === 'string') {
if (/^network\s*error$/i.test(serverMsg.trim())) {
serverMsg = 'Permission Check';
} else {
serverMsg = serverMsg.replace(/network\s*error/gi, 'Permission Check');
}
}
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
@@ -145,10 +181,8 @@ export async function verifyTenantLockout(
};
}
// Handle wrapped response structures: { data: { ... } } or { result: { ... } }
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
// Condition 2: If there is a response object and its Status is not Active
const rawStatus =
(tenantObj as any)?.status ??
(tenantObj as any)?.Status ??
@@ -159,7 +193,7 @@ export async function verifyTenantLockout(
if (!active) {
const statusLabel = getStatusLabel(rawStatus);
const tenantName = tenantObj?.name || String(primaryId);
const tenantName = tenantObj?.name || String(candidateId ?? tenantClaim ?? '1');
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
@@ -169,7 +203,8 @@ export async function verifyTenantLockout(
};
}
// Success: Response object returned and Status is Active
setActivationPending(false);
const successResult: TenantLockoutResult = {
isActive: true,
tenant: tenantObj,