From f3a8f78144ae67711232050b56a7e8912f9200f3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 26 Sep 2026 10:28:43 +0200 Subject: [PATCH] Fixed API calls on governance page Enforced multitenancy --- .../src/auth/AuthProvider.tsx | 126 +++- .../auth/AccountActivationModal.tsx | 19 +- .../governance/InstructionsSection.tsx | 696 +++++++++++++++++ .../components/governance/RulesSection.tsx | 700 +++++++++++++++++ .../monitoring/EvidenceReviewQueue.tsx | 148 ++-- .../src/components/ui/ProblemDetailsAlert.tsx | 9 +- .../src/components/ui/Toast.tsx | 7 +- .../src/pages/GovernancePage.tsx | 501 +++++++++++-- .../src/pages/ProjectsPage.tsx | 69 ++ .../src/services/api.ts | 709 +++++++++++++++++- .../src/services/httpClient.ts | 12 +- .../src/services/sessionExpiration.ts | 9 + .../src/services/tenantLockoutService.ts | 154 ++++ .../src/services/userSyncService.ts | 2 +- PostFundManagement.ConsoleUI/src/types.ts | 292 +++++++- .../src/utils/apiErrorParser.ts | 602 ++++++++------- 16 files changed, 3552 insertions(+), 503 deletions(-) create mode 100644 PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx create mode 100644 PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts diff --git a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx index 46330d2..8a881d5 100644 --- a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx @@ -15,6 +15,7 @@ import { import { AccountActivationModal } from '../components/auth/AccountActivationModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; import { encodeHashIdSync } from '../services/toolsService'; +import { verifyTenantLockout } from '../services/tenantLockoutService'; import { onSessionExpired, triggerSessionExpired, @@ -120,6 +121,79 @@ export function extractTenantId(claims?: UserClaims | null, token?: string | nul return null; } +export function extractTenantClaimValues( + claims?: UserClaims | null, + token?: string | null +): { + tenantId: number | string | null; + tenant: string | null; +} { + let tenantId: number | string | null = null; + let tenant: string | null = null; + + if (claims) { + const rawId = + claims.tenant_id ?? + (claims as any).tenantId ?? + claims['tenant_id'] ?? + claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid']; + if (rawId !== undefined && rawId !== null && rawId !== '') { + const parsed = Number(rawId); + tenantId = !isNaN(parsed) && isFinite(parsed) ? parsed : String(rawId).trim(); + } + + const rawTenant = + claims.tenant ?? + claims['tenant'] ?? + claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenant'] ?? + claims.tenant_name ?? + (claims as any).tenantName ?? + claims.org; + if (rawTenant !== undefined && rawTenant !== null && rawTenant !== '') { + if (typeof rawTenant === 'string') { + tenant = rawTenant.trim(); + } else if (typeof rawTenant === 'object' && rawTenant.name) { + tenant = String(rawTenant.name).trim(); + } + } + } + + if (token && (!tenantId || !tenant)) { + try { + const tokenClaims = parseJwtClaims(token); + if (!tenantId) { + const rawId = + tokenClaims.tenant_id ?? + tokenClaims.tenantId ?? + tokenClaims['tenant_id'] ?? + tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid']; + if (rawId !== undefined && rawId !== null && rawId !== '') { + const parsed = Number(rawId); + tenantId = !isNaN(parsed) && isFinite(parsed) ? parsed : String(rawId).trim(); + } + } + if (!tenant) { + const rawTenant = + tokenClaims.tenant ?? + tokenClaims['tenant'] ?? + tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenant'] ?? + tokenClaims.tenant_name ?? + (tokenClaims as any).tenantName ?? + tokenClaims.org; + if (rawTenant !== undefined && rawTenant !== null && rawTenant !== '') { + if (typeof rawTenant === 'string') { + tenant = rawTenant.trim(); + } else if (typeof rawTenant === 'object' && rawTenant.name) { + tenant = String(rawTenant.name).trim(); + } + } + } + } catch {} + } + + return { tenantId, tenant }; +} + export function getCurrentUserTenantId(): number | null { return extractTenantId( getCurrentUserClaims(), @@ -283,7 +357,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims)); - const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined; + const tenantClaimValues = extractTenantClaimValues(mergedClaims, token); + const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || tenantClaimValues.tenant || undefined; const parsedTenantId = extractTenantId(mergedClaims, token); setTenantId(parsedTenantId); @@ -304,6 +379,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => // Clean up code and state from URL window.history.replaceState({}, document.title, window.location.pathname); + // Verify tenant lockout at the instance tenant and tenant_id claims are retrieved + const tenantLockoutResult = await verifyTenantLockout( + parsedTenantId ?? tenantClaimValues.tenantId, + tenantClaim + ); + + if (!tenantLockoutResult.isActive) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found'); + setIsLoading(false); + return; + } + // Synchronize authenticated user profile with backend directory try { const syncResult = await syncUserAccount(userSub, token, profile, true); @@ -367,6 +456,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const storedTenantId = extractTenantId(parsedClaims, storedToken); setTenantId(storedTenantId); + const tenantClaimValues = extractTenantClaimValues(parsedClaims, storedToken); + const storedTenantClaim = (parsedClaims.tenant as string) || tenantClaimValues.tenant || undefined; let resolvedUser: AuthUserProfile | null = null; if (storedUser) { @@ -383,6 +474,20 @@ 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; + } + // Check if session is already marked pending activation or needs sync check const wasPending = isActivationPending(); setIsPendingActivation(wasPending); @@ -435,6 +540,25 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const syncUserSession = async (force = true): Promise => { try { + // Re-verify tenant status via GetTenant as part of the lockout resolution + const tenantClaimValues = extractTenantClaimValues(claims, accessToken); + const currentTenantId = tenantId ?? tenantClaimValues.tenantId; + const currentTenantClaim = (claims.tenant as string) || user?.tenant || tenantClaimValues.tenant || undefined; + + const tenantLockoutResult = await verifyTenantLockout(currentTenantId, currentTenantClaim); + 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, + }; + } + + // Tenant is verified Active: proceed with user sync const result = await syncUserAccount(sub, accessToken, user || undefined, force); if (result.isUnauthorized) { await logout(); diff --git a/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx b/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx index 36add5a..d96813f 100644 --- a/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx @@ -38,6 +38,13 @@ export const AccountActivationModal: React.FC = ({ const [errorMessage, setErrorMessage] = useState(initialError || null); const [syncAttemptCount, setSyncAttemptCount] = useState(0); + // Sync errorMessage when initialError updates + useEffect(() => { + if (initialError !== undefined) { + setErrorMessage(initialError); + } + }, [initialError]); + // Clear any existing session prompt and close background dialogs useEffect(() => { cancelSessionExpiration(); @@ -134,10 +141,12 @@ export const AccountActivationModal: React.FC = ({
- Access Restricted • Awaiting Enablement + Access Restricted • Lockout Active

