feat: auth-aware UI and role-based access control (#67)
All checks were successful
CI / backend-tests (push) Successful in 32s
CI / frontend-tests (push) Successful in 29s

## Summary

- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints

## Test plan

- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
This commit was merged in pull request #67.
This commit is contained in:
2026-03-21 11:44:05 +01:00
committed by TheFurya
parent f7731b0497
commit e8ded9184b
27 changed files with 826 additions and 347 deletions

View File

@@ -1,5 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components'
import { Layout, ProtectedRoute, AdminRoute } from './components'
import { AdminLayout } from './components/admin'
import {
AuthCallback,
@@ -35,18 +35,18 @@ function App() {
<Route path="signup" element={<Signup />} />
<Route path="auth/callback" element={<AuthCallback />} />
<Route path="runs" element={<RunList />} />
<Route path="runs/new" element={<NewRun />} />
<Route path="runs/new" element={<ProtectedRoute><NewRun /></ProtectedRoute>} />
<Route path="runs/:runId" element={<RunEncounters />} />
<Route path="runs/:runId/journal/:entryId" element={<JournalEntryPage />} />
<Route path="genlockes" element={<GenlockeList />} />
<Route path="genlockes/new" element={<NewGenlocke />} />
<Route path="genlockes/new" element={<ProtectedRoute><NewGenlocke /></ProtectedRoute>} />
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
<Route path="stats" element={<Stats />} />
<Route
path="runs/:runId/encounters"
element={<Navigate to=".." relative="path" replace />}
/>
<Route path="admin" element={<AdminLayout />}>
<Route path="admin" element={<AdminRoute><AdminLayout /></AdminRoute>}>
<Route index element={<Navigate to="/admin/games" replace />} />
<Route path="games" element={<AdminGames />} />
<Route path="games/:gameId" element={<AdminGameDetail />} />

View File

@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { useAuth } from '../contexts/AuthContext'
export function AdminRoute({ children }: { children: React.ReactNode }) {
const { user, loading, isAdmin } = useAuth()
const location = useLocation()
const toastShownRef = useRef(false)
useEffect(() => {
if (!loading && user && !isAdmin && !toastShownRef.current) {
toastShownRef.current = true
toast.error('Admin access required')
}
}, [loading, user, isAdmin])
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500" />
</div>
)
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (!isAdmin) {
return <Navigate to="/" replace />
}
return <>{children}</>
}

View File

@@ -2,62 +2,108 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Layout } from './Layout'
import { AuthProvider } from '../contexts/AuthContext'
vi.mock('../hooks/useTheme', () => ({
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
}))
const mockUseAuth = vi.fn()
vi.mock('../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}))
const loggedOutAuth = {
user: null,
session: null,
loading: false,
isAdmin: false,
signInWithEmail: vi.fn(),
signUpWithEmail: vi.fn(),
signInWithGoogle: vi.fn(),
signInWithDiscord: vi.fn(),
signOut: vi.fn(),
}
const adminAuth = {
...loggedOutAuth,
user: { email: 'admin@example.com' },
session: {},
isAdmin: true,
}
function renderLayout(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<AuthProvider>
<Layout />
</AuthProvider>
<Layout />
</MemoryRouter>
)
}
describe('Layout', () => {
it('renders all desktop navigation links', () => {
renderLayout()
expect(screen.getAllByRole('link', { name: /new run/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /my runs/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /admin/i })[0]).toBeInTheDocument()
describe('when logged out', () => {
beforeEach(() => mockUseAuth.mockReturnValue(loggedOutAuth))
it('renders logged-out navigation links', () => {
renderLayout()
expect(screen.getAllByRole('link', { name: /^home$/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /^runs$/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
})
it('does not show authenticated links', () => {
renderLayout()
expect(screen.queryByRole('link', { name: /new run/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /my runs/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /admin/i })).not.toBeInTheDocument()
})
it('shows sign-in link', () => {
renderLayout()
expect(screen.getByRole('link', { name: /sign in/i })).toBeInTheDocument()
})
})
describe('when logged in as admin', () => {
beforeEach(() => mockUseAuth.mockReturnValue(adminAuth))
it('renders authenticated navigation links', () => {
renderLayout()
expect(screen.getAllByRole('link', { name: /new run/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /my runs/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /admin/i })[0]).toBeInTheDocument()
})
it('shows the mobile dropdown when the hamburger is clicked', async () => {
renderLayout()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
await userEvent.click(hamburger)
expect(screen.getAllByRole('link', { name: /my runs/i }).length).toBeGreaterThan(1)
})
})
it('renders the brand logo link', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout()
expect(screen.getByRole('link', { name: /ant/i })).toBeInTheDocument()
})
it('renders the theme toggle button', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout()
expect(screen.getAllByRole('button', { name: /switch to light mode/i })[0]).toBeInTheDocument()
})
it('initially hides the mobile dropdown menu', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout()
// Mobile menu items exist in DOM but menu is hidden; the mobile dropdown
// only appears inside the sm:hidden block after state toggle.
// The hamburger button should be present.
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
})
it('shows the mobile dropdown when the hamburger is clicked', async () => {
renderLayout()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
await userEvent.click(hamburger)
// After click, the menu open state adds a dropdown with nav links
// We can verify the menu is open by checking a class change or that
// the nav links appear in the mobile dropdown section.
// The mobile dropdown renders navLinks in a div inside sm:hidden
expect(screen.getAllByRole('link', { name: /my runs/i }).length).toBeGreaterThan(1)
})
it('renders the footer with PokeDB attribution', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout()
expect(screen.getByRole('link', { name: /pokedb/i })).toBeInTheDocument()
})

View File

@@ -1,16 +1,8 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom'
import { useTheme } from '../hooks/useTheme'
import { useAuth } from '../contexts/AuthContext'
const navLinks = [
{ to: '/runs/new', label: 'New Run' },
{ to: '/runs', label: 'My Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
{ to: '/admin', label: 'Admin' },
]
function NavLink({
to,
active,
@@ -136,9 +128,34 @@ function UserMenu({ onAction }: { onAction?: () => void }) {
export function Layout() {
const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation()
const { user, isAdmin } = useAuth()
const navLinks = useMemo(() => {
if (!user) {
// Logged out: Home, Runs, Genlockes, Stats
return [
{ to: '/', label: 'Home' },
{ to: '/runs', label: 'Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
]
}
// Logged in: New Run, My Runs, Genlockes, Stats
const links = [
{ to: '/runs/new', label: 'New Run' },
{ to: '/runs', label: 'My Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
]
// Admin gets Admin link
if (isAdmin) {
links.push({ to: '/admin', label: 'Admin' })
}
return links
}, [user, isAdmin])
function isActive(to: string) {
if (to === '/runs/new') return location.pathname === '/runs/new'
if (to === '/' || to === '/runs/new') return location.pathname === to
return location.pathname.startsWith(to)
}

View File

@@ -1,3 +1,4 @@
export { AdminRoute } from './AdminRoute'
export { CustomRulesDisplay } from './CustomRulesDisplay'
export { ProtectedRoute } from './ProtectedRoute'
export { EggEncounterModal } from './EggEncounterModal'

View File

@@ -1,11 +1,20 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import type { User, Session, AuthError } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase'
import { api } from '../api/client'
interface UserProfile {
id: string
email: string
displayName: string | null
isAdmin: boolean
}
interface AuthState {
user: User | null
session: Session | null
loading: boolean
isAdmin: boolean
}
interface AuthContextValue extends AuthState {
@@ -18,22 +27,35 @@ interface AuthContextValue extends AuthState {
const AuthContext = createContext<AuthContextValue | null>(null)
async function syncUserProfile(session: Session | null): Promise<boolean> {
if (!session) return false
try {
const profile = await api.post<UserProfile>('/users/me', {})
return profile.isAdmin
} catch {
return false
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
session: null,
loading: true,
isAdmin: false,
})
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setState({ user: session?.user ?? null, session, loading: false })
supabase.auth.getSession().then(async ({ data: { session } }) => {
const isAdmin = await syncUserProfile(session)
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
})
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setState({ user: session?.user ?? null, session, loading: false })
} = supabase.auth.onAuthStateChange(async (_event, session) => {
const isAdmin = await syncUserProfile(session)
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
})
return () => subscription.unsubscribe()