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:
2026-05-02 02:37:01 -07:00
parent 14fbfacf9f
commit e3895d30b4
6 changed files with 336 additions and 98 deletions
@@ -0,0 +1,46 @@
Feature: View As Role
As a site admin
I want to temporarily view the site as a player or club admin
So that I can understand and improve the experience for each role
@happy-path @admin-features @issue-15
Scenario: Site admin sees role switcher in navigation
Given I am logged in as a site admin
When I view the navigation
Then I should see the role switcher dropdown
Then the role switcher should default to "Viewing as Site Admin"
@happy-path @admin-features @issue-15
Scenario: Site admin switches to player view
Given I am logged in as a site admin
When I select "View as Player" from the role switcher
Then I should see the player navigation links
And I should not see the "Admin" link
And I should not see the "Users" link
And I should see a banner indicating I am viewing as "Player"
@happy-path @admin-features @issue-15
Scenario: Site admin switches to tournament admin view
Given I am logged in as a site admin
When I select "View as Tournament Admin" from the role switcher
Then I should see the "Tournaments" link
And I should not see the "Admin" link
And I should not see the "Users" link
And I should see a banner indicating I am viewing as "Tournament Admin"
@happy-path @admin-features @issue-15
Scenario: Site admin switches to club admin view
Given I am logged in as a site admin
When I select "View as Club Admin" from the role switcher
Then I should see the "Admin" link
And I should see the "Users" link
And I should see a banner indicating I am viewing as "Club Admin"
@happy-path @admin-features @issue-15
Scenario: Site admin resets to site admin view
Given I am logged in as a site admin
When I select "View as Player" from the role switcher
And I click the "Reset to Site Admin" button
Then the role switcher should default to "Viewing as Site Admin"
And I should see the "Admin" link
And I should not see the viewing as banner
@@ -158,6 +158,58 @@ Given('I am logged in as a tournament admin', async function () {
console.log(`🌍 User created: ${credentials.email}`); console.log(`🌍 User created: ${credentials.email}`);
}); });
/**
* Precondition: I am logged in as a site admin
* Creates a new user and assigns site_admin role via Prisma
*/
Given('I am logged in as a site admin', async function () {
console.log('🌍 Creating and logging in as a site admin...');
const credentials = generateTestCredentials();
world.user = credentials;
await world.page.goto(`${world.baseURL}/auth/register`);
await world.page.waitForLoadState('domcontentloaded');
await world.page.fill('input[name="name"]', credentials.name);
await world.page.fill('input[name="email"]', credentials.email);
await world.page.fill('input[name="password"]', credentials.password);
await world.page.click('button[type="submit"]');
await world.page.waitForURL(/\/players\/\d+\/profile/, { timeout: 15000 });
const currentUrl = world.page.url();
const match = currentUrl.match(/\/players\/(\d+)\/profile/);
if (match) {
const playerId = match[1];
world.playerId = playerId;
const prisma = await world.getPrisma();
const player = await prisma.player.findUnique({
where: { id: parseInt(playerId) },
include: { user: true }
});
if (player && player.user) {
const userId = player.user.id;
(world.user as any).id = userId;
await prisma.user.update({
where: { id: userId },
data: { role: 'site_admin' }
});
console.log(`🌍 Assigned site_admin role to user: ${userId}`);
// Navigate to home page to trigger Navigation re-mount with new role
await world.page.goto(`${world.baseURL}/`);
await world.page.waitForLoadState('networkidle');
await world.page.waitForTimeout(1000);
}
}
console.log(`🌍 Site admin created: ${credentials.email}`);
});
/** /**
* Precondition: I am logged in as a club admin * Precondition: I am logged in as a club admin
* Uses a pre-existing admin user from the database * Uses a pre-existing admin user from the database
@@ -690,3 +690,61 @@ Then('I should be on the match result entry page', async function () {
console.log(`🌍 Checking current URL: ${currentUrl}`); console.log(`🌍 Checking current URL: ${currentUrl}`);
expect(currentUrl).toMatch(/\/matches\/|\/admin\/tournaments\/\d+\/(entry|results)/); expect(currentUrl).toMatch(/\/matches\/|\/admin\/tournaments\/\d+\/(entry|results)/);
}); });
// View As Role Steps
When('I view the navigation', async function () {
await world.page.waitForLoadState('networkidle');
await world.page.waitForTimeout(1000);
console.log('🌍 Viewing navigation');
});
Then('I should see the role switcher dropdown', async function () {
const switcher = world.page.locator('[data-testid="role-switcher"]');
await expect(switcher).toBeVisible({ timeout: 5000 });
console.log('🌍 Verified role switcher dropdown is visible');
});
Then('the role switcher should default to {string}', async function (expectedText: string) {
const switcher = world.page.locator('[data-testid="role-switcher"]');
const selectedValue = await switcher.inputValue();
const selectedText = await switcher.locator('option:checked').textContent();
console.log(`🌍 Dropdown selected text: "${selectedText}", value: "${selectedValue}"`);
expect(selectedText?.trim()).toBe(expectedText);
});
When('I select {string} from the role switcher', async function (optionText: string) {
const switcher = world.page.locator('[data-testid="role-switcher"]');
await switcher.selectOption({ label: optionText });
await world.page.waitForTimeout(500);
console.log(`🌍 Selected "${optionText}" from role switcher`);
});
Then('I should see the player navigation links', async function () {
await expect(world.page.locator('nav a:has-text("Rankings")')).toBeVisible();
await expect(world.page.locator('nav a:has-text("Tournaments")')).toBeVisible();
console.log('🌍 Verified player navigation links are visible');
});
Then('I should not see the {string} link', async function (linkText: string) {
const link = world.page.locator(`nav a:has-text("${linkText}")`);
await expect(link).not.toBeVisible({ timeout: 3000 });
console.log(`🌍 Verified "${linkText}" nav link is not visible`);
});
Then('I should see the {string} link', async function (linkText: string) {
const link = world.page.locator(`nav a:has-text("${linkText}")`);
await expect(link).toBeVisible({ timeout: 5000 });
console.log(`🌍 Verified "${linkText}" nav link is visible`);
});
Then('I should see a banner indicating I am viewing as {string}', async function (roleName: string) {
const banner = world.page.locator(`text=Viewing as ${roleName}`);
await expect(banner).toBeVisible({ timeout: 5000 });
console.log(`🌍 Verified viewing as ${roleName} banner is visible`);
});
Then('I should not see the viewing as banner', async function () {
const banner = world.page.locator('[data-testid="reset-view-as"]');
await expect(banner).not.toBeVisible({ timeout: 3000 });
console.log('🌍 Verified viewing as banner is not visible');
});
+3
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { SessionProvider } from "@/components/SessionProvider"; import { SessionProvider } from "@/components/SessionProvider";
import { RoleSwitcherProvider } from "@/components/RoleSwitcher";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
const inter = { const inter = {
@@ -24,8 +25,10 @@ export default function RootLayout({
> >
<body className="min-h-full flex flex-col overflow-x-hidden"> <body className="min-h-full flex flex-col overflow-x-hidden">
<SessionProvider> <SessionProvider>
<RoleSwitcherProvider>
{children} {children}
<Footer /> <Footer />
</RoleSwitcherProvider>
</SessionProvider> </SessionProvider>
</body> </body>
</html> </html>
+49 -7
View File
@@ -4,12 +4,13 @@ import Link from "next/link"
import { useSession } from "./SessionProvider" import { useSession } from "./SessionProvider"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRoleSwitcher } from "./RoleSwitcher"
export default function Navigation() { export default function Navigation() {
const { session, loading } = useSession() const { session, loading } = useSession()
const [userRole, setUserRole] = useState<string | null>(null) const [userRole, setUserRole] = useState<string | null>(null)
const { viewAsRole, setViewAsRole, effectiveRole } = useRoleSwitcher()
// Fetch user role whenever session changes
useEffect(() => { useEffect(() => {
const fetchUserRole = async () => { const fetchUserRole = async () => {
const userId = (session?.user as { id?: string })?.id const userId = (session?.user as { id?: string })?.id
@@ -34,19 +35,46 @@ export default function Navigation() {
}, [session]) }, [session])
const handleLogout = async () => { const handleLogout = async () => {
setViewAsRole(null)
await authClient.signOut() await authClient.signOut()
window.location.href = '/auth/login' window.location.href = '/auth/login'
} }
// Determine wordmark href based on session and role const displayRole = effectiveRole || userRole
// If session exists but role is not yet loaded, use /rankings as default for players const isSiteAdmin = userRole === "site_admin"
const wordmarkHref = session const wordmarkHref = session
? (userRole === "club_admin" || userRole === "site_admin") ? (displayRole === "club_admin" || displayRole === "site_admin")
? "/admin" ? "/admin"
: "/rankings" : "/rankings"
: "/"; : "/"
const roleLabels: Record<string, string> = {
player: "Player",
tournament_admin: "Tournament Admin",
club_admin: "Club Admin",
site_admin: "Site Admin",
}
return ( return (
<>
{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>
{" "}&mdash; 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"
>
Reset to Site Admin
</button>
</div>
</div>
)}
<nav className="bg-white shadow-sm"> <nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <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 justify-between h-16">
@@ -72,7 +100,7 @@ export default function Navigation() {
> >
Tournaments Tournaments
</Link> </Link>
{(userRole === "club_admin" || userRole === "site_admin") && ( {(displayRole === "club_admin" || displayRole === "site_admin") && (
<> <>
<Link <Link
href="/admin" href="/admin"
@@ -110,7 +138,20 @@ export default function Navigation() {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center min-w-0 overflow-hidden"> <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 ? ( {loading ? (
<div className="text-gray-500">Loading...</div> <div className="text-gray-500">Loading...</div>
) : session ? ( ) : session ? (
@@ -146,5 +187,6 @@ export default function Navigation() {
</div> </div>
</div> </div>
</nav> </nav>
</>
) )
} }
+37
View File
@@ -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
}