RBAC Improvements
Caching improvements Maker-Checker Implementation
This commit is contained in:
@@ -71,7 +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 { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
||||
@@ -1024,16 +1024,25 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
{...compForm.register('status')}
|
||||
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}
|
||||
disabled={opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries')}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries') ? ' (Requires Update Permission)' : ''}
|
||||
</option>
|
||||
))}
|
||||
{(() => {
|
||||
const canApproveComp = canApproveRecord('beneficiaries', editingComp?.createdBy);
|
||||
const isSelf = isMaker(editingComp?.createdBy);
|
||||
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveComp;
|
||||
let reason = '';
|
||||
if (isApprovedOpt && !canApprove('beneficiaries')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reason}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -110,7 +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 { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
||||
@@ -1022,7 +1022,7 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{canApprove('change_management') && (
|
||||
{canApproveRecord('change_management', cr.createdBy) && (
|
||||
<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"
|
||||
@@ -1030,6 +1030,11 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
<Check className="w-3 h-3" /> Approve
|
||||
</button>
|
||||
)}
|
||||
{canApprove('change_management') && isMaker(cr.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker
|
||||
</span>
|
||||
)}
|
||||
{canAdd('change_management') && (
|
||||
<button
|
||||
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
||||
@@ -1522,9 +1527,21 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
>
|
||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApprove('change_management')}>
|
||||
Approved & Enacted {!canApprove('change_management') ? '(Requires Update Permission)' : ''}
|
||||
</option>
|
||||
{(() => {
|
||||
const canApproveChange = canApproveRecord('change_management', editingChange?.createdBy);
|
||||
const isSelf = isMaker(editingChange?.createdBy);
|
||||
let reason = '';
|
||||
if (!canApprove('change_management')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApproveChange}>
|
||||
Approved & Enacted {reason}
|
||||
</option>
|
||||
);
|
||||
})()}
|
||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,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 { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
const currentUserId = sub || user?.id || '';
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
@@ -1982,16 +1982,33 @@ export const GovernancePage: React.FC = () => {
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
{...programmeForm.register('status')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(() => {
|
||||
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
|
||||
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
{...programmeForm.register('status')}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
|
||||
let reason = '';
|
||||
if (isApprovedOpt && !canApprove('governance')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reason}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -99,7 +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 { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
|
||||
|
||||
@@ -1002,7 +1002,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<Edit2 className="w-3 h-3" />
|
||||
<span>Update</span>
|
||||
</button>
|
||||
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
|
||||
{b.status !== ApprovalStatus.Approved && canApproveRecord('grant_financials', b.createdBy) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateBudgetMutation.isPending}
|
||||
@@ -1019,6 +1019,11 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
)}
|
||||
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && isMaker(b.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1159,7 +1164,7 @@ export const GrantsPage: React.FC = () => {
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{canApprove('grant_financials') && (
|
||||
{canApproveRecord('grant_financials', d.createdBy) && (
|
||||
<button
|
||||
onClick={() => approveMutation.mutate(d.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
@@ -1169,6 +1174,11 @@ export const GrantsPage: React.FC = () => {
|
||||
Approve Payment
|
||||
</button>
|
||||
)}
|
||||
{canApprove('grant_financials') && isMaker(d.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
|
||||
Maker-Checker (Creator)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1562,9 +1572,21 @@ 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} disabled={!canApprove('grant_financials')}>
|
||||
Approved {!canApprove('grant_financials') ? '(Requires Update Permission)' : ''}
|
||||
</option>
|
||||
{(() => {
|
||||
const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
|
||||
const isSelf = isMaker(editingBudget?.createdBy);
|
||||
let reason = '';
|
||||
if (!canApprove('grant_financials')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApproveBudget}>
|
||||
Approved {reason}
|
||||
</option>
|
||||
);
|
||||
})()}
|
||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
||||
</select>
|
||||
|
||||
@@ -43,6 +43,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 { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
||||
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||
@@ -100,6 +101,7 @@ export const WORKFLOW_STAGES = [
|
||||
export const MonitoringPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canExport } = useRbac();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const tabParam = searchParams.get('tab');
|
||||
@@ -435,7 +437,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||
{activeTab === 'contracts' && (
|
||||
{activeTab === 'contracts' && canAdd('grant_financials') && (
|
||||
<button
|
||||
onClick={() => setIsCreateContractModalOpen(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-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||
@@ -444,7 +446,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
<span>Create Contract & Upload PDF</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'indicators' && (
|
||||
{activeTab === 'indicators' && canAdd('evidence_risks') && (
|
||||
<button
|
||||
onClick={() => setIsIndicatorModalOpen(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-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||
@@ -453,7 +455,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
<span>Define Indicator</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'risks' && (
|
||||
{activeTab === 'risks' && canAdd('evidence_risks') && (
|
||||
<button
|
||||
onClick={openCreateRisk}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||
@@ -660,6 +662,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="evidence_risks"
|
||||
title="M&E Performance Indicators"
|
||||
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
|
||||
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -861,6 +864,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="evidence_risks"
|
||||
title="Project Risk Register & Heatmap"
|
||||
subtitle="Itemized audit log of identified risks and mitigation controls"
|
||||
filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`}
|
||||
|
||||
@@ -150,7 +150,7 @@ export const evaluateProjectScheduleRisk = (
|
||||
export const ProjectsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canExport } = useRbac();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
// Filter & Search States
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -954,22 +954,41 @@ export const ProjectsPage: React.FC = () => {
|
||||
{/* Governance Status */}
|
||||
<td className="py-4 px-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="space-y-1">
|
||||
<select
|
||||
value={p.status}
|
||||
onChange={(e) =>
|
||||
statusMutation.mutate({
|
||||
projectId: p.id,
|
||||
status: Number(e.target.value) as ApprovalStatus,
|
||||
})
|
||||
}
|
||||
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(() => {
|
||||
const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
|
||||
const isSelf = isMaker(p.createdBy);
|
||||
const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
|
||||
|
||||
return (
|
||||
<select
|
||||
value={p.status}
|
||||
disabled={!canModifyStatus}
|
||||
onChange={(e) =>
|
||||
statusMutation.mutate({
|
||||
projectId: p.id,
|
||||
status: Number(e.target.value) as ApprovalStatus,
|
||||
})
|
||||
}
|
||||
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
||||
let reasonText = '';
|
||||
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||
reasonText = ' (Requires Update Permission)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reasonText}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
||||
@@ -54,7 +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 { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -509,16 +509,25 @@ export const SiteVisitsPage: React.FC = () => {
|
||||
{...visitForm.register('status')}
|
||||
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}
|
||||
disabled={opt.value === ApprovalStatus.Approved && !canApprove('site_visits')}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === ApprovalStatus.Approved && !canApprove('site_visits') ? ' (Requires Update Permission)' : ''}
|
||||
</option>
|
||||
))}
|
||||
{(() => {
|
||||
const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
|
||||
const isSelf = isMaker(editingVisit?.createdBy);
|
||||
return APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveVisit;
|
||||
let reason = '';
|
||||
if (isApprovedOpt && !canApprove('site_visits')) {
|
||||
reason = ' (Requires Update Permission)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reason = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reason}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user