diff --git a/src/components/PermissionGate.jsx b/src/components/PermissionGate.jsx
new file mode 100644
index 0000000..b9c8ba1
--- /dev/null
+++ b/src/components/PermissionGate.jsx
@@ -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:
+ *
+ *
+ *
+ *
+ * }>
+ *
+ *
+ */
+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;
diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js
new file mode 100644
index 0000000..83e5fe0
--- /dev/null
+++ b/src/hooks/usePermissions.js
@@ -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 };
+};
diff --git a/src/pages/LeaderboardPage.jsx b/src/pages/LeaderboardPage.jsx
index f571d5c..7473a31 100644
--- a/src/pages/LeaderboardPage.jsx
+++ b/src/pages/LeaderboardPage.jsx
@@ -1,11 +1,13 @@
import React, { useState, useMemo } from 'react';
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 AnimatedNumber from '../components/AnimatedNumber';
+import { usePermissions } from '../hooks/usePermissions';
const LeaderboardPage = () => {
const { users, salesHistory } = useMockStore();
+ const { can } = usePermissions();
const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom'
const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate'
@@ -108,6 +110,20 @@ const LeaderboardPage = () => {
);
};
+ if (!can('leaderboard', 'view')) {
+ return (
+
+
+
+
+
+
Access Restricted
+
You don't have permission to view the Leaderboard. Contact your org admin for access.
+
+
+ );
+ }
+
return (
{/* Header */}
diff --git a/src/pages/LeadsListPage.jsx b/src/pages/LeadsListPage.jsx
index 4d6e267..8f2ab7b 100644
--- a/src/pages/LeadsListPage.jsx
+++ b/src/pages/LeadsListPage.jsx
@@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '../context/ThemeContext';
import { useMockStore } from '../data/mockStore';
+import { usePermissions } from '../hooks/usePermissions';
+import PermissionGate from '../components/PermissionGate';
import {
Plus, Search, MapPin, Phone, Zap, FileText,
- Clock, User, ChevronRight, Filter,
+ Clock, User, ChevronRight, Filter, Lock,
} from 'lucide-react';
// ---------------------------------------------------------------------------
@@ -183,6 +185,7 @@ function EmptyState({ isDark, onNewLead }) {
export default function LeadsListPage() {
const { theme } = useTheme();
const { leads } = useMockStore();
+ const { can } = usePermissions();
const navigate = useNavigate();
const isDark = theme === 'dark';
@@ -214,15 +217,22 @@ export default function LeadsListPage() {
{leads.length} total lead{leads.length !== 1 ? 's' : ''}
-
+ {can('leads', 'log') ? (
+
+ ) : (
+
+
+ New Lead
+
+ )}
{/* --- Search bar --- */}
diff --git a/src/pages/owner/OrgSettings.jsx b/src/pages/owner/OrgSettings.jsx
index 71898b8..708644a 100644
--- a/src/pages/owner/OrgSettings.jsx
+++ b/src/pages/owner/OrgSettings.jsx
@@ -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 {
Settings, Building2, Shield, Users, DollarSign, ChevronDown, ChevronRight,
Search, Check, X, Plus, Trash2, Edit3, Save, User, Crown,
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';
import { useMockStore } from '../../data/mockStore';
import { useAuth } from '../../context/AuthContext';
import { toast } from 'sonner';
+import { computeRolePermissionStats } from '../../utils/permissions';
const COMMISSION_TYPES = [
{ 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 togglePermission = (moduleKey, roleKey, action) => {
- const current = permissions[moduleKey]?.[roleKey] || [];
- const updated = current.includes(action)
- ? current.filter(a => a !== action)
- : [...current, action];
- onUpdate(moduleKey, roleKey, updated);
- };
+ const [filterModule, setFilterModule] = useState('all');
+ const [filterRole, setFilterRole] = useState('all');
+ const [changeLog, setChangeLog] = useState([]);
+ const [showLog, setShowLog] = useState(false);
+ const snapshotRef = useRef(JSON.stringify(permissions));
- 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 (
-
-
-
Click cells to toggle permissions per role
+
+ {/* ── Toolbar ── */}
+
+
+ {/* Module filter */}
+
+
+
+
+ {/* Role filter */}
+
+ {/* Change log toggle */}
+
+
+
+ {isDirty && (
+
+ )}
+
+
- {/* Desktop Matrix */}
-
+ {/* ── Role Summary Stats ── */}
+
+ {roles.map(role => {
+ const stats = roleStats[role.key];
+ const pct = stats.total > 0 ? Math.round((stats.granted / stats.total) * 100) : 0;
+ return (
+
+
+
+
{role.name.split(' ')[0]}
+
+
+ {stats.granted}
+ / {stats.total}
+
+
+
+ );
+ })}
+
+
+ {/* ── Desktop Matrix ── */}
+
-
+
| Module |
Action |
- {roles.map(role => (
-
- {role.name.split(' ')[0]}
+ {visibleRoles.map(role => (
+ |
+
+ {role.name.split(' ')[0]}
+ {role.key !== 'OWNER' && (
+
+
+
+
+ )}
+
|
))}
@@ -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"
rowSpan={module.actions.length}
>
-
-
- {module.label}
+
+
+
+ {module.label}
+
+
+
+
+
)}
{action} |
- {roles.map(role => {
+ {visibleRoles.map(role => {
const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
const isOwner = role.key === 'OWNER';
return (
@@ -289,13 +489,14 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
)}
@@ -308,40 +509,111 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
- {/* Mobile Cards */}
+ {/* ── Mobile Cards ── */}
- {roles.filter(r => r.key !== 'OWNER').map(role => (
-
-
- {moduleEntries.map(([moduleKey, module]) => (
-
-
{module.label}
-
- {module.actions.map(action => {
- const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
- return (
-
- );
- })}
+ {visibleRoles.filter(r => r.key !== 'OWNER').map(role => {
+ const stats = roleStats[role.key];
+ return (
+
+
+
+
+
{role.name}
+
{stats.granted}/{stats.total}
+
+
+
+
- ))}
+
+ {moduleEntries.map(([moduleKey, module]) => (
+
+
{module.label}
+
+ {module.actions.map(action => {
+ const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
+ return (
+
+ );
+ })}
+
+
+ ))}
+
+
+ );
+ })}
+
+
+ {/* ── Change Log ── */}
+
+ {showLog && (
+
+
+
+
+
+ Change Log
+ (unsaved session)
+
+ {changeLog.length > 0 && (
+
+ )}
+
+ {changeLog.length === 0 ? (
+
No changes yet
+ ) : (
+
+ {changeLog.map(entry => (
+
+ {entry.time}
+
+ {entry.granted ? '+' : '-'}
+
+
+ {entry.role}
+ /
+ {entry.module}
+ /
+ {entry.action}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+ {/* ── Info Footer ── */}
+
+
+
+
+
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.
- ))}
+
);
diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx
index 23a8842..62e6e67 100644
--- a/src/pages/owner/OwnerProjectDetail.jsx
+++ b/src/pages/owner/OwnerProjectDetail.jsx
@@ -18,6 +18,7 @@ import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
import CreateItemModal from '../../components/owner/CreateItemModal';
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
+import { usePermissions } from '../../hooks/usePermissions';
// --- 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";
@@ -141,6 +142,7 @@ const OwnerProjectDetail = () => {
removeUserCommissionOverride, updateProject,
} = useMockStore();
const navigate = useNavigate();
+ const { can } = usePermissions();
const [activeTab, setActiveTab] = useState('overview');
const [selectedCO, setSelectedCO] = useState(null);
const [selectedInvoice, setSelectedInvoice] = useState(null);
@@ -788,14 +790,16 @@ const OwnerProjectDetail = () => {
})()}
-
+ {can('commission', 'edit') && (
+
+ )}