From 70a9752d73d1477e46237c3647a99c397e88f6b8 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 16:09:40 +0200 Subject: [PATCH] =?UTF-8?q?refactor(layouts):=20shared=20RoleSwitcherPill?= =?UTF-8?q?=20across=20dashboards=20(=C2=A7D.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract ROLE_SWITCH_OPTIONS + switchableRoles computation from the two duplicated copies (role-nav.tsx + admin-sidebar.tsx) into a single src/components/layouts/role-switcher.tsx module. Adds a RoleSwitcherPill component placed top-right of every dashboard: - Hidden for single-role users - Hidden during impersonation - Same visual + click target across /jury, /mentor, /applicant, /observer, /award-master AND /admin (admin layout gains a small top-bar to host the pill) Removes the duplicate role-switcher items from the admin sidebar's bottom user-menu — one source of truth instead of three. Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md --- src/app/(admin)/layout.tsx | 7 ++ src/components/layouts/admin-sidebar.tsx | 61 +++-------------- src/components/layouts/role-nav.tsx | 25 ++----- src/components/layouts/role-switcher.tsx | 86 ++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 72 deletions(-) create mode 100644 src/components/layouts/role-switcher.tsx diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index 7895f60..4de9796 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma' import { requireRole } from '@/lib/auth-redirect' import { AdminSidebar } from '@/components/layouts/admin-sidebar' import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper' +import { RoleSwitcherPill } from '@/components/layouts/role-switcher' export default async function AdminLayout({ children, @@ -34,6 +35,12 @@ export default async function AdminLayout({
{/* Spacer for mobile header */}
+ {/* Top-bar — hosts the RoleSwitcherPill so multi-role admins + can switch dashboards from the same screen position used on + every other layout. Pill auto-hides for single-role users. */} +
+ +
{children}
diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx index 0bb9ca4..3597dd3 100644 --- a/src/components/layouts/admin-sidebar.tsx +++ b/src/components/layouts/admin-sidebar.tsx @@ -50,6 +50,7 @@ import { UserAvatar } from '@/components/shared/user-avatar' import { NotificationBell } from '@/components/shared/notification-bell' import { useSession } from 'next-auth/react' import { trpc } from '@/lib/trpc/client' +import { useRoleSwitcher } from './role-switcher' interface AdminSidebarProps { user: { @@ -157,14 +158,6 @@ const roleLabels: Record = { AWARD_MASTER: 'Award Master', } -// Role switcher config — maps roles to their dashboard views -const ROLE_SWITCH_OPTIONS: Record = { - JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale }, - MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake }, - OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye }, - AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy }, -} - export function AdminSidebar({ user }: AdminSidebarProps) { const pathname = usePathname() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) @@ -186,11 +179,10 @@ export function AdminSidebar({ user }: AdminSidebarProps) { const isSuperAdmin = user.role === 'SUPER_ADMIN' const roleLabel = roleLabels[user.role || ''] || 'User' - // Roles the user can switch to (non-admin roles they hold) - const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? [] - const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS).filter( - ([role]) => userRoles.includes(role as UserRole) - ) + // Roles the user can switch to — shared logic. Admin sidebar dropdown + // no longer renders these; the RoleSwitcherPill in the layout's top-bar + // handles role switching for admins, matching every other dashboard. + const { switchableRoles } = useRoleSwitcher('/admin') // Build dynamic admin nav with current edition's apply page const dynamicAdminNav = adminNavigation.map((item) => { @@ -374,46 +366,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) { - {switchableRoles.length > 0 && ( - <> - - {switchableRoles.length <= 2 ? ( - // Flat list for 1-2 roles - switchableRoles.map(([, opt]) => ( - - - - {opt.label} - - - )) - ) : ( - // Submenu for 3+ roles - - - - Switch View - - - {switchableRoles.map(([, opt]) => ( - - - - {opt.label} - - - ))} - - - )} - - )} + {/* Role switcher items moved to the layout's top-bar + RoleSwitcherPill — single source of truth across all + dashboards. */} diff --git a/src/components/layouts/role-nav.tsx b/src/components/layouts/role-nav.tsx index a02861a..3e1c7ff 100644 --- a/src/components/layouts/role-nav.tsx +++ b/src/components/layouts/role-nav.tsx @@ -19,14 +19,14 @@ import { import type { Route } from 'next' import type { LucideIcon } from 'lucide-react' import { - LogOut, Menu, Moon, Settings, Sun, User, X, Trophy, - LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft, + LogOut, Menu, Moon, Settings, Sun, User, X, + ArrowRightLeft, ExternalLink as ExternalLinkIcon, HelpCircle, Mail, } from 'lucide-react' -import type { UserRole } from '@prisma/client' import { useTheme } from 'next-themes' import { Logo } from '@/components/shared/logo' import { NotificationBell } from '@/components/shared/notification-bell' +import { useRoleSwitcher, RoleSwitcherPill } from './role-switcher' export type NavItem = { name: string @@ -54,16 +54,6 @@ type RoleNavProps = { helpEmail?: string } -// Role switcher config — maps roles to their dashboard views -const ROLE_SWITCH_OPTIONS: Record = { - SUPER_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard }, - PROGRAM_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard }, - JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale }, - MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake }, - OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye }, - AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy }, -} - function isNavItemActive(pathname: string, href: string, basePath: string): boolean { return pathname === href || (href !== basePath && pathname.startsWith(href)) } @@ -111,12 +101,8 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated]) - // Roles the user can switch to (excluding current view) - const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? [] - const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS) - .filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== basePath) - // Deduplicate admin paths (SUPER_ADMIN and PROGRAM_ADMIN both go to /admin) - .filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i) + // Roles the user can switch to (excluding current view) — shared logic + const { switchableRoles } = useRoleSwitcher(basePath) return (
@@ -200,6 +186,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi )} )} + diff --git a/src/components/layouts/role-switcher.tsx b/src/components/layouts/role-switcher.tsx new file mode 100644 index 0000000..6aea070 --- /dev/null +++ b/src/components/layouts/role-switcher.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useMemo } from 'react' +import Link from 'next/link' +import { useSession } from 'next-auth/react' +import { + ArrowRightLeft, + Eye, + Handshake, + LayoutDashboard, + Scale, + Trophy, + type LucideIcon, +} from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import type { Route } from 'next' +import type { UserRole } from '@prisma/client' + +export type RoleSwitchOption = { label: string; path: string; icon: LucideIcon } + +export const ROLE_SWITCH_OPTIONS: Record = { + SUPER_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard }, + PROGRAM_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard }, + JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale }, + MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake }, + OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye }, + AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy }, +} + +/** + * Returns the list of dashboards the current user can switch to, + * excluding the one matching `currentBasePath`. Deduplicates admin paths + * (SUPER_ADMIN + PROGRAM_ADMIN both go to /admin). + */ +export function useRoleSwitcher(currentBasePath: string): { + switchableRoles: Array<[string, RoleSwitchOption]> + isImpersonating: boolean +} { + const { data: session } = useSession() + const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? [] + const isImpersonating = !!session?.user?.impersonating + + const switchableRoles = useMemo(() => { + return Object.entries(ROLE_SWITCH_OPTIONS) + .filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== currentBasePath) + .filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i) + }, [userRoles, currentBasePath]) + + return { switchableRoles, isImpersonating } +} + +/** + * Top-right "Switch View" pill. Hidden for single-role users and during + * impersonation. Shows a popover listing alternate dashboards. + */ +export function RoleSwitcherPill({ currentBasePath }: { currentBasePath: string }) { + const { switchableRoles, isImpersonating } = useRoleSwitcher(currentBasePath) + if (switchableRoles.length === 0 || isImpersonating) return null + + return ( + + + + + + {switchableRoles.map(([role, opt]) => ( + + + + {opt.label} + + + ))} + + + ) +}