feat: auth-aware UI and role-based access control (#67)
## 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:
35
frontend/src/components/AdminRoute.tsx
Normal file
35
frontend/src/components/AdminRoute.tsx
Normal 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}</>
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AdminRoute } from './AdminRoute'
|
||||
export { CustomRulesDisplay } from './CustomRulesDisplay'
|
||||
export { ProtectedRoute } from './ProtectedRoute'
|
||||
export { EggEncounterModal } from './EggEncounterModal'
|
||||
|
||||
Reference in New Issue
Block a user