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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user