fix(nav): gate Admin manage links by actual permission, not just role
Audit follow-up (UI P1). The page guards authorize feature management via user_has_permission, but the Admin nav links were shown by role only: a member with a delegated forms/certifications/classes/equipment/door *.manage permission had no link (orphaned), and an admin lacking the permission saw a link that redirected. The layout now resolves those five permissions and the sidebar shows each link on (isAdmin || permission); Customize/Import/Settings stay admin-only. Also: aria-label on the forum delete button; comment the intentional Classes/Equipment nav-active exact-match.
This commit is contained in:
@@ -65,6 +65,7 @@ export function ThreadActions({ threadId, pinned, locked, canModerate, isAuthor
|
|||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="text-muted-foreground hover:text-red-500 transition p-1.5"
|
className="text-muted-foreground hover:text-red-500 transition p-1.5"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
|
aria-label="Delete thread"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -56,6 +56,25 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||||||
const roleMap = await getRoleLabelMap(supabase, member.space_id)
|
const roleMap = await getRoleLabelMap(supabase, member.space_id)
|
||||||
const roleName = roleMap[member.role]?.name ?? member.role
|
const roleName = roleMap[member.role]?.name ?? member.role
|
||||||
|
|
||||||
|
// The Admin nav section's feature-management links were previously gated by
|
||||||
|
// role only, so a member granted a delegated *.manage permission had no nav
|
||||||
|
// link to it (and the page guards authorize by permission, not role). Resolve
|
||||||
|
// the actual permissions here so the nav matches what the guards allow.
|
||||||
|
const isAdminRole = member.role === 'admin' || member.role === 'board'
|
||||||
|
const NAV_PERM_CODES = [
|
||||||
|
'forms.manage', 'certifications.manage', 'classes.manage',
|
||||||
|
'equipment.manage', 'door.manage',
|
||||||
|
] as const
|
||||||
|
const navPerms: Record<string, boolean> = {}
|
||||||
|
if (!isAdminRole) {
|
||||||
|
const results = await Promise.all(
|
||||||
|
NAV_PERM_CODES.map(perm =>
|
||||||
|
supabase.rpc('user_has_permission', { uid: user.id, sid: member.space_id, perm }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
NAV_PERM_CODES.forEach((perm, i) => { navPerms[perm] = !!results[i].data })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<div className="flex h-screen bg-background overflow-hidden">
|
<div className="flex h-screen bg-background overflow-hidden">
|
||||||
@@ -70,6 +89,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||||||
roleName={roleName}
|
roleName={roleName}
|
||||||
taskBadge={taskCount ?? 0}
|
taskBadge={taskCount ?? 0}
|
||||||
paymentBadge={paymentCount ?? 0}
|
paymentBadge={paymentCount ?? 0}
|
||||||
|
navPerms={navPerms}
|
||||||
/>
|
/>
|
||||||
<main id="main-content" tabIndex={-1} className="flex-1 overflow-y-auto pt-[52px] md:pt-0 flex flex-col">
|
<main id="main-content" tabIndex={-1} className="flex-1 overflow-y-auto pt-[52px] md:pt-0 flex flex-col">
|
||||||
<div className="flex-1">{children}</div>
|
<div className="flex-1">{children}</div>
|
||||||
|
|||||||
+22
-10
@@ -107,13 +107,23 @@ interface AppSidebarProps {
|
|||||||
taskBadge?: number
|
taskBadge?: number
|
||||||
commsBadge?: number
|
commsBadge?: number
|
||||||
paymentBadge?: number
|
paymentBadge?: number
|
||||||
|
navPerms?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
|
export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, paymentBadge = 0, navPerms = {} }: AppSidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const space = member.spaces
|
const space = member.spaces
|
||||||
const initials = (member.display_name || 'U').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
const initials = (member.display_name || 'U').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||||
const isAdmin = member.role === 'admin' || member.role === 'board'
|
const isAdmin = member.role === 'admin' || member.role === 'board'
|
||||||
|
// Feature-management links: admin/board see all; a member with a delegated
|
||||||
|
// *.manage permission sees just that one (matches the page guards).
|
||||||
|
const can = (perm: string) => isAdmin || !!navPerms[perm]
|
||||||
|
const canForms = can('forms.manage')
|
||||||
|
const canCerts = can('certifications.manage')
|
||||||
|
const canClasses = can('classes.manage')
|
||||||
|
const canEquip = can('equipment.manage')
|
||||||
|
const canDoor = can('door.manage')
|
||||||
|
const showAdminSection = isAdmin || canForms || canCerts || canClasses || canEquip || canDoor
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
const drawerCloseRef = useRef<HTMLButtonElement>(null)
|
const drawerCloseRef = useRef<HTMLButtonElement>(null)
|
||||||
@@ -174,6 +184,8 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
|
|||||||
</NavSection>
|
</NavSection>
|
||||||
|
|
||||||
<NavSection id="learn" title="Access & Resources">
|
<NavSection id="learn" title="Access & Resources">
|
||||||
|
{/* Classes/Equipment use exact-match (not isActive) so /classes/manage
|
||||||
|
and /equipment/manage highlight only the Admin link, not both. */}
|
||||||
<NavLink href="/classes" label="Classes" icon={GraduationCap} active={pathname === '/classes' || pathname.startsWith('/classes?')} onClick={onNav} />
|
<NavLink href="/classes" label="Classes" icon={GraduationCap} active={pathname === '/classes' || pathname.startsWith('/classes?')} onClick={onNav} />
|
||||||
<NavLink href="/equipment" label="Equipment" icon={Hammer} active={pathname === '/equipment' || pathname.startsWith('/equipment?')} onClick={onNav} />
|
<NavLink href="/equipment" label="Equipment" icon={Hammer} active={pathname === '/equipment' || pathname.startsWith('/equipment?')} onClick={onNav} />
|
||||||
<NavLink href="/doors" label="Doors" icon={DoorClosed} active={isActive('/doors')} onClick={onNav} />
|
<NavLink href="/doors" label="Doors" icon={DoorClosed} active={isActive('/doors')} onClick={onNav} />
|
||||||
@@ -199,16 +211,16 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
|
|||||||
<NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
|
<NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
|
||||||
</NavSection>
|
</NavSection>
|
||||||
|
|
||||||
{isAdmin && (
|
{showAdminSection && (
|
||||||
<NavSection id="admin" title="Admin">
|
<NavSection id="admin" title="Admin">
|
||||||
<NavLink href="/customize" label="Customize" icon={SlidersHorizontal} active={isActive('/customize')} onClick={onNav} />
|
{isAdmin && <NavLink href="/customize" label="Customize" icon={SlidersHorizontal} active={isActive('/customize')} onClick={onNav} />}
|
||||||
<NavLink href="/forms" label="Forms & waivers" icon={ClipboardList} active={isActive('/forms')} onClick={onNav} />
|
{canForms && <NavLink href="/forms" label="Forms & waivers" icon={ClipboardList} active={isActive('/forms')} onClick={onNav} />}
|
||||||
<NavLink href="/certifications" label="Certifications" icon={Award} active={isActive('/certifications')} onClick={onNav} />
|
{canCerts && <NavLink href="/certifications" label="Certifications" icon={Award} active={isActive('/certifications')} onClick={onNav} />}
|
||||||
<NavLink href="/classes/manage" label="Manage classes" icon={GraduationCap} active={isActive('/classes/manage')} onClick={onNav} />
|
{canClasses && <NavLink href="/classes/manage" label="Manage classes" icon={GraduationCap} active={isActive('/classes/manage')} onClick={onNav} />}
|
||||||
<NavLink href="/equipment/manage" label="Manage equipment" icon={Hammer} active={isActive('/equipment/manage')} onClick={onNav} />
|
{canEquip && <NavLink href="/equipment/manage" label="Manage equipment" icon={Hammer} active={isActive('/equipment/manage')} onClick={onNav} />}
|
||||||
<NavLink href="/door/manage" label="Door access" icon={DoorClosed} active={isActive('/door/manage')} onClick={onNav} />
|
{canDoor && <NavLink href="/door/manage" label="Door access" icon={DoorClosed} active={isActive('/door/manage')} onClick={onNav} />}
|
||||||
<NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} />
|
{isAdmin && <NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} />}
|
||||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} />
|
{isAdmin && <NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} />}
|
||||||
</NavSection>
|
</NavSection>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user