- Account Pending Administrator Activation + {errorMessage?.toLowerCase().includes('tenant') + ? 'Tenant Lockout • Account Inactive' + : 'Account Pending Administrator Activation'}

@@ -215,7 +224,11 @@ export const AccountActivationModal: React.FC = ({
-

Account Not Activated Yet

+

+ {errorMessage?.toLowerCase().includes('tenant') + ? 'Tenant Lockout In Effect' + : 'Account Not Activated Yet'} +

{errorMessage}

{syncAttemptCount > 0 && (

diff --git a/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx b/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx new file mode 100644 index 0000000..33a8df4 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx @@ -0,0 +1,696 @@ +import React, { useState, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + FileText, + Plus, + Search, + Filter, + RefreshCw, + Edit2, + Eye, + Layers, + Calendar, + User as UserIcon, + Tag, + CheckCircle2, + AlertCircle, +} from 'lucide-react'; +import { Modal } from '../ui/Modal'; +import { HelpTooltip } from '../ui/HelpTooltip'; +import { useToast } from '../ui/Toast'; +import { + Instruction, + Programme, + ActivityStatus, +} from '../../types'; +import { + getAllInstructions, + createInstruction, + updateInstruction, +} from '../../services/api'; +import { + getActivityStatusBadge, + formatDate, + getUserEmail, + ACTIVITY_STATUS_OPTIONS, +} from '../../lib/formatters'; + +const instructionSchema = z.object({ + programmeId: z.coerce.number().min(1, 'Please select an associated programme'), + name: z.string().min(3, 'Instruction title must be at least 3 characters'), + code: z.string().optional(), + description: z.string().min(5, 'Description is required (minimum 5 characters)'), + status: z.coerce.number().default(ActivityStatus.Active), +}); + +type InstructionFormValues = z.infer; + +interface InstructionsSectionProps { + programmes: Programme[]; + currentUserId: string; + preselectedProgrammeId?: string | number | null; + onClearPreselectedProgramme?: () => void; + isCreateModalOpenExternal?: boolean; + onCloseCreateModalExternal?: () => void; +} + +export const InstructionsSection: React.FC = ({ + programmes, + currentUserId, + preselectedProgrammeId, + onClearPreselectedProgramme, + isCreateModalOpenExternal, + onCloseCreateModalExternal, +}) => { + const queryClient = useQueryClient(); + const { success, error: toastError } = useToast(); + + const [searchTerm, setSearchTerm] = useState(''); + const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState( + preselectedProgrammeId ? String(preselectedProgrammeId) : 'all' + ); + const [statusFilter, setStatusFilter] = useState('all'); + + // Modals + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingInstruction, setEditingInstruction] = useState(null); + const [inspectingInstruction, setInspectingInstruction] = useState(null); + + // Sync external create modal trigger if provided + React.useEffect(() => { + if (isCreateModalOpenExternal) { + setEditingInstruction(null); + instructionForm.reset({ + programmeId: preselectedProgrammeId ? Number(preselectedProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0), + name: '', + code: '', + description: '', + status: ActivityStatus.Active, + }); + setIsModalOpen(true); + } + }, [isCreateModalOpenExternal, preselectedProgrammeId, programmes]); + + // Form setup + const instructionForm = useForm({ + resolver: zodResolver(instructionSchema), + defaultValues: { + programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0, + name: '', + code: '', + description: '', + status: ActivityStatus.Active, + }, + }); + + // Query: Get all instructions + const { + data: instructions = [], + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: ['all-instructions', programmes], + queryFn: () => getAllInstructions(programmes), + }); + + // Programme lookup helper + const programmeMap = useMemo(() => { + const map = new Map(); + programmes.forEach((p) => map.set(String(p.id), p)); + return map; + }, [programmes]); + + // Mutation: Create or Update Instruction + const mutation = useMutation({ + mutationFn: async (data: InstructionFormValues) => { + if (editingInstruction) { + await updateInstruction({ + instructionId: editingInstruction.id, + programmeId: data.programmeId, + code: data.code, + name: data.name, + description: data.description, + status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active, + userId: currentUserId, + }); + } else { + await createInstruction({ + programmeId: data.programmeId, + code: data.code, + name: data.name, + description: data.description, + status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active, + userId: currentUserId, + }); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['all-instructions'] }); + queryClient.invalidateQueries({ queryKey: ['instruments'] }); + success( + editingInstruction ? 'Instruction Updated' : 'Instruction Created', + editingInstruction + ? 'Funding instruction record updated successfully.' + : 'New funding instruction attached to programme successfully.' + ); + setIsModalOpen(false); + setEditingInstruction(null); + if (onCloseCreateModalExternal) onCloseCreateModalExternal(); + }, + onError: (err: any) => { + toastError('Save Failed', err?.message || 'Failed to save instruction'); + }, + }); + + // Handlers + const openCreateModal = (programmeId?: number | string) => { + setEditingInstruction(null); + instructionForm.reset({ + programmeId: programmeId + ? Number(programmeId) + : selectedProgrammeFilter !== 'all' + ? Number(selectedProgrammeFilter) + : programmes[0]?.id + ? Number(programmes[0].id) + : 0, + name: '', + code: '', + description: '', + status: ActivityStatus.Active, + }); + setIsModalOpen(true); + }; + + const openEditModal = (inst: Instruction) => { + setEditingInstruction(inst); + instructionForm.reset({ + programmeId: Number(inst.programmeId), + name: inst.name || '', + code: inst.code || '', + description: inst.description || '', + status: Number(inst.status), + }); + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + setEditingInstruction(null); + if (onCloseCreateModalExternal) onCloseCreateModalExternal(); + }; + + // Filter instructions + const filteredInstructions = useMemo(() => { + return instructions.filter((inst) => { + // Programme filter + if (selectedProgrammeFilter !== 'all' && String(inst.programmeId) !== String(selectedProgrammeFilter)) { + return false; + } + // Status filter + if (statusFilter !== 'all' && String(inst.status) !== String(statusFilter)) { + return false; + } + // Search term + if (searchTerm.trim()) { + const query = searchTerm.toLowerCase(); + const pName = programmeMap.get(String(inst.programmeId))?.name?.toLowerCase() || ''; + const matchTitle = (inst.name || '').toLowerCase().includes(query); + const matchCode = (inst.code || '').toLowerCase().includes(query); + const matchDesc = (inst.description || '').toLowerCase().includes(query); + const matchProg = pName.includes(query); + if (!matchTitle && !matchCode && !matchDesc && !matchProg) { + return false; + } + } + return true; + }); + }, [instructions, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]); + + return ( +

+ {/* Top Filter and Actions Bar */} +
+
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" + /> +
+ + {/* Programme Filter Dropdown */} +
+ + +
+ + {/* Status Filter Dropdown */} +
+ + +
+
+ + {/* Action Buttons */} +
+ + +
+
+ + {/* Instructions Table & Cards */} +
+
+
+ +

+ Funding Instructions & Instrument Guidelines ({filteredInstructions.length}) +

+ +
+ + {filteredInstructions.length} of {instructions.length} recorded + +
+ + {isLoading ? ( +
+ + Loading instructions directory... +
+ ) : filteredInstructions.length === 0 ? ( +
+
+ {searchTerm || selectedProgrammeFilter !== 'all' || statusFilter !== 'all' + ? 'No funding instructions match the active filters.' + : 'No funding instructions registered yet. Link instructions to programmes using the button below.'} +
+ +
+ ) : ( +
+ + + + + + + + + + + + + {filteredInstructions.map((inst) => { + const statusBadge = getActivityStatusBadge(inst.status); + const parentProg = programmeMap.get(String(inst.programmeId)); + + return ( + + + + + + + + + ); + })} + +
Code / RefInstruction TitleAssociated ProgrammeStatusCreated InfoActions
+ + {inst.code || `INST-${inst.id}`} + + +
{inst.name}
+ {inst.description && ( +
+ {inst.description} +
+ )} +
+ {parentProg ? ( + + + {parentProg.name} + + ) : ( + + Programme #{inst.programmeId} + + )} + + + {statusBadge.label} + + +
{formatDate(inst.createdAt)}
+ {inst.createdBy && ( +
{getUserEmail(inst.createdBy)}
+ )} +
+
+ + +
+
+
+ )} +
+ + {/* Modal: Create / Edit Instruction */} + +
mutation.mutate(data))} + className="space-y-4" + > + {/* Associated Programme Dropdown */} +
+
+ + +
+ + {instructionForm.formState.errors.programmeId && ( +

+ {instructionForm.formState.errors.programmeId.message} +

+ )} +
+ + {/* Title */} +
+
+ + +
+ + {instructionForm.formState.errors.name && ( +

+ {instructionForm.formState.errors.name.message} +

+ )} +
+ + {/* Code */} +
+
+ + +
+ +
+ + {/* Description */} +
+
+ + +
+