From 455462ded44a3664faf349c3bb4e486edc21b44c Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Tue, 12 May 2026 21:08:19 +0530 Subject: [PATCH] adds access modifiers for each role --- src/components/PermissionGate.jsx | 38 +++ src/hooks/usePermissions.js | 44 +++ src/pages/LeaderboardPage.jsx | 18 +- src/pages/LeadsListPage.jsx | 30 +- src/pages/owner/OrgSettings.jsx | 386 +++++++++++++++++++++---- src/pages/owner/OwnerProjectDetail.jsx | 20 +- src/utils/permissions.js | 100 ++++--- 7 files changed, 517 insertions(+), 119 deletions(-) create mode 100644 src/components/PermissionGate.jsx create mode 100644 src/hooks/usePermissions.js 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 ── */} +
- + - {roles.map(role => ( - ))} @@ -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} +
+
+ + +
)}
- {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 }
Module Action - {role.name.split(' ')[0]} + {visibleRoles.map(role => ( + +
+ {role.name.split(' ')[0]} + {role.key !== 'OWNER' && ( +
+ + +
+ )} +
{action}
- {/* Mobile Cards */} + {/* ── Mobile Cards ── */}
- {roles.filter(r => r.key !== 'OWNER').map(role => ( -
-
-
- {role.name} -
- {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') && ( + + )}