RBAC Improvements

Caching improvements
Maker-Checker Implementation
This commit is contained in:
2026-10-21 07:27:47 +02:00
parent dee9098379
commit 5d7e2f74ce
25 changed files with 1269 additions and 309 deletions
@@ -581,62 +581,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
} catch {}
}
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
);
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
setIsLoading(false);
return;
}
if (tenantLockoutResult.tenant?.name) {
if (resolvedUser) {
resolvedUser.tenant = tenantLockoutResult.tenant.name;
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setTenantName(tenantLockoutResult.tenant.name);
} else if (storedTenantClaim) {
if (storedTenantClaim) {
setTenantName(storedTenantClaim);
}
// Check if session is already marked pending activation or needs sync check
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
// Initial fast unblock: valid persisted session found, unblock UI immediately
setIsLoading(false);
try {
// Verify sync with browser cache priority (hotspot prevention)
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims)
(async () => {
try {
const tenantLockoutResult = await verifyTenantLockout(
storedTenantId ?? tenantClaimValues.tenantId,
storedTenantClaim
);
if (syncResult.isUnauthorized) {
// HTTP 401: Normal process of routing user to landing page
await logout();
return;
}
if (!tenantLockoutResult.isActive) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
return;
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
} else if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
if (tenantLockoutResult.tenant?.name) {
if (resolvedUser) {
resolvedUser.tenant = tenantLockoutResult.tenant.name;
setUser({ ...resolvedUser });
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
}
setTenantName(tenantLockoutResult.tenant.name);
}
const wasPending = isActivationPending();
setIsPendingActivation(wasPending);
setActivationError(getActivationPendingReason());
const syncResult = await syncUserAccount(
storedSub || '',
storedToken,
resolvedUser || undefined,
false
);
if (syncResult.isUnauthorized) {
await logout();
return;
}
if (syncResult.isActivationPending) {
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncResult.errorMessage || null);
return;
}
if (syncResult.success) {
setIsPendingActivation(false);
setIsSynced(true);
setActivationError(null);
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
try {
const resolved = await resolveUserRolesAndClaims(
storedSub || '',
parsedClaims,
@@ -657,17 +660,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
setClaims({ ...resolved.claims });
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
} catch {}
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
return;
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
}
} catch (syncErr: any) {
if (syncErr?.response?.status === 401) {
await logout();
return;
}
setIsPendingActivation(true);
setIsSynced(false);
setActivationError(syncErr?.message || 'Account activation pending');
}
})();
return;
}
} catch (err: unknown) {
console.warn('OIDC initialization notice:', err);
+54 -5
View File
@@ -142,12 +142,14 @@ export function resolveClaimActionSet(
actions.add('add');
actions.add('update');
actions.add('export');
} else if (val === 'view') {
} else if (val === 'view' || val === 'read') {
actions.add('view');
} else if (val === 'add') {
} else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') {
// 'add' encompasses create and non-approval edits
actions.add('view');
actions.add('add');
} else if (val === 'update') {
} else if (val === 'update' || val === 'approve' || val === 'verify') {
// 'update' strictly grants approval capability (+ view and add)
actions.add('view');
actions.add('add');
actions.add('update');
@@ -173,10 +175,13 @@ export function resolveClaimActionSet(
actions.add('update');
actions.add('export');
} else if (role === 'supervisor') {
// Supervisor performs operational review and verification/approval of milestones & evidence
actions.add('view');
actions.add('add');
actions.add('update');
actions.add('export');
} else if (role === 'contributor') {
// Contributor is strictly working-level execution: creating activities, submitting evidence, etc. (NO approve/update)
actions.add('view');
actions.add('add');
} else {
@@ -294,11 +299,36 @@ export function evaluateUserPermission(
return false;
}
/**
* Evaluates Maker-Checker separation of duties constraint.
* In PFM governance, the user who created (or initiated) a record ("Maker")
* is strictly barred from approving ("Checking") their own record.
*
* @param createdBy The user identifier of the record creator (Guid, sub, email)
* @param currentUserId Current logged-in user ID (user.id or sub)
* @param currentUserSub Current logged-in user subject claim
* @param currentUserEmail Current logged-in user email
* @returns boolean true if user is the creator (Maker), false otherwise
*/
export function isRecordMaker(
createdBy?: string | null,
currentUserId?: string | null,
currentUserSub?: string | null,
currentUserEmail?: string | null
): boolean {
if (!createdBy) return false;
const creator = createdBy.trim().toLowerCase();
if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true;
if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true;
if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true;
return false;
}
/**
* React hook exposing RBAC evaluation helpers for components.
*/
export function useRbac() {
const { roles, claims } = useAuth();
const { roles, claims, user, sub } = useAuth();
const permissions = useMemo(() => {
const isUnrestricted =
@@ -315,6 +345,22 @@ export function useRbac() {
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
const isMaker = (createdBy?: string | null): boolean => {
return isRecordMaker(createdBy, user?.id, sub, user?.email);
};
const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => {
if (!canApprove(claim)) return false;
// Maker-Checker policy: record creator cannot approve their own record
return !isMaker(createdBy);
};
const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => {
if (!canApprove(claim)) return 'Requires update/approval permission';
if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records';
return null;
};
return {
isUnrestricted,
canAccess,
@@ -322,8 +368,11 @@ export function useRbac() {
canAdd,
canApprove,
canExport,
isMaker,
canApproveRecord,
getApprovalRestrictionReason,
};
}, [roles, claims]);
}, [roles, claims, user, sub]);
return permissions;
}
@@ -351,6 +351,7 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
</button>
<ExportButton
claim="governance"
title="Funding Instruments Registry"
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
@@ -347,6 +347,7 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
</button>
<ExportButton
claim="governance"
title="Programme Rules Registry"
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
@@ -44,6 +44,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort';
import { useRbac } from '../../auth/rbac';
import {
Contract,
Award,
@@ -73,6 +74,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
}) => {
const queryClient = useQueryClient();
const { success, error: toastError, info } = useToast();
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
// Component state
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
@@ -602,6 +604,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
</button>
<ExportButton
claim="grant_financials"
title="Contract Registry & Agreements"
subtitle="Authoritative repository of bilateral legal agreements and contract files"
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
@@ -609,17 +612,19 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
data={filteredContracts}
/>
<button
type="button"
onClick={() => {
resetCreateForm();
setCreateModalOpen(true);
}}
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Create Contract
</button>
{canAdd('grant_financials') && (
<button
type="button"
onClick={() => {
resetCreateForm();
setCreateModalOpen(true);
}}
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Create Contract
</button>
)}
</div>
</div>
@@ -1224,17 +1229,34 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
<label className="block text-xs font-medium text-slate-200 mb-1">
Contract Lifecycle Status <span className="text-rose-400">*</span>
</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(Number(e.target.value))}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
>
{CONTRACT_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(() => {
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
const isSelf = isMaker(contractToUpdate?.createdBy);
return (
<select
value={newStatus}
onChange={(e) => setNewStatus(Number(e.target.value))}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 font-medium"
>
{CONTRACT_STATUS_OPTIONS.map((opt) => {
const isActiveOpt = opt.value === ContractStatus.Active;
const isOptDisabled = isActiveOpt && !canApproveContract;
let reasonText = '';
if (isActiveOpt && !canApprove('grant_financials')) {
reasonText = ' (Requires Update Permission)';
} else if (isActiveOpt && isSelf) {
reasonText = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()}
</div>
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
@@ -87,7 +87,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
@@ -824,7 +824,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
</button>
)}
{canApprove('evidence_risks') && (
{canApproveRecord('evidence_risks', ev.createdBy) && (
<button
type="button"
onClick={() =>
@@ -847,6 +847,11 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
</button>
)}
{canApprove('evidence_risks') && isMaker(ev.createdBy) && (
<span className="text-[10px] text-amber-400/90 italic font-mono px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded-md">
Maker-Checker (Creator)
</span>
)}
<button
type="button"
@@ -1022,7 +1027,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
</button>
)}
{canApprove('evidence_risks') && (
{canApproveRecord('evidence_risks', inspectEvidence.createdBy) && (
<button
type="button"
onClick={() =>
@@ -1045,6 +1050,12 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
</button>
)}
{canApprove('evidence_risks') && isMaker(inspectEvidence.createdBy) && (
<span className="text-xs text-amber-400 font-medium px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0" />
<span>Maker-Checker Policy: As the creator of this submission, you cannot approve it.</span>
</span>
)}
</div>
</div>
</div>
@@ -25,6 +25,7 @@ import {
updateMilestone,
} from '../../services/api';
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
import { useRbac } from '../../auth/rbac';
import {
formatDate,
getApprovalStatusBadge,
@@ -55,6 +56,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
@@ -175,7 +177,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
</div>
<div className="flex items-center gap-2">
{!isFormOpen && (
{!isFormOpen && canAdd('projects_milestones') && (
<button
type="button"
onClick={openCreateForm}
@@ -255,16 +257,33 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
{editingMilestone && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
<select
{...form.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(() => {
const canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
const isSelf = isMaker(editingMilestone.createdBy);
return (
<select
{...form.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveMilestone;
let reasonText = '';
if (isApprovedOpt && !canApprove('projects_milestones')) {
reasonText = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reasonText = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()}
</div>
)}
@@ -33,9 +33,9 @@ export function ExportButton<T>({
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { user, claims, tenantId } = useAuth();
const { canExport } = useRbac();
const { canExport, isUnrestricted } = useRbac();
const isClaimAllowed = claim ? canExport(claim) : true;
const isClaimAllowed = claim ? canExport(claim) : isUnrestricted;
const isDisabled = !data || data.length === 0 || !isClaimAllowed;
const updatePosition = useCallback(() => {
@@ -31,6 +31,7 @@ import {
updateUserStatus,
getSystemClaims,
} from '../../services/api';
import { clearCachedUserRbac } from '../../services/roleClaimsCache';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
import { decodeIdNumberHex } from '../../utils/hashids';
@@ -58,7 +59,6 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
canApprove('settings_users') ||
canApprove('tenant_management') ||
canApprove('tenant_self_service') ||
canAdd('tenant_self_service') ||
isUnrestricted;
const canManageSessions =
canApprove('settings_users') ||
@@ -129,6 +129,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
return assignUserRole(userId, roleId);
},
onSuccess: () => {
clearCachedUserRbac(userId);
success('Role Assigned', 'Role membership updated successfully.');
refetchUserRoles();
setSelectedRoleToAdd('');
@@ -139,6 +140,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
const removeRoleMutation = useMutation({
mutationFn: (roleId: string) => removeUserRole(userId, roleId),
onSuccess: () => {
clearCachedUserRbac(userId);
success('Role Removed', 'Role revoked from user.');
refetchUserRoles();
},
@@ -160,6 +162,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
});
},
onSuccess: () => {
clearCachedUserRbac(userId);
success('Claim Granted', 'User claim added successfully.');
refetchUserClaims();
setNewClaimType('');
@@ -171,6 +174,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
const deleteClaimMutation = useMutation({
mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId),
onSuccess: () => {
clearCachedUserRbac(userId);
success('Claim Revoked', 'Claim removed from user.');
refetchUserClaims();
},
@@ -1,11 +1,27 @@
import { QueryClient } from '@tanstack/react-query';
import { QueryClient, QueryKey, InvalidateQueryFilters } from '@tanstack/react-query';
import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 3, // 3 minutes default cache
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 1,
},
},
});
/**
* Invalidates queries while simultaneously bumping the mutation epoch.
* This guarantees that the background refetch generates a fresh query parameter (_v)
* that bypasses backend ASP.NET Core OutputCaching.
*/
export async function invalidateAndBustCache(
client: QueryClient,
filters?: InvalidateQueryFilters
): Promise<void> {
bumpMutationEpoch();
await client.invalidateQueries(filters);
}
@@ -71,7 +71,7 @@ type OutcomeFormValues = z.infer<typeof outcomeSchema>;
export const BeneficiariesPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
@@ -1024,16 +1024,25 @@ export const BeneficiariesPage: React.FC = () => {
{...compForm.register('status')}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries')}
>
{opt.label}
{opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries') ? ' (Requires Update Permission)' : ''}
</option>
))}
{(() => {
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
const isSelf = isMaker(editingComp?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveComp;
let reason = '';
if (isApprovedOpt && !canApprove('beneficiaries')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
});
})()}
</select>
</div>
)}
@@ -110,7 +110,7 @@ type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
export const ChangeManagementPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
@@ -1022,7 +1022,7 @@ export const ChangeManagementPage: React.FC = () => {
</span>
) : (
<>
{canApprove('change_management') && (
{canApproveRecord('change_management', cr.createdBy) && (
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
@@ -1030,6 +1030,11 @@ export const ChangeManagementPage: React.FC = () => {
<Check className="w-3 h-3" /> Approve
</button>
)}
{canApprove('change_management') && isMaker(cr.createdBy) && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker
</span>
)}
{canAdd('change_management') && (
<button
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
@@ -1522,9 +1527,21 @@ export const ChangeManagementPage: React.FC = () => {
>
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Review}>Under Active Review</option>
<option value={ApprovalStatus.Approved} disabled={!canApprove('change_management')}>
Approved &amp; Enacted {!canApprove('change_management') ? '(Requires Update Permission)' : ''}
</option>
{(() => {
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
const isSelf = isMaker(editingChange?.createdBy);
let reason = '';
if (!canApprove('change_management')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
Approved &amp; Enacted {reason}
</option>
);
})()}
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
</div>
@@ -133,7 +133,7 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user, claims } = useAuth();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
@@ -1982,16 +1982,33 @@ export const GovernancePage: React.FC = () => {
placement="top"
/>
</div>
<select
{...programmeForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(() => {
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
return (
<select
{...programmeForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
let reason = '';
if (isApprovedOpt && !canApprove('governance')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
})}
</select>
);
})()}
</div>
<div>
@@ -99,7 +99,7 @@ type DisbursementFormValues = z.infer<typeof disbursementSchema>;
export const GrantsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
@@ -1002,7 +1002,7 @@ export const GrantsPage: React.FC = () => {
<Edit2 className="w-3 h-3" />
<span>Update</span>
</button>
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
{b.status !== ApprovalStatus.Approved && canApproveRecord('grant_financials', b.createdBy) && (
<button
type="button"
disabled={updateBudgetMutation.isPending}
@@ -1019,6 +1019,11 @@ export const GrantsPage: React.FC = () => {
<span>Approve</span>
</button>
)}
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && isMaker(b.createdBy) && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker
</span>
)}
</div>
</td>
</tr>
@@ -1159,7 +1164,7 @@ export const GrantsPage: React.FC = () => {
>
<Edit2 className="w-3.5 h-3.5" />
</button>
{canApprove('grant_financials') && (
{canApproveRecord('grant_financials', d.createdBy) && (
<button
onClick={() => approveMutation.mutate(d.id)}
disabled={approveMutation.isPending}
@@ -1169,6 +1174,11 @@ export const GrantsPage: React.FC = () => {
Approve Payment
</button>
)}
{canApprove('grant_financials') && isMaker(d.createdBy) && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker (Creator)
</span>
)}
</>
)}
</div>
@@ -1562,9 +1572,21 @@ export const GrantsPage: React.FC = () => {
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value={ApprovalStatus.Approved} disabled={!canApprove('grant_financials')}>
Approved {!canApprove('grant_financials') ? '(Requires Update Permission)' : ''}
</option>
{(() => {
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
const isSelf = isMaker(editingBudget?.createdBy);
let reason = '';
if (!canApprove('grant_financials')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
Approved {reason}
</option>
);
})()}
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
</select>
@@ -43,6 +43,7 @@ import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
@@ -100,6 +101,7 @@ export const WORKFLOW_STAGES = [
export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canExport } = useRbac();
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
@@ -435,7 +437,7 @@ export const MonitoringPage: React.FC = () => {
</div>
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
{activeTab === 'contracts' && (
{activeTab === 'contracts' && canAdd('grant_financials') && (
<button
onClick={() => setIsCreateContractModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
@@ -444,7 +446,7 @@ export const MonitoringPage: React.FC = () => {
<span>Create Contract &amp; Upload PDF</span>
</button>
)}
{activeTab === 'indicators' && (
{activeTab === 'indicators' && canAdd('evidence_risks') && (
<button
onClick={() => setIsIndicatorModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
@@ -453,7 +455,7 @@ export const MonitoringPage: React.FC = () => {
<span>Define Indicator</span>
</button>
)}
{activeTab === 'risks' && (
{activeTab === 'risks' && canAdd('evidence_risks') && (
<button
onClick={openCreateRisk}
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
@@ -660,6 +662,7 @@ export const MonitoringPage: React.FC = () => {
</button>
<ExportButton
claim="evidence_risks"
title="M&E Performance Indicators"
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
@@ -861,6 +864,7 @@ export const MonitoringPage: React.FC = () => {
</button>
<ExportButton
claim="evidence_risks"
title="Project Risk Register & Heatmap"
subtitle="Itemized audit log of identified risks and mitigation controls"
filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`}
@@ -150,7 +150,7 @@ export const evaluateProjectScheduleRisk = (
export const ProjectsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
// Filter & Search States
const [searchTerm, setSearchTerm] = useState('');
@@ -954,22 +954,41 @@ export const ProjectsPage: React.FC = () => {
{/* Governance Status */}
<td className="py-4 px-4" onClick={(e) => e.stopPropagation()}>
<div className="space-y-1">
<select
value={p.status}
onChange={(e) =>
statusMutation.mutate({
projectId: p.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(() => {
const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
const isSelf = isMaker(p.createdBy);
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
return (
<select
value={p.status}
disabled={!canModifyStatus}
onChange={(e) =>
statusMutation.mutate({
projectId: p.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveThis;
let reasonText = '';
if (isApprovedOpt && !canApprove('projects_milestones')) {
reasonText = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reasonText = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()}
</div>
</td>
@@ -54,7 +54,7 @@ type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
export const SiteVisitsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
@@ -509,16 +509,25 @@ export const SiteVisitsPage: React.FC = () => {
{...visitForm.register('status')}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={opt.value === ApprovalStatus.Approved && !canApprove('site_visits')}
>
{opt.label}
{opt.value === ApprovalStatus.Approved && !canApprove('site_visits') ? ' (Requires Update Permission)' : ''}
</option>
))}
{(() => {
const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
const isSelf = isMaker(editingVisit?.createdBy);
return APPROVAL_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveVisit;
let reason = '';
if (isApprovedOpt && !canApprove('site_visits')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</option>
);
});
})()}
</select>
</div>
)}
@@ -5,6 +5,55 @@ import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './u
export const BASE_API_URL = getApiBaseUrl();
// ---------------------------------------------------------------------------
// Mutation Versioning & Cache-Busting Management
// ---------------------------------------------------------------------------
// ASP.NET Core OutputCaching caches GET responses based on Method + URI + QueryString.
// To ensure that mutations (create, update, approve, delete) immediately invalidate
// backend OutputCache without serving stale data, ConsoleUI tracks a mutation epoch.
// Every successful POST/PUT/PATCH/DELETE bumps this epoch, ensuring subsequent GET
// requests partition to a fresh cache key on the server.
// ---------------------------------------------------------------------------
const MUTATION_EPOCH_STORAGE_KEY = 'pfm_mutation_epoch';
let activeMutationEpoch: number = (() => {
if (typeof window !== 'undefined') {
try {
const stored = sessionStorage.getItem(MUTATION_EPOCH_STORAGE_KEY);
if (stored) {
const parsed = Number(stored);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
} catch {}
}
return Date.now();
})();
/**
* Returns the current client mutation epoch timestamp.
* Appended as `_v` to internal API GET requests to partition backend OutputCache entries.
*/
export function getMutationEpoch(): number {
return activeMutationEpoch;
}
/**
* Bumps the mutation epoch to the current timestamp.
* Called automatically after any successful POST/PUT/PATCH/DELETE request,
* or manually when cache busting is required.
* Ensures subsequent GET requests bypass stale ASP.NET Core OutputCache entries.
*/
export function bumpMutationEpoch(): number {
activeMutationEpoch = Date.now();
if (typeof window !== 'undefined') {
try {
sessionStorage.setItem(MUTATION_EPOCH_STORAGE_KEY, String(activeMutationEpoch));
} catch {}
}
return activeMutationEpoch;
}
// Create Axios Instance
export const apiClient = axios.create({
baseURL: BASE_API_URL,
@@ -15,7 +64,7 @@ export const apiClient = axios.create({
timeout: 15000,
});
// Request Interceptor to dynamically set baseURL and attach Bearer Token
// Request Interceptor to dynamically set baseURL, attach Bearer Token, and apply cache-busting
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Dynamically synchronize baseURL from runtime configuration
@@ -25,9 +74,46 @@ apiClient.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
const isExternal =
config.url?.startsWith('http://') ||
config.url?.startsWith('https://') ||
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
// Explicit freshness requested: attach fresh timestamp
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
} else {
config.params = config.params || {};
config.params._t = Date.now();
}
} else {
// Standard GET: attach current mutation epoch
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch();
}
}
}
}
// User Sync & Tenant Lifecycle Guard:
// All API calls require an authenticated and enabled user in the backend system.
const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
if (token && !isLifecycleRequest) {
// If user account is flagged as pending administrator activation, block downstream API calls
if (isActivationPending()) {
@@ -48,9 +134,17 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error)
);
// Response Interceptor for global HTTP 401 detection
// Response Interceptor for global HTTP 401 detection and mutation cache invalidation
apiClient.interceptors.response.use(
(response) => response,
(response) => {
// If response was from a successful mutating HTTP method (POST, PUT, PATCH, DELETE),
// automatically bump the mutation epoch so subsequent refetches bypass stale OutputCache
const method = response.config?.method?.toLowerCase();
if (method && ['post', 'put', 'patch', 'delete'].includes(method)) {
bumpMutationEpoch();
}
return response;
},
(error) => {
if (error?.response?.status === 401) {
const requestUrl = error.config?.url;
@@ -5,33 +5,64 @@ import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac';
export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_cache_system_roles';
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_cache_role_claims_';
export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_cache_system_claims';
export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_rbac_cache_version';
export const CURRENT_RBAC_CACHE_VERSION = 'v2.1';
/**
* Invalidates and purges legacy cached claims if the schema version differs.
*/
export function ensureFreshRbacCache(): void {
if (typeof window === 'undefined') return;
try {
const version = localStorage.getItem(LOCAL_STORAGE_RBAC_VERSION_KEY);
if (version !== CURRENT_RBAC_CACHE_VERSION) {
// Clear legacy claims and caches
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith(LOCAL_STORAGE_ROLE_CLAIMS_PREFIX) || key === 'pfm_user_claims')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
inMemoryRoleClaims.clear();
localStorage.setItem(LOCAL_STORAGE_RBAC_VERSION_KEY, CURRENT_RBAC_CACHE_VERSION);
}
} catch {}
}
// In-memory runtime caches
let inMemorySystemRoles: Role[] | null = null;
const inMemoryRoleClaims = new Map<string, Claim[]>();
let inMemorySystemClaims: Claim[] | null = null;
const inMemoryUserRoles = new Map<string, { roles: Role[]; timestamp: number }>();
const inMemoryUserClaims = new Map<string, { claims: Claim[]; timestamp: number }>();
const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
// Initial check on load
ensureFreshRbacCache();
// Standard role claim defaults matching PFM Identity Server specification
export const DEFAULT_ROLE_CLAIMS: Record<string, Claim[]> = {
contributor: [
{ claimId: 30, claimType: 'projects_milestones', claimValue: 'view, edit, update' },
{ claimId: 31, claimType: 'grant_financials', claimValue: 'view, edit, update' },
{ claimId: 32, claimType: 'evidence_risks', claimValue: 'view, edit, update' },
{ claimId: 33, claimType: 'beneficiaries', claimValue: 'view, edit, update' },
{ claimId: 34, claimType: 'change_management', claimValue: 'view, edit, update' },
{ claimId: 35, claimType: 'site_visits', claimValue: 'view, edit, update' },
{ claimId: 36, claimType: 'settings_invites', claimValue: 'view, edit, update' },
{ claimId: 30, claimType: 'projects_milestones', claimValue: 'view, add' },
{ claimId: 31, claimType: 'grant_financials', claimValue: 'view, add' },
{ claimId: 32, claimType: 'evidence_risks', claimValue: 'view, add' },
{ claimId: 33, claimType: 'beneficiaries', claimValue: 'view, add' },
{ claimId: 34, claimType: 'change_management', claimValue: 'view, add' },
{ claimId: 35, claimType: 'site_visits', claimValue: 'view, add' },
{ claimId: 36, claimType: 'settings_invites', claimValue: 'view, add' },
],
supervisor: [
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, add, export' },
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, export' },
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, export' },
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, export' },
{ claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, export' },
{ claimId: 25, claimType: 'change_management', claimValue: 'view, add, export' },
{ claimId: 26, claimType: 'site_visits', claimValue: 'view, add, export' },
{ claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, export' },
{ claimId: 28, claimType: 'settings_users', claimValue: 'view, add, export' },
{ claimId: 20, claimType: 'dashboard', claimValue: 'view, export' },
{ claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, update, export' },
{ claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, update, export' },
{ claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, update, export' },
{ claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, update, export' },
{ claimId: 25, claimType: 'change_management', claimValue: 'view, add, update, export' },
{ claimId: 26, claimType: 'site_visits', claimValue: 'view, add, update, export' },
{ claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, update, export' },
{ claimId: 28, claimType: 'settings_users', claimValue: 'view, add, update, export' },
],
manager: [
{ claimId: 10, claimType: 'dashboard', claimValue: 'all' },
@@ -154,8 +185,14 @@ export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void {
/**
* Fetches system roles from backend API, caching the result in memory and localStorage.
* Checks cache first to avoid redundant network round trips.
*/
export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise<Role[]> {
if (!forceRefresh) {
const cached = getCachedSystemRoles();
if (cached && cached.length > 0) return cached;
}
try {
const res = await apiClient.get<Role[]>('/v1/api/users/system/roles');
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
@@ -181,30 +218,69 @@ export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
return defaults;
}
const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
/**
* Fetches role claims for a role from the API, caching them under role ID and role name.
* Uses cache-first strategy to eliminate sequential network waterfalls on startup.
*/
export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string): Promise<Claim[]> {
export async function fetchAndCacheRoleClaims(
roleId: string,
roleName?: string,
forceRefresh = false
): Promise<Claim[]> {
const roleIdKey = roleId.toLowerCase().trim();
const roleNameKey = (roleName || '').toLowerCase().trim();
try {
const res = await apiClient.get<Claim[]>(`/v1/api/users/system/roles/${roleId}/claims`);
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
const normalized = res.data.map(normalizeClaim);
setCachedRoleClaims(roleIdKey, normalized);
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
return normalized;
// 1. Check in-memory and localStorage cache first
if (!forceRefresh) {
const cachedById = getCachedRoleClaims(roleIdKey);
if (cachedById && cachedById.length > 0) return cachedById;
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
}
} catch (err) {
// Network or server error, proceed to cache / defaults fallback
}
// Check cache by role ID
// 2. Resolve actual GUID if roleId is not formatted as a GUID
let effectiveGuid = GUID_REGEX.test(roleId.trim()) ? roleId.trim() : null;
if (!effectiveGuid) {
const cachedRoles = getCachedSystemRoles();
if (cachedRoles && cachedRoles.length > 0) {
const match = cachedRoles.find(
(r) =>
(r.name && r.name.toLowerCase() === roleNameKey) ||
(r.name && r.name.toLowerCase() === roleIdKey) ||
String(r.id).toLowerCase() === roleIdKey
);
if (match && match.id && GUID_REGEX.test(String(match.id).trim())) {
effectiveGuid = String(match.id).trim();
}
}
}
if (effectiveGuid) {
try {
const res = await apiClient.get<Claim[]>(`/v1/api/users/system/roles/${effectiveGuid}/claims`);
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
const normalized = res.data.map(normalizeClaim);
setCachedRoleClaims(roleIdKey, normalized);
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
if (effectiveGuid.toLowerCase() !== roleIdKey) {
setCachedRoleClaims(effectiveGuid.toLowerCase(), normalized);
}
return normalized;
}
} catch (err) {
// Network or server error, proceed to cache / defaults fallback
}
}
// Re-check cache by role ID or name
const cachedById = getCachedRoleClaims(roleIdKey);
if (cachedById && cachedById.length > 0) return cachedById;
// Check cache by role name
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
@@ -218,30 +294,118 @@ export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string)
return normalized;
}
/**
* Direct API calls for user roles and user claims
*/
export async function getUserRolesFromApi(userId: string): Promise<Role[]> {
try {
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
return res.data || [];
} catch {
return [];
export function clearCachedUserRbac(userId?: string): void {
if (userId) {
const key = userId.toLowerCase().trim();
inMemoryUserRoles.delete(key);
inMemoryUserClaims.delete(key);
if (typeof window !== 'undefined') {
try {
localStorage.removeItem(`pfm_cache_user_roles_${key}`);
localStorage.removeItem(`pfm_cache_user_claims_${key}`);
} catch {}
}
} else {
inMemoryUserRoles.clear();
inMemoryUserClaims.clear();
if (typeof window !== 'undefined') {
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && (k.startsWith('pfm_cache_user_roles_') || k.startsWith('pfm_cache_user_claims_'))) {
keysToRemove.push(k);
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
} catch {}
}
}
}
export async function getUserClaimsFromApi(userId: string): Promise<Claim[]> {
/**
* Direct API calls for user roles and user claims with session-level caching
*/
export async function getUserRolesFromApi(userId: string, forceRefresh = false): Promise<Role[]> {
const key = userId.toLowerCase().trim();
if (!forceRefresh) {
const cached = inMemoryUserRoles.get(key);
if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
return cached.roles;
}
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(`pfm_cache_user_roles_${key}`);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
inMemoryUserRoles.set(key, { roles: parsed, timestamp: Date.now() });
return parsed;
}
}
} catch {}
}
}
try {
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
const roles = res.data || [];
if (roles.length > 0) {
inMemoryUserRoles.set(key, { roles, timestamp: Date.now() });
if (typeof window !== 'undefined') {
try {
localStorage.setItem(`pfm_cache_user_roles_${key}`, JSON.stringify(roles));
} catch {}
}
}
return roles;
} catch {
return inMemoryUserRoles.get(key)?.roles || [];
}
}
export async function getUserClaimsFromApi(userId: string, forceRefresh = false): Promise<Claim[]> {
const key = userId.toLowerCase().trim();
if (!forceRefresh) {
const cached = inMemoryUserClaims.get(key);
if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
return cached.claims;
}
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(`pfm_cache_user_claims_${key}`);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
const normalized = parsed.map(normalizeClaim);
inMemoryUserClaims.set(key, { claims: normalized, timestamp: Date.now() });
return normalized;
}
}
} catch {}
}
}
try {
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
return res.data || [];
const claims = (res.data || []).map(normalizeClaim);
if (claims.length > 0) {
inMemoryUserClaims.set(key, { claims, timestamp: Date.now() });
if (typeof window !== 'undefined') {
try {
localStorage.setItem(`pfm_cache_user_claims_${key}`, JSON.stringify(claims));
} catch {}
}
}
return claims;
} catch {
return [];
return inMemoryUserClaims.get(key)?.claims || [];
}
}
/**
* Resolves user roles, role claims, and user claims (including tenant and tenant_id)
* and caches system roles and role claims as a persistent reference.
* concurrently using Promise.allSettled and caches system roles and claims.
*/
export async function resolveUserRolesAndClaims(
userSub: string,
@@ -254,27 +418,30 @@ export async function resolveUserRolesAndClaims(
tenant?: string;
tenant_id?: number;
}> {
ensureFreshRbacCache();
const mergedClaims: Record<string, any> = { ...initialClaims };
const resolvedRoles: string[] = [];
let userRoleObjects: Role[] = [];
let primaryRole = currentProfile.role || (initialClaims.role as string) || '';
let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined;
let tenant_id = currentProfile.tenant_id ?? (initialClaims.tenant_id ? Number(initialClaims.tenant_id) : undefined);
// 1. Get user's roles from backend API (Requirement 2)
if (userSub) {
try {
const userRoles = await getUserRolesFromApi(userSub);
if (Array.isArray(userRoles) && userRoles.length > 0) {
userRoles.forEach((r) => {
if (r.name && !resolvedRoles.includes(r.name)) {
resolvedRoles.push(r.name);
}
});
if (resolvedRoles.length > 0) {
primaryRole = resolvedRoles[0];
}
// 1. Concurrently fetch user's roles and user's claims in parallel
const [rolesResult, claimsResult] = await Promise.allSettled([
userSub ? getUserRolesFromApi(userSub) : Promise.resolve([]),
userSub ? getUserClaimsFromApi(userSub) : Promise.resolve([]),
]);
if (rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value) && rolesResult.value.length > 0) {
userRoleObjects = rolesResult.value;
rolesResult.value.forEach((r) => {
if (r.name && !resolvedRoles.includes(r.name)) {
resolvedRoles.push(r.name);
}
} catch {}
});
if (resolvedRoles.length > 0) {
primaryRole = resolvedRoles[0];
}
}
// Fallback to initial role if API returned empty
@@ -282,55 +449,57 @@ export async function resolveUserRolesAndClaims(
resolvedRoles.push(primaryRole);
}
// 2. For each role, retrieve role claims using the endpoint (Requirement 2 & 7)
// 2. Concurrently fetch role claims for all resolved roles in parallel
const allRoleClaims: Claim[] = [];
for (const roleName of resolvedRoles) {
try {
const roleClaims = await fetchAndCacheRoleClaims(roleName, roleName);
if (Array.isArray(roleClaims)) {
roleClaims.forEach((rc) => {
const norm = normalizeClaim(rc);
allRoleClaims.push(norm);
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue || 'all';
}
});
}
} catch {}
}
const roleClaimsPromises = resolvedRoles.map(async (roleName) => {
const matchedRoleObj = userRoleObjects.find(
(r) => r.name && r.name.toLowerCase() === roleName.toLowerCase()
);
const roleIdToPass = matchedRoleObj?.id ? String(matchedRoleObj.id) : roleName;
return fetchAndCacheRoleClaims(roleIdToPass, roleName);
});
const roleClaimsResults = await Promise.allSettled(roleClaimsPromises);
roleClaimsResults.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
res.value.forEach((rc) => {
const norm = normalizeClaim(rc);
allRoleClaims.push(norm);
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue || 'all';
}
});
}
});
if (allRoleClaims.length > 0) {
mergedClaims.role_claims = allRoleClaims;
}
// 3. Retrieve user claims from endpoint (Requirement 3)
if (userSub) {
try {
const userClaims = await getUserClaimsFromApi(userSub);
if (Array.isArray(userClaims) && userClaims.length > 0) {
userClaims.forEach((c) => {
const norm = normalizeClaim(c);
const type = (norm.claimType || '').toLowerCase().trim();
const val = norm.claimValue;
if (type === 'tenant' && val) {
tenant = String(val);
mergedClaims.tenant = String(val);
} else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
const parsed = Number(val);
if (!isNaN(parsed) && isFinite(parsed)) {
tenant_id = parsed;
mergedClaims.tenant_id = parsed;
}
}
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue;
}
});
mergedClaims.user_claims = userClaims.map(normalizeClaim);
// 3. Process user claims
if (claimsResult.status === 'fulfilled' && Array.isArray(claimsResult.value) && claimsResult.value.length > 0) {
claimsResult.value.forEach((c) => {
const norm = normalizeClaim(c);
const type = (norm.claimType || '').toLowerCase().trim();
const val = norm.claimValue;
if (type === 'tenant' && val) {
tenant = String(val);
mergedClaims.tenant = String(val);
} else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
const parsed = Number(val);
if (!isNaN(parsed) && isFinite(parsed)) {
tenant_id = parsed;
mergedClaims.tenant_id = parsed;
}
}
} catch {}
if (norm.claimType) {
mergedClaims[norm.claimType] = norm.claimValue;
}
});
mergedClaims.user_claims = claimsResult.value.map(normalizeClaim);
}
// 4. Background preload all system roles & role claims for reference (Requirement 7)
// 4. Background preload all system roles & role claims for reference (non-blocking)
preloadAndCacheAllRoleClaims().catch(() => {});
return {
@@ -343,13 +512,13 @@ export async function resolveUserRolesAndClaims(
}
/**
* Preloads and caches all system roles and their respective role claims.
* Preloads and caches all system roles and their respective role claims concurrently.
*/
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
const roles = await fetchAndCacheSystemRoles();
for (const role of roles) {
await fetchAndCacheRoleClaims(role.id, role.name);
}
await Promise.allSettled(
roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
);
}
/**
@@ -9,6 +9,18 @@ export interface TenantLockoutResult {
statusCode?: number;
}
// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations)
let cachedTenantLockout: {
key: string;
result: TenantLockoutResult;
timestamp: number;
} | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds
export function clearTenantLockoutCache(): void {
cachedTenantLockout = null;
}
/**
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
*/
@@ -63,7 +75,8 @@ export function getStatusLabel(status: any): string {
*/
export async function verifyTenantLockout(
tenantId?: number | string | null,
tenantClaim?: string | null
tenantClaim?: string | null,
forceRefresh = false
): Promise<TenantLockoutResult> {
const primaryId =
tenantId !== undefined && tenantId !== null && tenantId !== ''
@@ -83,6 +96,16 @@ export async function verifyTenantLockout(
};
}
const cacheKey = String(primaryId).trim().toLowerCase();
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if (
cachedTenantLockout.result.isActive &&
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
) {
return cachedTenantLockout.result;
}
}
let tenantRes: Tenant | null = null;
let lastError: any = null;
@@ -147,8 +170,14 @@ export async function verifyTenantLockout(
}
// Success: Response object returned and Status is Active
return {
const successResult: TenantLockoutResult = {
isActive: true,
tenant: tenantObj,
};
cachedTenantLockout = {
key: cacheKey,
result: successResult,
timestamp: Date.now(),
};
return successResult;
}
@@ -36,12 +36,22 @@ export interface UserSyncResult {
let inFlightSyncPromise: Promise<UserSyncResult> | null = null;
// In-memory cache to prevent synchronous localStorage parsing on every HTTP request
let inMemorySyncRecord: UserSyncRecord | null = null;
let inMemoryActivationPending: boolean | null = null;
let inMemoryActivationReason: string | null = null;
/**
* Checks if the user is currently flagged in the browser as pending administrator activation / lockout.
*/
export function isActivationPending(): boolean {
if (inMemoryActivationPending !== null) {
return inMemoryActivationPending;
}
try {
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
const val = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
inMemoryActivationPending = val;
return val;
} catch {
return false;
}
@@ -51,8 +61,13 @@ export function isActivationPending(): boolean {
* Gets the reason/error message recorded when activation check was last pending.
*/
export function getActivationPendingReason(): string | null {
if (inMemoryActivationReason !== null) {
return inMemoryActivationReason;
}
try {
return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
const reason = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
inMemoryActivationReason = reason;
return reason;
} catch {
return null;
}
@@ -62,6 +77,8 @@ export function getActivationPendingReason(): string | null {
* Sets or clears the pending activation flag in browser persistent storage.
*/
export function setActivationPending(pending: boolean, reason?: string): void {
inMemoryActivationPending = pending;
inMemoryActivationReason = reason || null;
try {
if (pending) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
@@ -91,10 +108,15 @@ export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string
return false;
}
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return false;
if (!inMemorySyncRecord) {
const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
if (!raw) return false;
const parsed: UserSyncRecord = JSON.parse(raw);
if (!parsed || parsed.status !== 'synced') return false;
inMemorySyncRecord = parsed;
}
const record: UserSyncRecord = JSON.parse(raw);
const record = inMemorySyncRecord;
if (!record || record.status !== 'synced') return false;
// Check if the stored sync matches the current user sub (if specified)
@@ -134,6 +156,7 @@ export function saveUserSyncInBrowser(
status: 'synced',
userData: userData || undefined,
};
inMemorySyncRecord = record;
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
} catch (err) {
console.warn('[UserSync] Failed to write sync record to localStorage:', err);
@@ -144,6 +167,9 @@ export function saveUserSyncInBrowser(
* Clears the user sync record from browser storage (used on logout or when activation fails).
*/
export function clearUserSyncInBrowser(): void {
inMemorySyncRecord = null;
inMemoryActivationPending = null;
inMemoryActivationReason = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
@@ -155,6 +181,7 @@ export function clearUserSyncInBrowser(): void {
* Clears only the successful sync record, leaving activation pending intact.
*/
export function clearSuccessfulSyncRecord(): void {
inMemorySyncRecord = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
} catch {}