diff --git a/src/components/owner/AccessControlPersonModal.jsx b/src/components/owner/AccessControlPersonModal.jsx new file mode 100644 index 0000000..c17a8a5 --- /dev/null +++ b/src/components/owner/AccessControlPersonModal.jsx @@ -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 ( + + {label} + + ); +} + +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( + + {isOpen && ( +
+ e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

{moduleLabel} Access

+

+ {overrideCount > 0 + ? `${overrideCount} person-level override${overrideCount !== 1 ? 's' : ''}` + : 'No person-level overrides'} +

+
+
+ +
+ +
+ {/* By Role */} +
+

By Role

+
+ {nonOwnerRoles.map(role => { + const roleActions = permissions[moduleKey]?.[role.key] || []; + const grantedCount = roleActions.length; + return ( +
+
+ +
+
+

{role.name}

+
+ {actions.map(action => { + const has = roleActions.includes(action); + return ( + + {has && } + {action} + + ); + })} +
+
+ {grantedCount}/{actions.length} +
+ ); + })} +
+
+ + {/* Divider */} +
+
+
+
+
+ By Person +
+
+ + {/* Search */} +
+ + 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" + /> +
+ + {/* Person list */} +
+ {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 ( +
+
+
+ {initials} +
+
+
+ {member.name} + {hasOverrides && o.userId === member.userId && o.effect === 'deny').length > 0 ? 'Has Overrides' : 'Overridden'} variant="granted" />} +
+
+ {member.email} + + {role?.name || member.roleKey} + +
+
+ {hasOverrides && ( + + )} +
+ +
+ {actions.map(action => { + const eff = getEffectiveAccess(member.userId, member.roleKey, action); + const hasOverride = eff.source !== 'role'; + return ( + + ); + })} +
+
+ ); + })} + + {filteredMembers.length === 0 && ( +

No members found

+ )} +
+
+ + {/* Footer */} +
+

+ Person overrides take priority over role permissions +

+ +
+ +
+ )} + , + document.body + ); +} diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 60312a3..ec359a9 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -5048,6 +5048,10 @@ export const MockStoreProvider = ({ children }) => { 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([ { roleKey: 'SALES_REP', enabled: true, type: 'percent_net', rate: 5 }, { roleKey: 'CANVASSER', enabled: true, type: 'flat', rate: 300 }, @@ -5306,6 +5310,21 @@ export const MockStoreProvider = ({ children }) => { [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, setRoleCommissionOverride: (override) => { setRoleCommissionOverrides(prev => { diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js index 83e5fe0..7450661 100644 --- a/src/hooks/usePermissions.js +++ b/src/hooks/usePermissions.js @@ -13,7 +13,7 @@ import { resolveOrgRoleKey } from '../utils/permissions'; */ export const usePermissions = () => { const { user } = useAuth(); - const { orgPermissions, orgMembers } = useMockStore(); + const { orgPermissions, orgMembers, userPermissionOverrides } = useMockStore(); const orgRoleKey = useMemo( () => resolveOrgRoleKey(user, orgMembers), @@ -24,10 +24,19 @@ export const usePermissions = () => { (moduleKey, action) => { if (!orgRoleKey) return false; 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] || []; return actions.includes(action); }, - [orgRoleKey, orgPermissions], + [orgRoleKey, orgPermissions, user, userPermissionOverrides], ); const canAny = useCallback( diff --git a/src/pages/owner/OrgSettings.jsx b/src/pages/owner/OrgSettings.jsx index ae7afd0..b350c16 100644 --- a/src/pages/owner/OrgSettings.jsx +++ b/src/pages/owner/OrgSettings.jsx @@ -13,6 +13,7 @@ import { usePermissions } from '../../hooks/usePermissions'; import { toast } from 'sonner'; import { computeRolePermissionStats } from '../../utils/permissions'; import OrgTeamManagement from '../../components/owner/OrgTeamManagement'; +import AccessControlPersonModal from '../../components/owner/AccessControlPersonModal'; const COMMISSION_TYPES = [ { value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' }, @@ -231,13 +232,25 @@ const RoleManagementSection = ({ roles, members }) => { // ──────────────────────────────────────────────────── // 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 [filterRole, setFilterRole] = useState('all'); const [changeLog, setChangeLog] = useState([]); const [showLog, setShowLog] = useState(false); + const [personModalModule, setPersonModalModule] = useState(null); 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 entries = Object.entries(permissionModules); if (filterModule === 'all') return entries; @@ -454,7 +467,7 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate } {module.label}
-
+
+
@@ -531,7 +557,22 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
{moduleEntries.map(([moduleKey, module]) => (
- {module.label} +
+ {module.label} + +
{module.actions.map(action => { const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action); @@ -607,6 +648,21 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate } )} + {/* ── Person Override Modal ── */} + 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 ── */}
@@ -614,6 +670,7 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }

Org Owner always has full access to all modules and cannot be restricted.

Permission changes apply immediately to all users with the affected role. Use the Save button to persist changes or Reset to revert.

+

Person overrides take priority over role permissions. A denied person override blocks access even if the role grants it.

@@ -1053,7 +1110,7 @@ const OrgSettings = () => { id: 'access', icon: Shield, title: 'Access Control Matrix', - subtitle: 'Configure module-level permissions per role', + subtitle: 'Configure module-level permissions by role and by person', color: '#10b981', content: ( { permissionModules={store.PERMISSION_MODULES} roles={store.orgRoles} onUpdate={store.updateOrgPermissions} + members={store.orgMembers} + userPermissionOverrides={store.userPermissionOverrides} + onSetUserOverride={store.setUserPermissionOverride} + onRemoveUserOverride={store.removeUserPermissionOverride} /> ), },