From 8fed46961a4ad6c27fbd6b2831f02ae4a57f8ff4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 4 Oct 2026 09:13:55 +0200 Subject: [PATCH] Added tenant management Applied basic access control --- PostFundManagement.ConsoleUI/src/App.tsx | 19 +- .../src/auth/AuthProvider.tsx | 142 +++ .../auth/PartnerAccessDeniedModal.tsx | 174 ++++ .../src/components/layout/AppLayout.tsx | 85 +- .../src/pages/TenantManagementPage.tsx | 949 ++++++++++++++++++ .../src/services/api.ts | 1 + PostFundManagement.ConsoleUI/src/types.ts | 1 + 7 files changed, 1349 insertions(+), 22 deletions(-) create mode 100644 PostFundManagement.ConsoleUI/src/components/auth/PartnerAccessDeniedModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx diff --git a/PostFundManagement.ConsoleUI/src/App.tsx b/PostFundManagement.ConsoleUI/src/App.tsx index d8da8e6..2fec0ce 100644 --- a/PostFundManagement.ConsoleUI/src/App.tsx +++ b/PostFundManagement.ConsoleUI/src/App.tsx @@ -2,7 +2,7 @@ import React, { Suspense, lazy } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from './lib/queryClient'; -import { AuthProvider, ProtectedRoute } from './auth/AuthProvider'; +import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider'; import { ToastProvider } from './components/ui/Toast'; import { AppLayout } from './components/layout/AppLayout'; @@ -15,6 +15,7 @@ const ChangeManagementPage = lazy(() => import('./pages/ChangeManagementPage').t const SiteVisitsPage = lazy(() => import('./pages/SiteVisitsPage').then(m => ({ default: m.SiteVisitsPage }))); const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage }))); const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage }))); +const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage }))); const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage }))); const RouteLoading = () => ( @@ -57,6 +58,22 @@ export default function App() { } /> } /> } /> + + + + } + /> + + + + } + /> } /> diff --git a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx index 8a881d5..41dbd4f 100644 --- a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx @@ -13,6 +13,7 @@ import { UserSyncResult, } from '../services/userSyncService'; import { AccountActivationModal } from '../components/auth/AccountActivationModal'; +import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal'; import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; import { encodeHashIdSync } from '../services/toolsService'; import { verifyTenantLockout } from '../services/tenantLockoutService'; @@ -44,7 +45,11 @@ interface AuthContextType { claims: UserClaims; tenantId: number | null; hashedTenantId: string | null; + tenantName: string | null; accessToken: string | null; + roles: string[]; + isPartner: boolean; + canManageTenants: boolean; isAuthenticated: boolean; isLoading: boolean; isSynced: boolean; @@ -65,6 +70,73 @@ export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user'; export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub'; export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims'; +export function extractUserRoles( + claims?: UserClaims | null, + user?: AuthUserProfile | null, + token?: string | null +): string[] { + const rolesSet = new Set(); + + const processRoles = (raw: any) => { + if (!raw) return; + if (Array.isArray(raw)) { + raw.forEach((r) => { + if (typeof r === 'string' && r.trim()) rolesSet.add(r.trim()); + }); + } else if (typeof raw === 'string' && raw.trim()) { + if (raw.includes(',')) { + raw.split(',').forEach((r) => { + if (r.trim()) rolesSet.add(r.trim()); + }); + } else { + rolesSet.add(raw.trim()); + } + } + }; + + if (claims) { + processRoles(claims.roles); + processRoles(claims.role); + processRoles(claims['roles']); + processRoles(claims['role']); + processRoles(claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']); + processRoles(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role']); + processRoles(claims['realm_access']?.roles); + processRoles(claims['resource_access']?.roles); + } + + if (token) { + try { + const tokenClaims = parseJwtClaims(token); + processRoles(tokenClaims.roles); + processRoles(tokenClaims.role); + processRoles(tokenClaims['roles']); + processRoles(tokenClaims['role']); + processRoles(tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']); + processRoles(tokenClaims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role']); + } catch {} + } + + if (user?.role && typeof user.role === 'string') { + processRoles(user.role); + } + if ((user as any)?.roles) { + processRoles((user as any).roles); + } + + return Array.from(rolesSet); +} + +export function hasPartnerRole(roles: string[]): boolean { + return roles.some((r) => r.toLowerCase() === 'partner'); +} + +export function canManageTenants(tenantId?: number | string | null, roles?: string[]): boolean { + const isTenantOne = Number(tenantId) === 1; + const hasAdmin = Array.isArray(roles) && roles.some((r) => r.toLowerCase() === 'admin'); + return isTenantOne && hasAdmin; +} + export function extractTenantId(claims?: UserClaims | null, token?: string | null): number | null { if (claims) { const raw = @@ -272,6 +344,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null ); }); + const [tenantName, setTenantName] = useState(() => { + const claims = getCurrentUserClaims(); + const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null; + const { tenant } = extractTenantClaimValues(claims, token); + return tenant; + }); const [accessToken, setAccessToken] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSynced, setIsSynced] = useState(() => isUserSyncedInBrowser()); @@ -393,6 +471,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => return; } + if (tenantLockoutResult.tenant?.name) { + profile.tenant = tenantLockoutResult.tenant.name; + setTenantName(tenantLockoutResult.tenant.name); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile)); + } else if (tenantClaim) { + setTenantName(tenantClaim); + } + // Synchronize authenticated user profile with backend directory try { const syncResult = await syncUserAccount(userSub, token, profile, true); @@ -488,6 +574,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => 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) { + setTenantName(storedTenantClaim); + } + // Check if session is already marked pending activation or needs sync check const wasPending = isActivationPending(); setIsPendingActivation(wasPending); @@ -633,6 +730,18 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }; + const roles = React.useMemo(() => { + return extractUserRoles(claims, user, accessToken); + }, [claims, user, accessToken]); + + const isPartner = React.useMemo(() => { + return hasPartnerRole(roles); + }, [roles]); + + const canManageTenantsAccess = React.useMemo(() => { + return canManageTenants(tenantId, roles); + }, [tenantId, roles]); + return ( = ({ children }) => claims, tenantId, hashedTenantId, + tenantName, accessToken, + roles, + isPartner, + canManageTenants: canManageTenantsAccess, isAuthenticated: !!accessToken, isLoading, isSynced, @@ -680,6 +793,8 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) isAuthenticated, isLoading, isPendingActivation, + isPartner, + roles, user, claims, sub, @@ -707,6 +822,18 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) return ; } + // Security Access Control: Users assigned the Partner role are denied entry to the console + if (isPartner || hasPartnerRole(roles)) { + return ( + + ); + } + // If user is authenticated but account has not yet been enabled by an administrator: // Render the non-dismissable activation modal, blocking all downstream layout and queries. if (isPendingActivation) { @@ -724,3 +851,18 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) return <>{children}; }; + +// Guard for Tenant Management (tenant_id == 1 and role list has Admin) +export const TenantAdminProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => { + const { canManageTenants: allowed, isLoading } = useAuth(); + + if (isLoading) { + return null; + } + + if (!allowed) { + return ; + } + + return <>{children}; +}; diff --git a/PostFundManagement.ConsoleUI/src/components/auth/PartnerAccessDeniedModal.tsx b/PostFundManagement.ConsoleUI/src/components/auth/PartnerAccessDeniedModal.tsx new file mode 100644 index 0000000..c7c04d8 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/auth/PartnerAccessDeniedModal.tsx @@ -0,0 +1,174 @@ +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ShieldAlert, + LogOut, + Ban, + Mail, + Building2, + Fingerprint, + Lock, +} from 'lucide-react'; +import { BrandLogo } from '../ui/BrandLogo'; +import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider'; +import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration'; + +interface PartnerAccessDeniedModalProps { + user: AuthUserProfile | null; + claims: UserClaims; + sub: string; + onLogout: () => Promise; +} + +export const PartnerAccessDeniedModal: React.FC = ({ + user, + claims, + sub, + onLogout, +}) => { + const navigate = useNavigate(); + + // Clear any existing session prompt and close background dialogs + useEffect(() => { + cancelSessionExpiration(); + dismissSubordinateModals(); + }, []); + + const displayName = + user?.name || + (claims.name as string) || + (claims.preferred_username as string) || + 'Authenticated User'; + + const displayEmail = + user?.email || + (claims.email as string) || + (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || + ''; + + const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Statutory Enterprise'; + const role = user?.role || (claims.role as string) || 'Partner'; + + const handleLogoutClick = async () => { + await onLogout(); + navigate('/', { replace: true }); + }; + + return ( +
+
+ {/* Glow Header Accent */} +
+ +
+ {/* Header */} +
+
+ +
+
+
+ + + Restricted Role + + Security Policy +
+

+ Access Denied: Partner Role +

+

+ Users assigned the Partner role by security are not permitted to access the PFM Backoffice Console. +

+
+
+ + {/* User & Identity Summary Card */} +
+
+ Account Name + {displayName} +
+ + {displayEmail && ( +
+ + + Email + + {displayEmail} +
+ )} + +
+ + + Assigned Role + + + {role} + +
+ + {tenant && ( +
+ + + Tenant Context + + {tenant} +
+ )} + + {sub && ( +
+ + + Subject Key + + {sub} +
+ )} +
+ + {/* Security Policy Information */} +
+

+ + Administrative Boundary Notice +

+

+ The PFM Console is reserved for statutory administrators, sponsors, programme managers, and governance officers. Partner credentials cannot access this environment. If you require backoffice administrative access, please request an updated role assignment from your system administrator. +

+
+ + {/* Actions */} +
+ +
+
+ + {/* Footer Brand watermark */} +
+
+ + Post Fund Management (PFM) +
+ National Security Gateway +
+
+
+ ); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index 54fd012..d3458a3 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -25,7 +25,7 @@ import { BrandLogo } from '../ui/BrandLogo'; import { HelpTooltip } from '../ui/HelpTooltip'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { useQuery } from '@tanstack/react-query'; -import { getUsers } from '../../services/api'; +import { getUsers, getTenant } from '../../services/api'; import { getUserEmail, registerUserEmail } from '../../lib/formatters'; interface SubNavItem { @@ -94,6 +94,12 @@ const navItems: NavItem[] = [ icon: ShieldAlert, help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.', }, + { + name: 'Tenant Management', + path: '/tenants', + icon: Database, + help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.', + }, { name: 'Settings & Communications', path: '/settings', @@ -103,7 +109,7 @@ const navItems: NavItem[] = [ ]; export const AppLayout: React.FC = () => { - const { user, claims, sub, tenantId, hashedTenantId, logout } = useAuth(); + const { user, claims, sub, tenantId, hashedTenantId, tenantName: authTenantName, canManageTenants, logout } = useAuth(); const [sidebarOpen, setSidebarOpen] = useState(true); const [userMenuOpen, setUserMenuOpen] = useState(false); const navigate = useNavigate(); @@ -112,6 +118,14 @@ export const AppLayout: React.FC = () => { await logout(); }; + // Only show Tenant Management if user's tenant_id == 1 and their role list has Admin in it + const visibleNavItems = navItems.filter((item) => { + if (item.path === '/tenants') { + return canManageTenants; + } + return true; + }); + // Pre-populate user directory cache so all components and modals resolve user emails instantly useQuery({ queryKey: ['users-directory'], @@ -119,6 +133,14 @@ export const AppLayout: React.FC = () => { staleTime: 5 * 60 * 1000, }); + // Query tenant endpoint to resolve tenant name if not provided in claims or profile + const { data: remoteTenant } = useQuery({ + queryKey: ['active-tenant-header', tenantId], + queryFn: () => getTenant(tenantId!), + enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName, + staleTime: 5 * 60 * 1000, + }); + // Ensure current logged-in user is registered in the email cache const effectiveEmail = ((user?.email || claims?.email || claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || claims?.upn) as string); if (sub && effectiveEmail) { @@ -143,14 +165,16 @@ export const AppLayout: React.FC = () => { (claims.upn as string) || (sub ? getUserEmail(sub) : ''); - // Extract tenant name directly from the user claim named 'tenant' + // Extract tenant name from user claim 'tenant', auth context, remote tenant endpoint, or organization name const tenantName = (claims?.tenant as string) || - user?.tenant || (claims?.['tenant'] as string) || + authTenantName || + user?.tenant || + remoteTenant?.name || user?.organisationName || (claims?.organisation as string) || - 'Department of Science, Innovation & Technology (DSI)'; + (tenantId ? `Tenant #${tenantId}` : 'Department of Science, Innovation & Technology (DSI)'); return (
@@ -189,7 +213,7 @@ export const AppLayout: React.FC = () => { {/* Navigation Items */}