access control by role plus by person in orgsettings
This commit is contained in:
@@ -0,0 +1,293 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, Search, Shield, Users, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
function StateChip({ label, variant }) {
|
||||||
|
const styles = {
|
||||||
|
role: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||||
|
granted: 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||||
|
denied: 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${styles[variant]}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccessControlPersonModal({
|
||||||
|
isOpen, onClose,
|
||||||
|
moduleKey, moduleLabel, actions,
|
||||||
|
permissions, roles, members,
|
||||||
|
userPermissionOverrides,
|
||||||
|
onSetOverride, onRemoveOverride,
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const nonOwnerMembers = useMemo(() =>
|
||||||
|
(members || []).filter(m => m.roleKey !== 'OWNER' && m.status === 'active'),
|
||||||
|
[members]
|
||||||
|
);
|
||||||
|
|
||||||
|
const nonOwnerRoles = useMemo(() =>
|
||||||
|
(roles || []).filter(r => r.key !== 'OWNER'),
|
||||||
|
[roles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const moduleOverrides = useMemo(() =>
|
||||||
|
(userPermissionOverrides || []).filter(o => o.moduleKey === moduleKey),
|
||||||
|
[userPermissionOverrides, moduleKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getEffectiveAccess = (userId, roleKey, action) => {
|
||||||
|
const override = moduleOverrides.find(
|
||||||
|
o => o.userId === userId && o.action === action
|
||||||
|
);
|
||||||
|
if (override) return { access: override.effect === 'grant', source: override.effect === 'grant' ? 'granted' : 'denied' };
|
||||||
|
const roleActions = permissions[moduleKey]?.[roleKey] || [];
|
||||||
|
return { access: roleActions.includes(action), source: 'role' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (userId, roleKey, action) => {
|
||||||
|
const override = moduleOverrides.find(
|
||||||
|
o => o.userId === userId && o.action === action
|
||||||
|
);
|
||||||
|
if (override) {
|
||||||
|
onRemoveOverride(userId, moduleKey, action);
|
||||||
|
} else {
|
||||||
|
const roleActions = permissions[moduleKey]?.[roleKey] || [];
|
||||||
|
onSetOverride(userId, moduleKey, action, roleActions.includes(action) ? 'deny' : 'grant');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearModuleOverrides = (userId) => {
|
||||||
|
const toRemove = moduleOverrides.filter(o => o.userId === userId);
|
||||||
|
toRemove.forEach(o => onRemoveOverride(userId, moduleKey, o.action));
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMembers = useMemo(() => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const list = nonOwnerMembers.filter(m =>
|
||||||
|
!q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) || m.roleKey.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const aHas = moduleOverrides.some(o => o.userId === a.userId);
|
||||||
|
const bHas = moduleOverrides.some(o => o.userId === b.userId);
|
||||||
|
if (aHas && !bHas) return -1;
|
||||||
|
if (!aHas && bHas) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [nonOwnerMembers, search, moduleOverrides]);
|
||||||
|
|
||||||
|
const overrideCount = useMemo(() => {
|
||||||
|
const userIds = new Set(moduleOverrides.map(o => o.userId));
|
||||||
|
return userIds.size;
|
||||||
|
}, [moduleOverrides]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-lg flex flex-col max-h-[85vh]"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07] shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 flex items-center justify-center">
|
||||||
|
<Shield size={15} className="text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold text-zinc-900 dark:text-white text-sm leading-none">{moduleLabel} Access</h2>
|
||||||
|
<p className="text-[11px] text-zinc-400 mt-0.5">
|
||||||
|
{overrideCount > 0
|
||||||
|
? `${overrideCount} person-level override${overrideCount !== 1 ? 's' : ''}`
|
||||||
|
: 'No person-level overrides'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-5">
|
||||||
|
{/* By Role */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest mb-3">By Role</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{nonOwnerRoles.map(role => {
|
||||||
|
const roleActions = permissions[moduleKey]?.[role.key] || [];
|
||||||
|
const grantedCount = roleActions.length;
|
||||||
|
return (
|
||||||
|
<div key={role.key} className="flex items-center gap-3 p-3 rounded-xl border border-zinc-100 dark:border-white/[0.07] bg-zinc-50 dark:bg-zinc-800/40">
|
||||||
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${role.color}15` }}>
|
||||||
|
<Users size={14} style={{ color: role.color }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{role.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{actions.map(action => {
|
||||||
|
const has = roleActions.includes(action);
|
||||||
|
return (
|
||||||
|
<span key={action} className={`text-[9px] font-bold capitalize px-1.5 py-0.5 rounded ${
|
||||||
|
has
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'bg-zinc-100 dark:bg-white/5 text-zinc-400 dark:text-zinc-600'
|
||||||
|
}`}>
|
||||||
|
{has && <Check size={7} className="inline mr-0.5 -mt-0.5" />}
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-zinc-400 shrink-0">{grantedCount}/{actions.length}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-zinc-100 dark:border-white/[0.07]" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="px-3 bg-white dark:bg-zinc-900 text-[11px] font-bold text-zinc-400 uppercase tracking-widest">By Person</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name, email, or role..."
|
||||||
|
className="w-full pl-8 pr-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition-shadow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Person list */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{filteredMembers.map(member => {
|
||||||
|
const role = roles.find(r => r.key === member.roleKey);
|
||||||
|
const hasOverrides = moduleOverrides.some(o => o.userId === member.userId);
|
||||||
|
const initials = member.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`p-3 rounded-xl border transition-colors ${
|
||||||
|
hasOverrides
|
||||||
|
? 'border-emerald-200 dark:border-emerald-500/20 bg-emerald-50/30 dark:bg-emerald-500/5'
|
||||||
|
: 'border-zinc-100 dark:border-white/[0.05] bg-transparent hover:bg-zinc-50 dark:hover:bg-white/[0.03]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 text-[11px] font-bold"
|
||||||
|
style={{ backgroundColor: `${role?.color || '#3b82f6'}15`, color: role?.color || '#3b82f6' }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold text-zinc-800 dark:text-zinc-100 truncate">{member.name}</span>
|
||||||
|
{hasOverrides && <StateChip label={moduleOverrides.filter(o => o.userId === member.userId && o.effect === 'deny').length > 0 ? 'Has Overrides' : 'Overridden'} variant="granted" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-[11px] text-zinc-400">{member.email}</span>
|
||||||
|
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded-full border border-zinc-200 dark:border-white/10 text-zinc-500">
|
||||||
|
{role?.name || member.roleKey}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasOverrides && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleClearModuleOverrides(member.userId)}
|
||||||
|
className="text-[9px] font-semibold text-red-500 hover:text-red-600 dark:hover:text-red-400 transition-colors shrink-0 px-2 py-1 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 pl-11">
|
||||||
|
{actions.map(action => {
|
||||||
|
const eff = getEffectiveAccess(member.userId, member.roleKey, action);
|
||||||
|
const hasOverride = eff.source !== 'role';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggle(member.userId, member.roleKey, action)}
|
||||||
|
className={`group px-2.5 py-1.5 rounded-lg text-[11px] font-semibold capitalize border transition-all duration-150 ${
|
||||||
|
eff.access
|
||||||
|
? hasOverride
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-300 dark:border-emerald-500/30 text-emerald-700 dark:text-emerald-400 ring-1 ring-emerald-200 dark:ring-emerald-500/20'
|
||||||
|
: 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: hasOverride
|
||||||
|
? 'bg-amber-50 dark:bg-amber-500/10 border-amber-300 dark:border-amber-500/30 text-amber-700 dark:text-amber-400 ring-1 ring-amber-200 dark:ring-amber-500/20'
|
||||||
|
: 'bg-zinc-50 dark:bg-white/[0.03] border-zinc-200 dark:border-white/10 text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400'
|
||||||
|
}`}
|
||||||
|
title={`${action} — ${eff.source === 'role' ? 'Via Role' : eff.source === 'granted' ? 'Granted' : 'Denied'}${hasOverride ? ' (click to revert)' : ' (click to override)'}`}
|
||||||
|
>
|
||||||
|
{eff.access && <Check size={9} className="inline mr-0.5 -mt-0.5" />}
|
||||||
|
{action}
|
||||||
|
{hasOverride && (
|
||||||
|
<span className={`ml-1 text-[8px] font-bold uppercase px-1 py-0 rounded ${
|
||||||
|
eff.source === 'granted'
|
||||||
|
? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
|
{eff.source === 'granted' ? 'Granted' : 'Denied'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredMembers.length === 0 && (
|
||||||
|
<p className="text-center text-sm text-zinc-400 py-6">No members found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-3 border-t border-zinc-100 dark:border-white/[0.07] shrink-0 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
Person overrides take priority over role permissions
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5048,6 +5048,10 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
subcontractor_tasks: { label: 'Subcontractor Tasks', actions: ['view','assign','receive'] },
|
subcontractor_tasks: { label: 'Subcontractor Tasks', actions: ['view','assign','receive'] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Per-user permission overrides: grant or deny specific module+action regardless of role
|
||||||
|
// Shape: [{ userId, moduleKey, action, effect: 'grant' | 'deny' }]
|
||||||
|
const [userPermissionOverrides, setUserPermissionOverrides] = useState([]);
|
||||||
|
|
||||||
const [roleCommissionOverrides, setRoleCommissionOverrides] = useState([
|
const [roleCommissionOverrides, setRoleCommissionOverrides] = useState([
|
||||||
{ roleKey: 'SALES_REP', enabled: true, type: 'percent_net', rate: 5 },
|
{ roleKey: 'SALES_REP', enabled: true, type: 'percent_net', rate: 5 },
|
||||||
{ roleKey: 'CANVASSER', enabled: true, type: 'flat', rate: 300 },
|
{ roleKey: 'CANVASSER', enabled: true, type: 'flat', rate: 300 },
|
||||||
@@ -5306,6 +5310,21 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
[moduleKey]: { ...prev[moduleKey], [roleKey]: actions },
|
[moduleKey]: { ...prev[moduleKey], [roleKey]: actions },
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
userPermissionOverrides,
|
||||||
|
setUserPermissionOverride: (userId, moduleKey, action, effect) => {
|
||||||
|
setUserPermissionOverrides(prev => {
|
||||||
|
const filtered = prev.filter(o => !(o.userId === userId && o.moduleKey === moduleKey && o.action === action));
|
||||||
|
return [...filtered, { userId, moduleKey, action, effect }];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeUserPermissionOverride: (userId, moduleKey, action) => {
|
||||||
|
setUserPermissionOverrides(prev =>
|
||||||
|
prev.filter(o => !(o.userId === userId && o.moduleKey === moduleKey && o.action === action))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
clearUserPermissionOverrides: (userId) => {
|
||||||
|
setUserPermissionOverrides(prev => prev.filter(o => o.userId !== userId));
|
||||||
|
},
|
||||||
roleCommissionOverrides,
|
roleCommissionOverrides,
|
||||||
setRoleCommissionOverride: (override) => {
|
setRoleCommissionOverride: (override) => {
|
||||||
setRoleCommissionOverrides(prev => {
|
setRoleCommissionOverrides(prev => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { resolveOrgRoleKey } from '../utils/permissions';
|
|||||||
*/
|
*/
|
||||||
export const usePermissions = () => {
|
export const usePermissions = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { orgPermissions, orgMembers } = useMockStore();
|
const { orgPermissions, orgMembers, userPermissionOverrides } = useMockStore();
|
||||||
|
|
||||||
const orgRoleKey = useMemo(
|
const orgRoleKey = useMemo(
|
||||||
() => resolveOrgRoleKey(user, orgMembers),
|
() => resolveOrgRoleKey(user, orgMembers),
|
||||||
@@ -24,10 +24,19 @@ export const usePermissions = () => {
|
|||||||
(moduleKey, action) => {
|
(moduleKey, action) => {
|
||||||
if (!orgRoleKey) return false;
|
if (!orgRoleKey) return false;
|
||||||
if (orgRoleKey === 'OWNER') return true;
|
if (orgRoleKey === 'OWNER') return true;
|
||||||
|
|
||||||
|
// Person-level override takes priority
|
||||||
|
if (user?.id && userPermissionOverrides) {
|
||||||
|
const override = userPermissionOverrides.find(
|
||||||
|
o => o.userId === user.id && o.moduleKey === moduleKey && o.action === action
|
||||||
|
);
|
||||||
|
if (override) return override.effect === 'grant';
|
||||||
|
}
|
||||||
|
|
||||||
const actions = orgPermissions[moduleKey]?.[orgRoleKey] || [];
|
const actions = orgPermissions[moduleKey]?.[orgRoleKey] || [];
|
||||||
return actions.includes(action);
|
return actions.includes(action);
|
||||||
},
|
},
|
||||||
[orgRoleKey, orgPermissions],
|
[orgRoleKey, orgPermissions, user, userPermissionOverrides],
|
||||||
);
|
);
|
||||||
|
|
||||||
const canAny = useCallback(
|
const canAny = useCallback(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { usePermissions } from '../../hooks/usePermissions';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { computeRolePermissionStats } from '../../utils/permissions';
|
import { computeRolePermissionStats } from '../../utils/permissions';
|
||||||
import OrgTeamManagement from '../../components/owner/OrgTeamManagement';
|
import OrgTeamManagement from '../../components/owner/OrgTeamManagement';
|
||||||
|
import AccessControlPersonModal from '../../components/owner/AccessControlPersonModal';
|
||||||
|
|
||||||
const COMMISSION_TYPES = [
|
const COMMISSION_TYPES = [
|
||||||
{ value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' },
|
{ value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' },
|
||||||
@@ -231,13 +232,25 @@ const RoleManagementSection = ({ roles, members }) => {
|
|||||||
// ────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────
|
||||||
// 3. Access Control Matrix (Full)
|
// 3. Access Control Matrix (Full)
|
||||||
// ────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────
|
||||||
const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }) => {
|
const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate, members, userPermissionOverrides, onSetUserOverride, onRemoveUserOverride }) => {
|
||||||
const [filterModule, setFilterModule] = useState('all');
|
const [filterModule, setFilterModule] = useState('all');
|
||||||
const [filterRole, setFilterRole] = useState('all');
|
const [filterRole, setFilterRole] = useState('all');
|
||||||
const [changeLog, setChangeLog] = useState([]);
|
const [changeLog, setChangeLog] = useState([]);
|
||||||
const [showLog, setShowLog] = useState(false);
|
const [showLog, setShowLog] = useState(false);
|
||||||
|
const [personModalModule, setPersonModalModule] = useState(null);
|
||||||
const snapshotRef = useRef(JSON.stringify(permissions));
|
const snapshotRef = useRef(JSON.stringify(permissions));
|
||||||
|
|
||||||
|
const moduleOverrideCounts = useMemo(() => {
|
||||||
|
const counts = {};
|
||||||
|
for (const o of (userPermissionOverrides || [])) {
|
||||||
|
if (!counts[o.moduleKey]) counts[o.moduleKey] = new Set();
|
||||||
|
counts[o.moduleKey].add(o.userId);
|
||||||
|
}
|
||||||
|
const result = {};
|
||||||
|
for (const [k, v] of Object.entries(counts)) result[k] = v.size;
|
||||||
|
return result;
|
||||||
|
}, [userPermissionOverrides]);
|
||||||
|
|
||||||
const moduleEntries = useMemo(() => {
|
const moduleEntries = useMemo(() => {
|
||||||
const entries = Object.entries(permissionModules);
|
const entries = Object.entries(permissionModules);
|
||||||
if (filterModule === 'all') return entries;
|
if (filterModule === 'all') return entries;
|
||||||
@@ -454,7 +467,7 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
|
|||||||
<Shield size={12} className="text-zinc-400 dark:text-zinc-600" />
|
<Shield size={12} className="text-zinc-400 dark:text-zinc-600" />
|
||||||
{module.label}
|
{module.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleAllForModule(moduleKey, true)}
|
onClick={() => toggleAllForModule(moduleKey, true)}
|
||||||
@@ -471,6 +484,19 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
|
|||||||
>
|
>
|
||||||
<Square size={10} />
|
<Square size={10} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPersonModalModule(moduleKey)}
|
||||||
|
title={`Manage person overrides for ${module.label}`}
|
||||||
|
className="relative p-0.5 rounded hover:bg-blue-50 dark:hover:bg-blue-500/10 text-zinc-300 dark:text-zinc-700 hover:text-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Users size={10} />
|
||||||
|
{moduleOverrideCounts[moduleKey] > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-blue-500 text-white text-[7px] font-bold flex items-center justify-center">
|
||||||
|
{moduleOverrideCounts[moduleKey]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -531,7 +557,22 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
|
|||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
{moduleEntries.map(([moduleKey, module]) => (
|
{moduleEntries.map(([moduleKey, module]) => (
|
||||||
<div key={moduleKey} className="space-y-1.5">
|
<div key={moduleKey} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">{module.label}</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">{module.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPersonModalModule(moduleKey)}
|
||||||
|
className="relative flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-500/20 hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Users size={9} />
|
||||||
|
Person
|
||||||
|
{moduleOverrideCounts[moduleKey] > 0 && (
|
||||||
|
<span className="ml-0.5 w-3.5 h-3.5 rounded-full bg-blue-500 text-white text-[7px] font-bold flex items-center justify-center">
|
||||||
|
{moduleOverrideCounts[moduleKey]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{module.actions.map(action => {
|
{module.actions.map(action => {
|
||||||
const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
|
const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
|
||||||
@@ -607,6 +648,21 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Person Override Modal ── */}
|
||||||
|
<AccessControlPersonModal
|
||||||
|
isOpen={!!personModalModule}
|
||||||
|
onClose={() => setPersonModalModule(null)}
|
||||||
|
moduleKey={personModalModule || ''}
|
||||||
|
moduleLabel={personModalModule ? (permissionModules[personModalModule]?.label || personModalModule) : ''}
|
||||||
|
actions={personModalModule ? (permissionModules[personModalModule]?.actions || []) : []}
|
||||||
|
permissions={permissions}
|
||||||
|
roles={roles}
|
||||||
|
members={members}
|
||||||
|
userPermissionOverrides={userPermissionOverrides}
|
||||||
|
onSetOverride={onSetUserOverride}
|
||||||
|
onRemoveOverride={onRemoveUserOverride}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Info Footer ── */}
|
{/* ── Info Footer ── */}
|
||||||
<div className="p-4 bg-zinc-50 dark:bg-white/[0.02] rounded-xl border border-zinc-200 dark:border-white/5">
|
<div className="p-4 bg-zinc-50 dark:bg-white/[0.02] rounded-xl border border-zinc-200 dark:border-white/5">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -614,6 +670,7 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
|
|||||||
<div className="text-[11px] text-zinc-500 space-y-1">
|
<div className="text-[11px] text-zinc-500 space-y-1">
|
||||||
<p><span className="font-bold text-zinc-700 dark:text-zinc-400">Org Owner</span> always has full access to all modules and cannot be restricted.</p>
|
<p><span className="font-bold text-zinc-700 dark:text-zinc-400">Org Owner</span> always has full access to all modules and cannot be restricted.</p>
|
||||||
<p>Permission changes apply immediately to all users with the affected role. Use the <span className="font-mono">Save</span> button to persist changes or <span className="font-mono">Reset</span> to revert.</p>
|
<p>Permission changes apply immediately to all users with the affected role. Use the <span className="font-mono">Save</span> button to persist changes or <span className="font-mono">Reset</span> to revert.</p>
|
||||||
|
<p><span className="font-bold text-zinc-700 dark:text-zinc-400">Person overrides</span> take priority over role permissions. A denied person override blocks access even if the role grants it.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1053,7 +1110,7 @@ const OrgSettings = () => {
|
|||||||
id: 'access',
|
id: 'access',
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
title: 'Access Control Matrix',
|
title: 'Access Control Matrix',
|
||||||
subtitle: 'Configure module-level permissions per role',
|
subtitle: 'Configure module-level permissions by role and by person',
|
||||||
color: '#10b981',
|
color: '#10b981',
|
||||||
content: (
|
content: (
|
||||||
<AccessControlMatrix
|
<AccessControlMatrix
|
||||||
@@ -1061,6 +1118,10 @@ const OrgSettings = () => {
|
|||||||
permissionModules={store.PERMISSION_MODULES}
|
permissionModules={store.PERMISSION_MODULES}
|
||||||
roles={store.orgRoles}
|
roles={store.orgRoles}
|
||||||
onUpdate={store.updateOrgPermissions}
|
onUpdate={store.updateOrgPermissions}
|
||||||
|
members={store.orgMembers}
|
||||||
|
userPermissionOverrides={store.userPermissionOverrides}
|
||||||
|
onSetUserOverride={store.setUserPermissionOverride}
|
||||||
|
onRemoveUserOverride={store.removeUserPermissionOverride}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user