Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe47377d1 | |||
| c32239d557 | |||
| e3895d30b4 |
@@ -1,3 +1,10 @@
|
|||||||
|
## [0.1.15] - 2026-05-02
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Merge branch 'main' of https://git.notsosm.art/david/euchre_camp
|
||||||
|
- feat: add view-as-role feature for site admins (#15)
|
||||||
|
|
||||||
## [0.1.14] - 2026-05-02
|
## [0.1.14] - 2026-05-02
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "euchre_camp",
|
"name": "euchre_camp",
|
||||||
"version": "0.1.14",
|
"version": "0.1.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev",
|
"dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{" "}— 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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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