refactor(layouts): shared RoleSwitcherPill across dashboards (§D.6)
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
This commit is contained in:
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { requireRole } from '@/lib/auth-redirect'
|
import { requireRole } from '@/lib/auth-redirect'
|
||||||
import { AdminSidebar } from '@/components/layouts/admin-sidebar'
|
import { AdminSidebar } from '@/components/layouts/admin-sidebar'
|
||||||
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
|
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
|
||||||
|
import { RoleSwitcherPill } from '@/components/layouts/role-switcher'
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
@@ -34,6 +35,12 @@ export default async function AdminLayout({
|
|||||||
<main className="lg:pl-64">
|
<main className="lg:pl-64">
|
||||||
{/* Spacer for mobile header */}
|
{/* Spacer for mobile header */}
|
||||||
<div className="h-16 lg:hidden" />
|
<div className="h-16 lg:hidden" />
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="sticky top-0 z-30 flex h-12 items-center justify-end gap-2 border-b bg-card/80 backdrop-blur px-4">
|
||||||
|
<RoleSwitcherPill currentBasePath="/admin" />
|
||||||
|
</div>
|
||||||
<div className="container-app py-6 lg:py-8">{children}</div>
|
<div className="container-app py-6 lg:py-8">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { UserAvatar } from '@/components/shared/user-avatar'
|
|||||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { useRoleSwitcher } from './role-switcher'
|
||||||
|
|
||||||
interface AdminSidebarProps {
|
interface AdminSidebarProps {
|
||||||
user: {
|
user: {
|
||||||
@@ -157,14 +158,6 @@ const roleLabels: Record<string, string> = {
|
|||||||
AWARD_MASTER: 'Award Master',
|
AWARD_MASTER: 'Award Master',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role switcher config — maps roles to their dashboard views
|
|
||||||
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof 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 },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminSidebar({ user }: AdminSidebarProps) {
|
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
@@ -186,11 +179,10 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
||||||
const roleLabel = roleLabels[user.role || ''] || 'User'
|
const roleLabel = roleLabels[user.role || ''] || 'User'
|
||||||
|
|
||||||
// Roles the user can switch to (non-admin roles they hold)
|
// Roles the user can switch to — shared logic. Admin sidebar dropdown
|
||||||
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
|
// no longer renders these; the RoleSwitcherPill in the layout's top-bar
|
||||||
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS).filter(
|
// handles role switching for admins, matching every other dashboard.
|
||||||
([role]) => userRoles.includes(role as UserRole)
|
const { switchableRoles } = useRoleSwitcher('/admin')
|
||||||
)
|
|
||||||
|
|
||||||
// Build dynamic admin nav with current edition's apply page
|
// Build dynamic admin nav with current edition's apply page
|
||||||
const dynamicAdminNav = adminNavigation.map((item) => {
|
const dynamicAdminNav = adminNavigation.map((item) => {
|
||||||
@@ -374,46 +366,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{switchableRoles.length > 0 && (
|
{/* Role switcher items moved to the layout's top-bar
|
||||||
<>
|
RoleSwitcherPill — single source of truth across all
|
||||||
<DropdownMenuSeparator className="my-1" />
|
dashboards. */}
|
||||||
{switchableRoles.length <= 2 ? (
|
|
||||||
// Flat list for 1-2 roles
|
|
||||||
switchableRoles.map(([, opt]) => (
|
|
||||||
<DropdownMenuItem key={opt.path} asChild>
|
|
||||||
<Link
|
|
||||||
href={opt.path as Route}
|
|
||||||
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
|
|
||||||
>
|
|
||||||
<opt.icon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span>{opt.label}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
// Submenu for 3+ roles
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger className="flex items-center gap-2.5 rounded-md px-2 py-2">
|
|
||||||
<ArrowRightLeft className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span>Switch View</span>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="min-w-[160px]">
|
|
||||||
{switchableRoles.map(([, opt]) => (
|
|
||||||
<DropdownMenuItem key={opt.path} asChild>
|
|
||||||
<Link
|
|
||||||
href={opt.path as Route}
|
|
||||||
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
|
|
||||||
>
|
|
||||||
<opt.icon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span>{opt.label}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuSeparator className="my-1" />
|
<DropdownMenuSeparator className="my-1" />
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ import {
|
|||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
LogOut, Menu, Moon, Settings, Sun, User, X, Trophy,
|
LogOut, Menu, Moon, Settings, Sun, User, X,
|
||||||
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
|
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { UserRole } from '@prisma/client'
|
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import { Logo } from '@/components/shared/logo'
|
import { Logo } from '@/components/shared/logo'
|
||||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||||
|
import { useRoleSwitcher, RoleSwitcherPill } from './role-switcher'
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
name: string
|
name: string
|
||||||
@@ -54,16 +54,6 @@ type RoleNavProps = {
|
|||||||
helpEmail?: string
|
helpEmail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role switcher config — maps roles to their dashboard views
|
|
||||||
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
|
|
||||||
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 {
|
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
||||||
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
// Roles the user can switch to (excluding current view)
|
// Roles the user can switch to (excluding current view) — shared logic
|
||||||
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
|
const { switchableRoles } = useRoleSwitcher(basePath)
|
||||||
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)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b bg-card">
|
<header className="sticky top-0 z-40 border-b bg-card">
|
||||||
@@ -200,6 +186,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<RoleSwitcherPill currentBasePath={basePath} />
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
86
src/components/layouts/role-switcher.tsx
Normal file
86
src/components/layouts/role-switcher.tsx
Normal file
@@ -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<string, RoleSwitchOption> = {
|
||||||
|
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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5">
|
||||||
|
<ArrowRightLeft className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Switch View</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-44">
|
||||||
|
{switchableRoles.map(([role, opt]) => (
|
||||||
|
<DropdownMenuItem key={role} asChild>
|
||||||
|
<Link href={opt.path as Route} className="flex cursor-pointer items-center">
|
||||||
|
<opt.icon className="mr-2 h-4 w-4" />
|
||||||
|
{opt.label}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user