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

@@ -0,0 +1,29 @@
"""add is_admin to users
Revision ID: p7e8f9a0b1c2
Revises: o6d7e8f9a0b1
Create Date: 2026-03-21 10:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "p7e8f9a0b1c2"
down_revision: str | Sequence[str] | None = "o6d7e8f9a0b1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("users", "is_admin")

View File

@@ -5,7 +5,7 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.auth import AuthUser, require_admin, require_auth
from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
@@ -86,7 +86,7 @@ async def reorder_bosses(
game_id: int,
data: BossReorderRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -130,7 +130,7 @@ async def create_boss(
game_id: int,
data: BossBattleCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -161,7 +161,7 @@ async def update_boss(
boss_id: int,
data: BossBattleUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -202,7 +202,7 @@ async def delete_boss(
game_id: int,
boss_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -225,7 +225,7 @@ async def bulk_import_bosses(
game_id: int,
items: list[BulkBossItem],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -268,7 +268,7 @@ async def set_boss_team(
boss_id: int,
team: list[BossPokemonInput],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)

View File

@@ -3,6 +3,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.auth import AuthUser, require_admin
from app.core.database import get_session
from app.models.evolution import Evolution
from app.models.pokemon import Pokemon
@@ -89,7 +90,9 @@ async def list_evolutions(
@router.post("/evolutions", response_model=EvolutionAdminResponse, status_code=201)
async def create_evolution(
data: EvolutionCreate, session: AsyncSession = Depends(get_session)
data: EvolutionCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
from_pokemon = await session.get(Pokemon, data.from_pokemon_id)
if from_pokemon is None:
@@ -117,6 +120,7 @@ async def update_evolution(
evolution_id: int,
data: EvolutionUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
evolution = await session.get(Evolution, evolution_id)
if evolution is None:
@@ -150,7 +154,9 @@ async def update_evolution(
@router.delete("/evolutions/{evolution_id}", status_code=204)
async def delete_evolution(
evolution_id: int, session: AsyncSession = Depends(get_session)
evolution_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
evolution = await session.get(Evolution, evolution_id)
if evolution is None:
@@ -164,6 +170,7 @@ async def delete_evolution(
async def bulk_import_evolutions(
items: list[BulkEvolutionItem],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
# Build pokeapi_id -> id mapping
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))

View File

@@ -6,7 +6,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.auth import AuthUser, require_admin
from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.game import Game
@@ -232,7 +232,7 @@ async def list_game_routes(
async def create_game(
data: GameCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
existing = await session.execute(select(Game).where(Game.slug == data.slug))
if existing.scalar_one_or_none() is not None:
@@ -252,7 +252,7 @@ async def update_game(
game_id: int,
data: GameUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
game = await session.get(Game, game_id)
if game is None:
@@ -280,7 +280,7 @@ async def update_game(
async def delete_game(
game_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
result = await session.execute(
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
@@ -338,7 +338,7 @@ async def create_route(
game_id: int,
data: RouteCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -354,7 +354,7 @@ async def reorder_routes(
game_id: int,
data: RouteReorderRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -381,7 +381,7 @@ async def update_route(
route_id: int,
data: RouteUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -402,7 +402,7 @@ async def delete_route(
game_id: int,
route_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
@@ -437,7 +437,7 @@ async def bulk_import_routes(
game_id: int,
items: list[BulkRouteItem],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)

View File

@@ -3,6 +3,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from app.core.auth import AuthUser, require_admin
from app.core.database import get_session
from app.models.evolution import Evolution
from app.models.pokemon import Pokemon
@@ -68,6 +69,7 @@ async def list_pokemon(
async def bulk_import_pokemon(
items: list[BulkImportItem],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
created = 0
updated = 0
@@ -100,7 +102,9 @@ async def bulk_import_pokemon(
@router.post("/pokemon", response_model=PokemonResponse, status_code=201)
async def create_pokemon(
data: PokemonCreate, session: AsyncSession = Depends(get_session)
data: PokemonCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
existing = await session.execute(
select(Pokemon).where(Pokemon.pokeapi_id == data.pokeapi_id)
@@ -321,6 +325,7 @@ async def update_pokemon(
pokemon_id: int,
data: PokemonUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None:
@@ -349,7 +354,11 @@ async def update_pokemon(
@router.delete("/pokemon/{pokemon_id}", status_code=204)
async def delete_pokemon(pokemon_id: int, session: AsyncSession = Depends(get_session)):
async def delete_pokemon(
pokemon_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
result = await session.execute(
select(Pokemon)
.where(Pokemon.id == pokemon_id)
@@ -405,6 +414,7 @@ async def add_route_encounter(
route_id: int,
data: RouteEncounterCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
route = await session.get(Route, route_id)
if route is None:
@@ -436,6 +446,7 @@ async def update_route_encounter(
encounter_id: int,
data: RouteEncounterUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
result = await session.execute(
select(RouteEncounter)
@@ -466,6 +477,7 @@ async def remove_route_encounter(
route_id: int,
encounter_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
encounter = await session.execute(
select(RouteEncounter).where(

View File

@@ -16,6 +16,7 @@ class UserResponse(CamelModel):
id: UUID
email: str
display_name: str | None = None
is_admin: bool = False
@router.post("/me", response_model=UserResponse)

View File

@@ -1,9 +1,14 @@
from dataclasses import dataclass
from uuid import UUID
import jwt
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_session
from app.models.user import User
@dataclass
@@ -81,3 +86,22 @@ def require_auth(user: AuthUser | None = Depends(get_current_user)) -> AuthUser:
headers={"WWW-Authenticate": "Bearer"},
)
return user
async def require_admin(
user: AuthUser = Depends(require_auth),
session: AsyncSession = Depends(get_session),
) -> AuthUser:
"""
Dependency that requires admin privileges.
Raises 401 if not authenticated, 403 if not an admin.
"""
result = await session.execute(select(User).where(User.id == UUID(user.id)))
db_user = result.scalar_one_or_none()
if db_user is None or not db_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import DateTime, String, func
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -19,6 +19,7 @@ class User(Base):
id: Mapped[UUID] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(100))
is_admin: Mapped[bool] = mapped_column(Boolean, server_default="false")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)