Merge pull request 'Implemented rbac' (#141) from finishline into main
Reviewed-on: #141
This commit was merged in pull request #141.
This commit is contained in:
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { queryClient } from './lib/queryClient';
|
import { queryClient } from './lib/queryClient';
|
||||||
import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider';
|
import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider';
|
||||||
|
import { ClaimProtectedRoute, useRbac } from './auth/rbac';
|
||||||
import { ToastProvider } from './components/ui/Toast';
|
import { ToastProvider } from './components/ui/Toast';
|
||||||
import { AppLayout } from './components/layout/AppLayout';
|
import { AppLayout } from './components/layout/AppLayout';
|
||||||
|
|
||||||
@@ -27,6 +28,18 @@ const RouteLoading = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DefaultRouteRedirect = () => {
|
||||||
|
const { canView } = useRbac();
|
||||||
|
if (canView('dashboard')) return <Navigate to="/dashboard" replace />;
|
||||||
|
if (canView('projects_milestones')) return <Navigate to="/projects" replace />;
|
||||||
|
if (canView('grant_financials')) return <Navigate to="/grants" replace />;
|
||||||
|
if (canView('evidence_risks')) return <Navigate to="/monitoring" replace />;
|
||||||
|
if (canView('governance')) return <Navigate to="/governance" replace />;
|
||||||
|
if (canView('beneficiaries')) return <Navigate to="/projects/beneficiaries" replace />;
|
||||||
|
if (canView('settings_users') || canView('settings_invites')) return <Navigate to="/settings" replace />;
|
||||||
|
return <Navigate to="/projects" replace />;
|
||||||
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -47,17 +60,94 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/dashboard" element={<ExecutiveDashboard />} />
|
<Route
|
||||||
<Route path="/governance" element={<GovernancePage />} />
|
path="/dashboard"
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
element={
|
||||||
<Route path="/projects/beneficiaries" element={<BeneficiariesPage />} />
|
<ClaimProtectedRoute claim="dashboard">
|
||||||
<Route path="/beneficiaries" element={<BeneficiariesPage />} />
|
<ExecutiveDashboard />
|
||||||
<Route path="/projects/change-management" element={<ChangeManagementPage />} />
|
</ClaimProtectedRoute>
|
||||||
<Route path="/change-management" element={<ChangeManagementPage />} />
|
}
|
||||||
<Route path="/projects/site-visits" element={<SiteVisitsPage />} />
|
/>
|
||||||
<Route path="/site-visits" element={<SiteVisitsPage />} />
|
<Route
|
||||||
<Route path="/grants" element={<GrantsPage />} />
|
path="/governance"
|
||||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
element={
|
||||||
|
<ClaimProtectedRoute claim="governance">
|
||||||
|
<GovernancePage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/projects"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="projects_milestones">
|
||||||
|
<ProjectsPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/projects/beneficiaries"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="beneficiaries">
|
||||||
|
<BeneficiariesPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/beneficiaries"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="beneficiaries">
|
||||||
|
<BeneficiariesPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/projects/change-management"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="change_management">
|
||||||
|
<ChangeManagementPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/change-management"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="change_management">
|
||||||
|
<ChangeManagementPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/projects/site-visits"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="site_visits">
|
||||||
|
<SiteVisitsPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/site-visits"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="site_visits">
|
||||||
|
<SiteVisitsPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/grants"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="grant_financials">
|
||||||
|
<GrantsPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/monitoring"
|
||||||
|
element={
|
||||||
|
<ClaimProtectedRoute claim="evidence_risks">
|
||||||
|
<MonitoringPage />
|
||||||
|
</ClaimProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/tenants"
|
path="/tenants"
|
||||||
element={
|
element={
|
||||||
@@ -78,7 +168,7 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Fallback Redirect */}
|
{/* Fallback Redirect */}
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<DefaultRouteRedirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
@@ -87,3 +177,4 @@ export default function App() {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
cancelSessionExpiration,
|
cancelSessionExpiration,
|
||||||
dismissSubordinateModals,
|
dismissSubordinateModals,
|
||||||
} from '../services/sessionExpiration';
|
} from '../services/sessionExpiration';
|
||||||
|
import { resolveUserRolesAndClaims } from '../services/roleClaimsCache';
|
||||||
|
|
||||||
export interface AuthUserProfile {
|
export interface AuthUserProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -495,6 +496,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setIsPendingActivation(false);
|
setIsPendingActivation(false);
|
||||||
setIsSynced(true);
|
setIsSynced(true);
|
||||||
setActivationError(null);
|
setActivationError(null);
|
||||||
|
|
||||||
|
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
||||||
|
try {
|
||||||
|
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
|
||||||
|
if (resolved.primaryRole) {
|
||||||
|
profile.role = resolved.primaryRole;
|
||||||
|
}
|
||||||
|
if (resolved.tenant) {
|
||||||
|
profile.tenant = resolved.tenant;
|
||||||
|
setTenantName(resolved.tenant);
|
||||||
|
}
|
||||||
|
if (resolved.tenant_id !== undefined) {
|
||||||
|
profile.tenant_id = resolved.tenant_id;
|
||||||
|
setTenantId(resolved.tenant_id);
|
||||||
|
}
|
||||||
|
setUser({ ...profile });
|
||||||
|
setClaims({ ...resolved.claims });
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
} catch (syncErr: any) {
|
} catch (syncErr: any) {
|
||||||
if (syncErr?.response?.status === 401) {
|
if (syncErr?.response?.status === 401) {
|
||||||
@@ -613,6 +634,30 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setIsPendingActivation(false);
|
setIsPendingActivation(false);
|
||||||
setIsSynced(true);
|
setIsSynced(true);
|
||||||
setActivationError(null);
|
setActivationError(null);
|
||||||
|
|
||||||
|
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
||||||
|
try {
|
||||||
|
const resolved = await resolveUserRolesAndClaims(
|
||||||
|
storedSub || '',
|
||||||
|
parsedClaims,
|
||||||
|
resolvedUser || {}
|
||||||
|
);
|
||||||
|
if (resolvedUser) {
|
||||||
|
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
|
||||||
|
if (resolved.tenant) {
|
||||||
|
resolvedUser.tenant = resolved.tenant;
|
||||||
|
setTenantName(resolved.tenant);
|
||||||
|
}
|
||||||
|
if (resolved.tenant_id !== undefined) {
|
||||||
|
resolvedUser.tenant_id = resolved.tenant_id;
|
||||||
|
setTenantId(resolved.tenant_id);
|
||||||
|
}
|
||||||
|
setUser({ ...resolvedUser });
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||||
|
}
|
||||||
|
setClaims({ ...resolved.claims });
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
} catch (syncErr: any) {
|
} catch (syncErr: any) {
|
||||||
if (syncErr?.response?.status === 401) {
|
if (syncErr?.response?.status === 401) {
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
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'
|
||||||
|
| '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: '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<string, RoleClaim[]> = {
|
||||||
|
admin: ['unrestricted'],
|
||||||
|
executive: [
|
||||||
|
'dashboard',
|
||||||
|
'governance',
|
||||||
|
'projects_milestones',
|
||||||
|
'grant_financials',
|
||||||
|
'evidence_risks',
|
||||||
|
'tenant_self_service',
|
||||||
|
],
|
||||||
|
supervisor: [
|
||||||
|
'dashboard',
|
||||||
|
'projects_milestones',
|
||||||
|
'grant_financials',
|
||||||
|
'evidence_risks',
|
||||||
|
'beneficiaries',
|
||||||
|
'change_management',
|
||||||
|
'site_visits',
|
||||||
|
'settings_invites',
|
||||||
|
'settings_users',
|
||||||
|
],
|
||||||
|
manager: [
|
||||||
|
'dashboard',
|
||||||
|
'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<ClaimAction> {
|
||||||
|
const actions = new Set<ClaimAction>();
|
||||||
|
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') {
|
||||||
|
actions.add('view');
|
||||||
|
} else if (val === 'add') {
|
||||||
|
actions.add('view');
|
||||||
|
actions.add('add');
|
||||||
|
} else if (val === 'update') {
|
||||||
|
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') {
|
||||||
|
actions.add('view');
|
||||||
|
actions.add('add');
|
||||||
|
actions.add('export');
|
||||||
|
} else if (role === 'contributor') {
|
||||||
|
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<string, any> = {},
|
||||||
|
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<string, any> = {},
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook exposing RBAC evaluation helpers for components.
|
||||||
|
*/
|
||||||
|
export function useRbac() {
|
||||||
|
const { roles, claims } = 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');
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUnrestricted,
|
||||||
|
canAccess,
|
||||||
|
canView,
|
||||||
|
canAdd,
|
||||||
|
canApprove,
|
||||||
|
canExport,
|
||||||
|
};
|
||||||
|
}, [roles, claims]);
|
||||||
|
|
||||||
|
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('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);
|
||||||
|
};
|
||||||
@@ -29,12 +29,14 @@ import { ErrorBoundary } from '../ui/ErrorBoundary';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getUsers, getTenant } from '../../services/api';
|
import { getUsers, getTenant } from '../../services/api';
|
||||||
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
|
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
|
||||||
|
import { useRbac, RoleClaim } from '../../auth/rbac';
|
||||||
|
|
||||||
interface SubNavItem {
|
interface SubNavItem {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
help?: string;
|
help?: string;
|
||||||
|
claim?: RoleClaim;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -42,6 +44,7 @@ interface NavItem {
|
|||||||
path: string;
|
path: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
help: string;
|
help: string;
|
||||||
|
claim?: RoleClaim;
|
||||||
subItems?: SubNavItem[];
|
subItems?: SubNavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,36 +54,42 @@ const navItems: NavItem[] = [
|
|||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
help: 'Strategic macro metrics, portfolio value, disbursed funding, ISO 31000 risk heatmap, and projects requiring immediate intervention.',
|
help: 'Strategic macro metrics, portfolio value, disbursed funding, ISO 31000 risk heatmap, and projects requiring immediate intervention.',
|
||||||
|
claim: 'dashboard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Governance & Structure',
|
name: 'Governance & Structure',
|
||||||
path: '/governance',
|
path: '/governance',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
help: 'Master records for legal entities, holding structures, investment portfolios, and thematic programmes funded under statutory mandates.',
|
help: 'Master records for legal entities, holding structures, investment portfolios, and thematic programmes funded under statutory mandates.',
|
||||||
|
claim: 'governance',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Projects & Milestones',
|
name: 'Projects & Milestones',
|
||||||
path: '/projects',
|
path: '/projects',
|
||||||
icon: FolderKanban,
|
icon: FolderKanban,
|
||||||
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
|
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
|
||||||
|
claim: 'projects_milestones',
|
||||||
subItems: [
|
subItems: [
|
||||||
{
|
{
|
||||||
name: 'Beneficiaries',
|
name: 'Beneficiaries',
|
||||||
path: '/projects/beneficiaries',
|
path: '/projects/beneficiaries',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
help: 'Manage target beneficiaries, demographics, and statutory compliance items satisfying beneficiaries.',
|
help: 'Manage target beneficiaries, demographics, and statutory compliance items satisfying beneficiaries.',
|
||||||
|
claim: 'beneficiaries',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Change Management',
|
name: 'Change Management',
|
||||||
path: '/projects/change-management',
|
path: '/projects/change-management',
|
||||||
icon: GitPullRequest,
|
icon: GitPullRequest,
|
||||||
help: 'Track project issues, contractual change requests, and resolve changes in-place.',
|
help: 'Track project issues, contractual change requests, and resolve changes in-place.',
|
||||||
|
claim: 'change_management',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Site Visits',
|
name: 'Site Visits',
|
||||||
path: '/projects/site-visits',
|
path: '/projects/site-visits',
|
||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
||||||
|
claim: 'site_visits',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -89,18 +98,21 @@ const navItems: NavItem[] = [
|
|||||||
path: '/grants',
|
path: '/grants',
|
||||||
icon: Coins,
|
icon: Coins,
|
||||||
help: 'Financial management including statutory awards, multi-year budget line allocations, tranches, and scheduled payment disbursements.',
|
help: 'Financial management including statutory awards, multi-year budget line allocations, tranches, and scheduled payment disbursements.',
|
||||||
|
claim: 'grant_financials',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'M&E, Evidence & Risks',
|
name: 'M&E, Evidence & Risks',
|
||||||
path: '/monitoring',
|
path: '/monitoring',
|
||||||
icon: ShieldAlert,
|
icon: ShieldAlert,
|
||||||
help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.',
|
help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.',
|
||||||
|
claim: 'evidence_risks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Tenant Management',
|
name: 'Tenant Management',
|
||||||
path: '/tenants',
|
path: '/tenants',
|
||||||
icon: Database,
|
icon: Database,
|
||||||
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
||||||
|
claim: 'tenant_management',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Settings & Communications',
|
name: 'Settings & Communications',
|
||||||
@@ -111,7 +123,9 @@ const navItems: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const AppLayout: React.FC = () => {
|
export const AppLayout: React.FC = () => {
|
||||||
const { user, claims, sub, tenantId, hashedTenantId, tenantName: authTenantName, canManageTenants, logout } = useAuth();
|
const { user, claims, sub, tenantId, hashedTenantId, tenantName: authTenantName, canManageTenants, logout, roles } = useAuth();
|
||||||
|
const userRole = user?.role || (Array.isArray(roles) && roles.length > 0 ? roles[0] : (claims?.role as string) || 'User');
|
||||||
|
const { canView } = useRbac();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -120,13 +134,38 @@ export const AppLayout: React.FC = () => {
|
|||||||
await logout();
|
await logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only show Tenant Management if user's tenant_id == 1 and their role list has Admin in it
|
// Filter navigation shelf strictly according to RBAC Role Claims
|
||||||
const visibleNavItems = navItems.filter((item) => {
|
const visibleNavItems = navItems
|
||||||
if (item.path === '/tenants') {
|
.filter((item) => {
|
||||||
return canManageTenants;
|
if (item.path === '/tenants') {
|
||||||
}
|
return canManageTenants || canView('tenant_management');
|
||||||
return true;
|
}
|
||||||
});
|
if (item.path === '/settings') {
|
||||||
|
return (
|
||||||
|
canView('settings_users') ||
|
||||||
|
canView('settings_invites') ||
|
||||||
|
canView('settings_notifications') ||
|
||||||
|
canView('settings_auditlog') ||
|
||||||
|
canView('tenant_self_service')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.claim) {
|
||||||
|
return canView(item.claim);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
if (!item.subItems) return item;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
subItems: item.subItems.filter((subItem) => {
|
||||||
|
if (subItem.claim) {
|
||||||
|
return canView(subItem.claim);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Pre-populate user directory cache so all components and modals resolve user emails instantly
|
// Pre-populate user directory cache so all components and modals resolve user emails instantly
|
||||||
useQuery({
|
useQuery({
|
||||||
@@ -355,6 +394,11 @@ export const AppLayout: React.FC = () => {
|
|||||||
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
|
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
|
||||||
{tokenUserEmail || 'Authenticated User'}
|
{tokenUserEmail || 'Authenticated User'}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-semibold text-blue-400 bg-blue-500/10 border border-blue-500/25 px-1.5 py-0.5 rounded truncate" title={`Assigned Role: ${userRole}`}>
|
||||||
|
Role: {userRole}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
@@ -369,13 +413,14 @@ export const AppLayout: React.FC = () => {
|
|||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-10 w-10 shrink-0 rounded-xl bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400 relative group cursor-pointer shadow-xs"
|
className="h-10 w-10 shrink-0 rounded-xl bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400 relative group cursor-pointer shadow-xs"
|
||||||
title={`${tokenUserName} (${tokenUserEmail})`}
|
title={`${tokenUserName} (${tokenUserEmail}) • Role: ${userRole}`}
|
||||||
>
|
>
|
||||||
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : 'AU'}
|
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : 'AU'}
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-emerald-500 border-2 border-[#0d121f]" />
|
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-emerald-500 border-2 border-[#0d121f]" />
|
||||||
<div className="absolute left-full ml-3 px-3 py-2 bg-[#0d1322] text-white text-xs font-medium rounded-xl shadow-2xl border border-slate-700 whitespace-nowrap z-50 pointer-events-none opacity-0 group-hover:opacity-100 transition-all duration-150">
|
<div className="absolute left-full ml-3 px-3 py-2 bg-[#0d1322] text-white text-xs font-medium rounded-xl shadow-2xl border border-slate-700 whitespace-nowrap z-50 pointer-events-none opacity-0 group-hover:opacity-100 transition-all duration-150">
|
||||||
<div className="font-semibold">{tokenUserName}</div>
|
<div className="font-semibold">{tokenUserName}</div>
|
||||||
<div className="text-[10px] text-slate-400 font-mono mt-0.5">{tokenUserEmail}</div>
|
<div className="text-[10px] text-slate-400 font-mono mt-0.5">{tokenUserEmail}</div>
|
||||||
|
<div className="text-[10px] text-blue-400 font-semibold mt-1">Role: {userRole}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -474,9 +519,14 @@ export const AppLayout: React.FC = () => {
|
|||||||
{/* User Profile and Controls */}
|
{/* User Profile and Controls */}
|
||||||
<div className="relative flex items-center gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
<div className="text-right hidden sm:block">
|
<div className="text-right hidden sm:block">
|
||||||
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
{tokenUserName}
|
<p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}>
|
||||||
</p>
|
{tokenUserName}
|
||||||
|
</p>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-blue-400 bg-blue-500/10 border border-blue-500/25 px-1.5 py-0.5 rounded truncate" title={`Role: ${userRole}`}>
|
||||||
|
{userRole}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
|
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
|
||||||
{tokenUserEmail}
|
{tokenUserEmail}
|
||||||
</p>
|
</p>
|
||||||
@@ -498,8 +548,14 @@ export const AppLayout: React.FC = () => {
|
|||||||
onMouseLeave={() => setUserMenuOpen(false)}
|
onMouseLeave={() => setUserMenuOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="p-2.5 border-b border-slate-800">
|
<div className="p-2.5 border-b border-slate-800">
|
||||||
<p className="text-xs font-semibold text-white">{tokenUserName}</p>
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
<p className="text-[11px] text-slate-300 font-medium truncate">{tokenUserEmail || (sub ? getUserEmail(sub) : '')}</p>
|
<p className="text-xs font-semibold text-white truncate">{tokenUserName}</p>
|
||||||
|
<span className="text-[10px] font-semibold uppercase text-blue-400 bg-blue-500/10 border border-blue-500/25 px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
{userRole}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-300 font-medium truncate mt-0.5">{tokenUserEmail || (sub ? getUserEmail(sub) : '')}</p>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-1">Role: <strong className="text-blue-300">{userRole}</strong></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { useToast } from '../ui/Toast';
|
|||||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
import { ExportButton } from '../ui/ExportButton';
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
import { ExportColumn } from '../../utils/exportUtils';
|
import { ExportColumn } from '../../utils/exportUtils';
|
||||||
|
import { useRbac } from '../../auth/rbac';
|
||||||
|
|
||||||
export interface EvidenceReviewQueueProps {
|
export interface EvidenceReviewQueueProps {
|
||||||
initialProjectId?: string | number | 'all';
|
initialProjectId?: string | number | 'all';
|
||||||
@@ -86,6 +87,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canApprove, canExport } = useRbac();
|
||||||
|
|
||||||
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
|
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
|
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
|
||||||
@@ -454,6 +456,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="evidence_risks"
|
||||||
title="Deliverable Evidence & Verification Ledger"
|
title="Deliverable Evidence & Verification Ledger"
|
||||||
subtitle="Audited records of submitted milestone deliverables and verification decisions"
|
subtitle="Audited records of submitted milestone deliverables and verification decisions"
|
||||||
filename={`pfm-evidence-review-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-evidence-review-${new Date().toISOString().split('T')[0]}`}
|
||||||
@@ -461,15 +464,17 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
data={filteredEvidence}
|
data={filteredEvidence}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canAdd('evidence_risks') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setIsManualIngestModalOpen(true)}
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 border border-transparent px-3.5 py-2 rounded-xl transition shadow-md shadow-blue-500/20 cursor-pointer"
|
onClick={() => setIsManualIngestModalOpen(true)}
|
||||||
title="Upload document on behalf of awardee"
|
className="inline-flex items-center gap-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 border border-transparent px-3.5 py-2 rounded-xl transition shadow-md shadow-blue-500/20 cursor-pointer"
|
||||||
>
|
title="Upload document on behalf of awardee"
|
||||||
<Upload className="w-3.5 h-3.5" />
|
>
|
||||||
<span>Ingest Evidence</span>
|
<Upload className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>Ingest Evidence</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -795,49 +800,53 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
<span>Inspect</span>
|
<span>Inspect</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{canAdd('evidence_risks') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
reviewMutation.mutate({
|
onClick={() =>
|
||||||
id: ev.id,
|
reviewMutation.mutate({
|
||||||
status: ApprovalStatus.Rejected,
|
id: ev.id,
|
||||||
version: ev.version,
|
status: ApprovalStatus.Rejected,
|
||||||
evidenceType: ev.type,
|
version: ev.version,
|
||||||
})
|
evidenceType: ev.type,
|
||||||
}
|
})
|
||||||
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
}
|
||||||
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
|
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
||||||
ev.status === ApprovalStatus.Rejected
|
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
|
||||||
? 'bg-rose-500/20 text-rose-300 border-rose-500/30 opacity-75'
|
ev.status === ApprovalStatus.Rejected
|
||||||
: 'text-rose-400 hover:text-white bg-rose-500/10 hover:bg-rose-600 border border-rose-500/25'
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/30 opacity-75'
|
||||||
}`}
|
: 'text-rose-400 hover:text-white bg-rose-500/10 hover:bg-rose-600 border border-rose-500/25'
|
||||||
title="Reject evidence submission"
|
}`}
|
||||||
>
|
title="Reject evidence submission"
|
||||||
<XCircle className="w-3.5 h-3.5" />
|
>
|
||||||
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{canApprove('evidence_risks') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
reviewMutation.mutate({
|
onClick={() =>
|
||||||
id: ev.id,
|
reviewMutation.mutate({
|
||||||
status: ApprovalStatus.Approved,
|
id: ev.id,
|
||||||
version: ev.version,
|
status: ApprovalStatus.Approved,
|
||||||
evidenceType: ev.type,
|
version: ev.version,
|
||||||
})
|
evidenceType: ev.type,
|
||||||
}
|
})
|
||||||
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
}
|
||||||
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
|
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
|
||||||
ev.status === ApprovalStatus.Approved
|
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
|
||||||
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30 opacity-75'
|
ev.status === ApprovalStatus.Approved
|
||||||
: 'text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25'
|
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30 opacity-75'
|
||||||
}`}
|
: 'text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25'
|
||||||
title="Approve evidence submission"
|
}`}
|
||||||
>
|
title="Approve evidence submission"
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
>
|
||||||
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -989,49 +998,53 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
<button
|
{canAdd('evidence_risks') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
reviewMutation.mutate({
|
onClick={() =>
|
||||||
id: inspectEvidence.id,
|
reviewMutation.mutate({
|
||||||
status: ApprovalStatus.Rejected,
|
id: inspectEvidence.id,
|
||||||
version: inspectEvidence.version,
|
status: ApprovalStatus.Rejected,
|
||||||
evidenceType: inspectEvidence.type,
|
version: inspectEvidence.version,
|
||||||
})
|
evidenceType: inspectEvidence.type,
|
||||||
}
|
})
|
||||||
disabled={reviewMutation.isPending}
|
}
|
||||||
className={`px-4 py-2 text-xs font-semibold rounded-xl transition cursor-pointer flex items-center gap-1.5 ${
|
disabled={reviewMutation.isPending}
|
||||||
inspectEvidence.status === ApprovalStatus.Rejected
|
className={`px-4 py-2 text-xs font-semibold rounded-xl transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
? 'bg-rose-600/30 text-rose-300 border border-rose-500/40'
|
inspectEvidence.status === ApprovalStatus.Rejected
|
||||||
: 'text-white bg-rose-600 hover:bg-rose-500 shadow-md shadow-rose-600/20'
|
? 'bg-rose-600/30 text-rose-300 border border-rose-500/40'
|
||||||
}`}
|
: 'text-white bg-rose-600 hover:bg-rose-500 shadow-md shadow-rose-600/20'
|
||||||
title="Reject evidence submission via API"
|
}`}
|
||||||
>
|
title="Reject evidence submission via API"
|
||||||
<XCircle className="w-3.5 h-3.5" />
|
>
|
||||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Rejected ? 'Rejecting...' : 'Reject Evidence'}</span>
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Rejected ? 'Rejecting...' : 'Reject Evidence'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{canApprove('evidence_risks') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
reviewMutation.mutate({
|
onClick={() =>
|
||||||
id: inspectEvidence.id,
|
reviewMutation.mutate({
|
||||||
status: ApprovalStatus.Approved,
|
id: inspectEvidence.id,
|
||||||
version: inspectEvidence.version,
|
status: ApprovalStatus.Approved,
|
||||||
evidenceType: inspectEvidence.type,
|
version: inspectEvidence.version,
|
||||||
})
|
evidenceType: inspectEvidence.type,
|
||||||
}
|
})
|
||||||
disabled={reviewMutation.isPending}
|
}
|
||||||
className={`px-4 py-2 text-xs font-semibold rounded-xl transition cursor-pointer flex items-center gap-1.5 ${
|
disabled={reviewMutation.isPending}
|
||||||
inspectEvidence.status === ApprovalStatus.Approved
|
className={`px-4 py-2 text-xs font-semibold rounded-xl transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
? 'bg-emerald-600/30 text-emerald-300 border border-emerald-500/40'
|
inspectEvidence.status === ApprovalStatus.Approved
|
||||||
: 'text-white bg-emerald-600 hover:bg-emerald-500 shadow-md shadow-emerald-600/20'
|
? 'bg-emerald-600/30 text-emerald-300 border border-emerald-500/40'
|
||||||
}`}
|
: 'text-white bg-emerald-600 hover:bg-emerald-500 shadow-md shadow-emerald-600/20'
|
||||||
title="Approve evidence submission via API"
|
}`}
|
||||||
>
|
title="Approve evidence submission via API"
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
>
|
||||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
|
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
|
||||||
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
|
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
|
||||||
import { useAuth } from '../../auth/AuthProvider';
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
|
import { useRbac, RoleClaim } from '../../auth/rbac';
|
||||||
|
|
||||||
export interface ExportButtonProps<T = any> {
|
export interface ExportButtonProps<T = any> {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,6 +14,7 @@ export interface ExportButtonProps<T = any> {
|
|||||||
label?: string;
|
label?: string;
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
className?: string;
|
className?: string;
|
||||||
|
claim?: RoleClaim;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportButton<T>({
|
export function ExportButton<T>({
|
||||||
@@ -24,14 +26,17 @@ export function ExportButton<T>({
|
|||||||
label = 'Export',
|
label = 'Export',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
className = '',
|
className = '',
|
||||||
|
claim,
|
||||||
}: ExportButtonProps<T>) {
|
}: ExportButtonProps<T>) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null);
|
const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const { user, claims, tenantId } = useAuth();
|
const { user, claims, tenantId } = useAuth();
|
||||||
|
const { canExport } = useRbac();
|
||||||
|
|
||||||
const isDisabled = !data || data.length === 0;
|
const isClaimAllowed = claim ? canExport(claim) : true;
|
||||||
|
const isDisabled = !data || data.length === 0 || !isClaimAllowed;
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
if (!dropdownRef.current) return;
|
if (!dropdownRef.current) return;
|
||||||
@@ -67,10 +72,10 @@ export function ExportButton<T>({
|
|||||||
};
|
};
|
||||||
}, [isOpen, updatePosition]);
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
const handleExportSpreadsheet = () => {
|
const handleExportSpreadsheet = async () => {
|
||||||
if (isDisabled) return;
|
if (isDisabled) return;
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
exportToSpreadsheet({
|
await exportToSpreadsheet({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
filename,
|
filename,
|
||||||
@@ -81,10 +86,10 @@ export function ExportButton<T>({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPdf = () => {
|
const handleExportPdf = async () => {
|
||||||
if (isDisabled) return;
|
if (isDisabled) return;
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
exportToPdf({
|
await exportToPdf({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { useToast } from '../ui/Toast';
|
||||||
|
import { createUser, getSystemRoles, assignUserRole, getTenants } from '../../services/api';
|
||||||
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
|
import { useRbac } from '../../auth/rbac';
|
||||||
|
import { CreateUserRequest, Role, Tenant } from '../../types';
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
tenantId: z.string().min(1, 'Tenant is required'),
|
||||||
|
idNumber: z
|
||||||
|
.string()
|
||||||
|
.min(5, 'Identification Number must be at least 5 characters')
|
||||||
|
.max(30, 'Identification Number is too long')
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'ID Number must be alphanumeric (Hex-encodable)'),
|
||||||
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
userName: z.string().optional(),
|
||||||
|
phoneNumber: z.string().optional(),
|
||||||
|
initialRoleId: z.string().optional(),
|
||||||
|
lockoutEnabled: z.boolean().default(false),
|
||||||
|
twoFactorEnabled: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||||
|
|
||||||
|
interface CreateUserModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
defaultTenantId?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
defaultTenantId,
|
||||||
|
}) => {
|
||||||
|
const { tenantId: userTenantId, canManageTenants } = useAuth();
|
||||||
|
const { canAdd, canView, isUnrestricted } = useRbac();
|
||||||
|
const canCreate = canAdd('settings_users') || canAdd('tenant_self_service') || canAdd('tenant_management') || isUnrestricted;
|
||||||
|
const hasTenantSelfHelpAccess = canAdd('tenant_self_service') || canView('tenant_self_service');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
|
const effectiveDefaultTenant = defaultTenantId
|
||||||
|
? String(defaultTenantId)
|
||||||
|
: userTenantId
|
||||||
|
? String(userTenantId)
|
||||||
|
: '1';
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CreateUserFormValues>({
|
||||||
|
resolver: zodResolver(createUserSchema),
|
||||||
|
defaultValues: {
|
||||||
|
tenantId: effectiveDefaultTenant,
|
||||||
|
idNumber: '',
|
||||||
|
email: '',
|
||||||
|
userName: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
initialRoleId: '',
|
||||||
|
lockoutEnabled: false,
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
reset({
|
||||||
|
tenantId: effectiveDefaultTenant,
|
||||||
|
idNumber: '',
|
||||||
|
email: '',
|
||||||
|
userName: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
initialRoleId: '',
|
||||||
|
lockoutEnabled: false,
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen, effectiveDefaultTenant, reset]);
|
||||||
|
|
||||||
|
// System roles for assignment
|
||||||
|
const { data: systemRoles = [], isLoading: loadingRoles } = useQuery<Role[]>({
|
||||||
|
queryKey: ['system-roles'],
|
||||||
|
queryFn: getSystemRoles,
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out Admin role if user has access to tenant self-help (Requirement 1)
|
||||||
|
const assignableRoles = React.useMemo(() => {
|
||||||
|
return systemRoles.filter((r) => {
|
||||||
|
const isRoleAdmin = r.name.toLowerCase() === 'admin' || r.id === '1';
|
||||||
|
if (isRoleAdmin && hasTenantSelfHelpAccess) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [systemRoles, hasTenantSelfHelpAccess]);
|
||||||
|
|
||||||
|
// Tenants list for super-admins
|
||||||
|
const { data: tenantsList = [] } = useQuery<Tenant[]>({
|
||||||
|
queryKey: ['tenants'],
|
||||||
|
queryFn: getTenants,
|
||||||
|
enabled: isOpen && canManageTenants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUserMutation = useMutation({
|
||||||
|
mutationFn: async (values: CreateUserFormValues) => {
|
||||||
|
// Security enforcement: Users with tenant self-help access cannot create admin users
|
||||||
|
if (values.initialRoleId && hasTenantSelfHelpAccess) {
|
||||||
|
const selectedRole = systemRoles.find((r) => r.id === values.initialRoleId);
|
||||||
|
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1')) {
|
||||||
|
throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: CreateUserRequest = {
|
||||||
|
email: values.email.trim(),
|
||||||
|
userName: values.userName?.trim() || values.email.split('@')[0],
|
||||||
|
emailConfirmed: true,
|
||||||
|
phoneNumber: values.phoneNumber?.trim() || null,
|
||||||
|
phoneNumberConfirmed: false,
|
||||||
|
LockoutEnabled: values.lockoutEnabled,
|
||||||
|
twoFactorEnabled: values.twoFactorEnabled,
|
||||||
|
accessFailedCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await createUser(values.tenantId, values.idNumber.trim(), payload);
|
||||||
|
|
||||||
|
// If an initial role was chosen and we have a newly generated user,
|
||||||
|
// invalidate query cache to refresh directory
|
||||||
|
return values;
|
||||||
|
},
|
||||||
|
onSuccess: (values) => {
|
||||||
|
success('User Created', `User ${values.email} provisioned under Tenant #${values.tenantId}.`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toastError('User Creation Failed', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: CreateUserFormValues) => {
|
||||||
|
createUserMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Provision Tenant User"
|
||||||
|
subtitle="Creates a new tenant user with Hashids Hex-encoded ID and RBAC capabilities without direct DB intervention."
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 text-xs">
|
||||||
|
{/* Tenant Scope Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-300 font-semibold mb-1 flex items-center gap-1.5">
|
||||||
|
<Building className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>Tenant Partition Boundary</span>
|
||||||
|
</label>
|
||||||
|
{canManageTenants && tenantsList.length > 0 ? (
|
||||||
|
<select
|
||||||
|
{...register('tenantId')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
{tenantsList.map((t) => (
|
||||||
|
<option key={t.id} value={String(t.id)}>
|
||||||
|
Tenant #{t.id} — {t.name || 'Enterprise Tenant'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
{...register('tenantId')}
|
||||||
|
className="w-full bg-slate-900/60 border border-slate-800 rounded-xl px-3 py-2 text-slate-400 cursor-not-allowed font-mono"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errors.tenantId && <p className="text-rose-400 text-[11px] mt-1">{errors.tenantId.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ID Number with Hashids Hex guidance */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-300 font-semibold mb-1 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Hash className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>National ID / Identification Number</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-blue-400 font-mono">Hashids Hex Encoded in API</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. 9501015800085 or ZA-982341"
|
||||||
|
{...register('idNumber')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-1">
|
||||||
|
Standard South African National ID (13 digits) or alphanumeric identifier. The client automatically converts and hashes this via Hashids Hex.
|
||||||
|
</p>
|
||||||
|
{errors.idNumber && <p className="text-rose-400 text-[11px] mt-1">{errors.idNumber.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email & Username */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-300 font-semibold mb-1 flex items-center gap-1.5">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>Email Address</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="user@organisation.gov.za"
|
||||||
|
{...register('email')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="text-rose-400 text-[11px] mt-1">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-300 font-semibold mb-1">
|
||||||
|
Username (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. jsmith"
|
||||||
|
{...register('userName')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone & Initial Role */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-300 font-semibold mb-1 flex items-center gap-1.5">
|
||||||
|
<Phone className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>Phone Number (Optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="+27 82 000 0000"
|
||||||
|
{...register('phoneNumber')}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-300 font-semibold mb-1 flex items-center gap-1.5">
|
||||||
|
<Shield className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>Primary System Role</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('initialRoleId')}
|
||||||
|
disabled={loadingRoles}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Select Initial Role (Optional)</option>
|
||||||
|
{assignableRoles.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{hasTenantSelfHelpAccess && (
|
||||||
|
<p className="text-[10px] text-amber-400 mt-1">
|
||||||
|
Tenant Self-Help Policy: Admin user creation is restricted.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Flags */}
|
||||||
|
<div className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
|
||||||
|
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Security & Authentication Flags
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('lockoutEnabled')}
|
||||||
|
className="rounded border-slate-700 text-blue-600 focus:ring-blue-500 w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>Enable Account Lockout Protection</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('twoFactorEnabled')}
|
||||||
|
className="rounded border-slate-700 text-blue-600 focus:ring-blue-500 w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>Require Two-Factor Auth</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canCreate && (
|
||||||
|
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs flex items-center gap-2">
|
||||||
|
<span>User provisioning is restricted by your RBAC claim assignment.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canCreate || createUserMutation.isPending}
|
||||||
|
className="inline-flex items-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-600/30 transition cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-3.5 h-3.5" />
|
||||||
|
<span>{createUserMutation.isPending ? 'Provisioning...' : 'Provision User'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
Key,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
LogOut,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
User as UserIcon,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
FileText,
|
||||||
|
Hash,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { useToast } from '../ui/Toast';
|
||||||
|
import {
|
||||||
|
getUser,
|
||||||
|
getUserRoles,
|
||||||
|
getSystemRoles,
|
||||||
|
assignUserRole,
|
||||||
|
removeUserRole,
|
||||||
|
getUserClaims,
|
||||||
|
addUserClaim,
|
||||||
|
deleteUserClaim,
|
||||||
|
forceLogoutUser,
|
||||||
|
setUserLockout,
|
||||||
|
updateUserStatus,
|
||||||
|
getSystemClaims,
|
||||||
|
} from '../../services/api';
|
||||||
|
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface UserSecurityModalProps {
|
||||||
|
user: User | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||||
|
user,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
||||||
|
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
||||||
|
|
||||||
|
const canManageRolesAndClaims = canApprove('settings_users') || canApprove('tenant_management') || isUnrestricted;
|
||||||
|
const canManageSessions = canApprove('settings_users') || canApprove('tenant_management') || hasTenantSelfHelpAccess || isUnrestricted;
|
||||||
|
const canChangeStatus = canAdd('settings_users') || canAdd('tenant_management') || canApprove('settings_users') || isUnrestricted;
|
||||||
|
|
||||||
|
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 || '';
|
||||||
|
|
||||||
|
// 1. Fetch fresh user details
|
||||||
|
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
||||||
|
queryKey: ['user-detail', userId],
|
||||||
|
queryFn: () => (userId ? getUser(userId) : null),
|
||||||
|
enabled: isOpen && !!userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Fetch assigned user roles
|
||||||
|
const { data: userRoles = [], isLoading: loadingUserRoles, refetch: refetchUserRoles } = useQuery<Role[]>({
|
||||||
|
queryKey: ['user-roles', userId],
|
||||||
|
queryFn: () => (userId ? getUserRoles(userId) : []),
|
||||||
|
enabled: isOpen && !!userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Fetch system roles directory
|
||||||
|
const { data: systemRoles = [], isLoading: loadingSystemRoles } = useQuery<Role[]>({
|
||||||
|
queryKey: ['system-roles'],
|
||||||
|
queryFn: getSystemRoles,
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Fetch user claims
|
||||||
|
const { data: userClaims = [], isLoading: loadingUserClaims, refetch: refetchUserClaims } = useQuery<Claim[]>({
|
||||||
|
queryKey: ['user-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<Claim[]>({
|
||||||
|
queryKey: ['system-claims'],
|
||||||
|
queryFn: getSystemClaims,
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Role Mutations
|
||||||
|
const assignRoleMutation = useMutation({
|
||||||
|
mutationFn: (roleId: string) => {
|
||||||
|
const selectedRole = systemRoles.find((r) => r.id === roleId);
|
||||||
|
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1') && hasTenantSelfHelpAccess) {
|
||||||
|
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.');
|
||||||
|
}
|
||||||
|
return assignUserRole(userId, roleId);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
success('Role Assigned', 'Role membership updated successfully.');
|
||||||
|
refetchUserRoles();
|
||||||
|
setSelectedRoleToAdd('');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Role Assignment Failed', err),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeRoleMutation = useMutation({
|
||||||
|
mutationFn: (roleId: string) => removeUserRole(userId, roleId),
|
||||||
|
onSuccess: () => {
|
||||||
|
success('Role Removed', 'Role revoked from user.');
|
||||||
|
refetchUserRoles();
|
||||||
|
},
|
||||||
|
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: () => {
|
||||||
|
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: () => {
|
||||||
|
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),
|
||||||
|
onSuccess: () => success('Session Terminated', 'Target user session revoked. Re-authentication required.'),
|
||||||
|
onError: (err: any) => toastError('Logout Failed', err),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lockoutMutation = useMutation({
|
||||||
|
mutationFn: (disableLock: boolean) => setUserLockout(userId, disableLock),
|
||||||
|
onSuccess: (_, disableLock) => {
|
||||||
|
success(
|
||||||
|
disableLock ? 'Account Unlocked' : 'Account Locked',
|
||||||
|
`User account lockout status successfully modified.`
|
||||||
|
);
|
||||||
|
refetchUser();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Lockout Update Failed', err),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
|
||||||
|
onSuccess: () => {
|
||||||
|
success('Status Updated', 'User activity status changed.');
|
||||||
|
refetchUser();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => toastError('Status Update Failed', err),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const effectiveUser = freshUser || user;
|
||||||
|
const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
|
||||||
|
const assignedRoleIds = new Set(userRoles.map((r) => r.id));
|
||||||
|
const availableRoles = systemRoles.filter((r) => {
|
||||||
|
if (assignedRoleIds.has(r.id)) return false;
|
||||||
|
if (hasTenantSelfHelpAccess && (r.name.toLowerCase() === 'admin' || r.id === '1')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignableClaims = React.useMemo(() => {
|
||||||
|
const claimMap = new Map<string, { claim: string; label: string }>();
|
||||||
|
systemClaims.forEach((c) => {
|
||||||
|
const type = c.claimType || c.type;
|
||||||
|
if (type) {
|
||||||
|
if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
claimMap.set(type, { claim: type, label: type });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ROLE_CLAIM_DEFINITIONS.forEach((def) => {
|
||||||
|
if (hasTenantSelfHelpAccess && (def.claim === 'unrestricted' || (def.claim as string) === 'admin')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
claimMap.set(def.claim, { claim: def.claim, label: `${def.claim} (${def.label})` });
|
||||||
|
});
|
||||||
|
return Array.from(claimMap.values());
|
||||||
|
}, [systemClaims, hasTenantSelfHelpAccess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`User Administration: ${effectiveUser.email || effectiveUser.id}`}
|
||||||
|
subtitle="Direct tenant-level user, role, claims, and session administration without database intervention."
|
||||||
|
>
|
||||||
|
<div className="space-y-4 text-xs">
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-slate-800 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('overview')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer ${
|
||||||
|
activeTab === 'overview'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Profile & Status
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('roles')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'roles'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
<span>Roles ({userRoles.length})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('claims')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'claims'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Key className="w-3.5 h-3.5" />
|
||||||
|
<span>Claims ({userClaims.length})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('sessions')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition cursor-pointer flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'sessions'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Lock className="w-3.5 h-3.5" />
|
||||||
|
<span>Security & Sessions</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TAB 1: Overview & Profile */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2.5">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Email:</span>
|
||||||
|
<span className="text-white font-medium">{effectiveUser.email || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User UUID:</span>
|
||||||
|
<span className="font-mono text-slate-300 text-[11px] break-all">{effectiveUser.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Id Number:</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-white text-xs">{effectiveUser.idNumber || '—'}</span>
|
||||||
|
{decodedId && decodedId !== effectiveUser.idNumber && (
|
||||||
|
<span className="text-[10px] text-blue-400 font-mono">(Decoded: {decodedId})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
|
||||||
|
<span className="font-mono text-slate-300">
|
||||||
|
{effectiveUser.lastLoginAt ? formatDate(effectiveUser.lastLoginAt) : 'Never'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800/80 pt-2 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-0.5">
|
||||||
|
Account Status:
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${
|
||||||
|
getActivityStatusBadge(effectiveUser.status).className
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getActivityStatusBadge(effectiveUser.status).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{effectiveUser.status === ActivityStatus.Active ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
className="px-2.5 py-1 text-xs font-semibold text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 rounded-lg transition"
|
||||||
|
>
|
||||||
|
Suspend User
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => statusMutation.mutate(ActivityStatus.Active)}
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
className="px-2.5 py-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition"
|
||||||
|
>
|
||||||
|
Activate User
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 2: Roles */}
|
||||||
|
{activeTab === 'roles' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Add Role selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedRoleToAdd}
|
||||||
|
onChange={(e) => setSelectedRoleToAdd(e.target.value)}
|
||||||
|
disabled={loadingSystemRoles || availableRoles.length === 0}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{availableRoles.length === 0 ? 'All system roles assigned' : 'Select role to assign...'}
|
||||||
|
</option>
|
||||||
|
{availableRoles.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)}
|
||||||
|
disabled={!selectedRoleToAdd || assignRoleMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Assign Role</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List of currently assigned roles */}
|
||||||
|
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
|
||||||
|
{loadingUserRoles ? (
|
||||||
|
<div className="p-4 text-center text-slate-500">Loading roles...</div>
|
||||||
|
) : userRoles.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-slate-500">No roles assigned to this user.</div>
|
||||||
|
) : (
|
||||||
|
userRoles.map((role) => (
|
||||||
|
<div key={role.id} className="p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-semibold flex items-center gap-1.5">
|
||||||
|
<Shield className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>{role.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-mono">Role ID: {role.id}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRoleMutation.mutate(role.id)}
|
||||||
|
disabled={removeRoleMutation.isPending}
|
||||||
|
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition"
|
||||||
|
title="Revoke Role"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 3: Claims */}
|
||||||
|
{activeTab === 'claims' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Add claim inline form */}
|
||||||
|
<div className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Grant Role Claim / Custom Claim</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">RBAC Standard Policy</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-slate-400 mb-1 font-medium">Claim Domain</label>
|
||||||
|
<select
|
||||||
|
value={assignableClaims.some((d) => d.claim === newClaimType) ? newClaimType : (newClaimType ? 'custom' : '')}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === 'custom') {
|
||||||
|
setNewClaimType('');
|
||||||
|
} else {
|
||||||
|
setNewClaimType(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Select System Claim --</option>
|
||||||
|
{assignableClaims.map((def) => (
|
||||||
|
<option key={def.claim} value={def.claim}>
|
||||||
|
{def.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">-- Custom Claim Type --</option>
|
||||||
|
</select>
|
||||||
|
{(!assignableClaims.some((d) => d.claim === newClaimType) || newClaimType === '') && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Custom Claim Type..."
|
||||||
|
value={newClaimType}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-slate-400 mb-1 font-medium">Claim Value / Action Assignment</label>
|
||||||
|
<select
|
||||||
|
value={CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) ? newClaimValue : (newClaimValue ? 'custom' : '')}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === 'custom') {
|
||||||
|
setNewClaimValue('');
|
||||||
|
} else {
|
||||||
|
setNewClaimValue(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Action Value --</option>
|
||||||
|
{CLAIM_ACTION_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.action} value={opt.action}>
|
||||||
|
{opt.action}: {opt.description}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">-- Custom Value --</option>
|
||||||
|
</select>
|
||||||
|
{(!CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) || newClaimValue === '') && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Custom Value (e.g. true)..."
|
||||||
|
value={newClaimValue}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addClaimMutation.mutate()}
|
||||||
|
disabled={!newClaimType.trim() || !newClaimValue.trim() || addClaimMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-semibold transition disabled:opacity-50 cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Grant Claim</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing claims list */}
|
||||||
|
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
|
||||||
|
{loadingUserClaims ? (
|
||||||
|
<div className="p-4 text-center text-slate-500">Loading user claims...</div>
|
||||||
|
) : userClaims.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-slate-500">No active claims found for user.</div>
|
||||||
|
) : (
|
||||||
|
userClaims.map((claim, idx) => (
|
||||||
|
<div key={idx} className="p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium flex items-center gap-1.5">
|
||||||
|
<Key className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>{claim.type || 'Custom Claim'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-300 font-mono mt-0.5">
|
||||||
|
Value: <span className="text-emerald-400">{claim.value}</span>
|
||||||
|
{claim.issuer && <span className="text-slate-500 ml-2">({claim.issuer})</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{claim.type && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteClaimMutation.mutate(claim.type || String(idx))}
|
||||||
|
disabled={deleteClaimMutation.isPending}
|
||||||
|
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition"
|
||||||
|
title="Delete Claim"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 4: Sessions & Direct Account Interventions */}
|
||||||
|
{activeTab === 'sessions' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-4">
|
||||||
|
{/* Force Logout Action */}
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||||
|
<LogOut className="w-4 h-4 text-amber-400" />
|
||||||
|
<span>Revoke Active User Session</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="User Session Invalidation"
|
||||||
|
content="Enables a tenant self-service user to immediately logout a user if they are currently logged in, terminating their active token and session."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
Terminates all active JWT tokens and sessions immediately without direct database intervention.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => logoutMutation.mutate()}
|
||||||
|
disabled={logoutMutation.isPending || !canManageSessions}
|
||||||
|
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{logoutMutation.isPending ? 'Revoking...' : 'Force Logout'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lock / Unlock Account */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-semibold flex items-center gap-1.5">
|
||||||
|
<Lock className="w-4 h-4 text-rose-400" />
|
||||||
|
<span>Account Lockout Control</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Account Lockout Control"
|
||||||
|
content="Enables a tenant self-service user to change a user's lockout status. The system defaults to a lockout period of 100 years when locking a user account."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
Temporarily lock out or re-enable the user from logging into this tenant. The system defaults to a lockout period of 100 years.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => lockoutMutation.mutate(false)}
|
||||||
|
disabled={lockoutMutation.isPending || !canManageSessions}
|
||||||
|
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Lock User
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => lockoutMutation.mutate(true)}
|
||||||
|
disabled={lockoutMutation.isPending || !canManageSessions}
|
||||||
|
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl font-semibold transition cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Unlock User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { jsPDF } from 'jspdf';
|
|
||||||
import autoTable from 'jspdf-autotable';
|
|
||||||
|
|
||||||
export interface ExportColumn {
|
export interface ExportColumn {
|
||||||
header: string;
|
header: string;
|
||||||
@@ -53,7 +51,7 @@ export function exportToSpreadsheet({ filename, columns, data }: ExportDataOptio
|
|||||||
/**
|
/**
|
||||||
* Draws the high-visibility official PFM vector brand insignia onto the jsPDF document.
|
* Draws the high-visibility official PFM vector brand insignia onto the jsPDF document.
|
||||||
*/
|
*/
|
||||||
function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void {
|
function drawPfmLogo(doc: any, x: number, y: number, size: number): void {
|
||||||
const scale = size / 64;
|
const scale = size / 64;
|
||||||
doc.saveGraphicsState();
|
doc.saveGraphicsState();
|
||||||
|
|
||||||
@@ -183,9 +181,14 @@ function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void {
|
|||||||
* Generates an executive-grade PDF document in LANDSCAPE mode with clear PFM logo visibility,
|
* Generates an executive-grade PDF document in LANDSCAPE mode with clear PFM logo visibility,
|
||||||
* metadata header, and structured autoTable data.
|
* metadata header, and structured autoTable data.
|
||||||
*/
|
*/
|
||||||
export function exportToPdf({ title, subtitle, filename, columns, data }: ExportDataOptions): void {
|
export async function exportToPdf({ title, subtitle, filename, columns, data }: ExportDataOptions): Promise<void> {
|
||||||
if (!data || data.length === 0) return;
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
|
import('jspdf'),
|
||||||
|
import('jspdf-autotable'),
|
||||||
|
]);
|
||||||
|
|
||||||
// Enforce Landscape orientation
|
// Enforce Landscape orientation
|
||||||
const doc = new jsPDF({
|
const doc = new jsPDF({
|
||||||
orientation: 'landscape',
|
orientation: 'landscape',
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
|
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
|
||||||
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
|
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
|
||||||
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
|
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
|
||||||
@@ -70,6 +71,7 @@ type OutcomeFormValues = z.infer<typeof outcomeSchema>;
|
|||||||
export const BeneficiariesPage: React.FC = () => {
|
export const BeneficiariesPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canApprove, canExport } = useRbac();
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
||||||
@@ -574,23 +576,26 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="beneficiaries"
|
||||||
data={sortedBens}
|
data={sortedBens}
|
||||||
columns={benExportColumns}
|
columns={benExportColumns}
|
||||||
filename={`project_beneficiaries_${new Date().toISOString().split('T')[0]}`}
|
filename={`project_beneficiaries_${new Date().toISOString().split('T')[0]}`}
|
||||||
title="Beneficiaries Register"
|
title="Beneficiaries Register"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canAdd('beneficiaries') && (
|
||||||
onClick={() => {
|
<button
|
||||||
setEditingBen(null);
|
onClick={() => {
|
||||||
benForm.reset({ name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 });
|
setEditingBen(null);
|
||||||
setIsBenModalOpen(true);
|
benForm.reset({ name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 });
|
||||||
}}
|
setIsBenModalOpen(true);
|
||||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
}}
|
||||||
>
|
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||||
<Plus className="w-3.5 h-3.5" />
|
>
|
||||||
<span>Register Beneficiary</span>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>Register Beneficiary</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
|
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
|
||||||
@@ -759,24 +764,27 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="beneficiaries"
|
||||||
data={sortedComps}
|
data={sortedComps}
|
||||||
columns={compExportColumns}
|
columns={compExportColumns}
|
||||||
filename={`compliance_requirements_${new Date().toISOString().split('T')[0]}`}
|
filename={`compliance_requirements_${new Date().toISOString().split('T')[0]}`}
|
||||||
title="Compliance Requirements"
|
title="Compliance Requirements"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canAdd('beneficiaries') && (
|
||||||
onClick={() => {
|
<button
|
||||||
setEditingComp(null);
|
onClick={() => {
|
||||||
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
|
setEditingComp(null);
|
||||||
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
|
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
|
||||||
setIsCompModalOpen(true);
|
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
|
||||||
}}
|
setIsCompModalOpen(true);
|
||||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
}}
|
||||||
>
|
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
||||||
<CheckSquare className="w-3.5 h-3.5 text-emerald-400" />
|
>
|
||||||
<span>Add Compliance Item</span>
|
<CheckSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
</button>
|
<span>Add Compliance Item</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1017,8 +1025,13 @@ export const BeneficiariesPage: React.FC = () => {
|
|||||||
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"
|
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) => (
|
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
disabled={opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries')}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
{opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries') ? ' (Requires Update Permission)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
import { ApprovalStatusBadge, PriorityBadge } from '../components/ui/StatusBadge';
|
import { ApprovalStatusBadge, PriorityBadge } from '../components/ui/StatusBadge';
|
||||||
import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types';
|
import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types';
|
||||||
import {
|
import {
|
||||||
@@ -109,6 +110,7 @@ type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
|
|||||||
export const ChangeManagementPage: React.FC = () => {
|
export const ChangeManagementPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canApprove, canExport } = useRbac();
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
||||||
@@ -722,23 +724,26 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="change_management"
|
||||||
data={sortedIssues}
|
data={sortedIssues}
|
||||||
columns={issueExportCols}
|
columns={issueExportCols}
|
||||||
filename={`project_issues_${new Date().toISOString().split('T')[0]}`}
|
filename={`project_issues_${new Date().toISOString().split('T')[0]}`}
|
||||||
title="Project Issues Register"
|
title="Project Issues Register"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canAdd('change_management') && (
|
||||||
onClick={() => {
|
<button
|
||||||
setEditingIssue(null);
|
onClick={() => {
|
||||||
issueForm.reset({ title: '', description: '', priority: Priority.Medium, assignedTo: '' });
|
setEditingIssue(null);
|
||||||
setIsIssueModalOpen(true);
|
issueForm.reset({ title: '', description: '', priority: Priority.Medium, assignedTo: '' });
|
||||||
}}
|
setIsIssueModalOpen(true);
|
||||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-amber-600 to-orange-600 text-white hover:from-amber-500 hover:to-orange-500 shadow-md shadow-amber-600/25 transition cursor-pointer"
|
}}
|
||||||
>
|
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-amber-600 to-orange-600 text-white hover:from-amber-500 hover:to-orange-500 shadow-md shadow-amber-600/25 transition cursor-pointer"
|
||||||
<Plus className="w-3.5 h-3.5" />
|
>
|
||||||
<span>Log Issue</span>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>Log Issue</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -920,28 +925,31 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="change_management"
|
||||||
data={sortedChanges}
|
data={sortedChanges}
|
||||||
columns={changeExportCols}
|
columns={changeExportCols}
|
||||||
filename={`change_requests_${new Date().toISOString().split('T')[0]}`}
|
filename={`change_requests_${new Date().toISOString().split('T')[0]}`}
|
||||||
title="Change Requests Register"
|
title="Change Requests Register"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canAdd('change_management') && (
|
||||||
onClick={() => {
|
<button
|
||||||
setEditingChange(null);
|
onClick={() => {
|
||||||
changeForm.reset({
|
setEditingChange(null);
|
||||||
title: '',
|
changeForm.reset({
|
||||||
justification: '',
|
title: '',
|
||||||
revisedBudget: 0,
|
justification: '',
|
||||||
revisionDate: new Date().toISOString().split('T')[0],
|
revisedBudget: 0,
|
||||||
});
|
revisionDate: new Date().toISOString().split('T')[0],
|
||||||
setIsChangeModalOpen(true);
|
});
|
||||||
}}
|
setIsChangeModalOpen(true);
|
||||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
}}
|
||||||
>
|
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
||||||
<GitPullRequest className="w-3.5 h-3.5 text-blue-400" />
|
>
|
||||||
<span>Request Change</span>
|
<GitPullRequest className="w-3.5 h-3.5 text-blue-400" />
|
||||||
</button>
|
<span>Request Change</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1014,18 +1022,22 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
{canApprove('change_management') && (
|
||||||
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
<button
|
||||||
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"
|
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"
|
||||||
<Check className="w-3 h-3" /> Approve
|
>
|
||||||
</button>
|
<Check className="w-3 h-3" /> Approve
|
||||||
<button
|
</button>
|
||||||
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
)}
|
||||||
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
{canAdd('change_management') && (
|
||||||
>
|
<button
|
||||||
<XCircle className="w-3 h-3" /> Reject
|
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
||||||
</button>
|
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3 h-3" /> Reject
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1510,7 +1522,9 @@ export const ChangeManagementPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||||
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
||||||
<option value={ApprovalStatus.Approved}>Approved & Enacted</option>
|
<option value={ApprovalStatus.Approved} disabled={!canApprove('change_management')}>
|
||||||
|
Approved & Enacted {!canApprove('change_management') ? '(Requires Update Permission)' : ''}
|
||||||
|
</option>
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { useToast } from '../components/ui/Toast';
|
|||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||||
import { RulesSection } from '../components/governance/RulesSection';
|
import { RulesSection } from '../components/governance/RulesSection';
|
||||||
@@ -122,6 +123,7 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
|||||||
export const GovernancePage: React.FC = () => {
|
export const GovernancePage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { sub, user, claims } = useAuth();
|
const { sub, user, claims } = useAuth();
|
||||||
|
const { canAdd, canApprove, canExport } = useRbac();
|
||||||
const currentUserId = sub || user?.id || '';
|
const currentUserId = sub || user?.id || '';
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
|
||||||
@@ -808,7 +810,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Tab Actions */}
|
{/* Tab Actions */}
|
||||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||||
{activeTab === 'organisations' && (
|
{activeTab === 'organisations' && canAdd('governance') && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateOrg}
|
onClick={openCreateOrg}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
@@ -817,7 +819,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span>Register Organisation</span>
|
<span>Register Organisation</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'portfolios' && (
|
{activeTab === 'portfolios' && canAdd('governance') && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreatePortfolio}
|
onClick={openCreatePortfolio}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
@@ -826,7 +828,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span>Create Portfolio</span>
|
<span>Create Portfolio</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'programmes' && (
|
{activeTab === 'programmes' && canAdd('governance') && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateProgramme}
|
onClick={openCreateProgramme}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
@@ -835,7 +837,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span>Create Programme</span>
|
<span>Create Programme</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(activeTab === 'instruments' || activeTab === 'instructions') && (
|
{(activeTab === 'instruments' || activeTab === 'instructions') && canAdd('governance') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsInstructionCreateTriggered(true)}
|
onClick={() => setIsInstructionCreateTriggered(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-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
@@ -844,7 +846,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span>Add Funding Instrument</span>
|
<span>Add Funding Instrument</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'rules' && (
|
{activeTab === 'rules' && canAdd('governance') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsRuleCreateTriggered(true)}
|
onClick={() => setIsRuleCreateTriggered(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-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
@@ -973,6 +975,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span>Refresh Directory</span>
|
<span>Refresh Directory</span>
|
||||||
</button>
|
</button>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="governance"
|
||||||
title="Organisation Master Registry"
|
title="Organisation Master Registry"
|
||||||
subtitle="Official directory of verified recipient and funder entities"
|
subtitle="Official directory of verified recipient and funder entities"
|
||||||
filename={`pfm-organisations-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-organisations-${new Date().toISOString().split('T')[0]}`}
|
||||||
@@ -1108,6 +1111,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span>Refresh Portfolios</span>
|
<span>Refresh Portfolios</span>
|
||||||
</button>
|
</button>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="governance"
|
||||||
title="Funder Portfolios Registry"
|
title="Funder Portfolios Registry"
|
||||||
subtitle="High-level funding groupings and thematic capital allocations"
|
subtitle="High-level funding groupings and thematic capital allocations"
|
||||||
filename={`pfm-portfolios-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-portfolios-${new Date().toISOString().split('T')[0]}`}
|
||||||
@@ -1239,6 +1243,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span>Refresh Programmes</span>
|
<span>Refresh Programmes</span>
|
||||||
</button>
|
</button>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="governance"
|
||||||
title="Strategic Programmes Hierarchy"
|
title="Strategic Programmes Hierarchy"
|
||||||
subtitle="Operational funding vehicles linked to funder portfolios"
|
subtitle="Operational funding vehicles linked to funder portfolios"
|
||||||
filename={`pfm-programmes-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-programmes-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { useToast } from '../components/ui/Toast';
|
|||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
import {
|
import {
|
||||||
Award,
|
Award,
|
||||||
Budget,
|
Budget,
|
||||||
@@ -98,6 +99,7 @@ type DisbursementFormValues = z.infer<typeof disbursementSchema>;
|
|||||||
export const GrantsPage: React.FC = () => {
|
export const GrantsPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canApprove, canExport } = useRbac();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
|
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
|
||||||
|
|
||||||
@@ -666,7 +668,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center">
|
<div className="flex items-center gap-2 relative z-10 self-start md:self-center">
|
||||||
{activeTab === 'awards' && (
|
{activeTab === 'awards' && canAdd('grant_financials') && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateAward}
|
onClick={openCreateAward}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||||
@@ -675,7 +677,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span>Register Approved Award</span>
|
<span>Register Approved Award</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'budgets' && (
|
{activeTab === 'budgets' && canAdd('grant_financials') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsBudgetModalOpen(true)}
|
onClick={() => setIsBudgetModalOpen(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-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||||
@@ -684,7 +686,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span>Add Budget Line</span>
|
<span>Add Budget Line</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'disbursements' && (
|
{activeTab === 'disbursements' && canAdd('grant_financials') && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateDisbursement}
|
onClick={openCreateDisbursement}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||||
@@ -802,6 +804,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span>Refresh Awards</span>
|
<span>Refresh Awards</span>
|
||||||
</button>
|
</button>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="grant_financials"
|
||||||
title="Grant Awards Registry"
|
title="Grant Awards Registry"
|
||||||
subtitle="Official directory of approved beneficiary funding allocations"
|
subtitle="Official directory of approved beneficiary funding allocations"
|
||||||
filename={`pfm-awards-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-awards-${new Date().toISOString().split('T')[0]}`}
|
||||||
@@ -915,6 +918,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span>Refresh Budgets</span>
|
<span>Refresh Budgets</span>
|
||||||
</button>
|
</button>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="grant_financials"
|
||||||
title="Project Budgets Registry"
|
title="Project Budgets Registry"
|
||||||
subtitle="Detailed statutory project budget allocations and category lines"
|
subtitle="Detailed statutory project budget allocations and category lines"
|
||||||
filename={`pfm-budgets-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-budgets-${new Date().toISOString().split('T')[0]}`}
|
||||||
@@ -998,7 +1002,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
<span>Update</span>
|
<span>Update</span>
|
||||||
</button>
|
</button>
|
||||||
{b.status !== ApprovalStatus.Approved && (
|
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={updateBudgetMutation.isPending}
|
disabled={updateBudgetMutation.isPending}
|
||||||
@@ -1057,6 +1061,7 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<span>Refresh Disbursements</span>
|
<span>Refresh Disbursements</span>
|
||||||
</button>
|
</button>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="grant_financials"
|
||||||
title="Disbursement Tranches Ledger"
|
title="Disbursement Tranches Ledger"
|
||||||
subtitle="Milestone-linked funding releases and payment authorizations"
|
subtitle="Milestone-linked funding releases and payment authorizations"
|
||||||
filename={`pfm-disbursements-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-disbursements-${new Date().toISOString().split('T')[0]}`}
|
||||||
@@ -1154,14 +1159,16 @@ export const GrantsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{canApprove('grant_financials') && (
|
||||||
onClick={() => approveMutation.mutate(d.id)}
|
<button
|
||||||
disabled={approveMutation.isPending}
|
onClick={() => approveMutation.mutate(d.id)}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 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-lg shadow-md shadow-blue-500/20 transition cursor-pointer"
|
disabled={approveMutation.isPending}
|
||||||
>
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 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-lg shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
>
|
||||||
Approve Payment
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
</button>
|
Approve Payment
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1555,7 +1562,9 @@ export const GrantsPage: React.FC = () => {
|
|||||||
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
|
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"
|
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}>Approved</option>
|
<option value={ApprovalStatus.Approved} disabled={!canApprove('grant_financials')}>
|
||||||
|
Approved {!canApprove('grant_financials') ? '(Requires Update Permission)' : ''}
|
||||||
|
</option>
|
||||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||||
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useAuth } from '../auth/AuthProvider';
|
|||||||
import { BrandLogo } from '../components/ui/BrandLogo';
|
import { BrandLogo } from '../components/ui/BrandLogo';
|
||||||
|
|
||||||
export const LandingPage: React.FC = () => {
|
export const LandingPage: React.FC = () => {
|
||||||
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation } = useAuth();
|
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation, roles } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleEnterConsole = async () => {
|
const handleEnterConsole = async () => {
|
||||||
@@ -192,6 +192,13 @@ export const LandingPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-slate-800/80 text-[11px] flex items-center justify-between text-slate-400">
|
||||||
|
<span>Assigned Role:</span>
|
||||||
|
<span className="font-semibold uppercase tracking-wider text-blue-400 bg-blue-500/10 border border-blue-500/25 px-1.5 py-0.5 rounded text-[10px] truncate max-w-[200px]">
|
||||||
|
{user?.role || (roles.length > 0 ? roles[0] : (claims.role as string) || 'User')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guidance Message */}
|
{/* Guidance Message */}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import {
|
import {
|
||||||
Project,
|
Project,
|
||||||
@@ -149,6 +150,7 @@ export const evaluateProjectScheduleRisk = (
|
|||||||
export const ProjectsPage: React.FC = () => {
|
export const ProjectsPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canExport } = useRbac();
|
||||||
|
|
||||||
// Filter & Search States
|
// Filter & Search States
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -477,13 +479,15 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
|
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
|
||||||
<button
|
{canAdd('projects_milestones') && (
|
||||||
onClick={handleOpenCreateProject}
|
<button
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
onClick={handleOpenCreateProject}
|
||||||
>
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
|
||||||
<Plus className="w-4 h-4" />
|
>
|
||||||
<span>Create Project</span>
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
<span>Create Project</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -628,6 +632,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="projects_milestones"
|
||||||
title="Projects & Milestones Register"
|
title="Projects & Milestones Register"
|
||||||
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
|
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
|
||||||
filename="projects-milestones-register"
|
filename="projects-milestones-register"
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import {
|
|||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { CreateUserModal } from '../components/users/CreateUserModal';
|
||||||
|
import { UserSecurityModal } from '../components/users/UserSecurityModal';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
import {
|
import {
|
||||||
getUsers,
|
getUsers,
|
||||||
getUser,
|
getUser,
|
||||||
@@ -97,6 +100,23 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
||||||
|
|
||||||
|
const { canView, canAdd, canExport } = useRbac();
|
||||||
|
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
||||||
|
const canViewInvites = canView('settings_invites');
|
||||||
|
const canViewNotifications = canView('settings_notifications');
|
||||||
|
const canViewAudit = canView('settings_auditlog');
|
||||||
|
|
||||||
|
const canAddUser = canAdd('settings_users') || canAdd('tenant_self_service');
|
||||||
|
const canAddInvite = canAdd('settings_invites');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (activeTab === 'users' && !canViewUsers) {
|
||||||
|
if (canViewInvites) setActiveTab('invites');
|
||||||
|
else if (canViewNotifications) setActiveTab('notifications');
|
||||||
|
else if (canViewAudit) setActiveTab('audit');
|
||||||
|
}
|
||||||
|
}, [activeTab, canViewUsers, canViewInvites, canViewNotifications, canViewAudit]);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. User Directory State
|
// 1. User Directory State
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -105,6 +125,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [userRoleFilter, setUserRoleFilter] = useState<string>('all');
|
const [userRoleFilter, setUserRoleFilter] = useState<string>('all');
|
||||||
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
|
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
|
||||||
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
|
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
|
||||||
|
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
|
||||||
|
|
||||||
const userFilter: UserFilterRequest = {
|
const userFilter: UserFilterRequest = {
|
||||||
email: userEmailSearch.trim() || undefined,
|
email: userEmailSearch.trim() || undefined,
|
||||||
@@ -572,77 +593,85 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
||||||
<button
|
{canViewUsers && (
|
||||||
onClick={() => setActiveTab('users')}
|
<button
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
onClick={() => setActiveTab('users')}
|
||||||
activeTab === 'users'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
activeTab === 'users'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
}`}
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
>
|
}`}
|
||||||
<Users className="w-4 h-4" />
|
>
|
||||||
<span>User Directory ({usersList.length})</span>
|
<Users className="w-4 h-4" />
|
||||||
<HelpTooltip
|
<span>User Directory ({usersList.length})</span>
|
||||||
title="User Directory"
|
<HelpTooltip
|
||||||
content="Authorized system users, role memberships, and account lifecycle states."
|
title="User Directory"
|
||||||
size="sm"
|
content="Authorized system users, role memberships, and account lifecycle states."
|
||||||
placement="top"
|
size="sm"
|
||||||
/>
|
placement="top"
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{canViewInvites && (
|
||||||
onClick={() => setActiveTab('invites')}
|
<button
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
onClick={() => setActiveTab('invites')}
|
||||||
activeTab === 'invites'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
activeTab === 'invites'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
}`}
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
>
|
}`}
|
||||||
<Mail className="w-4 h-4" />
|
>
|
||||||
<span>Communications & Invites ({invitesList.length})</span>
|
<Mail className="w-4 h-4" />
|
||||||
<HelpTooltip
|
<span>Communications & Invites ({invitesList.length})</span>
|
||||||
title="Communications Dispatch"
|
<HelpTooltip
|
||||||
content="Statutory onboarding invitations and applicant notification ledger."
|
title="Communications Dispatch"
|
||||||
size="sm"
|
content="Statutory onboarding invitations and applicant notification ledger."
|
||||||
placement="top"
|
size="sm"
|
||||||
/>
|
placement="top"
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{canViewNotifications && (
|
||||||
onClick={() => setActiveTab('notifications')}
|
<button
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
onClick={() => setActiveTab('notifications')}
|
||||||
activeTab === 'notifications'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
activeTab === 'notifications'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
}`}
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
>
|
}`}
|
||||||
<Bell className="w-4 h-4" />
|
>
|
||||||
<span>Gateways & Notifications ({fetchedNotifications.length})</span>
|
<Bell className="w-4 h-4" />
|
||||||
<HelpTooltip
|
<span>Gateways & Notifications ({fetchedNotifications.length})</span>
|
||||||
title="Notification Gateways"
|
<HelpTooltip
|
||||||
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
|
title="Notification Gateways"
|
||||||
size="sm"
|
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
|
||||||
placement="top"
|
size="sm"
|
||||||
/>
|
placement="top"
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{canViewAudit && (
|
||||||
onClick={() => setActiveTab('audit')}
|
<button
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
onClick={() => setActiveTab('audit')}
|
||||||
activeTab === 'audit'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
activeTab === 'audit'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
}`}
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
>
|
}`}
|
||||||
<History className="w-4 h-4" />
|
>
|
||||||
<span>Audit & Telemetry Ledger ({auditLogs.length})</span>
|
<History className="w-4 h-4" />
|
||||||
<HelpTooltip
|
<span>Audit & Telemetry Ledger ({auditLogs.length})</span>
|
||||||
title="Audit Trail"
|
<HelpTooltip
|
||||||
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
|
title="Audit Trail"
|
||||||
size="sm"
|
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
|
||||||
placement="top"
|
size="sm"
|
||||||
/>
|
placement="top"
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ============================================================== */}
|
{/* ============================================================== */}
|
||||||
@@ -725,12 +754,25 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<span>Refresh</span>
|
<span>Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{canAddUser && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateUserModalOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||||
|
title="Provision a new tenant user with Hashids Hex ID and role assignment"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Provision User</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
title="User Directory & Access Register"
|
title="User Directory & Access Register"
|
||||||
subtitle="Canonical directory of authorized users and account states"
|
subtitle="Canonical directory of authorized users and account states"
|
||||||
filename={`pfm-users-directory-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-users-directory-${new Date().toISOString().split('T')[0]}`}
|
||||||
columns={userExportColumns}
|
columns={userExportColumns}
|
||||||
data={sortedUsers}
|
data={sortedUsers}
|
||||||
|
claim="settings_users"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -830,10 +872,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setSelectedUserForDetail(u);
|
setSelectedUserForDetail(u);
|
||||||
setIsUserDetailModalOpen(true);
|
setIsUserDetailModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 rounded-lg transition cursor-pointer"
|
||||||
|
title="Manage User Roles, Claims, Status, and Security Sessions"
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3 text-blue-400" />
|
<Shield className="w-3 h-3 text-blue-400" />
|
||||||
Inspect
|
Manage Access
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Status switch actions */}
|
{/* Status switch actions */}
|
||||||
@@ -948,15 +991,18 @@ export const SettingsPage: React.FC = () => {
|
|||||||
filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`}
|
||||||
columns={inviteExportColumns}
|
columns={inviteExportColumns}
|
||||||
data={sortedInvites}
|
data={sortedInvites}
|
||||||
|
claim="settings_invites"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canAddInvite && (
|
||||||
onClick={() => setIsSendInviteModalOpen(true)}
|
<button
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
onClick={() => setIsSendInviteModalOpen(true)}
|
||||||
>
|
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||||
<Send className="w-3.5 h-3.5" />
|
>
|
||||||
<span>Dispatch Invitation</span>
|
<Send className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>Dispatch Invitation</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1227,6 +1273,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`}
|
||||||
columns={notificationExportColumns}
|
columns={notificationExportColumns}
|
||||||
data={filteredNotifications}
|
data={filteredNotifications}
|
||||||
|
claim="settings_notifications"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1493,6 +1540,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
|
||||||
columns={auditExportColumns}
|
columns={auditExportColumns}
|
||||||
data={filteredAuditLogs}
|
data={filteredAuditLogs}
|
||||||
|
claim="settings_auditlog"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2092,73 +2140,24 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* ============================================================== */}
|
{/* ============================================================== */}
|
||||||
{/* Modal: User Inspection */}
|
{/* Modal: User Administration & Role/Claim/Session Control */}
|
||||||
{/* ============================================================== */}
|
{/* ============================================================== */}
|
||||||
<Modal
|
<UserSecurityModal
|
||||||
isOpen={isUserDetailModalOpen}
|
isOpen={isUserDetailModalOpen}
|
||||||
onClose={() => setIsUserDetailModalOpen(false)}
|
onClose={() => {
|
||||||
title="User Access & Security Details"
|
setIsUserDetailModalOpen(false);
|
||||||
>
|
setSelectedUserForDetail(null);
|
||||||
{selectedUserForDetail && (
|
}}
|
||||||
<div className="space-y-4 text-xs">
|
user={selectedUserForDetail}
|
||||||
{loadingUserFreshDetail ? (
|
/>
|
||||||
<div className="p-6 text-center text-slate-400">
|
|
||||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
|
|
||||||
Fetching user profile details...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
|
|
||||||
<span className="text-white font-semibold text-xs">
|
|
||||||
{userFreshDetail?.email || selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
|
|
||||||
<span className="font-mono text-slate-300 text-xs break-all">
|
|
||||||
{userFreshDetail?.id || selectedUserForDetail.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Id Number:</span>
|
|
||||||
<span className="font-mono text-slate-200 text-xs">
|
|
||||||
{userFreshDetail?.idNumber || selectedUserForDetail.idNumber || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">System Role:</span>
|
|
||||||
<UserRoleBadge role={userFreshDetail?.role ?? selectedUserForDetail.role} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between pt-1">
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
|
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).className}`}>
|
|
||||||
{getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
|
|
||||||
<span className="font-mono text-slate-300">
|
|
||||||
{(userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt) ? formatDate((userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt)!) : 'Never'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pt-2 flex justify-end gap-2">
|
{/* ============================================================== */}
|
||||||
<button
|
{/* Modal: Provision Tenant User */}
|
||||||
type="button"
|
{/* ============================================================== */}
|
||||||
onClick={() => setIsUserDetailModalOpen(false)}
|
<CreateUserModal
|
||||||
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition"
|
isOpen={isCreateUserModalOpen}
|
||||||
>
|
onClose={() => setIsCreateUserModalOpen(false)}
|
||||||
Close
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* ============================================================== */}
|
{/* ============================================================== */}
|
||||||
{/* Modal: Notification Payload & Data Inspection */}
|
{/* Modal: Notification Payload & Data Inspection */}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|||||||
import { ExportButton } from '../components/ui/ExportButton';
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
|
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
|
||||||
import { SiteVisit, ApprovalStatus } from '../types';
|
import { SiteVisit, ApprovalStatus } from '../types';
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,7 @@ type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
|
|||||||
export const SiteVisitsPage: React.FC = () => {
|
export const SiteVisitsPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const { canAdd, canApprove, canExport } = useRbac();
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -349,28 +351,31 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
|
claim="site_visits"
|
||||||
data={sortedVisits}
|
data={sortedVisits}
|
||||||
columns={visitExportCols}
|
columns={visitExportCols}
|
||||||
filename={`site_visits_${new Date().toISOString().split('T')[0]}`}
|
filename={`site_visits_${new Date().toISOString().split('T')[0]}`}
|
||||||
title="Site Visits Register"
|
title="Site Visits Register"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canAdd('site_visits') && (
|
||||||
onClick={() => {
|
<button
|
||||||
setEditingVisit(null);
|
onClick={() => {
|
||||||
visitForm.reset({
|
setEditingVisit(null);
|
||||||
inspectorName: '',
|
visitForm.reset({
|
||||||
visitedAt: new Date().toISOString().split('T')[0],
|
inspectorName: '',
|
||||||
findings: '',
|
visitedAt: new Date().toISOString().split('T')[0],
|
||||||
status: ApprovalStatus.Pending,
|
findings: '',
|
||||||
});
|
status: ApprovalStatus.Pending,
|
||||||
setIsVisitModalOpen(true);
|
});
|
||||||
}}
|
setIsVisitModalOpen(true);
|
||||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-md shadow-emerald-600/25 transition cursor-pointer"
|
}}
|
||||||
>
|
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-md shadow-emerald-600/25 transition cursor-pointer"
|
||||||
<Plus className="w-3.5 h-3.5" />
|
>
|
||||||
<span>Log Site Visit</span>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>Log Site Visit</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -505,8 +510,13 @@ export const SiteVisitsPage: React.FC = () => {
|
|||||||
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"
|
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) => (
|
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
disabled={opt.value === ApprovalStatus.Approved && !canApprove('site_visits')}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
{opt.value === ApprovalStatus.Approved && !canApprove('site_visits') ? ' (Requires Update Permission)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ import { useTableSort, SortableTh } from '../utils/tableSort';
|
|||||||
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
|
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
|
||||||
import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
|
import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
|
||||||
import { formatDate, getOrganisationTypeLabel, getUserEmail, registerUserEmails } from '../lib/formatters';
|
import { formatDate, getOrganisationTypeLabel, getUserEmail, registerUserEmails } from '../lib/formatters';
|
||||||
|
import { CreateUserModal } from '../components/users/CreateUserModal';
|
||||||
|
import { UserSecurityModal } from '../components/users/UserSecurityModal';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
|
import { useRbac } from '../auth/rbac';
|
||||||
|
|
||||||
const createTenantSchema = z.object({
|
const createTenantSchema = z.object({
|
||||||
name: z.string().min(2, 'Tenant name must be at least 2 characters').max(100, 'Tenant name too long'),
|
name: z.string().min(2, 'Tenant name must be at least 2 characters').max(100, 'Tenant name too long'),
|
||||||
@@ -265,10 +268,177 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TenantUsersSubList: React.FC<{
|
||||||
|
tenantId: number | string;
|
||||||
|
tenantName?: string | null;
|
||||||
|
onProvisionUser: (tenantId: number | string) => void;
|
||||||
|
onManageUser: (user: User) => void;
|
||||||
|
}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => {
|
||||||
|
const { data: usersDirectory = [], isLoading, refetch } = useQuery<User[]>({
|
||||||
|
queryKey: ['tenant-users', tenantId],
|
||||||
|
queryFn: () => getUsers(1, 200),
|
||||||
|
staleTime: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantUsers = useMemo(() => {
|
||||||
|
return usersDirectory.filter((u) => {
|
||||||
|
if (u.tenant_id !== undefined && u.tenant_id !== null) {
|
||||||
|
return String(u.tenant_id) === String(tenantId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [usersDirectory, tenantId]);
|
||||||
|
|
||||||
|
const { canAdd } = useRbac();
|
||||||
|
const canProvision = canAdd('tenant_management') || canAdd('tenant_self_service') || canAdd('settings_users');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-4 px-6 border-l-4 border-indigo-500 bg-linear-to-r from-indigo-950/20 via-slate-900/40 to-slate-950/60 space-y-3">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
|
||||||
|
<UserIcon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-xs font-bold text-white tracking-tight">
|
||||||
|
Authorized Users for {tenantName || `Tenant #${tenantId}`}
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] font-semibold text-indigo-300 bg-indigo-500/20 px-2 py-0.5 rounded-full border border-indigo-500/30">
|
||||||
|
{tenantUsers.length} Users
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Users provisioned under Tenant #{tenantId} partition boundary. Manage roles, claims, and session security without DB access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="p-1 text-slate-400 hover:text-white rounded hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Refresh user directory"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin text-indigo-400' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{canProvision && (
|
||||||
|
<button
|
||||||
|
onClick={() => onProvisionUser(tenantId)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Provision User for Tenant</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-slate-800/80 bg-slate-900/40">
|
||||||
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-900/80 border-b border-slate-800/80 text-slate-400 text-[11px] uppercase tracking-wider font-semibold">
|
||||||
|
<th className="py-2 px-3">Email Address</th>
|
||||||
|
<th className="py-2 px-3">User UUID</th>
|
||||||
|
<th className="py-2 px-3">Id Number (Hex)</th>
|
||||||
|
<th className="py-2 px-3">Role</th>
|
||||||
|
<th className="py-2 px-3">Status</th>
|
||||||
|
<th className="py-2 px-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
|
{isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-4 text-center text-slate-500">Loading tenant users...</td>
|
||||||
|
</tr>
|
||||||
|
) : tenantUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-4 text-center text-slate-500">No users provisioned under Tenant #{tenantId}.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
tenantUsers.map((u) => (
|
||||||
|
<tr key={u.id} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="py-2 px-3 text-white font-medium">{u.email || '—'}</td>
|
||||||
|
<td className="py-2 px-3 font-mono text-slate-400 text-[11px]">{u.id}</td>
|
||||||
|
<td className="py-2 px-3 font-mono text-slate-300 text-[11px]">{u.idNumber || '—'}</td>
|
||||||
|
<td className="py-2 px-3 text-slate-300">{u.role || 'Standard User'}</td>
|
||||||
|
<td className="py-2 px-3"><ActivityStatusBadge status={u.status} /></td>
|
||||||
|
<td className="py-2 px-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onManageUser(u)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 rounded-lg transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-3 h-3 text-blue-400" />
|
||||||
|
Manage Access & Roles
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TenantExpandedPanel: React.FC<{
|
||||||
|
tenantId: number | string;
|
||||||
|
tenantName?: string | null;
|
||||||
|
onProvisionUser: (tenantId: number | string) => void;
|
||||||
|
onManageUser: (user: User) => void;
|
||||||
|
}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'orgs' | 'users'>('orgs');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-950/80 border-b border-slate-800">
|
||||||
|
<div className="px-6 pt-3 flex gap-2 border-b border-slate-800/80">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('orgs')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-semibold rounded-t-lg transition flex items-center gap-1.5 cursor-pointer ${
|
||||||
|
activeTab === 'orgs'
|
||||||
|
? 'bg-blue-600/20 text-blue-400 border-b-2 border-blue-500'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Building2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Scoped Organisations</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-semibold rounded-t-lg transition flex items-center gap-1.5 cursor-pointer ${
|
||||||
|
activeTab === 'users'
|
||||||
|
? 'bg-indigo-600/20 text-indigo-400 border-b-2 border-indigo-500'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>Tenant Users & Administration</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'orgs' ? (
|
||||||
|
<TenantOrganisationsSubList tenantId={tenantId} tenantName={tenantName} />
|
||||||
|
) : (
|
||||||
|
<TenantUsersSubList
|
||||||
|
tenantId={tenantId}
|
||||||
|
tenantName={tenantName}
|
||||||
|
onProvisionUser={onProvisionUser}
|
||||||
|
onManageUser={onManageUser}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TenantManagementPage: React.FC = () => {
|
export const TenantManagementPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
const { tenantId: currentTenantId, tenantName: currentTenantName } = useAuth();
|
const { tenantId: currentTenantId, tenantName: currentTenantName, canManageTenants } = useAuth();
|
||||||
|
const { canAdd, isUnrestricted, canApprove } = useRbac();
|
||||||
|
const canCreateTenant = canManageTenants || (canAdd('tenant_management') && isUnrestricted);
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
@@ -279,6 +449,10 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
|
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
|
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
|
||||||
|
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
|
||||||
|
const [createUserTenantId, setCreateUserTenantId] = useState<number | string | null>(null);
|
||||||
|
const [securityUser, setSecurityUser] = useState<User | null>(null);
|
||||||
|
const [isSecurityModalOpen, setIsSecurityModalOpen] = useState(false);
|
||||||
|
|
||||||
// Query Tenants
|
// Query Tenants
|
||||||
const {
|
const {
|
||||||
@@ -460,15 +634,18 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
data={sortedData}
|
data={sortedData}
|
||||||
columns={exportColumns}
|
columns={exportColumns}
|
||||||
label="Export Tenants"
|
label="Export Tenants"
|
||||||
|
claim="tenant_management"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{canCreateTenant && (
|
||||||
onClick={handleOpenCreate}
|
<button
|
||||||
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"
|
onClick={handleOpenCreate}
|
||||||
>
|
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" />
|
>
|
||||||
<span>New Tenant</span>
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
<span>New Tenant</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -740,11 +917,22 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Expanded Sub-List of Organisations using getTenantOrganisations */}
|
{/* Expanded Sub-List of Organisations and Users */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr className="bg-slate-950/70 border-b border-slate-800/80">
|
<tr className="bg-slate-950/70 border-b border-slate-800/80">
|
||||||
<td colSpan={7} className="p-0">
|
<td colSpan={7} className="p-0">
|
||||||
<TenantOrganisationsSubList tenantId={tenant.id} tenantName={tenant.name} />
|
<TenantExpandedPanel
|
||||||
|
tenantId={tenant.id}
|
||||||
|
tenantName={tenant.name}
|
||||||
|
onProvisionUser={(tId) => {
|
||||||
|
setCreateUserTenantId(tId);
|
||||||
|
setIsCreateUserModalOpen(true);
|
||||||
|
}}
|
||||||
|
onManageUser={(u) => {
|
||||||
|
setSecurityUser(u);
|
||||||
|
setIsSecurityModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -987,6 +1175,26 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Provision User Modal */}
|
||||||
|
<CreateUserModal
|
||||||
|
isOpen={isCreateUserModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreateUserModalOpen(false);
|
||||||
|
setCreateUserTenantId(null);
|
||||||
|
}}
|
||||||
|
defaultTenantId={createUserTenantId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User Security & Administration Modal */}
|
||||||
|
<UserSecurityModal
|
||||||
|
isOpen={isSecurityModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsSecurityModalOpen(false);
|
||||||
|
setSecurityUser(null);
|
||||||
|
}}
|
||||||
|
user={securityUser}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,8 +92,18 @@ import {
|
|||||||
Notification,
|
Notification,
|
||||||
NotificationPlatform,
|
NotificationPlatform,
|
||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
|
Role,
|
||||||
|
Claim,
|
||||||
|
CreateUserRequest,
|
||||||
|
AddUserClaimRequest,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
encodeIdNumberHex,
|
||||||
|
encodeEntityId,
|
||||||
|
toSafeInt64String,
|
||||||
|
} from '../utils/hashids';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parseFriendlyError,
|
parseFriendlyError,
|
||||||
FriendlyError,
|
FriendlyError,
|
||||||
@@ -1364,6 +1374,90 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus |
|
|||||||
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
|
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createUser = async (
|
||||||
|
tenantId: number | string,
|
||||||
|
idNumber: string,
|
||||||
|
payload: CreateUserRequest
|
||||||
|
): Promise<void> => {
|
||||||
|
const encodedTenant = encodeEntityId(tenantId) || String(tenantId);
|
||||||
|
const encodedIdNumber = encodeIdNumberHex(idNumber);
|
||||||
|
await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, {
|
||||||
|
id: payload.id || null,
|
||||||
|
userName: payload.userName || null,
|
||||||
|
email: payload.email || null,
|
||||||
|
emailConfirmed: payload.emailConfirmed ?? true,
|
||||||
|
phoneNumber: payload.phoneNumber || null,
|
||||||
|
phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false,
|
||||||
|
LockoutEnabled: payload.LockoutEnabled ?? false,
|
||||||
|
twoFactorEnabled: payload.twoFactorEnabled ?? false,
|
||||||
|
accessFailedCount: payload.accessFailedCount !== undefined ? Number(payload.accessFailedCount) : 0,
|
||||||
|
lockoutEnd: payload.lockoutEnd || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchAndCacheSystemRoles,
|
||||||
|
fetchAndCacheRoleClaims,
|
||||||
|
getSystemClaims,
|
||||||
|
getCachedSystemRoles,
|
||||||
|
getCachedRoleClaims,
|
||||||
|
preloadAndCacheAllRoleClaims,
|
||||||
|
} from './roleClaimsCache';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getSystemClaims,
|
||||||
|
getCachedSystemRoles,
|
||||||
|
getCachedRoleClaims,
|
||||||
|
preloadAndCacheAllRoleClaims,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemRoles = async (): Promise<Role[]> => {
|
||||||
|
return await fetchAndCacheSystemRoles();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserRoles = async (userId: string): Promise<Role[]> => {
|
||||||
|
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignUserRole = async (userId: string, roleId: string): Promise<void> => {
|
||||||
|
await apiClient.patch(`/v1/api/users/${userId}/roles/${roleId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeUserRole = async (userId: string, roleId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/v1/api/users/${userId}/roles/${roleId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRoleClaims = async (roleId: string, roleName?: string): Promise<Claim[]> => {
|
||||||
|
return await fetchAndCacheRoleClaims(roleId, roleName);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserClaims = async (userId: string): Promise<Claim[]> => {
|
||||||
|
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addUserClaim = async (payload: AddUserClaimRequest): Promise<void> => {
|
||||||
|
await apiClient.patch('/v1/api/users/user/claims', {
|
||||||
|
userId: payload.userId,
|
||||||
|
claimId: Number(payload.claimId),
|
||||||
|
claimType: payload.claimType,
|
||||||
|
claimValue: payload.claimValue,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserClaim = async (targetUserId: string, claimId: string | number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/v1/api/users/${targetUserId}/claims/${claimId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forceLogoutUser = async (userId: string): Promise<void> => {
|
||||||
|
await apiClient.put(`/v1/api/users/${userId}/session/logout`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setUserLockout = async (userId: string, disable: boolean): Promise<void> => {
|
||||||
|
await apiClient.put(`/v1/api/users/${userId}/session/lockout/${disable}`);
|
||||||
|
};
|
||||||
|
|
||||||
import {
|
import {
|
||||||
syncUserAccount,
|
syncUserAccount,
|
||||||
ensureUserSynced,
|
ensureUserSynced,
|
||||||
@@ -1421,29 +1515,21 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
|||||||
const tenantId = getCurrentUserTenantId();
|
const tenantId = getCurrentUserTenantId();
|
||||||
const hostOrgId =
|
const hostOrgId =
|
||||||
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
||||||
? toIntegerId(payload.organisationId)
|
? toSafeInt64String(payload.organisationId)
|
||||||
: (tenantId !== null ? tenantId : 0);
|
: (tenantId !== null ? String(tenantId) : '0');
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
organisationId: hostOrgId,
|
organisationId: hostOrgId,
|
||||||
invitedOrganisationId: toIntegerId(payload.invitedOrganisationId),
|
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
||||||
awardId: toIntegerId(payload.awardId),
|
awardId: toSafeInt64String(payload.awardId),
|
||||||
contractId: toIntegerId(payload.contractId),
|
contractId: toSafeInt64String(payload.contractId),
|
||||||
recipient: payload.recipient,
|
recipient: payload.recipient,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const res = await apiClient.post<Invite>('/v1/api/communications/invites', body);
|
||||||
const res = await apiClient.post<Invite>('/v1/api/communications/invites', body);
|
return res.data;
|
||||||
return res.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.response?.status === 404) {
|
|
||||||
const res = await apiClient.post<Invite>('/v1/api/communications/invite', body);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> => {
|
export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> => {
|
||||||
@@ -1451,6 +1537,11 @@ export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> =>
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getApplicantData = async (applicantUserId: string): Promise<Invite> => {
|
||||||
|
const res = await apiClient.get<Invite>(`/v1/api/communications/applicants/${applicantUserId}/all`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const getApplicantInvite = async (applicantUserId: string): Promise<Invite> => {
|
export const getApplicantInvite = async (applicantUserId: string): Promise<Invite> => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicants/${applicantUserId}`);
|
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicants/${applicantUserId}`);
|
||||||
@@ -1481,26 +1572,13 @@ export const getNotifications = async (organisationId?: string | number): Promis
|
|||||||
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<Notification[]>(
|
const res = await apiClient.get<Notification[]>(
|
||||||
`/v1/api/communications/norifications/${encodeURIComponent(hashedOrgId)}`
|
`/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}`
|
||||||
);
|
);
|
||||||
if (res.data && Array.isArray(res.data)) {
|
if (res.data && Array.isArray(res.data)) {
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.response?.status === 404) {
|
console.warn('[getNotifications] API error fetching notifications:', err);
|
||||||
try {
|
|
||||||
const fallbackRes = await apiClient.get<Notification[]>(
|
|
||||||
`/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}`
|
|
||||||
);
|
|
||||||
if (fallbackRes.data && Array.isArray(fallbackRes.data)) {
|
|
||||||
return fallbackRes.data;
|
|
||||||
}
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
console.warn('[getNotifications] Notification fallback route error:', fallbackErr);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[getNotifications] API error fetching notifications:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { apiClient } from './httpClient';
|
||||||
|
import { Role, Claim } from '../types';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// In-memory runtime caches
|
||||||
|
let inMemorySystemRoles: Role[] | null = null;
|
||||||
|
const inMemoryRoleClaims = new Map<string, Claim[]>();
|
||||||
|
let inMemorySystemClaims: Claim[] | null = null;
|
||||||
|
|
||||||
|
// 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' },
|
||||||
|
],
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
manager: [
|
||||||
|
{ claimId: 10, claimType: 'dashboard', claimValue: 'all' },
|
||||||
|
{ claimId: 11, claimType: 'projects_milestones', claimValue: 'all' },
|
||||||
|
{ claimId: 12, claimType: 'grant_financials', claimValue: 'all' },
|
||||||
|
{ claimId: 13, claimType: 'evidence_risks', claimValue: 'all' },
|
||||||
|
{ claimId: 14, claimType: 'beneficiaries', claimValue: 'all' },
|
||||||
|
{ claimId: 15, claimType: 'change_management', claimValue: 'all' },
|
||||||
|
{ claimId: 16, claimType: 'site_visits', claimValue: 'all' },
|
||||||
|
{ claimId: 17, claimType: 'settings_invites', claimValue: 'all' },
|
||||||
|
{ claimId: 18, claimType: 'settings_users', claimValue: 'all' },
|
||||||
|
{ claimId: 19, claimType: 'settings_auditlog', claimValue: 'all' },
|
||||||
|
{ claimId: 29, claimType: 'settings_notifications', claimValue: 'all' },
|
||||||
|
{ claimId: 37, claimType: 'tenant_self_service', claimValue: 'all' },
|
||||||
|
],
|
||||||
|
executive: [
|
||||||
|
{ claimId: 1, claimType: 'dashboard', claimValue: 'all' },
|
||||||
|
{ claimId: 2, claimType: 'governance', claimValue: 'all' },
|
||||||
|
{ claimId: 3, claimType: 'projects_milestones', claimValue: 'all' },
|
||||||
|
{ claimId: 4, claimType: 'grant_financials', claimValue: 'all' },
|
||||||
|
{ claimId: 5, claimType: 'evidence_risks', claimValue: 'all' },
|
||||||
|
{ claimId: 6, claimType: 'tenant_self_service', claimValue: 'all' },
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
{ claimId: 99, claimType: 'unrestricted', claimValue: 'all' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes claim objects to contain both claimType/claimValue and type/value
|
||||||
|
*/
|
||||||
|
export function normalizeClaim(c: any): Claim {
|
||||||
|
const claimType = c.claimType || c.type || c.name || '';
|
||||||
|
const claimValue = c.claimValue || c.value || 'all';
|
||||||
|
const claimId = c.claimId ?? c.id ?? undefined;
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
type: claimType,
|
||||||
|
value: claimValue,
|
||||||
|
claimType,
|
||||||
|
claimValue,
|
||||||
|
claimId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve cached system roles from memory or localStorage.
|
||||||
|
*/
|
||||||
|
export function getCachedSystemRoles(): Role[] | null {
|
||||||
|
if (inMemorySystemRoles && inMemorySystemRoles.length > 0) {
|
||||||
|
return inMemorySystemRoles;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
inMemorySystemRoles = parsed;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store system roles into memory and localStorage.
|
||||||
|
*/
|
||||||
|
export function setCachedSystemRoles(roles: Role[]): void {
|
||||||
|
inMemorySystemRoles = roles;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_SYSTEM_ROLES_KEY, JSON.stringify(roles));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve cached role claims for a specific role ID or role name.
|
||||||
|
*/
|
||||||
|
export function getCachedRoleClaims(roleKey: string): Claim[] | null {
|
||||||
|
const normalizedKey = roleKey.toLowerCase().trim();
|
||||||
|
if (inMemoryRoleClaims.has(normalizedKey)) {
|
||||||
|
return inMemoryRoleClaims.get(normalizedKey)!;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`${LOCAL_STORAGE_ROLE_CLAIMS_PREFIX}${normalizedKey}`);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const normalized = parsed.map(normalizeClaim);
|
||||||
|
inMemoryRoleClaims.set(normalizedKey, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store role claims into memory and localStorage.
|
||||||
|
*/
|
||||||
|
export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void {
|
||||||
|
const normalizedKey = roleKey.toLowerCase().trim();
|
||||||
|
const normalized = claims.map(normalizeClaim);
|
||||||
|
inMemoryRoleClaims.set(normalizedKey, normalized);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
`${LOCAL_STORAGE_ROLE_CLAIMS_PREFIX}${normalizedKey}`,
|
||||||
|
JSON.stringify(normalized)
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches system roles from backend API, caching the result in memory and localStorage.
|
||||||
|
*/
|
||||||
|
export async function fetchAndCacheSystemRoles(): Promise<Role[]> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<Role[]>('/v1/api/users/system/roles');
|
||||||
|
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
setCachedSystemRoles(res.data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If request fails, fall back to cached roles
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = getCachedSystemRoles();
|
||||||
|
if (cached && cached.length > 0) return cached;
|
||||||
|
|
||||||
|
// Fallback defaults if offline / bootstrapping
|
||||||
|
const defaults: Role[] = [
|
||||||
|
{ id: '1', name: 'Admin' },
|
||||||
|
{ id: '2', name: 'Executive' },
|
||||||
|
{ id: '3', name: 'Supervisor' },
|
||||||
|
{ id: '4', name: 'Manager' },
|
||||||
|
{ id: '5', name: 'Contributor' },
|
||||||
|
];
|
||||||
|
setCachedSystemRoles(defaults);
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches role claims for a role from the API, caching them under role ID and role name.
|
||||||
|
*/
|
||||||
|
export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string): 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;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network or server error, proceed to cache / defaults fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache by role ID
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback from predefined matrix
|
||||||
|
const fallback = (roleNameKey && DEFAULT_ROLE_CLAIMS[roleNameKey]) || DEFAULT_ROLE_CLAIMS[roleIdKey] || [];
|
||||||
|
const normalized = fallback.map(normalizeClaim);
|
||||||
|
setCachedRoleClaims(roleIdKey, normalized);
|
||||||
|
if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
|
||||||
|
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 async function getUserClaimsFromApi(userId: string): Promise<Claim[]> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
|
||||||
|
return res.data || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves user roles, role claims, and user claims (including tenant and tenant_id)
|
||||||
|
* and caches system roles and role claims as a persistent reference.
|
||||||
|
*/
|
||||||
|
export async function resolveUserRolesAndClaims(
|
||||||
|
userSub: string,
|
||||||
|
initialClaims: Record<string, any> = {},
|
||||||
|
currentProfile: { role?: string; tenant?: string; tenant_id?: number } = {}
|
||||||
|
): Promise<{
|
||||||
|
roles: string[];
|
||||||
|
claims: Record<string, any>;
|
||||||
|
primaryRole: string;
|
||||||
|
tenant?: string;
|
||||||
|
tenant_id?: number;
|
||||||
|
}> {
|
||||||
|
const mergedClaims: Record<string, any> = { ...initialClaims };
|
||||||
|
const resolvedRoles: string[] = [];
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to initial role if API returned empty
|
||||||
|
if (resolvedRoles.length === 0 && primaryRole) {
|
||||||
|
resolvedRoles.push(primaryRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For each role, retrieve role claims using the endpoint (Requirement 2 & 7)
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Background preload all system roles & role claims for reference (Requirement 7)
|
||||||
|
preloadAndCacheAllRoleClaims().catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: resolvedRoles,
|
||||||
|
claims: mergedClaims,
|
||||||
|
primaryRole,
|
||||||
|
tenant,
|
||||||
|
tenant_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preloads and caches all system roles and their respective role claims.
|
||||||
|
*/
|
||||||
|
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
|
||||||
|
const roles = await fetchAndCacheSystemRoles();
|
||||||
|
for (const role of roles) {
|
||||||
|
await fetchAndCacheRoleClaims(role.id, role.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all system claims in the identity management system.
|
||||||
|
* Any claim is assignable to an authorized user (with admin being a special case).
|
||||||
|
*/
|
||||||
|
export async function getSystemClaims(): Promise<Claim[]> {
|
||||||
|
if (inMemorySystemClaims && inMemorySystemClaims.length > 0) {
|
||||||
|
return inMemorySystemClaims;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_STORAGE_SYSTEM_CLAIMS_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
inMemorySystemClaims = parsed;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimsMap = new Map<string, Claim>();
|
||||||
|
|
||||||
|
// 1. Try system claims endpoint directly
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<Claim[]>('/v1/api/users/system/claims');
|
||||||
|
if (res.data && Array.isArray(res.data)) {
|
||||||
|
res.data.forEach((c) => {
|
||||||
|
const norm = normalizeClaim(c);
|
||||||
|
if (norm.claimType) claimsMap.set(norm.claimType, norm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 2. Aggregate role claims from all system roles
|
||||||
|
try {
|
||||||
|
const roles = await fetchAndCacheSystemRoles();
|
||||||
|
for (const r of roles) {
|
||||||
|
const claims = await fetchAndCacheRoleClaims(r.id, r.name);
|
||||||
|
claims.forEach((c) => {
|
||||||
|
const norm = normalizeClaim(c);
|
||||||
|
if (norm.claimType && !claimsMap.has(norm.claimType)) {
|
||||||
|
claimsMap.set(norm.claimType, norm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 3. Ensure all standard defined claims from RBAC specification exist
|
||||||
|
ROLE_CLAIM_DEFINITIONS.forEach((def, index) => {
|
||||||
|
if (!claimsMap.has(def.claim)) {
|
||||||
|
claimsMap.set(def.claim, {
|
||||||
|
claimId: index + 1,
|
||||||
|
claimType: def.claim,
|
||||||
|
claimValue: 'all',
|
||||||
|
type: def.claim,
|
||||||
|
value: 'all',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allClaims = Array.from(claimsMap.values());
|
||||||
|
inMemorySystemClaims = allClaims;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_SYSTEM_CLAIMS_KEY, JSON.stringify(allClaims));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allClaims;
|
||||||
|
}
|
||||||
@@ -256,6 +256,57 @@ export enum UserRole {
|
|||||||
RecipientViewer = 5,
|
RecipientViewer = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Claim {
|
||||||
|
id?: string | number | null;
|
||||||
|
claimId?: number | string | null;
|
||||||
|
claimType?: string | null;
|
||||||
|
claimValue?: string | null;
|
||||||
|
issuer?: string | null;
|
||||||
|
originalIssuer?: string | null;
|
||||||
|
properties?: Record<string, string> | null;
|
||||||
|
subject?: ClaimsIdentity | null;
|
||||||
|
type?: string | null;
|
||||||
|
value?: string | null;
|
||||||
|
valueType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimsIdentity {
|
||||||
|
authenticationType?: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
actor?: ClaimsIdentity | null;
|
||||||
|
bootstrapContext?: any;
|
||||||
|
claims?: Claim[] | null;
|
||||||
|
label?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
nameClaimType?: string | null;
|
||||||
|
roleClaimType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
id?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
emailConfirmed?: boolean;
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
phoneNumberConfirmed?: boolean;
|
||||||
|
LockoutEnabled?: boolean;
|
||||||
|
twoFactorEnabled?: boolean;
|
||||||
|
accessFailedCount?: number | string;
|
||||||
|
lockoutEnd?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddUserClaimRequest {
|
||||||
|
userId: string;
|
||||||
|
claimId: number | string;
|
||||||
|
claimType: string;
|
||||||
|
claimValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -264,10 +315,16 @@ export interface User {
|
|||||||
role?: string | UserRole | null;
|
role?: string | UserRole | null;
|
||||||
idNumber?: string | null;
|
idNumber?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
userName?: string | null;
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
lockoutEnabled?: boolean;
|
||||||
|
lockoutEnd?: string | null;
|
||||||
|
twoFactorEnabled?: boolean;
|
||||||
|
accessFailedCount?: number | string;
|
||||||
organisationId?: number | string;
|
organisationId?: number | string;
|
||||||
organisationName?: string;
|
organisationName?: string;
|
||||||
tenant?: string;
|
tenant?: string;
|
||||||
tenant_id?: number;
|
tenant_id?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SystemUser = User;
|
export type SystemUser = User;
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import * as XLSX from 'xlsx';
|
|
||||||
import jsPDF from 'jspdf';
|
|
||||||
import autoTable from 'jspdf-autotable';
|
|
||||||
|
|
||||||
export interface ExportColumn<T = any> {
|
export interface ExportColumn<T = any> {
|
||||||
header: string;
|
header: string;
|
||||||
key?: keyof T | string;
|
key?: keyof T | string;
|
||||||
@@ -23,7 +19,7 @@ export interface ExportOptions<T = any> {
|
|||||||
* Draws the official PFM Shield and Brand Header on jsPDF in landscape mode
|
* Draws the official PFM Shield and Brand Header on jsPDF in landscape mode
|
||||||
*/
|
*/
|
||||||
function drawPfmPdfHeader(
|
function drawPfmPdfHeader(
|
||||||
doc: jsPDF,
|
doc: any,
|
||||||
title: string,
|
title: string,
|
||||||
subtitle?: string,
|
subtitle?: string,
|
||||||
tenantName?: string,
|
tenantName?: string,
|
||||||
@@ -127,7 +123,7 @@ function drawPfmPdfHeader(
|
|||||||
/**
|
/**
|
||||||
* Export tabular data to an Excel / Spreadsheet file (.xlsx)
|
* Export tabular data to an Excel / Spreadsheet file (.xlsx)
|
||||||
*/
|
*/
|
||||||
export function exportToSpreadsheet<T>(options: ExportOptions<T>): void {
|
export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise<void> {
|
||||||
const { title, filename, columns, data } = options;
|
const { title, filename, columns, data } = options;
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
@@ -135,6 +131,8 @@ export function exportToSpreadsheet<T>(options: ExportOptions<T>): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const XLSX = await import('xlsx');
|
||||||
|
|
||||||
// Format table rows based on columns config
|
// Format table rows based on columns config
|
||||||
const rows = data.map((item) => {
|
const rows = data.map((item) => {
|
||||||
const rowObj: Record<string, any> = {};
|
const rowObj: Record<string, any> = {};
|
||||||
@@ -185,7 +183,7 @@ export function exportToSpreadsheet<T>(options: ExportOptions<T>): void {
|
|||||||
/**
|
/**
|
||||||
* Export tabular data to a landscape PDF file with PFM Logo and branding
|
* Export tabular data to a landscape PDF file with PFM Logo and branding
|
||||||
*/
|
*/
|
||||||
export function exportToPdf<T>(options: ExportOptions<T>): void {
|
export async function exportToPdf<T>(options: ExportOptions<T>): Promise<void> {
|
||||||
const { title, subtitle, filename, columns, data, tenantName, userEmail } = options;
|
const { title, subtitle, filename, columns, data, tenantName, userEmail } = options;
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
@@ -193,6 +191,11 @@ export function exportToPdf<T>(options: ExportOptions<T>): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
|
import('jspdf'),
|
||||||
|
import('jspdf-autotable'),
|
||||||
|
]);
|
||||||
|
|
||||||
// Landscape A4 doc: width: 841.89 pt, height: 595.28 pt
|
// Landscape A4 doc: width: 841.89 pt, height: 595.28 pt
|
||||||
const doc = new jsPDF({
|
const doc = new jsPDF({
|
||||||
orientation: 'landscape',
|
orientation: 'landscape',
|
||||||
@@ -240,7 +243,7 @@ export function exportToPdf<T>(options: ExportOptions<T>): void {
|
|||||||
alternateRowStyles: {
|
alternateRowStyles: {
|
||||||
fillColor: [248, 250, 252], // slate-50
|
fillColor: [248, 250, 252], // slate-50
|
||||||
},
|
},
|
||||||
didDrawPage: (dataArg) => {
|
didDrawPage: (dataArg: any) => {
|
||||||
// Footer on all pages
|
// Footer on all pages
|
||||||
const pageCount = (doc.internal as any).getNumberOfPages();
|
const pageCount = (doc.internal as any).getNumberOfPages();
|
||||||
const currentPage = dataArg.pageNumber;
|
const currentPage = dataArg.pageNumber;
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import Hashids from 'hashids';
|
||||||
|
|
||||||
|
const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||||
|
? import.meta.env
|
||||||
|
: {}) as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export const HASHER_SETTINGS = {
|
||||||
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||||
|
salt:
|
||||||
|
(env.VITE_HASHER_SALT as string) ||
|
||||||
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
|
|
||||||
|
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
||||||
|
let updated = false;
|
||||||
|
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||||
|
HASHER_SETTINGS.salt = salt.trim();
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||||
|
const parsed = Number(minLength);
|
||||||
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
|
HASHER_SETTINGS.minHashLength = parsed;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if candidate is already a HashID generated by this hasher.
|
||||||
|
*/
|
||||||
|
export function isHashId(candidate: string | null | undefined): boolean {
|
||||||
|
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decodedNumbers = hasher.decode(candidate);
|
||||||
|
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
|
||||||
|
const decodedHex = hasher.decodeHex(candidate);
|
||||||
|
if (decodedHex && decodedHex.length > 0) return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a user idNumber using Hashids Hex encoding.
|
||||||
|
* Supports South African national IDs (13 digits), passports, and alphanumeric keys.
|
||||||
|
*/
|
||||||
|
export function encodeIdNumberHex(idNumber: string | number | null | undefined): string {
|
||||||
|
if (idNumber === undefined || idNumber === null) return '';
|
||||||
|
const str = String(idNumber).trim();
|
||||||
|
if (!str) return '';
|
||||||
|
|
||||||
|
// Prevent double-encoding if already a Hashid
|
||||||
|
if (isHashId(str)) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already pure hex digits (e.g. standard decimal SA ID 9501015800085 or hex hash)
|
||||||
|
if (/^[0-9a-fA-F]+$/.test(str)) {
|
||||||
|
try {
|
||||||
|
const encoded = hasher.encodeHex(str.toLowerCase());
|
||||||
|
if (encoded) return encoded;
|
||||||
|
} catch {
|
||||||
|
// Fallback below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For arbitrary alphanumeric ID strings, encode character bytes to hex
|
||||||
|
try {
|
||||||
|
const hex = Array.from(str)
|
||||||
|
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
const encoded = hasher.encodeHex(hex);
|
||||||
|
return encoded || str;
|
||||||
|
} catch {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a Hashids Hex encoded string back to original idNumber.
|
||||||
|
*/
|
||||||
|
export function decodeIdNumberHex(hash: string | null | undefined): string {
|
||||||
|
if (!hash || typeof hash !== 'string') return '';
|
||||||
|
const trimmed = hash.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedHex = hasher.decodeHex(trimmed);
|
||||||
|
if (!decodedHex) return trimmed;
|
||||||
|
|
||||||
|
// Check if decoded hex represents ASCII text with 2-char hex pairs
|
||||||
|
if (/^[0-9a-fA-F]{4,}$/.test(decodedHex) && decodedHex.length % 2 === 0) {
|
||||||
|
let isText = true;
|
||||||
|
let text = '';
|
||||||
|
for (let i = 0; i < decodedHex.length; i += 2) {
|
||||||
|
const code = parseInt(decodedHex.slice(i, i + 2), 16);
|
||||||
|
if (code < 32 || code > 126) {
|
||||||
|
isText = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
text += String.fromCharCode(code);
|
||||||
|
}
|
||||||
|
if (isText && text.length > 0) return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodedHex;
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts any numeric or string ID to a safe 64-bit integer string.
|
||||||
|
* Preserves precision beyond 2^53 - 1 by avoiding standard float coercion.
|
||||||
|
*/
|
||||||
|
export function toSafeInt64String(val: string | number | bigint | null | undefined): string {
|
||||||
|
if (val === undefined || val === null || val === '') return '';
|
||||||
|
if (typeof val === 'bigint') return val.toString();
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
if (isNaN(val) || !isFinite(val)) return '';
|
||||||
|
return Math.trunc(val).toString();
|
||||||
|
}
|
||||||
|
const str = String(val).trim();
|
||||||
|
if (/^-?\d+$/.test(str)) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
|
||||||
|
*/
|
||||||
|
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
|
||||||
|
if (id === undefined || id === null || id === '') return '';
|
||||||
|
|
||||||
|
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
|
||||||
|
if (!str) return '';
|
||||||
|
|
||||||
|
if (isHashId(str)) return str;
|
||||||
|
|
||||||
|
// If safe integer range
|
||||||
|
if (/^\d+$/.test(str)) {
|
||||||
|
try {
|
||||||
|
const num = Number(str);
|
||||||
|
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
|
||||||
|
return hasher.encode(num);
|
||||||
|
}
|
||||||
|
// For integers above 2^53 - 1, encode as hex
|
||||||
|
const bigHex = BigInt(str).toString(16);
|
||||||
|
return hasher.encodeHex(bigHex);
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically decodes a standard HashID string to its canonical ID representation as string.
|
||||||
|
*/
|
||||||
|
export function decodeEntityId(hashId: string | number | null | undefined): string {
|
||||||
|
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||||
|
const str = String(hashId).trim();
|
||||||
|
if (!str) return '';
|
||||||
|
|
||||||
|
if (/^-?\d+$/.test(str)) return str;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = hasher.decode(str);
|
||||||
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
|
return decoded[0].toString();
|
||||||
|
}
|
||||||
|
const decodedHex = hasher.decodeHex(str);
|
||||||
|
if (decodedHex) {
|
||||||
|
try {
|
||||||
|
return BigInt('0x' + decodedHex).toString();
|
||||||
|
} catch {
|
||||||
|
return decodedHex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export default defineConfig(async ({ command }) => {
|
|||||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
chunkSizeWarningLimit: 600,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
onwarn(warning, defaultHandler) {
|
onwarn(warning, defaultHandler) {
|
||||||
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
|
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
|
||||||
@@ -62,24 +63,63 @@ export default defineConfig(async ({ command }) => {
|
|||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (id.includes('node_modules')) {
|
if (id.includes('node_modules')) {
|
||||||
if (id.includes('react') || id.includes('scheduler')) {
|
// 1. Heavy export and PDF document generation
|
||||||
return 'vendor-react';
|
if (id.includes('xlsx')) {
|
||||||
|
return 'vendor-xlsx';
|
||||||
}
|
}
|
||||||
if (id.includes('@tanstack')) {
|
if (id.includes('html2canvas')) {
|
||||||
return 'vendor-query';
|
return 'vendor-html2canvas';
|
||||||
}
|
}
|
||||||
if (id.includes('oidc-client-ts')) {
|
if (id.includes('fflate')) {
|
||||||
return 'vendor-auth';
|
return 'vendor-fflate';
|
||||||
}
|
}
|
||||||
|
if (id.includes('dompurify')) {
|
||||||
|
return 'vendor-purify';
|
||||||
|
}
|
||||||
|
if (id.includes('fast-png')) {
|
||||||
|
return 'vendor-png';
|
||||||
|
}
|
||||||
|
if (id.includes('polyfills.es.js') || id.includes('jspdf/dist/polyfills')) {
|
||||||
|
return 'vendor-pdf-polyfills';
|
||||||
|
}
|
||||||
|
if (id.includes('jspdf-autotable')) {
|
||||||
|
return 'vendor-pdf-autotable';
|
||||||
|
}
|
||||||
|
if (id.includes('jspdf') || id.includes('canvg')) {
|
||||||
|
return 'vendor-pdf';
|
||||||
|
}
|
||||||
|
// 2. Iconography
|
||||||
if (id.includes('lucide-react')) {
|
if (id.includes('lucide-react')) {
|
||||||
return 'vendor-icons';
|
return 'vendor-icons';
|
||||||
}
|
}
|
||||||
|
// 3. Query & state management
|
||||||
|
if (id.includes('@tanstack')) {
|
||||||
|
return 'vendor-query';
|
||||||
|
}
|
||||||
|
// 4. OIDC Authentication
|
||||||
|
if (id.includes('oidc-client-ts')) {
|
||||||
|
return 'vendor-auth';
|
||||||
|
}
|
||||||
|
// 5. Schema & form validation
|
||||||
if (id.includes('zod') || id.includes('react-hook-form') || id.includes('@hookform')) {
|
if (id.includes('zod') || id.includes('react-hook-form') || id.includes('@hookform')) {
|
||||||
return 'vendor-forms';
|
return 'vendor-forms';
|
||||||
}
|
}
|
||||||
|
// 6. HTTP networking
|
||||||
if (id.includes('axios')) {
|
if (id.includes('axios')) {
|
||||||
return 'vendor-http';
|
return 'vendor-http';
|
||||||
}
|
}
|
||||||
|
// 7. Client routing
|
||||||
|
if (id.includes('react-router')) {
|
||||||
|
return 'vendor-router';
|
||||||
|
}
|
||||||
|
// 8. Core React framework runtime
|
||||||
|
if (
|
||||||
|
id.includes('/react/') ||
|
||||||
|
id.includes('/react-dom/') ||
|
||||||
|
id.includes('/scheduler/')
|
||||||
|
) {
|
||||||
|
return 'vendor-react';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user