feat: add view-as-role feature for site admins (#15)
Site admins can now temporarily view the site as a player, tournament admin, or club admin to understand the experience for each role. Changes: - Added RoleSwitcher context and provider for client-side role simulation - Added role switcher dropdown in Navigation (visible only to site admins) - Added yellow banner showing current viewing-as role with reset button - Navigation links and wordmark href now use effective role for conditional display - Added BDD feature file with 5 scenarios covering all role transitions - Added step definitions for site admin login and role switcher interactions The view-as feature is purely UI-level - server-side permissions remain unchanged. 5 scenarios, 27 steps, all passing.
This commit is contained in:
+5
-2
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { SessionProvider } from "@/components/SessionProvider";
|
||||
import { RoleSwitcherProvider } from "@/components/RoleSwitcher";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
const inter = {
|
||||
@@ -24,8 +25,10 @@ export default function RootLayout({
|
||||
>
|
||||
<body className="min-h-full flex flex-col overflow-x-hidden">
|
||||
<SessionProvider>
|
||||
{children}
|
||||
<Footer />
|
||||
<RoleSwitcherProvider>
|
||||
{children}
|
||||
<Footer />
|
||||
</RoleSwitcherProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+138
-96
@@ -4,12 +4,13 @@ import Link from "next/link"
|
||||
import { useSession } from "./SessionProvider"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRoleSwitcher } from "./RoleSwitcher"
|
||||
|
||||
export default function Navigation() {
|
||||
const { session, loading } = useSession()
|
||||
const [userRole, setUserRole] = useState<string | null>(null)
|
||||
const { viewAsRole, setViewAsRole, effectiveRole } = useRoleSwitcher()
|
||||
|
||||
// Fetch user role whenever session changes
|
||||
useEffect(() => {
|
||||
const fetchUserRole = async () => {
|
||||
const userId = (session?.user as { id?: string })?.id
|
||||
@@ -34,117 +35,158 @@ export default function Navigation() {
|
||||
}, [session])
|
||||
|
||||
const handleLogout = async () => {
|
||||
setViewAsRole(null)
|
||||
await authClient.signOut()
|
||||
window.location.href = '/auth/login'
|
||||
}
|
||||
|
||||
// Determine wordmark href based on session and role
|
||||
// If session exists but role is not yet loaded, use /rankings as default for players
|
||||
const wordmarkHref = session
|
||||
? (userRole === "club_admin" || userRole === "site_admin")
|
||||
? "/admin"
|
||||
const displayRole = effectiveRole || userRole
|
||||
const isSiteAdmin = userRole === "site_admin"
|
||||
|
||||
const wordmarkHref = session
|
||||
? (displayRole === "club_admin" || displayRole === "site_admin")
|
||||
? "/admin"
|
||||
: "/rankings"
|
||||
: "/";
|
||||
: "/"
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
player: "Player",
|
||||
tournament_admin: "Tournament Admin",
|
||||
club_admin: "Club Admin",
|
||||
site_admin: "Site Admin",
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 no-underline flex-shrink-0"
|
||||
<>
|
||||
{viewAsRole && (
|
||||
<div className="bg-yellow-50 border-b border-yellow-200 px-4 py-2">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<span className="font-medium">Viewing as {roleLabels[viewAsRole]}</span>
|
||||
{" "}— you are seeing what a {roleLabels[viewAsRole]?.toLowerCase()} would see.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setViewAsRole(null)}
|
||||
className="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline"
|
||||
data-testid="reset-view-as"
|
||||
>
|
||||
EuchreCamp
|
||||
</Link>
|
||||
<div className="hidden md:ml-6 md:flex md:space-x-8 min-w-0 overflow-hidden">
|
||||
Reset to Site Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href="/rankings"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 no-underline flex-shrink-0"
|
||||
>
|
||||
Rankings
|
||||
EuchreCamp
|
||||
</Link>
|
||||
{session && (
|
||||
<>
|
||||
<Link
|
||||
href="/admin/tournaments"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
<div className="hidden md:ml-6 md:flex md:space-x-8 min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href="/rankings"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Rankings
|
||||
</Link>
|
||||
{session && (
|
||||
<>
|
||||
<Link
|
||||
href="/admin/tournaments"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Tournaments
|
||||
</Link>
|
||||
{(displayRole === "club_admin" || displayRole === "site_admin") && (
|
||||
<>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/matches"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Matches
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/players"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Players
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Users
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/matches/upload"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Upload Matches
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center min-w-0 overflow-hidden space-x-4">
|
||||
{isSiteAdmin && (
|
||||
<select
|
||||
value={viewAsRole || ""}
|
||||
onChange={(e) => setViewAsRole(e.target.value ? e.target.value as "player" | "tournament_admin" | "club_admin" : null)}
|
||||
className="text-sm border border-gray-300 rounded-md px-2 py-1 bg-white text-gray-700 focus:outline-none focus:ring-green-500 focus:border-green-500"
|
||||
data-testid="role-switcher"
|
||||
>
|
||||
<option value="">Viewing as Site Admin</option>
|
||||
<option value="player">View as Player</option>
|
||||
<option value="tournament_admin">View as Tournament Admin</option>
|
||||
<option value="club_admin">View as Club Admin</option>
|
||||
</select>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
) : session ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-gray-700 text-sm font-medium">
|
||||
{(session.user as { name?: string; email?: string })?.name ||
|
||||
(session.user as { name?: string; email?: string })?.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
|
||||
>
|
||||
Tournaments
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
{(userRole === "club_admin" || userRole === "site_admin") && (
|
||||
<>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/matches"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Matches
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/players"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Players
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Users
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/matches/upload"
|
||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||
>
|
||||
Upload Matches
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center min-w-0 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
) : session ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-gray-700 text-sm font-medium">
|
||||
{(session.user as { name?: string; email?: string })?.name ||
|
||||
(session.user as { name?: string; email?: string })?.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from "react"
|
||||
|
||||
type ViewAsRole = "player" | "tournament_admin" | "club_admin" | null
|
||||
|
||||
interface RoleSwitcherContextType {
|
||||
viewAsRole: ViewAsRole
|
||||
setViewAsRole: (role: ViewAsRole) => void
|
||||
effectiveRole: string | null
|
||||
}
|
||||
|
||||
const RoleSwitcherContext = createContext<RoleSwitcherContextType | undefined>(undefined)
|
||||
|
||||
export function RoleSwitcherProvider({ children }: { children: ReactNode }) {
|
||||
const [viewAsRole, setViewAsRole] = useState<ViewAsRole>(null)
|
||||
|
||||
const value = {
|
||||
viewAsRole,
|
||||
setViewAsRole: useCallback((role: ViewAsRole) => setViewAsRole(role), []),
|
||||
effectiveRole: viewAsRole,
|
||||
}
|
||||
|
||||
return (
|
||||
<RoleSwitcherContext.Provider value={value}>
|
||||
{children}
|
||||
</RoleSwitcherContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useRoleSwitcher() {
|
||||
const context = useContext(RoleSwitcherContext)
|
||||
if (!context) {
|
||||
throw new Error("useRoleSwitcher must be used within RoleSwitcherProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user