RBAC Improvements
Caching improvements Maker-Checker Implementation
This commit is contained in:
@@ -351,6 +351,7 @@ export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps>
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Funding Instruments Registry"
|
||||
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
|
||||
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
|
||||
|
||||
@@ -347,6 +347,7 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Programme Rules Registry"
|
||||
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
|
||||
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
|
||||
|
||||
@@ -44,6 +44,7 @@ import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { ExportColumn } from '../../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../../utils/tableSort';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import {
|
||||
Contract,
|
||||
Award,
|
||||
@@ -73,6 +74,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError, info } = useToast();
|
||||
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
// Component state
|
||||
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
|
||||
@@ -602,6 +604,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="grant_financials"
|
||||
title="Contract Registry & Agreements"
|
||||
subtitle="Authoritative repository of bilateral legal agreements and contract files"
|
||||
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -609,17 +612,19 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
data={filteredContracts}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetCreateForm();
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
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" />
|
||||
Create Contract
|
||||
</button>
|
||||
{canAdd('grant_financials') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetCreateForm();
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
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" />
|
||||
Create Contract
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1224,17 +1229,34 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
<label className="block text-xs font-medium text-slate-200 mb-1">
|
||||
Contract Lifecycle Status <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(Number(e.target.value))}
|
||||
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{CONTRACT_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(() => {
|
||||
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
|
||||
const isSelf = isMaker(contractToUpdate?.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(Number(e.target.value))}
|
||||
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 font-medium"
|
||||
>
|
||||
{CONTRACT_STATUS_OPTIONS.map((opt) => {
|
||||
const isActiveOpt = opt.value === ContractStatus.Active;
|
||||
const isOptDisabled = isActiveOpt && !canApproveContract;
|
||||
let reasonText = '';
|
||||
if (isActiveOpt && !canApprove('grant_financials')) {
|
||||
reasonText = ' (Requires Update Permission)';
|
||||
} else if (isActiveOpt && isSelf) {
|
||||
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
{opt.label}{reasonText}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
|
||||
|
||||
@@ -87,7 +87,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canExport } = useRbac();
|
||||
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
|
||||
@@ -824,7 +824,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canApprove('evidence_risks') && (
|
||||
{canApproveRecord('evidence_risks', ev.createdBy) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -847,6 +847,11 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<span>{ev.status === ApprovalStatus.Approved ? 'Approved' : 'Approve'}</span>
|
||||
</button>
|
||||
)}
|
||||
{canApprove('evidence_risks') && isMaker(ev.createdBy) && (
|
||||
<span className="text-[10px] text-amber-400/90 italic font-mono px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded-md">
|
||||
Maker-Checker (Creator)
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -1022,7 +1027,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canApprove('evidence_risks') && (
|
||||
{canApproveRecord('evidence_risks', inspectEvidence.createdBy) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -1045,6 +1050,12 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
||||
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
|
||||
</button>
|
||||
)}
|
||||
{canApprove('evidence_risks') && isMaker(inspectEvidence.createdBy) && (
|
||||
<span className="text-xs text-amber-400 font-medium px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||
<span>Maker-Checker Policy: As the creator of this submission, you cannot approve it.</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
updateMilestone,
|
||||
} from '../../services/api';
|
||||
import { Project, Milestone, Priority, ApprovalStatus } from '../../types';
|
||||
import { useRbac } from '../../auth/rbac';
|
||||
import {
|
||||
formatDate,
|
||||
getApprovalStatusBadge,
|
||||
@@ -55,6 +56,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
|
||||
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
|
||||
@@ -175,7 +177,7 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFormOpen && (
|
||||
{!isFormOpen && canAdd('projects_milestones') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateForm}
|
||||
@@ -255,16 +257,33 @@ export const ProjectMilestonesModal: React.FC<ProjectMilestonesModalProps> = ({
|
||||
{editingMilestone && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
|
||||
<select
|
||||
{...form.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 canApproveMilestone = canApproveRecord('projects_milestones', editingMilestone.createdBy);
|
||||
const isSelf = isMaker(editingMilestone.createdBy);
|
||||
|
||||
return (
|
||||
<select
|
||||
{...form.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 && !canApproveMilestone;
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ export function ExportButton<T>({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, claims, tenantId } = useAuth();
|
||||
const { canExport } = useRbac();
|
||||
const { canExport, isUnrestricted } = useRbac();
|
||||
|
||||
const isClaimAllowed = claim ? canExport(claim) : true;
|
||||
const isClaimAllowed = claim ? canExport(claim) : isUnrestricted;
|
||||
const isDisabled = !data || data.length === 0 || !isClaimAllowed;
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
updateUserStatus,
|
||||
getSystemClaims,
|
||||
} from '../../services/api';
|
||||
import { clearCachedUserRbac } from '../../services/roleClaimsCache';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||
@@ -58,7 +59,6 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
canApprove('settings_users') ||
|
||||
canApprove('tenant_management') ||
|
||||
canApprove('tenant_self_service') ||
|
||||
canAdd('tenant_self_service') ||
|
||||
isUnrestricted;
|
||||
const canManageSessions =
|
||||
canApprove('settings_users') ||
|
||||
@@ -129,6 +129,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
return assignUserRole(userId, roleId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Role Assigned', 'Role membership updated successfully.');
|
||||
refetchUserRoles();
|
||||
setSelectedRoleToAdd('');
|
||||
@@ -139,6 +140,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
const removeRoleMutation = useMutation({
|
||||
mutationFn: (roleId: string) => removeUserRole(userId, roleId),
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Role Removed', 'Role revoked from user.');
|
||||
refetchUserRoles();
|
||||
},
|
||||
@@ -160,6 +162,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Claim Granted', 'User claim added successfully.');
|
||||
refetchUserClaims();
|
||||
setNewClaimType('');
|
||||
@@ -171,6 +174,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
const deleteClaimMutation = useMutation({
|
||||
mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId),
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Claim Revoked', 'Claim removed from user.');
|
||||
refetchUserClaims();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user