Implemented rbac

This commit is contained in:
2026-10-11 11:16:08 +02:00
parent 45a6c0aca0
commit 1142cfd94e
24 changed files with 3033 additions and 432 deletions
@@ -40,6 +40,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
@@ -70,6 +71,7 @@ type OutcomeFormValues = z.infer<typeof outcomeSchema>;
export const BeneficiariesPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
@@ -574,23 +576,26 @@ export const BeneficiariesPage: React.FC = () => {
</button>
<ExportButton
claim="beneficiaries"
data={sortedBens}
columns={benExportColumns}
filename={`project_beneficiaries_${new Date().toISOString().split('T')[0]}`}
title="Beneficiaries Register"
/>
<button
onClick={() => {
setEditingBen(null);
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"
>
<Plus className="w-3.5 h-3.5" />
<span>Register Beneficiary</span>
</button>
{canAdd('beneficiaries') && (
<button
onClick={() => {
setEditingBen(null);
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"
>
<Plus className="w-3.5 h-3.5" />
<span>Register Beneficiary</span>
</button>
)}
</div>
</div>
<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>
<ExportButton
claim="beneficiaries"
data={sortedComps}
columns={compExportColumns}
filename={`compliance_requirements_${new Date().toISOString().split('T')[0]}`}
title="Compliance Requirements"
/>
<button
onClick={() => {
setEditingComp(null);
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
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"
>
<CheckSquare className="w-3.5 h-3.5 text-emerald-400" />
<span>Add Compliance Item</span>
</button>
{canAdd('beneficiaries') && (
<button
onClick={() => {
setEditingComp(null);
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
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"
>
<CheckSquare className="w-3.5 h-3.5 text-emerald-400" />
<span>Add Compliance Item</span>
</button>
)}
</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"
>
{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.value === ApprovalStatus.Approved && !canApprove('beneficiaries') ? ' (Requires Update Permission)' : ''}
</option>
))}
</select>
@@ -39,6 +39,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { ApprovalStatusBadge, PriorityBadge } from '../components/ui/StatusBadge';
import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types';
import {
@@ -109,6 +110,7 @@ type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
export const ChangeManagementPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
@@ -722,23 +724,26 @@ export const ChangeManagementPage: React.FC = () => {
</button>
<ExportButton
claim="change_management"
data={sortedIssues}
columns={issueExportCols}
filename={`project_issues_${new Date().toISOString().split('T')[0]}`}
title="Project Issues Register"
/>
<button
onClick={() => {
setEditingIssue(null);
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"
>
<Plus className="w-3.5 h-3.5" />
<span>Log Issue</span>
</button>
{canAdd('change_management') && (
<button
onClick={() => {
setEditingIssue(null);
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"
>
<Plus className="w-3.5 h-3.5" />
<span>Log Issue</span>
</button>
)}
</div>
</div>
@@ -920,28 +925,31 @@ export const ChangeManagementPage: React.FC = () => {
</button>
<ExportButton
claim="change_management"
data={sortedChanges}
columns={changeExportCols}
filename={`change_requests_${new Date().toISOString().split('T')[0]}`}
title="Change Requests Register"
/>
<button
onClick={() => {
setEditingChange(null);
changeForm.reset({
title: '',
justification: '',
revisedBudget: 0,
revisionDate: new Date().toISOString().split('T')[0],
});
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"
>
<GitPullRequest className="w-3.5 h-3.5 text-blue-400" />
<span>Request Change</span>
</button>
{canAdd('change_management') && (
<button
onClick={() => {
setEditingChange(null);
changeForm.reset({
title: '',
justification: '',
revisedBudget: 0,
revisionDate: new Date().toISOString().split('T')[0],
});
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"
>
<GitPullRequest className="w-3.5 h-3.5 text-blue-400" />
<span>Request Change</span>
</button>
)}
</div>
</div>
@@ -1014,18 +1022,22 @@ export const ChangeManagementPage: React.FC = () => {
</span>
) : (
<>
<button
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>
<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"
>
<XCircle className="w-3 h-3" /> Reject
</button>
{canApprove('change_management') && (
<button
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>
)}
{canAdd('change_management') && (
<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"
>
<XCircle className="w-3 h-3" /> Reject
</button>
)}
</>
)}
</div>
@@ -1510,7 +1522,9 @@ export const ChangeManagementPage: React.FC = () => {
>
<option value={ApprovalStatus.Pending}>Pending 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 &amp; Enacted {!canApprove('change_management') ? '(Requires Update Permission)' : ''}
</option>
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
</div>
@@ -57,6 +57,7 @@ import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider';
import { useRbac } from '../auth/rbac';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection';
@@ -122,6 +123,7 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user, claims } = useAuth();
const { canAdd, canApprove, canExport } = useRbac();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
@@ -808,7 +810,7 @@ export const GovernancePage: React.FC = () => {
{/* Tab Actions */}
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
{activeTab === 'organisations' && (
{activeTab === 'organisations' && canAdd('governance') && (
<button
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"
@@ -817,7 +819,7 @@ export const GovernancePage: React.FC = () => {
<span>Register Organisation</span>
</button>
)}
{activeTab === 'portfolios' && (
{activeTab === 'portfolios' && canAdd('governance') && (
<button
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"
@@ -826,7 +828,7 @@ export const GovernancePage: React.FC = () => {
<span>Create Portfolio</span>
</button>
)}
{activeTab === 'programmes' && (
{activeTab === 'programmes' && canAdd('governance') && (
<button
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"
@@ -835,7 +837,7 @@ export const GovernancePage: React.FC = () => {
<span>Create Programme</span>
</button>
)}
{(activeTab === 'instruments' || activeTab === 'instructions') && (
{(activeTab === 'instruments' || activeTab === 'instructions') && canAdd('governance') && (
<button
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"
@@ -844,7 +846,7 @@ export const GovernancePage: React.FC = () => {
<span>Add Funding Instrument</span>
</button>
)}
{activeTab === 'rules' && (
{activeTab === 'rules' && canAdd('governance') && (
<button
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"
@@ -973,6 +975,7 @@ export const GovernancePage: React.FC = () => {
<span>Refresh Directory</span>
</button>
<ExportButton
claim="governance"
title="Organisation Master Registry"
subtitle="Official directory of verified recipient and funder entities"
filename={`pfm-organisations-${new Date().toISOString().split('T')[0]}`}
@@ -1108,6 +1111,7 @@ export const GovernancePage: React.FC = () => {
<span>Refresh Portfolios</span>
</button>
<ExportButton
claim="governance"
title="Funder Portfolios Registry"
subtitle="High-level funding groupings and thematic capital allocations"
filename={`pfm-portfolios-${new Date().toISOString().split('T')[0]}`}
@@ -1239,6 +1243,7 @@ export const GovernancePage: React.FC = () => {
<span>Refresh Programmes</span>
</button>
<ExportButton
claim="governance"
title="Strategic Programmes Hierarchy"
subtitle="Operational funding vehicles linked to funder portfolios"
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 { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import {
Award,
Budget,
@@ -98,6 +99,7 @@ type DisbursementFormValues = z.infer<typeof disbursementSchema>;
export const GrantsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
@@ -666,7 +668,7 @@ export const GrantsPage: React.FC = () => {
</div>
<div className="flex items-center gap-2 relative z-10 self-start md:self-center">
{activeTab === 'awards' && (
{activeTab === 'awards' && canAdd('grant_financials') && (
<button
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"
@@ -675,7 +677,7 @@ export const GrantsPage: React.FC = () => {
<span>Register Approved Award</span>
</button>
)}
{activeTab === 'budgets' && (
{activeTab === 'budgets' && canAdd('grant_financials') && (
<button
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"
@@ -684,7 +686,7 @@ export const GrantsPage: React.FC = () => {
<span>Add Budget Line</span>
</button>
)}
{activeTab === 'disbursements' && (
{activeTab === 'disbursements' && canAdd('grant_financials') && (
<button
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"
@@ -802,6 +804,7 @@ export const GrantsPage: React.FC = () => {
<span>Refresh Awards</span>
</button>
<ExportButton
claim="grant_financials"
title="Grant Awards Registry"
subtitle="Official directory of approved beneficiary funding allocations"
filename={`pfm-awards-${new Date().toISOString().split('T')[0]}`}
@@ -915,6 +918,7 @@ export const GrantsPage: React.FC = () => {
<span>Refresh Budgets</span>
</button>
<ExportButton
claim="grant_financials"
title="Project Budgets Registry"
subtitle="Detailed statutory project budget allocations and category lines"
filename={`pfm-budgets-${new Date().toISOString().split('T')[0]}`}
@@ -998,7 +1002,7 @@ export const GrantsPage: React.FC = () => {
<Edit2 className="w-3 h-3" />
<span>Update</span>
</button>
{b.status !== ApprovalStatus.Approved && (
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
<button
type="button"
disabled={updateBudgetMutation.isPending}
@@ -1057,6 +1061,7 @@ export const GrantsPage: React.FC = () => {
<span>Refresh Disbursements</span>
</button>
<ExportButton
claim="grant_financials"
title="Disbursement Tranches Ledger"
subtitle="Milestone-linked funding releases and payment authorizations"
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" />
</button>
<button
onClick={() => approveMutation.mutate(d.id)}
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
</button>
{canApprove('grant_financials') && (
<button
onClick={() => approveMutation.mutate(d.id)}
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
</button>
)}
</>
)}
</div>
@@ -1555,7 +1562,9 @@ export const GrantsPage: React.FC = () => {
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"
>
<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.Rejected}>Rejected / Blocked</option>
</select>
@@ -18,7 +18,7 @@ import { useAuth } from '../auth/AuthProvider';
import { BrandLogo } from '../components/ui/BrandLogo';
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 handleEnterConsole = async () => {
@@ -192,6 +192,13 @@ export const LandingPage: React.FC = () => {
</span>
</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>
{/* Guidance Message */}
@@ -36,6 +36,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useToast } from '../components/ui/Toast';
import { useRbac } from '../auth/rbac';
import { useTableSort, SortableTh } from '../utils/tableSort';
import {
Project,
@@ -149,6 +150,7 @@ export const evaluateProjectScheduleRisk = (
export const ProjectsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canExport } = useRbac();
// Filter & Search States
const [searchTerm, setSearchTerm] = useState('');
@@ -477,13 +479,15 @@ export const ProjectsPage: React.FC = () => {
</div>
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
<button
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>
</button>
{canAdd('projects_milestones') && (
<button
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>
</button>
)}
</div>
</div>
@@ -628,6 +632,7 @@ export const ProjectsPage: React.FC = () => {
{/* Export Button */}
<ExportButton
claim="projects_milestones"
title="Projects & Milestones Register"
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
filename="projects-milestones-register"
@@ -38,6 +38,9 @@ import {
User as UserIcon,
RotateCcw,
} from 'lucide-react';
import { CreateUserModal } from '../components/users/CreateUserModal';
import { UserSecurityModal } from '../components/users/UserSecurityModal';
import { useRbac } from '../auth/rbac';
import {
getUsers,
getUser,
@@ -97,6 +100,23 @@ export const SettingsPage: React.FC = () => {
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
// ==========================================
@@ -105,6 +125,7 @@ export const SettingsPage: React.FC = () => {
const [userRoleFilter, setUserRoleFilter] = useState<string>('all');
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
const userFilter: UserFilterRequest = {
email: userEmailSearch.trim() || undefined,
@@ -572,77 +593,85 @@ export const SettingsPage: React.FC = () => {
{/* Navigation Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'users'
? '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>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="top"
/>
</button>
{canViewUsers && (
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'users'
? '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>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="top"
/>
</button>
)}
<button
onClick={() => setActiveTab('invites')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'invites'
? '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 &amp; Invites ({invitesList.length})</span>
<HelpTooltip
title="Communications Dispatch"
content="Statutory onboarding invitations and applicant notification ledger."
size="sm"
placement="top"
/>
</button>
{canViewInvites && (
<button
onClick={() => setActiveTab('invites')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'invites'
? '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 &amp; Invites ({invitesList.length})</span>
<HelpTooltip
title="Communications Dispatch"
content="Statutory onboarding invitations and applicant notification ledger."
size="sm"
placement="top"
/>
</button>
)}
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'notifications'
? '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 &amp; Notifications ({fetchedNotifications.length})</span>
<HelpTooltip
title="Notification Gateways"
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
size="sm"
placement="top"
/>
</button>
{canViewNotifications && (
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'notifications'
? '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 &amp; Notifications ({fetchedNotifications.length})</span>
<HelpTooltip
title="Notification Gateways"
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
size="sm"
placement="top"
/>
</button>
)}
<button
onClick={() => setActiveTab('audit')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'audit'
? '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 &amp; Telemetry Ledger ({auditLogs.length})</span>
<HelpTooltip
title="Audit Trail"
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
size="sm"
placement="top"
/>
</button>
{canViewAudit && (
<button
onClick={() => setActiveTab('audit')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'audit'
? '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 &amp; Telemetry Ledger ({auditLogs.length})</span>
<HelpTooltip
title="Audit Trail"
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
size="sm"
placement="top"
/>
</button>
)}
</div>
{/* ============================================================== */}
@@ -725,12 +754,25 @@ export const SettingsPage: React.FC = () => {
<span>Refresh</span>
</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
title="User Directory &amp; Access Register"
subtitle="Canonical directory of authorized users and account states"
filename={`pfm-users-directory-${new Date().toISOString().split('T')[0]}`}
columns={userExportColumns}
data={sortedUsers}
claim="settings_users"
/>
<button
@@ -830,10 +872,11 @@ export const SettingsPage: React.FC = () => {
setSelectedUserForDetail(u);
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" />
Inspect
<Shield className="w-3 h-3 text-blue-400" />
Manage Access
</button>
{/* Status switch actions */}
@@ -948,15 +991,18 @@ export const SettingsPage: React.FC = () => {
filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`}
columns={inviteExportColumns}
data={sortedInvites}
claim="settings_invites"
/>
<button
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>
</button>
{canAddInvite && (
<button
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>
</button>
)}
</div>
</div>
@@ -1227,6 +1273,7 @@ export const SettingsPage: React.FC = () => {
filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`}
columns={notificationExportColumns}
data={filteredNotifications}
claim="settings_notifications"
/>
</div>
</div>
@@ -1493,6 +1540,7 @@ export const SettingsPage: React.FC = () => {
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
columns={auditExportColumns}
data={filteredAuditLogs}
claim="settings_auditlog"
/>
</div>
</div>
@@ -2092,73 +2140,24 @@ export const SettingsPage: React.FC = () => {
</Modal>
{/* ============================================================== */}
{/* Modal: User Inspection */}
{/* Modal: User Administration & Role/Claim/Session Control */}
{/* ============================================================== */}
<Modal
<UserSecurityModal
isOpen={isUserDetailModalOpen}
onClose={() => setIsUserDetailModalOpen(false)}
title="User Access & Security Details"
>
{selectedUserForDetail && (
<div className="space-y-4 text-xs">
{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>
)}
onClose={() => {
setIsUserDetailModalOpen(false);
setSelectedUserForDetail(null);
}}
user={selectedUserForDetail}
/>
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsUserDetailModalOpen(false)}
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition"
>
Close
</button>
</div>
</div>
)}
</Modal>
{/* ============================================================== */}
{/* Modal: Provision Tenant User */}
{/* ============================================================== */}
<CreateUserModal
isOpen={isCreateUserModalOpen}
onClose={() => setIsCreateUserModalOpen(false)}
/>
{/* ============================================================== */}
{/* Modal: Notification Payload & Data Inspection */}
@@ -33,6 +33,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
import { SiteVisit, ApprovalStatus } from '../types';
import {
@@ -53,6 +54,7 @@ type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
export const SiteVisitsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
@@ -349,28 +351,31 @@ export const SiteVisitsPage: React.FC = () => {
</button>
<ExportButton
claim="site_visits"
data={sortedVisits}
columns={visitExportCols}
filename={`site_visits_${new Date().toISOString().split('T')[0]}`}
title="Site Visits Register"
/>
<button
onClick={() => {
setEditingVisit(null);
visitForm.reset({
inspectorName: '',
visitedAt: new Date().toISOString().split('T')[0],
findings: '',
status: ApprovalStatus.Pending,
});
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"
>
<Plus className="w-3.5 h-3.5" />
<span>Log Site Visit</span>
</button>
{canAdd('site_visits') && (
<button
onClick={() => {
setEditingVisit(null);
visitForm.reset({
inspectorName: '',
visitedAt: new Date().toISOString().split('T')[0],
findings: '',
status: ApprovalStatus.Pending,
});
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"
>
<Plus className="w-3.5 h-3.5" />
<span>Log Site Visit</span>
</button>
)}
</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"
>
{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.value === ApprovalStatus.Approved && !canApprove('site_visits') ? ' (Requires Update Permission)' : ''}
</option>
))}
</select>
@@ -38,7 +38,10 @@ import { useTableSort, SortableTh } from '../utils/tableSort';
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
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 { useRbac } from '../auth/rbac';
const createTenantSchema = z.object({
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 &amp; 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 &amp; 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 = () => {
const queryClient = useQueryClient();
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 [statusFilter, setStatusFilter] = useState<string>('all');
@@ -279,6 +449,10 @@ export const TenantManagementPage: React.FC = () => {
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
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
const {
@@ -460,15 +634,18 @@ export const TenantManagementPage: React.FC = () => {
data={sortedData}
columns={exportColumns}
label="Export Tenants"
claim="tenant_management"
/>
<button
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>
</button>
{canCreateTenant && (
<button
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>
</button>
)}
</div>
</div>
@@ -740,11 +917,22 @@ export const TenantManagementPage: React.FC = () => {
</td>
</tr>
{/* Expanded Sub-List of Organisations using getTenantOrganisations */}
{/* Expanded Sub-List of Organisations and Users */}
{isExpanded && (
<tr className="bg-slate-950/70 border-b border-slate-800/80">
<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>
</tr>
)}
@@ -987,6 +1175,26 @@ export const TenantManagementPage: React.FC = () => {
</div>
)}
</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>
);
};