From c4cba104d1920988743bef081e24463ee814040b Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 22:27:26 -0700 Subject: [PATCH] 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. --- app/(app)/forum/[id]/thread-actions.tsx | 1 + app/(app)/layout.tsx | 20 ++++++++++++++++ components/app-sidebar.tsx | 32 +++++++++++++++++-------- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/app/(app)/forum/[id]/thread-actions.tsx b/app/(app)/forum/[id]/thread-actions.tsx index 5069f0f..8e2e840 100644 --- a/app/(app)/forum/[id]/thread-actions.tsx +++ b/app/(app)/forum/[id]/thread-actions.tsx @@ -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" > diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 302caaa..d102a50 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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 = {} + 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 (
@@ -70,6 +89,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod roleName={roleName} taskBadge={taskCount ?? 0} paymentBadge={paymentCount ?? 0} + navPerms={navPerms} />
{children}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 16a0253..d544751 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -107,13 +107,23 @@ interface AppSidebarProps { taskBadge?: number commsBadge?: number paymentBadge?: number + navPerms?: Record } -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(null) const drawerCloseRef = useRef(null) @@ -174,6 +184,8 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa + {/* Classes/Equipment use exact-match (not isActive) so /classes/manage + and /equipment/manage highlight only the Admin link, not both. */} @@ -199,16 +211,16 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa - {isAdmin && ( + {showAdminSection && ( - - - - - - - - + {isAdmin && } + {canForms && } + {canCerts && } + {canClasses && } + {canEquip && } + {canDoor && } + {isAdmin && } + {isAdmin && } )}