import React, { useMemo } from 'react'; import { Navigate } from 'react-router-dom'; import { useAuth } from './AuthProvider'; export type RoleClaim = | 'dashboard' | 'governance' | 'projects_milestones' | 'grant_financials' | 'evidence_risks' | 'beneficiaries' | 'change_management' | 'site_visits' | 'compliance' | 'settings_invites' | 'settings_users' | 'settings_auditlog' | 'settings_notifications' | 'tenant_self_service' | 'tenant_management' | 'unrestricted'; export type ClaimAction = 'view' | 'add' | 'update' | 'export' | 'all'; export interface RoleClaimDefinition { claim: RoleClaim; label: string; description: string; } export const ROLE_CLAIM_DEFINITIONS: RoleClaimDefinition[] = [ { claim: 'dashboard', label: 'Executive Dashboard', description: 'Strategic macro metrics and ISO 31000 portfolio health' }, { claim: 'governance', label: 'Governance & Structure', description: 'Holding entities, investment portfolios, and statutory programmes' }, { claim: 'projects_milestones', label: 'Projects & Milestones', description: 'Work breakdown structure, scheduled milestones, and progress' }, { claim: 'grant_financials', label: 'Grant & Financials', description: 'Funding awards, budget line allocations, and disbursements' }, { claim: 'evidence_risks', label: 'M&E, Evidence & Risks', description: 'Evidence verification queue and ISO 31000 risk registers' }, { claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' }, { claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' }, { claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' }, { claim: 'compliance', label: 'Compliance', description: 'Statutory compliance dashboard, outcome fulfillment, and risk monitoring' }, { claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' }, { claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' }, { claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' }, { claim: 'settings_notifications', label: 'Settings Notifications', description: 'SMTP engines, notification dispatch, and gateways' }, { claim: 'tenant_self_service', label: 'Tenant Self-Service', description: 'Tenant configuration parameters and self-managed sub-users' }, { claim: 'tenant_management', label: 'Tenant Management', description: 'Multi-tenant partition directory and cross-tenant isolation' }, { claim: 'unrestricted', label: 'Unrestricted Access', description: 'Full system-wide administrative control' }, ]; export const CLAIM_ACTION_OPTIONS: { action: ClaimAction; label: string; description: string }[] = [ { action: 'view', label: 'view', description: 'Can view records based on role' }, { action: 'add', label: 'add', description: 'Can create records or give feedback; change non-approve statuses' }, { action: 'update', label: 'update', description: 'Can use approve status change in dropdowns' }, { action: 'export', label: 'export', description: 'Can click export buttons (Spreadsheet/PDF)' }, { action: 'all', label: 'all', description: 'Can do everything' }, ]; /** * Base Role to Claims mapping according to PFM RBAC Specification. */ export const ROLE_CLAIM_MAP: Record = { admin: ['unrestricted'], executive: [ 'dashboard', 'compliance', 'governance', 'projects_milestones', 'grant_financials', 'evidence_risks', 'tenant_self_service', ], supervisor: [ 'dashboard', 'compliance', 'projects_milestones', 'grant_financials', 'evidence_risks', 'beneficiaries', 'change_management', 'site_visits', 'settings_invites', 'settings_users', ], manager: [ 'dashboard', 'compliance', 'projects_milestones', 'grant_financials', 'evidence_risks', 'beneficiaries', 'change_management', 'site_visits', 'settings_invites', 'settings_users', 'settings_auditlog', 'settings_notifications', 'tenant_self_service', ], contributor: [ 'projects_milestones', 'grant_financials', 'evidence_risks', 'beneficiaries', 'change_management', 'site_visits', 'settings_invites', ], }; /** * Resolves action capability set given a claim value string. * - view: can view records based on role * - add: can add/create records or give feedback, change statuses other than approve (+ view) * - update: can use approve status change in drop downs (+ view, + add) * - export: can click export buttons (+ view) * - all: can do everything */ export function resolveClaimActionSet( claimValue: string | null | undefined, roleName?: string ): Set { const actions = new Set(); if (!claimValue && !roleName) return actions; const rawVal = (claimValue || '').toLowerCase().trim(); const role = (roleName || '').toLowerCase().trim(); // Explicit claim assignment values if (rawVal) { if (rawVal === 'all' || rawVal === 'unrestricted' || rawVal === 'true' || rawVal === '1') { actions.add('all'); actions.add('view'); actions.add('add'); actions.add('update'); actions.add('export'); return actions; } if (rawVal === 'none' || rawVal === 'restricted' || rawVal === 'false' || rawVal === '0') { return actions; } const tokens = rawVal.split(',').map((t) => t.trim()); for (const val of tokens) { if (val === 'all') { actions.add('all'); actions.add('view'); actions.add('add'); actions.add('update'); actions.add('export'); } else if (val === 'view' || val === 'read') { actions.add('view'); } else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') { // 'add' encompasses create and non-approval edits actions.add('view'); actions.add('add'); } 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'); } else if (val === 'export') { actions.add('view'); actions.add('export'); } } return actions; } // Default fallback by role if no overriding claim value specified if (role === 'admin') { actions.add('all'); actions.add('view'); actions.add('add'); actions.add('update'); actions.add('export'); } else if (role === 'executive' || role === 'manager') { actions.add('all'); actions.add('view'); actions.add('add'); 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 { actions.add('view'); } return actions; } /** * Helper to extract explicit claim value from varied dictionary structures */ export function extractExplicitClaimValue( claims: Record = {}, claim: RoleClaim ): string | null { if (!claims || typeof claims !== 'object') return null; if (claims[claim] !== undefined && claims[claim] !== null) { return String(claims[claim]).trim(); } if (claims[`role_claim:${claim}`] !== undefined) { return String(claims[`role_claim:${claim}`]).trim(); } if (claims[`claim:${claim}`] !== undefined) { return String(claims[`claim:${claim}`]).trim(); } const noUnderscore = claim.replace(/_/g, ''); if (claims[noUnderscore] !== undefined) { return String(claims[noUnderscore]).trim(); } const arrayCandidates = [ claims.claims, claims.UserClaims, claims.user_claims, claims.role_claims, ]; for (const arr of arrayCandidates) { if (Array.isArray(arr)) { for (const item of arr) { if (typeof item === 'object' && item !== null) { const type = (item.type || item.claimType || item.name || '').toLowerCase().trim(); if (type === claim || type === noUnderscore) { return String(item.value || item.claimValue || 'all').trim(); } } else if (typeof item === 'string') { const [type, val] = item.split(':').map((s) => s.trim().toLowerCase()); if (type === claim || type === noUnderscore) { return val || 'all'; } } } } } return null; } /** * Evaluates whether a user with given roles and token claims has permission for a specific claim and action. * If a role claim is not present, the user is fully restricted to performing actions associated with claim in role. */ export function evaluateUserPermission( roles: string[] = [], claims: Record = {}, claim: RoleClaim, action: ClaimAction = 'view' ): boolean { // Security lockout: Partner role is completely locked out of this console const isPartner = roles.some((r) => r.toLowerCase().trim() === 'partner'); if (isPartner) return false; // Unrestricted Admin or unrestricted claim -> full access const hasUnrestrictedRole = roles.some((r) => r.toLowerCase().trim() === 'admin'); if (hasUnrestrictedRole) return true; const rawUnrestricted = extractExplicitClaimValue(claims, 'unrestricted'); if ( rawUnrestricted === 'all' || rawUnrestricted === 'true' || rawUnrestricted === '1' || claims.unrestricted === true ) { return true; } if (claim === 'unrestricted') { return hasUnrestrictedRole; } // 1. Check if user has explicit claim in claims dictionary const rawClaimValue = extractExplicitClaimValue(claims, claim); if (rawClaimValue !== null && rawClaimValue !== '') { const actionSet = resolveClaimActionSet(rawClaimValue); return actionSet.has('all') || actionSet.has(action); } // 2. Check base role mappings let claimFoundInRole = false; for (const r of roles) { const normalizedRole = r.toLowerCase().trim(); const allowedClaims = ROLE_CLAIM_MAP[normalizedRole]; if (allowedClaims && allowedClaims.includes(claim)) { claimFoundInRole = true; const actionSet = resolveClaimActionSet(undefined, normalizedRole); if (actionSet.has('all') || actionSet.has(action)) { return true; } } } // If a role claim is not present, the user is fully restricted 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, user, sub } = useAuth(); const permissions = useMemo(() => { const isUnrestricted = roles.some((r) => r.toLowerCase().trim() === 'admin') || claims?.unrestricted === true || claims?.unrestricted === 'all'; const canAccess = (claim: RoleClaim, action: ClaimAction = 'view'): boolean => { return evaluateUserPermission(roles, claims, claim, action); }; const canView = (claim: RoleClaim): boolean => canAccess(claim, 'view'); const canAdd = (claim: RoleClaim): boolean => canAccess(claim, 'add'); const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update'); const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export'); const isMaker = (createdBy?: string | null): boolean => { return isRecordMaker(createdBy, user?.id, sub, user?.email); }; const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => { if (!canApprove(claim)) return false; // 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, canView, canAdd, canApprove, canExport, isMaker, canApproveRecord, getApprovalRestrictionReason, }; }, [roles, claims, user, sub]); return permissions; } /** * Route protection component for claim-restricted screens. */ export const ClaimProtectedRoute: React.FC<{ claim: RoleClaim; action?: ClaimAction; children: React.ReactNode; }> = ({ claim, action = 'view', children }) => { const { canAccess, canView } = useRbac(); const allowed = canAccess(claim, action); if (!allowed) { let fallbackPath = '/dashboard'; if (canView('dashboard')) fallbackPath = '/dashboard'; else if (canView('compliance')) fallbackPath = '/compliance'; else if (canView('projects_milestones')) fallbackPath = '/projects'; else if (canView('grant_financials')) fallbackPath = '/grants'; else if (canView('evidence_risks')) fallbackPath = '/monitoring'; else if (canView('governance')) fallbackPath = '/governance'; else if (canView('beneficiaries')) fallbackPath = '/projects/beneficiaries'; else if (canView('settings_users') || canView('settings_invites')) fallbackPath = '/settings'; return React.createElement(Navigate, { to: fallbackPath, replace: true }); } return React.createElement(React.Fragment, null, children); }; /** * Route protection for Tenant Management (Solution Owners [Tenant 1 + Admin] OR Tenant Self-Service users). */ export const TenantManagementProtectedRoute: React.FC<{ children: React.ReactNode; }> = ({ children }) => { const { canManageTenants, isLoading } = useAuth(); const { canView, canAccess } = useRbac(); if (isLoading) { return null; } const hasAccess = canManageTenants || canView('tenant_management') || canView('tenant_self_service') || canAccess('tenant_self_service'); if (!hasAccess) { return React.createElement(Navigate, { to: '/dashboard', replace: true }); } return React.createElement(React.Fragment, null, children); };