From ccc49df720d6f4a92a2d88d1a3fe71c46fbab6fe Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 22 Oct 2026 07:39:33 +0200 Subject: [PATCH] Refactored projects page, fixed security user modal --- .../monitoring/ContractsManagement.tsx | 4 +- .../projects/ProjectExpandedRow.tsx | 4 +- .../projects/ProjectTimelineGantt.tsx | 28 +- .../components/users/UserSecurityModal.tsx | 479 +++++++++--------- .../src/pages/MonitoringPage.tsx | 15 +- .../src/pages/ProjectsPage.tsx | 56 +- .../src/pages/SettingsPage.tsx | 39 +- 7 files changed, 288 insertions(+), 337 deletions(-) diff --git a/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx b/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx index 1ab9eb5..8b9bd7c 100644 --- a/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx +++ b/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx @@ -78,7 +78,7 @@ export const ContractsManagement: React.FC = ({ // Component state const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false); - const isCreateModalOpen = isCreateModalOpenExternal ?? internalCreateModalOpen; + const isCreateModalOpen = Boolean(isCreateModalOpenExternal) || internalCreateModalOpen; const setCreateModalOpen = (open: boolean) => { if (onCloseCreateModalExternal && !open) { onCloseCreateModalExternal(); @@ -652,7 +652,7 @@ export const ContractsManagement: React.FC = ({

{searchTerm || statusFilter !== 'all' ? 'No contracts match the current search filters. Try resetting the filters.' - : 'No contracts have been created yet. Click "Create Contract & Upload PDF" to link an approved award.'} + : 'No contracts have been created yet. Click "Create Contract" to link an approved award.'}

{!(searchTerm || statusFilter !== 'all') && ( @@ -295,7 +295,7 @@ export const ProjectTimelineGantt: React.FC = ({ {/* Layer 2: Activities Track */}
-
+
Work Breakdown Activities ({activities.length}) @@ -303,7 +303,7 @@ export const ProjectTimelineGantt: React.FC = ({ @@ -366,7 +366,7 @@ export const ProjectTimelineGantt: React.FC = ({
{/* Bottom Summary KPI Bar */} -
+
Project Duration diff --git a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx index a55975b..3105daf 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx @@ -24,20 +24,17 @@ import { assignUserRole, removeUserRole, getUserClaims, - addUserClaim, - deleteUserClaim, forceLogoutUser, setUserLockout, updateUserStatus, - getSystemClaims, } from '../../services/api'; -import { clearCachedUserRbac } from '../../services/roleClaimsCache'; +import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/roleClaimsCache'; import { HelpTooltip } from '../ui/HelpTooltip'; import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber'; import { decodeIdNumberHex } from '../../utils/hashids'; import { User, Role, Claim, ActivityStatus } from '../../types'; import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters'; -import { useRbac, ROLE_CLAIM_DEFINITIONS, CLAIM_ACTION_OPTIONS } from '../../auth/rbac'; +import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac'; interface UserSecurityModalProps { user: User | null; @@ -74,9 +71,6 @@ export const UserSecurityModal: React.FC = ({ const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview'); - // Claim creation form state - const [newClaimType, setNewClaimType] = useState(''); - const [newClaimValue, setNewClaimValue] = useState(''); const [selectedRoleToAdd, setSelectedRoleToAdd] = useState(''); const userId = user?.id || ''; @@ -102,23 +96,106 @@ export const UserSecurityModal: React.FC = ({ enabled: isOpen, }); - // 4. Fetch user claims - const { data: userClaims = [], isLoading: loadingUserClaims, refetch: refetchUserClaims } = useQuery({ - queryKey: ['user-claims', userId], + // Effective role derivation for hooks (Single-role policy) + const rawRoleValue = freshUser?.role || user?.role; + const userRoleStr = rawRoleValue != null ? String(rawRoleValue).trim() : ''; + + const assignedRole = React.useMemo(() => { + if (Array.isArray(userRoles) && userRoles.length > 0) { + return userRoles[0]; + } + if (userRoleStr && Array.isArray(systemRoles)) { + const match = systemRoles.find( + (r) => + r && + ((r.name && r.name.toLowerCase() === userRoleStr.toLowerCase()) || + String(r.id) === userRoleStr) + ); + if (match) return match; + return { id: userRoleStr, name: userRoleStr } as Role; + } + return null; + }, [userRoles, userRoleStr, systemRoles]); + + const assignedRoleId = assignedRole?.id || (assignedRole as any)?.Id || ''; + const assignedRoleName = assignedRole?.name || (assignedRole as any)?.Name || ''; + + // 4. Fetch claims derived from the user's role by hitting the endpoint + const { + data: roleClaims = [], + isLoading: loadingRoleClaims, + refetch: refetchRoleClaims, + } = useQuery({ + queryKey: ['role-claims-detail', assignedRoleId, assignedRoleName], + queryFn: async () => { + if (!assignedRoleId && !assignedRoleName) return []; + return await fetchAndCacheRoleClaims(String(assignedRoleId), assignedRoleName, true); + }, + enabled: isOpen && (!!assignedRoleId || !!assignedRoleName), + }); + + // 5. Fetch direct user claims if any + const { + data: directClaims = [], + isLoading: loadingDirectClaims, + refetch: refetchDirectClaims, + } = useQuery({ + queryKey: ['user-direct-claims', userId], queryFn: () => (userId ? getUserClaims(userId) : []), enabled: isOpen && !!userId, }); - // 5. Fetch all system claims across the identity management system (Requirement 4 & 7) - const { data: systemClaims = [], isLoading: loadingSystemClaims } = useQuery({ - queryKey: ['system-claims'], - queryFn: getSystemClaims, - enabled: isOpen, - }); + // Combined claims with metadata + const userEffectiveClaims = React.useMemo(() => { + const map = new Map(); - // Role Mutations + // 1. Role claims from endpoint + if (Array.isArray(roleClaims)) { + roleClaims.forEach((c) => { + if (!c) return; + const type = (c.claimType || c.type || '').trim(); + if (!type) return; + const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase()); + map.set(type.toLowerCase(), { + ...c, + claimType: type, + claimValue: c.claimValue || c.value || 'all', + label: def?.label || type, + description: def?.description || 'Security claim granted via role membership', + source: assignedRoleName ? `Role: ${assignedRoleName}` : 'Role Policy', + }); + }); + } + + // 2. Direct user claims if any + if (Array.isArray(directClaims)) { + directClaims.forEach((c) => { + if (!c) return; + const type = (c.claimType || c.type || '').trim(); + if (!type) return; + if (!map.has(type.toLowerCase())) { + const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase()); + map.set(type.toLowerCase(), { + ...c, + claimType: type, + claimValue: c.claimValue || c.value || 'all', + label: def?.label || type, + description: def?.description || 'Direct user account claim', + source: 'Direct User Claim', + }); + } + }); + } + + return Array.from(map.values()); + }, [roleClaims, directClaims, assignedRoleName]); + + // Role Mutations (Strictly single-role per user) const assignRoleMutation = useMutation({ mutationFn: (roleId: string) => { + if ((Array.isArray(userRoles) ? userRoles : []).length >= 1) { + throw new Error('Single-role policy: This user already has an assigned role. Please remove the existing role before assigning a new one.'); + } const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find( (r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId)) ); @@ -130,8 +207,11 @@ export const UserSecurityModal: React.FC = ({ }, onSuccess: () => { clearCachedUserRbac(userId); - success('Role Assigned', 'Role membership updated successfully.'); + success('Role Assigned', 'Role assigned successfully.'); refetchUserRoles(); + refetchUser(); + refetchRoleClaims(); + queryClient.invalidateQueries({ queryKey: ['users'] }); setSelectedRoleToAdd(''); }, onError: (err: any) => toastError('Role Assignment Failed', err), @@ -143,44 +223,13 @@ export const UserSecurityModal: React.FC = ({ clearCachedUserRbac(userId); success('Role Removed', 'Role revoked from user.'); refetchUserRoles(); + refetchUser(); + refetchRoleClaims(); + queryClient.invalidateQueries({ queryKey: ['users'] }); }, onError: (err: any) => toastError('Role Removal Failed', err), }); - // Claim Mutations - const addClaimMutation = useMutation({ - mutationFn: () => { - const normalizedType = newClaimType.toLowerCase().trim(); - if ((normalizedType === 'unrestricted' || normalizedType === 'admin') && hasTenantSelfHelpAccess) { - throw new Error('Security policy: Users with tenant self-help access cannot assign Admin or Unrestricted claims.'); - } - return addUserClaim({ - userId, - claimId: Math.floor(Math.random() * 10000) + 1, - claimType: newClaimType.trim(), - claimValue: newClaimValue.trim(), - }); - }, - onSuccess: () => { - clearCachedUserRbac(userId); - success('Claim Granted', 'User claim added successfully.'); - refetchUserClaims(); - setNewClaimType(''); - setNewClaimValue(''); - }, - onError: (err: any) => toastError('Claim Creation Failed', err), - }); - - const deleteClaimMutation = useMutation({ - mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId), - onSuccess: () => { - clearCachedUserRbac(userId); - success('Claim Revoked', 'Claim removed from user.'); - refetchUserClaims(); - }, - onError: (err: any) => toastError('Claim Revocation Failed', err), - }); - // Session & Lockout Mutations const logoutMutation = useMutation({ mutationFn: () => forceLogoutUser(userId), @@ -210,56 +259,22 @@ export const UserSecurityModal: React.FC = ({ onError: (err: any) => toastError('Status Update Failed', err), }); - const assignableClaims = React.useMemo(() => { - const claimMap = new Map(); - if (Array.isArray(systemClaims)) { - systemClaims.forEach((c) => { - if (!c) return; - const type = c.claimType || c.type; - if (type && typeof type === 'string') { - if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) { - return; - } - claimMap.set(type, { claim: type, label: type }); - } - }); - } - if (Array.isArray(ROLE_CLAIM_DEFINITIONS)) { - ROLE_CLAIM_DEFINITIONS.forEach((def) => { - if (!def || !def.claim) return; - const claimStr = String(def.claim); - if (hasTenantSelfHelpAccess && (claimStr.toLowerCase() === 'unrestricted' || claimStr.toLowerCase() === 'admin')) { - return; - } - claimMap.set(claimStr, { claim: claimStr, label: `${claimStr} (${def.label || claimStr})` }); - }); - } - return Array.from(claimMap.values()); - }, [systemClaims, hasTenantSelfHelpAccess]); - - const assignedRoleIds = React.useMemo(() => { - return new Set( - (Array.isArray(userRoles) ? userRoles : []).map((r) => r?.id || (r as any)?.Id).filter(Boolean) - ); - }, [userRoles]); - const availableRoles = React.useMemo(() => { return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => { if (!r) return false; const rId = r.id || (r as any).Id; const rName = r.name || (r as any).Name || ''; - if (rId && assignedRoleIds.has(rId)) return false; if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) { return false; } return true; }); - }, [systemRoles, assignedRoleIds, hasTenantSelfHelpAccess]); + }, [systemRoles, hasTenantSelfHelpAccess]); if (!isOpen || !user) return null; - const effectiveUser = freshUser || user; - const decodedId = effectiveUser?.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; + const effectiveUser: User = freshUser || user; + const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; return ( = ({ }`} > - Claims ({(Array.isArray(userClaims) ? userClaims : []).length}) + Claims ({userEffectiveClaims.length})
)} - {/* TAB 2: Roles */} + {/* TAB 2: Roles (Single-Role Policy) */} {activeTab === 'roles' && (
- {/* Add Role selector */} -
- setSelectedRoleToAdd(e.target.value)} + disabled={loadingSystemRoles || availableRoles.length === 0 || !canManageRolesAndClaims || assignRoleMutation.isPending} + className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50 text-xs" + > + - ); - })} - - -
+ {availableRoles.map((r: any) => { + const roleOptId = r?.id || r?.Id || String(r); + const roleOptName = r?.name || r?.Name || String(r); + return ( + + ); + })} + + +
+
+ )} {/* List of currently assigned roles */}
{loadingUserRoles ? ( -
Loading roles...
+
+ + Loading assigned role... +
) : (Array.isArray(userRoles) ? userRoles : []).length === 0 ? ( -
No roles assigned to this user.
+
+ +

No role assigned to this user.

+

Select a role above to assign it. Users must have exactly one role.

+
) : ( (Array.isArray(userRoles) ? userRoles : []).map((role) => { if (!role) return null; const roleId = role.id || (role as any).Id || String(role); const roleName = role.name || (role as any).Name || String(role); return ( -
-
-
- - {roleName} +
+
+
+ + {roleName} + + Active +
-
Role ID: {roleId}
+
Role ID: {roleId}
); @@ -468,127 +516,74 @@ export const UserSecurityModal: React.FC = ({
)} - {/* TAB 3: Claims */} + {/* TAB 3: Claims (Read-Only Inherited from Role) */} {activeTab === 'claims' && (
- {/* Add claim inline form */} -
-
- Grant Role Claim / Custom Claim - RBAC Standard Policy -
-
-
- - - {(!assignableClaims.some((d) => d.claim === newClaimType) || newClaimType === '') && ( - setNewClaimType(e.target.value)} - className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500" - /> - )} -
-
- - - {(!CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) || newClaimValue === '') && ( - setNewClaimValue(e.target.value)} - className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500" - /> - )} + {/* Informational Policy Banner */} +
+
+ +
+ Role-Derived Security Claims +

+ {assignedRoleName ? ( + <>Claims are inherited directly from the assigned role ({assignedRoleName}). They cannot be manually added or removed at the individual user level. + ) : ( + <>Assign a role on the Roles tab to grant security claims to this user. + )} +

-
- -
+ + Read-Only +
- {/* Existing claims list */} + {/* Claims list */}
- {loadingUserClaims ? ( -
Loading user claims...
- ) : (Array.isArray(userClaims) ? userClaims : []).length === 0 ? ( -
No active claims found for user.
+ {loadingRoleClaims || loadingDirectClaims ? ( +
+ + Loading security claims from role endpoint... +
+ ) : userEffectiveClaims.length === 0 ? ( +
+ +

No active claims found for user.

+

+ {assignedRoleName + ? `The assigned role (${assignedRoleName}) does not contain any configured permission claims.` + : 'Assign a role to this user on the Roles tab to establish functional permissions.'} +

+
) : ( - (Array.isArray(userClaims) ? userClaims : []).map((claim, idx) => { - if (!claim) return null; + userEffectiveClaims.map((claim, idx) => { const claimType = claim.claimType || claim.type || 'Custom Claim'; - const claimVal = claim.claimValue ?? claim.value ?? ''; - const claimId = claim.claimId || (claim as any).id || claimType || String(idx); + const claimVal = claim.claimValue ?? claim.value ?? 'all'; return ( -
-
-
- - {claimType} -
-
- Value: {claimVal} - {claim.issuer && ({claim.issuer})} +
+
+
+ + {claim.label || claimType} + + {claimType} +
+ {claim.description && ( +

+ {claim.description} +

+ )} +
+
+ + Value: {claimVal} + + + {claim.source} +
- {claimType && ( - - )}
); }) diff --git a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx index 9b7cb72..8d14245 100644 --- a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx @@ -108,7 +108,6 @@ export const MonitoringPage: React.FC = () => { const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>( tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts' ); - const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false); useEffect(() => { const t = searchParams.get('tab'); @@ -437,15 +436,6 @@ export const MonitoringPage: React.FC = () => {
- {activeTab === 'contracts' && canAdd('grant_financials') && ( - - )} {activeTab === 'indicators' && canAdd('evidence_risks') && (