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:
Moheeb Zara
2026-05-16 22:27:26 -07:00
parent 3efa3cdb46
commit c4cba104d1
3 changed files with 43 additions and 10 deletions
+1
View File
@@ -65,6 +65,7 @@ export function ThreadActions({ threadId, pinned, locked, canModerate, isAuthor
onClick={handleDelete}
className="text-muted-foreground hover:text-red-500 transition p-1.5"
title="Delete"
aria-label="Delete thread"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
+20
View File
@@ -56,6 +56,25 @@ export default async function AppLayout({ children }: { children: React.ReactNod
const roleMap = await getRoleLabelMap(supabase, member.space_id)
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 (
<ConfirmProvider>
<div className="flex h-screen bg-background overflow-hidden">
@@ -70,6 +89,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
roleName={roleName}
taskBadge={taskCount ?? 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">
<div className="flex-1">{children}</div>
+22 -10
View File
@@ -107,13 +107,23 @@ interface AppSidebarProps {
taskBadge?: number
commsBadge?: 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 space = member.spaces
const initials = (member.display_name || 'U').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
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 menuButtonRef = useRef<HTMLButtonElement>(null)
const drawerCloseRef = useRef<HTMLButtonElement>(null)
@@ -174,6 +184,8 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
</NavSection>
<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="/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} />
@@ -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} />
</NavSection>
{isAdmin && (
{showAdminSection && (
<NavSection id="admin" title="Admin">
<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} />
<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} />
<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} />
<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="/customize" label="Customize" icon={SlidersHorizontal} active={isActive('/customize')} onClick={onNav} />}
{canForms && <NavLink href="/forms" label="Forms & waivers" icon={ClipboardList} active={isActive('/forms')} onClick={onNav} />}
{canCerts && <NavLink href="/certifications" label="Certifications" icon={Award} active={isActive('/certifications')} onClick={onNav} />}
{canClasses && <NavLink href="/classes/manage" label="Manage classes" icon={GraduationCap} active={isActive('/classes/manage')} onClick={onNav} />}
{canEquip && <NavLink href="/equipment/manage" label="Manage equipment" icon={Hammer} active={isActive('/equipment/manage')} onClick={onNav} />}
{canDoor && <NavLink href="/door/manage" label="Door access" icon={DoorClosed} active={isActive('/door/manage')} onClick={onNav} />}
{isAdmin && <NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} />}
{isAdmin && <NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} />}
</NavSection>
)}
</nav>