Implemented rbac
This commit is contained in:
@@ -29,12 +29,14 @@ import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getUsers, getTenant } from '../../services/api';
|
||||
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
|
||||
import { useRbac, RoleClaim } from '../../auth/rbac';
|
||||
|
||||
interface SubNavItem {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.ElementType;
|
||||
help?: string;
|
||||
claim?: RoleClaim;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -42,6 +44,7 @@ interface NavItem {
|
||||
path: string;
|
||||
icon: React.ElementType;
|
||||
help: string;
|
||||
claim?: RoleClaim;
|
||||
subItems?: SubNavItem[];
|
||||
}
|
||||
|
||||
@@ -51,36 +54,42 @@ const navItems: NavItem[] = [
|
||||
path: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
help: 'Strategic macro metrics, portfolio value, disbursed funding, ISO 31000 risk heatmap, and projects requiring immediate intervention.',
|
||||
claim: 'dashboard',
|
||||
},
|
||||
{
|
||||
name: 'Governance & Structure',
|
||||
path: '/governance',
|
||||
icon: Building2,
|
||||
help: 'Master records for legal entities, holding structures, investment portfolios, and thematic programmes funded under statutory mandates.',
|
||||
claim: 'governance',
|
||||
},
|
||||
{
|
||||
name: 'Projects & Milestones',
|
||||
path: '/projects',
|
||||
icon: FolderKanban,
|
||||
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
|
||||
claim: 'projects_milestones',
|
||||
subItems: [
|
||||
{
|
||||
name: 'Beneficiaries',
|
||||
path: '/projects/beneficiaries',
|
||||
icon: Users,
|
||||
help: 'Manage target beneficiaries, demographics, and statutory compliance items satisfying beneficiaries.',
|
||||
claim: 'beneficiaries',
|
||||
},
|
||||
{
|
||||
name: 'Change Management',
|
||||
path: '/projects/change-management',
|
||||
icon: GitPullRequest,
|
||||
help: 'Track project issues, contractual change requests, and resolve changes in-place.',
|
||||
claim: 'change_management',
|
||||
},
|
||||
{
|
||||
name: 'Site Visits',
|
||||
path: '/projects/site-visits',
|
||||
icon: MapPin,
|
||||
help: 'Log physical site visits, field observations, inspector findings, and project feedback.',
|
||||
claim: 'site_visits',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -89,18 +98,21 @@ const navItems: NavItem[] = [
|
||||
path: '/grants',
|
||||
icon: Coins,
|
||||
help: 'Financial management including statutory awards, multi-year budget line allocations, tranches, and scheduled payment disbursements.',
|
||||
claim: 'grant_financials',
|
||||
},
|
||||
{
|
||||
name: 'M&E, Evidence & Risks',
|
||||
path: '/monitoring',
|
||||
icon: ShieldAlert,
|
||||
help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.',
|
||||
claim: 'evidence_risks',
|
||||
},
|
||||
{
|
||||
name: 'Tenant Management',
|
||||
path: '/tenants',
|
||||
icon: Database,
|
||||
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
||||
claim: 'tenant_management',
|
||||
},
|
||||
{
|
||||
name: 'Settings & Communications',
|
||||
@@ -111,7 +123,9 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
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 [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@@ -120,13 +134,38 @@ export const AppLayout: React.FC = () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
// Only show Tenant Management if user's tenant_id == 1 and their role list has Admin in it
|
||||
const visibleNavItems = navItems.filter((item) => {
|
||||
if (item.path === '/tenants') {
|
||||
return canManageTenants;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Filter navigation shelf strictly according to RBAC Role Claims
|
||||
const visibleNavItems = navItems
|
||||
.filter((item) => {
|
||||
if (item.path === '/tenants') {
|
||||
return canManageTenants || canView('tenant_management');
|
||||
}
|
||||
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
|
||||
useQuery({
|
||||
@@ -355,6 +394,11 @@ export const AppLayout: React.FC = () => {
|
||||
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
|
||||
{tokenUserEmail || 'Authenticated User'}
|
||||
</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>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
@@ -369,13 +413,14 @@ export const AppLayout: React.FC = () => {
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<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"
|
||||
title={`${tokenUserName} (${tokenUserEmail})`}
|
||||
title={`${tokenUserName} (${tokenUserEmail}) • Role: ${userRole}`}
|
||||
>
|
||||
{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]" />
|
||||
<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="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>
|
||||
<button
|
||||
@@ -474,9 +519,14 @@ export const AppLayout: React.FC = () => {
|
||||
{/* User Profile and Controls */}
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
|
||||
{tokenUserName}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}>
|
||||
{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}>
|
||||
{tokenUserEmail}
|
||||
</p>
|
||||
@@ -498,8 +548,14 @@ export const AppLayout: React.FC = () => {
|
||||
onMouseLeave={() => setUserMenuOpen(false)}
|
||||
>
|
||||
<div className="p-2.5 border-b border-slate-800">
|
||||
<p className="text-xs font-semibold text-white">{tokenUserName}</p>
|
||||
<p className="text-[11px] text-slate-300 font-medium truncate">{tokenUserEmail || (sub ? getUserEmail(sub) : '')}</p>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<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>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -57,6 +57,7 @@ import { useToast } from '../ui/Toast';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { ExportColumn } from '../../utils/exportUtils';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
|
||||
export interface EvidenceReviewQueueProps {
|
||||
initialProjectId?: string | number | 'all';
|
||||
@@ -86,6 +87,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
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
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
|
||||
@@ -454,6 +456,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="evidence_risks"
|
||||
title="Deliverable Evidence & Verification Ledger"
|
||||
subtitle="Audited records of submitted milestone deliverables and verification decisions"
|
||||
filename={`pfm-evidence-review-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -461,15 +464,17 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
data={filteredEvidence}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsManualIngestModalOpen(true)}
|
||||
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>
|
||||
</button>
|
||||
{canAdd('evidence_risks') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsManualIngestModalOpen(true)}
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -795,49 +800,53 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<span>Inspect</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
id: ev.id,
|
||||
status: ApprovalStatus.Rejected,
|
||||
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 ${
|
||||
ev.status === ApprovalStatus.Rejected
|
||||
? '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"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
|
||||
</button>
|
||||
{canAdd('evidence_risks') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
id: ev.id,
|
||||
status: ApprovalStatus.Rejected,
|
||||
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 ${
|
||||
ev.status === ApprovalStatus.Rejected
|
||||
? '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"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
id: ev.id,
|
||||
status: ApprovalStatus.Approved,
|
||||
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 ${
|
||||
ev.status === ApprovalStatus.Approved
|
||||
? '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"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
||||
</button>
|
||||
{canApprove('evidence_risks') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
id: ev.id,
|
||||
status: ApprovalStatus.Approved,
|
||||
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 ${
|
||||
ev.status === ApprovalStatus.Approved
|
||||
? '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"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -989,49 +998,53 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
id: inspectEvidence.id,
|
||||
status: ApprovalStatus.Rejected,
|
||||
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 ${
|
||||
inspectEvidence.status === ApprovalStatus.Rejected
|
||||
? '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"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Rejected ? 'Rejecting...' : 'Reject Evidence'}</span>
|
||||
</button>
|
||||
{canAdd('evidence_risks') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
id: inspectEvidence.id,
|
||||
status: ApprovalStatus.Rejected,
|
||||
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 ${
|
||||
inspectEvidence.status === ApprovalStatus.Rejected
|
||||
? '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"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Rejected ? 'Rejecting...' : 'Reject Evidence'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
id: inspectEvidence.id,
|
||||
status: ApprovalStatus.Approved,
|
||||
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 ${
|
||||
inspectEvidence.status === ApprovalStatus.Approved
|
||||
? '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"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
|
||||
</button>
|
||||
{canApprove('evidence_risks') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
reviewMutation.mutate({
|
||||
id: inspectEvidence.id,
|
||||
status: ApprovalStatus.Approved,
|
||||
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 ${
|
||||
inspectEvidence.status === ApprovalStatus.Approved
|
||||
? '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"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
|
||||
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { useRbac, RoleClaim } from '../../auth/rbac';
|
||||
|
||||
export interface ExportButtonProps<T = any> {
|
||||
title: string;
|
||||
@@ -13,6 +14,7 @@ export interface ExportButtonProps<T = any> {
|
||||
label?: string;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
claim?: RoleClaim;
|
||||
}
|
||||
|
||||
export function ExportButton<T>({
|
||||
@@ -24,14 +26,17 @@ export function ExportButton<T>({
|
||||
label = 'Export',
|
||||
size = 'md',
|
||||
className = '',
|
||||
claim,
|
||||
}: ExportButtonProps<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
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(() => {
|
||||
if (!dropdownRef.current) return;
|
||||
@@ -67,10 +72,10 @@ export function ExportButton<T>({
|
||||
};
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
const handleExportSpreadsheet = () => {
|
||||
const handleExportSpreadsheet = async () => {
|
||||
if (isDisabled) return;
|
||||
setIsOpen(false);
|
||||
exportToSpreadsheet({
|
||||
await exportToSpreadsheet({
|
||||
title,
|
||||
subtitle,
|
||||
filename,
|
||||
@@ -81,10 +86,10 @@ export function ExportButton<T>({
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportPdf = () => {
|
||||
const handleExportPdf = async () => {
|
||||
if (isDisabled) return;
|
||||
setIsOpen(false);
|
||||
exportToPdf({
|
||||
await exportToPdf({
|
||||
title,
|
||||
subtitle,
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user