adds access modifiers for each role

This commit is contained in:
Satyam Rastogi
2026-05-12 21:08:19 +05:30
parent fea4ad2d22
commit 455462ded4
7 changed files with 517 additions and 119 deletions
+38
View File
@@ -0,0 +1,38 @@
import { usePermissions } from '../hooks/usePermissions';
/**
* Conditionally renders children based on org-level permissions.
*
* Props:
* module permission module key (e.g. 'jobs', 'leads', 'financials')
* action single action string (e.g. 'view', 'edit', 'delete')
* any array of actions; renders if user has ANY of them
* all array of actions; renders if user has ALL of them
* fallback optional element rendered when access is denied
*
* Usage:
* <PermissionGate module="jobs" action="edit">
* <EditButton />
* </PermissionGate>
*
* <PermissionGate module="financials" any={['view', 'edit']} fallback={<LockedBanner />}>
* <FinancialsPanel />
* </PermissionGate>
*/
const PermissionGate = ({ module, action, any, all, fallback = null, children }) => {
const { can, canAny, canAll } = usePermissions();
let allowed = false;
if (action) {
allowed = can(module, action);
} else if (any) {
allowed = canAny(module, any);
} else if (all) {
allowed = canAll(module, all);
}
return allowed ? children : fallback;
};
export default PermissionGate;
+44
View File
@@ -0,0 +1,44 @@
import { useCallback, useMemo } from 'react';
import { useAuth } from '../context/AuthContext';
import { useMockStore } from '../data/mockStore';
import { resolveOrgRoleKey } from '../utils/permissions';
/**
* Hook that provides permission checks against the dynamic orgPermissions matrix.
*
* Usage:
* const { can, canAny, canAll, orgRoleKey } = usePermissions();
* if (can('jobs', 'edit')) { ... }
* if (canAny('leads', ['log', 'verify'])) { ... }
*/
export const usePermissions = () => {
const { user } = useAuth();
const { orgPermissions, orgMembers } = useMockStore();
const orgRoleKey = useMemo(
() => resolveOrgRoleKey(user, orgMembers),
[user, orgMembers],
);
const can = useCallback(
(moduleKey, action) => {
if (!orgRoleKey) return false;
if (orgRoleKey === 'OWNER') return true;
const actions = orgPermissions[moduleKey]?.[orgRoleKey] || [];
return actions.includes(action);
},
[orgRoleKey, orgPermissions],
);
const canAny = useCallback(
(moduleKey, actions) => actions.some(a => can(moduleKey, a)),
[can],
);
const canAll = useCallback(
(moduleKey, actions) => actions.every(a => can(moduleKey, a)),
[can],
);
return { can, canAny, canAll, orgRoleKey };
};
+17 -1
View File
@@ -1,11 +1,13 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useMockStore } from '../data/mockStore'; import { useMockStore } from '../data/mockStore';
import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown } from 'lucide-react'; import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown, ShieldOff } from 'lucide-react';
import { SpotlightCard } from '../components/SpotlightCard'; import { SpotlightCard } from '../components/SpotlightCard';
import AnimatedNumber from '../components/AnimatedNumber'; import AnimatedNumber from '../components/AnimatedNumber';
import { usePermissions } from '../hooks/usePermissions';
const LeaderboardPage = () => { const LeaderboardPage = () => {
const { users, salesHistory } = useMockStore(); const { users, salesHistory } = useMockStore();
const { can } = usePermissions();
const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom' const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom'
const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate' const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate'
@@ -108,6 +110,20 @@ const LeaderboardPage = () => {
); );
}; };
if (!can('leaderboard', 'view')) {
return (
<div className="min-h-full flex items-center justify-center p-8">
<div className="text-center space-y-4 max-w-md">
<div className="mx-auto w-16 h-16 rounded-2xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
<ShieldOff size={28} className="text-zinc-400" />
</div>
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">Access Restricted</h2>
<p className="text-sm text-zinc-500">You don't have permission to view the Leaderboard. Contact your org admin for access.</p>
</div>
</div>
);
}
return ( return (
<div className="min-h-full p-8 max-w-7xl mx-auto space-y-8 pb-20"> <div className="min-h-full p-8 max-w-7xl mx-auto space-y-8 pb-20">
{/* Header */} {/* Header */}
+11 -1
View File
@@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import { useMockStore } from '../data/mockStore'; import { useMockStore } from '../data/mockStore';
import { usePermissions } from '../hooks/usePermissions';
import PermissionGate from '../components/PermissionGate';
import { import {
Plus, Search, MapPin, Phone, Zap, FileText, Plus, Search, MapPin, Phone, Zap, FileText,
Clock, User, ChevronRight, Filter, Clock, User, ChevronRight, Filter, Lock,
} from 'lucide-react'; } from 'lucide-react';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -183,6 +185,7 @@ function EmptyState({ isDark, onNewLead }) {
export default function LeadsListPage() { export default function LeadsListPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const { leads } = useMockStore(); const { leads } = useMockStore();
const { can } = usePermissions();
const navigate = useNavigate(); const navigate = useNavigate();
const isDark = theme === 'dark'; const isDark = theme === 'dark';
@@ -214,6 +217,7 @@ export default function LeadsListPage() {
{leads.length} total lead{leads.length !== 1 ? 's' : ''} {leads.length} total lead{leads.length !== 1 ? 's' : ''}
</p> </p>
</div> </div>
{can('leads', 'log') ? (
<button <button
type="button" type="button"
onClick={() => navigate('/emp/fa/leads/new')} onClick={() => navigate('/emp/fa/leads/new')}
@@ -223,6 +227,12 @@ export default function LeadsListPage() {
<Plus size={14} /> <Plus size={14} />
New Lead New Lead
</button> </button>
) : (
<div className="flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-zinc-400 bg-zinc-200 dark:bg-zinc-800 cursor-not-allowed shrink-0" title="You don't have permission to create leads">
<Lock size={14} />
New Lead
</div>
)}
</div> </div>
{/* --- Search bar --- */} {/* --- Search bar --- */}
+300 -28
View File
@@ -1,14 +1,16 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Settings, Building2, Shield, Users, DollarSign, ChevronDown, ChevronRight, Settings, Building2, Shield, Users, DollarSign, ChevronDown, ChevronRight,
Search, Check, X, Plus, Trash2, Edit3, Save, User, Crown, Search, Check, X, Plus, Trash2, Edit3, Save, User, Crown,
ToggleLeft, ToggleRight, Info, ArrowRight, Mail, Phone, MapPin, Clock, ToggleLeft, ToggleRight, Info, ArrowRight, Mail, Phone, MapPin, Clock,
AlertTriangle, UserPlus, UserMinus, Eye, Pencil, Lock AlertTriangle, UserPlus, UserMinus, Eye, Pencil, Lock,
RotateCcw, Filter, CheckSquare, Square, BarChart3, History
} from 'lucide-react'; } from 'lucide-react';
import { useMockStore } from '../../data/mockStore'; import { useMockStore } from '../../data/mockStore';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { computeRolePermissionStats } from '../../utils/permissions';
const COMMISSION_TYPES = [ const COMMISSION_TYPES = [
{ value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' }, { value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' },
@@ -225,35 +227,213 @@ const RoleManagementSection = ({ roles, members }) => {
}; };
// ──────────────────────────────────────────────────── // ────────────────────────────────────────────────────
// 3. Access Control Matrix // 3. Access Control Matrix (Full)
// ──────────────────────────────────────────────────── // ────────────────────────────────────────────────────
const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }) => { const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }) => {
const togglePermission = (moduleKey, roleKey, action) => { const [filterModule, setFilterModule] = useState('all');
const current = permissions[moduleKey]?.[roleKey] || []; const [filterRole, setFilterRole] = useState('all');
const updated = current.includes(action) const [changeLog, setChangeLog] = useState([]);
? current.filter(a => a !== action) const [showLog, setShowLog] = useState(false);
: [...current, action]; const snapshotRef = useRef(JSON.stringify(permissions));
onUpdate(moduleKey, roleKey, updated);
};
const moduleEntries = Object.entries(permissionModules); const moduleEntries = useMemo(() => {
const entries = Object.entries(permissionModules);
if (filterModule === 'all') return entries;
return entries.filter(([key]) => key === filterModule);
}, [permissionModules, filterModule]);
const visibleRoles = useMemo(() => {
if (filterRole === 'all') return roles;
return roles.filter(r => r.key === filterRole);
}, [roles, filterRole]);
const isDirty = JSON.stringify(permissions) !== snapshotRef.current;
const togglePermission = useCallback((moduleKey, roleKey, action) => {
const current = permissions[moduleKey]?.[roleKey] || [];
const has = current.includes(action);
const updated = has ? current.filter(a => a !== action) : [...current, action];
onUpdate(moduleKey, roleKey, updated);
const modLabel = permissionModules[moduleKey]?.label || moduleKey;
const roleName = roles.find(r => r.key === roleKey)?.name || roleKey;
setChangeLog(prev => [
{ id: Date.now(), module: modLabel, role: roleName, action, granted: !has, time: new Date().toLocaleTimeString() },
...prev.slice(0, 49),
]);
}, [permissions, onUpdate, permissionModules, roles]);
const toggleAllForRole = useCallback((roleKey, grant) => {
for (const [moduleKey, mod] of Object.entries(permissionModules)) {
onUpdate(moduleKey, roleKey, grant ? [...mod.actions] : []);
}
const roleName = roles.find(r => r.key === roleKey)?.name || roleKey;
toast.success(grant ? `All permissions granted for ${roleName}` : `All permissions revoked for ${roleName}`);
}, [permissionModules, onUpdate, roles]);
const toggleAllForModule = useCallback((moduleKey, grant) => {
const mod = permissionModules[moduleKey];
if (!mod) return;
for (const role of roles) {
if (role.key === 'OWNER') continue;
onUpdate(moduleKey, role.key, grant ? [...mod.actions] : []);
}
toast.success(grant ? `All roles granted access to ${mod.label}` : `All roles revoked from ${mod.label}`);
}, [permissionModules, roles, onUpdate]);
const handleReset = useCallback(() => {
const snap = JSON.parse(snapshotRef.current);
for (const [moduleKey, roleMap] of Object.entries(snap)) {
for (const [roleKey, actions] of Object.entries(roleMap)) {
onUpdate(moduleKey, roleKey, actions);
}
}
setChangeLog([]);
toast.success('Permissions reset to last saved state');
}, [onUpdate]);
const handleSave = useCallback(() => {
snapshotRef.current = JSON.stringify(permissions);
setChangeLog([]);
toast.success('Access control permissions saved');
}, [permissions]);
const roleStats = useMemo(() => {
const map = {};
for (const role of roles) {
if (role.key === 'OWNER') {
const total = Object.values(permissionModules).reduce((s, m) => s + m.actions.length, 0);
map[role.key] = { granted: total, total };
} else {
map[role.key] = computeRolePermissionStats(role.key, permissions, permissionModules);
}
}
return map;
}, [roles, permissions, permissionModules]);
return ( return (
<div className="space-y-4"> <div className="space-y-5">
<div className="flex items-center gap-2 text-xs text-zinc-500"> {/* ── Toolbar ── */}
<Info size={12} /> Click cells to toggle permissions per role <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
{/* Module filter */}
<div className="flex items-center gap-1.5">
<Filter size={12} className="text-zinc-400" />
<select
value={filterModule}
onChange={e => setFilterModule(e.target.value)}
className="bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-zinc-700 dark:text-zinc-300 outline-none"
>
<option value="all">All Modules</option>
{Object.entries(permissionModules).map(([key, mod]) => (
<option key={key} value={key}>{mod.label}</option>
))}
</select>
</div>
{/* Role filter */}
<select
value={filterRole}
onChange={e => setFilterRole(e.target.value)}
className="bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-zinc-700 dark:text-zinc-300 outline-none"
>
<option value="all">All Roles</option>
{roles.map(r => (
<option key={r.key} value={r.key}>{r.name}</option>
))}
</select>
{/* Change log toggle */}
<button
type="button"
onClick={() => setShowLog(p => !p)}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-[10px] font-semibold transition-colors ${
showLog
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 text-blue-600 dark:text-blue-400'
: 'bg-zinc-50 dark:bg-white/[0.03] border-zinc-200 dark:border-white/10 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<History size={10} />
Log{changeLog.length > 0 && ` (${changeLog.length})`}
</button>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<button
type="button"
onClick={handleReset}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-white/10 text-xs font-semibold text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
>
<RotateCcw size={11} /> Reset
</button>
)}
<button
type="button"
onClick={handleSave}
disabled={!isDirty}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 border border-emerald-200 dark:border-emerald-500/30 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-500/30 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Save size={11} /> Save Changes
</button>
</div>
</div> </div>
{/* Desktop Matrix */} {/* ── Role Summary Stats ── */}
<div className="hidden lg:block overflow-x-auto"> <div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{roles.map(role => {
const stats = roleStats[role.key];
const pct = stats.total > 0 ? Math.round((stats.granted / stats.total) * 100) : 0;
return (
<div key={role.key} className="px-3 py-2.5 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-1.5 mb-1.5">
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: role.color }} />
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">{role.name.split(' ')[0]}</span>
</div>
<div className="flex items-end justify-between">
<span className="text-lg font-bold font-mono" style={{ color: role.color }}>{stats.granted}</span>
<span className="text-[10px] text-zinc-400">/ {stats.total}</span>
</div>
<div className="mt-1.5 h-1 rounded-full bg-zinc-200 dark:bg-white/10 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${pct}%`, backgroundColor: role.color }}
/>
</div>
</div>
);
})}
</div>
{/* ── Desktop Matrix ── */}
<div className="hidden lg:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/[0.06]">
<table className="w-full border-collapse"> <table className="w-full border-collapse">
<thead> <thead>
<tr> <tr className="bg-zinc-50/50 dark:bg-white/[0.02]">
<th className="text-left text-[10px] font-bold uppercase tracking-wider text-zinc-500 px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-48">Module</th> <th className="text-left text-[10px] font-bold uppercase tracking-wider text-zinc-500 px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-48">Module</th>
<th className="text-left text-[10px] font-bold uppercase tracking-wider text-zinc-500 px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-28">Action</th> <th className="text-left text-[10px] font-bold uppercase tracking-wider text-zinc-500 px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-28">Action</th>
{roles.map(role => ( {visibleRoles.map(role => (
<th key={role.key} className="text-center text-[10px] font-bold uppercase tracking-wider px-3 py-3 border-b border-zinc-200 dark:border-white/5 min-w-[90px]" style={{ color: role.color }}> <th key={role.key} className="text-center text-[10px] font-bold uppercase tracking-wider px-3 py-3 border-b border-zinc-200 dark:border-white/5 min-w-[100px]">
{role.name.split(' ')[0]} <div className="flex flex-col items-center gap-1">
<span style={{ color: role.color }}>{role.name.split(' ')[0]}</span>
{role.key !== 'OWNER' && (
<div className="flex gap-0.5">
<button
type="button"
onClick={() => toggleAllForRole(role.key, true)}
title={`Grant all to ${role.name}`}
className="p-0.5 rounded hover:bg-emerald-50 dark:hover:bg-emerald-500/10 text-zinc-400 hover:text-emerald-500 transition-colors"
>
<CheckSquare size={10} />
</button>
<button
type="button"
onClick={() => toggleAllForRole(role.key, false)}
title={`Revoke all from ${role.name}`}
className="p-0.5 rounded hover:bg-red-50 dark:hover:bg-red-500/10 text-zinc-400 hover:text-red-500 transition-colors"
>
<Square size={10} />
</button>
</div>
)}
</div>
</th> </th>
))} ))}
</tr> </tr>
@@ -267,14 +447,34 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
className="text-xs font-semibold text-zinc-900 dark:text-white px-4 py-2.5 border-b border-zinc-100 dark:border-white/5 align-top" className="text-xs font-semibold text-zinc-900 dark:text-white px-4 py-2.5 border-b border-zinc-100 dark:border-white/5 align-top"
rowSpan={module.actions.length} rowSpan={module.actions.length}
> >
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<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">
<button
type="button"
onClick={() => toggleAllForModule(moduleKey, true)}
title={`Grant all roles for ${module.label}`}
className="p-0.5 rounded hover:bg-emerald-50 dark:hover:bg-emerald-500/10 text-zinc-300 dark:text-zinc-700 hover:text-emerald-500 transition-colors"
>
<CheckSquare size={10} />
</button>
<button
type="button"
onClick={() => toggleAllForModule(moduleKey, false)}
title={`Revoke all roles for ${module.label}`}
className="p-0.5 rounded hover:bg-red-50 dark:hover:bg-red-500/10 text-zinc-300 dark:text-zinc-700 hover:text-red-500 transition-colors"
>
<Square size={10} />
</button>
</div>
</div>
</td> </td>
)} )}
<td className="text-[11px] text-zinc-500 dark:text-zinc-400 px-4 py-2.5 border-b border-zinc-100 dark:border-white/5 capitalize font-mono">{action}</td> <td className="text-[11px] text-zinc-500 dark:text-zinc-400 px-4 py-2.5 border-b border-zinc-100 dark:border-white/5 capitalize font-mono">{action}</td>
{roles.map(role => { {visibleRoles.map(role => {
const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action); const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
const isOwner = role.key === 'OWNER'; const isOwner = role.key === 'OWNER';
return ( return (
@@ -289,13 +489,14 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
<button <button
type="button" type="button"
onClick={() => togglePermission(moduleKey, role.key, action)} onClick={() => togglePermission(moduleKey, role.key, action)}
className={`mx-auto w-6 h-6 rounded-md border flex items-center justify-center transition-all duration-150 ${ className={`mx-auto w-7 h-7 rounded-lg border flex items-center justify-center transition-all duration-150 ${
hasPermission hasPermission
? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/15 hover:bg-emerald-100 dark:hover:bg-emerald-500/25' ? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/15 hover:bg-emerald-100 dark:hover:bg-emerald-500/25 shadow-sm shadow-emerald-500/10'
: 'border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] hover:bg-zinc-100 dark:hover:bg-white/[0.06]' : 'border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] hover:bg-zinc-100 dark:hover:bg-white/[0.06]'
}`} }`}
title={`${hasPermission ? 'Revoke' : 'Grant'} ${action} on ${module.label} for ${role.name}`}
> >
{hasPermission && <Check size={12} className="text-emerald-500 dark:text-emerald-400" />} {hasPermission && <Check size={13} className="text-emerald-500 dark:text-emerald-400" strokeWidth={2.5} />}
</button> </button>
)} )}
</td> </td>
@@ -308,16 +509,26 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
</table> </table>
</div> </div>
{/* Mobile Cards */} {/* ── Mobile Cards ── */}
<div className="lg:hidden space-y-3"> <div className="lg:hidden space-y-3">
{roles.filter(r => r.key !== 'OWNER').map(role => ( {visibleRoles.filter(r => r.key !== 'OWNER').map(role => {
<div key={role.key} className="rounded-xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] p-4 space-y-3"> const stats = roleStats[role.key];
return (
<div key={role.key} className="rounded-xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-zinc-50/50 dark:bg-white/[0.02] border-b border-zinc-100 dark:border-white/5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }} /> <div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }} />
<span className="text-sm font-bold text-zinc-900 dark:text-white">{role.name}</span> <span className="text-sm font-bold text-zinc-900 dark:text-white">{role.name}</span>
<span className="text-[10px] font-mono text-zinc-400">{stats.granted}/{stats.total}</span>
</div> </div>
<div className="flex gap-1">
<button type="button" onClick={() => toggleAllForRole(role.key, true)} className="px-2 py-1 rounded-md text-[9px] font-bold bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/20">All</button>
<button type="button" onClick={() => toggleAllForRole(role.key, false)} className="px-2 py-1 rounded-md text-[9px] font-bold bg-zinc-100 dark:bg-white/5 text-zinc-500 border border-zinc-200 dark:border-white/10">None</button>
</div>
</div>
<div className="p-4 space-y-3">
{moduleEntries.map(([moduleKey, module]) => ( {moduleEntries.map(([moduleKey, module]) => (
<div key={moduleKey} className="space-y-1"> <div key={moduleKey} className="space-y-1.5">
<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>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{module.actions.map(action => { {module.actions.map(action => {
@@ -333,6 +544,7 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
: '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' : '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'
}`} }`}
> >
{hasPermission && <Check size={8} className="inline mr-0.5 -mt-0.5" />}
{action} {action}
</button> </button>
); );
@@ -341,8 +553,68 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
</div> </div>
))} ))}
</div> </div>
</div>
);
})}
</div>
{/* ── Change Log ── */}
<AnimatePresence>
{showLog && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-xl border border-blue-200 dark:border-blue-500/10 bg-blue-50/50 dark:bg-blue-500/[0.03] p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<History size={14} className="text-blue-500 dark:text-blue-400" />
<span className="text-xs font-bold text-zinc-700 dark:text-white">Change Log</span>
<span className="text-[10px] text-zinc-500">(unsaved session)</span>
</div>
{changeLog.length > 0 && (
<button type="button" onClick={() => setChangeLog([])} className="text-[10px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">Clear</button>
)}
</div>
{changeLog.length === 0 ? (
<p className="text-xs text-zinc-400 dark:text-zinc-600 italic">No changes yet</p>
) : (
<div className="space-y-1 max-h-40 overflow-y-auto custom-scrollbar">
{changeLog.map(entry => (
<div key={entry.id} className="flex items-center gap-2 text-[11px] px-2 py-1 rounded-md bg-white/60 dark:bg-white/[0.03]">
<span className="text-zinc-400 font-mono text-[9px] w-16 shrink-0">{entry.time}</span>
<span className={`font-bold ${entry.granted ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}`}>
{entry.granted ? '+' : '-'}
</span>
<span className="text-zinc-700 dark:text-zinc-300">
<span className="font-semibold">{entry.role}</span>
<span className="text-zinc-400 mx-1">/</span>
<span>{entry.module}</span>
<span className="text-zinc-400 mx-1">/</span>
<span className="font-mono capitalize">{entry.action}</span>
</span>
</div>
))} ))}
</div> </div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── 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="flex items-start gap-2">
<Info size={14} className="text-zinc-400 dark:text-zinc-600 mt-0.5 shrink-0" />
<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>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>
</div>
</div>
</div>
</div> </div>
); );
}; };
+4
View File
@@ -18,6 +18,7 @@ import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal'; import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
import CreateItemModal from '../../components/owner/CreateItemModal'; import CreateItemModal from '../../components/owner/CreateItemModal';
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal'; import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
import { usePermissions } from '../../hooks/usePermissions';
// --- NEOMORPHIC STYLING CONSTANTS --- // --- NEOMORPHIC STYLING CONSTANTS ---
const NEO_PANEL_CLASS = "dark:bg-zinc-900/60 dark:backdrop-blur-3xl border border-zinc-200 dark:border-white/5 dark:shadow-[8px_8px_16px_rgba(0,0,0,0.6),-8px_-8px_16px_rgba(255,255,255,0.02)] rounded-3xl relative overflow-hidden transition-all duration-300"; const NEO_PANEL_CLASS = "dark:bg-zinc-900/60 dark:backdrop-blur-3xl border border-zinc-200 dark:border-white/5 dark:shadow-[8px_8px_16px_rgba(0,0,0,0.6),-8px_-8px_16px_rgba(255,255,255,0.02)] rounded-3xl relative overflow-hidden transition-all duration-300";
@@ -141,6 +142,7 @@ const OwnerProjectDetail = () => {
removeUserCommissionOverride, updateProject, removeUserCommissionOverride, updateProject,
} = useMockStore(); } = useMockStore();
const navigate = useNavigate(); const navigate = useNavigate();
const { can } = usePermissions();
const [activeTab, setActiveTab] = useState('overview'); const [activeTab, setActiveTab] = useState('overview');
const [selectedCO, setSelectedCO] = useState(null); const [selectedCO, setSelectedCO] = useState(null);
const [selectedInvoice, setSelectedInvoice] = useState(null); const [selectedInvoice, setSelectedInvoice] = useState(null);
@@ -788,6 +790,7 @@ const OwnerProjectDetail = () => {
})()} })()}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{can('commission', 'edit') && (
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); setCommissionSettingsOpen(true); }} onClick={(e) => { e.stopPropagation(); setCommissionSettingsOpen(true); }}
@@ -796,6 +799,7 @@ const OwnerProjectDetail = () => {
> >
<Settings size={15} className="text-zinc-400 dark:text-zinc-500 group-hover:text-amber-500 dark:group-hover:text-[#fda913] transition-colors" /> <Settings size={15} className="text-zinc-400 dark:text-zinc-500 group-hover:text-amber-500 dark:group-hover:text-[#fda913] transition-colors" />
</button> </button>
)}
<div className="relative"> <div className="relative">
<button type="button" onClick={(e) => { e.stopPropagation(); setOpenTooltip(prev => prev === 'commission' ? null : 'commission'); }} className="p-1 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"> <button type="button" onClick={(e) => { e.stopPropagation(); setOpenTooltip(prev => prev === 'commission' ? null : 'commission'); }} className="p-1 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
<Info size={14} className="text-zinc-400 dark:text-zinc-500" /> <Info size={14} className="text-zinc-400 dark:text-zinc-500" />
+57 -43
View File
@@ -1,21 +1,18 @@
import { ROLES } from '../context/AuthContext'; import { ROLES } from '../context/AuthContext';
// ─── Legacy static permissions (preserved for MaskedData, etc.) ───
export const PERMISSIONS = { export const PERMISSIONS = {
// Admin / Owner Capabilities
VIEW_ALL_PROJECTS: 'VIEW_ALL_PROJECTS', VIEW_ALL_PROJECTS: 'VIEW_ALL_PROJECTS',
VIEW_ALL_PERSONNEL: 'VIEW_ALL_PERSONNEL', VIEW_ALL_PERSONNEL: 'VIEW_ALL_PERSONNEL',
VIEW_FINANCIALS: 'VIEW_FINANCIALS', VIEW_FINANCIALS: 'VIEW_FINANCIALS',
VIEW_SENSITIVE_DATA: 'VIEW_SENSITIVE_DATA', // SSN, Bank Accounts VIEW_SENSITIVE_DATA: 'VIEW_SENSITIVE_DATA',
MANAGE_USERS: 'MANAGE_USERS', MANAGE_USERS: 'MANAGE_USERS',
APPROVE_DOCUMENTS: 'APPROVE_DOCUMENTS', APPROVE_DOCUMENTS: 'APPROVE_DOCUMENTS',
// Contractor / Vendor Capabilities
VIEW_ASSIGNED_PROJECTS: 'VIEW_ASSIGNED_PROJECTS', VIEW_ASSIGNED_PROJECTS: 'VIEW_ASSIGNED_PROJECTS',
UPLOAD_DOCUMENTS: 'UPLOAD_DOCUMENTS', UPLOAD_DOCUMENTS: 'UPLOAD_DOCUMENTS',
SUBMIT_INVOICES: 'SUBMIT_INVOICES', SUBMIT_INVOICES: 'SUBMIT_INVOICES',
MANAGE_OWN_CREW: 'MANAGE_OWN_CREW', MANAGE_OWN_CREW: 'MANAGE_OWN_CREW',
// Subcontractor Capabilities
VIEW_ASSIGNED_TASKS: 'VIEW_ASSIGNED_TASKS', VIEW_ASSIGNED_TASKS: 'VIEW_ASSIGNED_TASKS',
UPDATE_TASK_STATUS: 'UPDATE_TASK_STATUS' UPDATE_TASK_STATUS: 'UPDATE_TASK_STATUS'
}; };
@@ -35,7 +32,6 @@ const ROLE_PERMISSIONS = {
PERMISSIONS.VIEW_FINANCIALS, PERMISSIONS.VIEW_FINANCIALS,
PERMISSIONS.MANAGE_USERS, PERMISSIONS.MANAGE_USERS,
PERMISSIONS.APPROVE_DOCUMENTS PERMISSIONS.APPROVE_DOCUMENTS
// Note: Admin might NOT have VIEW_SENSITIVE_DATA by default without extra auth
], ],
[ROLES.CONTRACTOR]: [ [ROLES.CONTRACTOR]: [
PERMISSIONS.VIEW_ASSIGNED_PROJECTS, PERMISSIONS.VIEW_ASSIGNED_PROJECTS,
@@ -47,55 +43,73 @@ const ROLE_PERMISSIONS = {
PERMISSIONS.VIEW_ASSIGNED_TASKS, PERMISSIONS.VIEW_ASSIGNED_TASKS,
PERMISSIONS.UPDATE_TASK_STATUS PERMISSIONS.UPDATE_TASK_STATUS
], ],
[ROLES.FIELD_AGENT]: [ [ROLES.FIELD_AGENT]: [],
// Standard CRM permissions [ROLES.CUSTOMER]: []
],
[ROLES.CUSTOMER]: [
// Portal permissions
]
}; };
/**
* Checks if a user has a specific permission.
* @param {Object} user - The user object from AuthContext
* @param {String} permission - The permission key to check
* @returns {Boolean}
*/
export const hasPermission = (user, permission) => { export const hasPermission = (user, permission) => {
if (!user || !user.role) return false; if (!user || !user.role) return false;
const userPermissions = ROLE_PERMISSIONS[user.role] || []; const userPermissions = ROLE_PERMISSIONS[user.role] || [];
return userPermissions.includes(permission); return userPermissions.includes(permission);
}; };
/**
* Filters a list of projects based on user role.
* @param {Object} user - The user object
* @param {Array} projects - Array of project objects
* @returns {Array} - Filtered projects
*/
export const filterProjectsForUser = (user, projects) => { export const filterProjectsForUser = (user, projects) => {
if (!user) return []; if (!user) return [];
if (user.role === ROLES.OWNER || user.role === ROLES.ADMIN) return projects;
if (user.role === ROLES.OWNER || user.role === ROLES.ADMIN) { if (user.role === ROLES.CONTRACTOR) return projects.filter(p => p.contractorId === user.id);
return projects; if (user.role === ROLES.SUBCONTRACTOR) return projects.filter(p => p.subcontractorIds?.includes(user.id));
}
if (user.role === ROLES.CONTRACTOR) {
return projects.filter(p => p.contractorId === user.id);
}
if (user.role === ROLES.SUBCONTRACTOR) {
return projects.filter(p => p.subcontractorIds?.includes(user.id));
}
return []; return [];
}; };
/**
* Determines if sensitive data should be masked.
* @param {Object} user - The viewer
* @returns {Boolean} - True if data should be visible (NOT masked), False if it should be masked.
*/
export const canViewSensitiveData = (user) => { export const canViewSensitiveData = (user) => {
return hasPermission(user, PERMISSIONS.VIEW_SENSITIVE_DATA); return hasPermission(user, PERMISSIONS.VIEW_SENSITIVE_DATA);
}; };
// ─── Dynamic org-permission system ───
const AUTH_ROLE_TO_ORG_KEY = {
OWNER: 'OWNER',
ADMIN: 'ADMIN',
FIELD_AGENT: 'CANVASSER',
SUBCONTRACTOR: 'SUBCONTRACTOR',
};
/**
* Resolve the org-level role key for a user.
* Priority: orgMembers lookup (exact userId match) → auth-role fallback map.
*/
export const resolveOrgRoleKey = (user, orgMembers = []) => {
if (!user) return null;
const member = orgMembers.find(
m => m.userId === user.id || m.userId === user.empId,
);
if (member) return member.roleKey;
return AUTH_ROLE_TO_ORG_KEY[user.role] || null;
};
/**
* Check a single module+action against the orgPermissions matrix.
* Owner always returns true.
*/
export const checkOrgPermission = (orgRoleKey, orgPermissions, moduleKey, action) => {
if (!orgRoleKey) return false;
if (orgRoleKey === 'OWNER') return true;
const actions = orgPermissions?.[moduleKey]?.[orgRoleKey] || [];
return actions.includes(action);
};
/**
* Compute summary stats for a role across the permission matrix.
*/
export const computeRolePermissionStats = (roleKey, orgPermissions, permissionModules) => {
let granted = 0;
let total = 0;
for (const [moduleKey, mod] of Object.entries(permissionModules)) {
total += mod.actions.length;
const roleActions = orgPermissions[moduleKey]?.[roleKey] || [];
granted += roleActions.length;
}
return { granted, total };
};