6 Commits

Author SHA1 Message Date
7ff271efba chore(auth-aware-ui-and-role-based-access-control): Expose admin status to frontend via user API
The frontend needs to know if the current user is an admin so it can show/hide the Admin nav link and protect admin routes client-side.

Bean: nuzlocke-tracker-5svj

chore: Update beans
2026-03-21 11:23:54 +01:00
c7259a429a feat: make navigation menu auth-aware
Navigation links now change based on authentication state:
- Logged out: Home, Runs, Genlockes, Stats
- Logged in: New Run, My Runs, Genlockes, Stats
- Admin: adds Admin link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:22:24 +01:00
da33c62d62 feat: protect frontend routes with ProtectedRoute and AdminRoute
- Wrap /runs/new and /genlockes/new with ProtectedRoute (requires login)
- Create AdminRoute component that checks isAdmin, redirects non-admins
  with a toast notification
- Wrap all /admin/* routes with AdminRoute
- Deep-linking preserved: unauthenticated users redirect to login, then
  back to the original protected route after auth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:19:30 +01:00
bbc6f2c3f4 feat: expose admin status to frontend via user API
Add is_admin field to UserResponse schema and update AuthContext to
fetch user profile after login, storing and exposing isAdmin boolean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:19:10 +01:00
2e66186fac feat: add require_admin dependency and protect admin endpoints
Add require_admin FastAPI dependency that checks is_admin column on users
table. Apply it to all admin-facing write endpoints (games, pokemon,
evolutions, bosses, routes CRUD). Run-scoped endpoints remain protected
by require_auth only since they manage user's own data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:14:55 +01:00
1042fff2b8 feat: add is_admin column to users table
Add `is_admin` boolean column (default false) via Alembic migration
for role-based access control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:10:29 +01:00
21 changed files with 435 additions and 80 deletions

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-2zwg
title: Protect frontend routes with ProtectedRoute and AdminRoute
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z
updated_at: 2026-03-21T10:19:41Z
parent: nuzlocke-tracker-ce4o
blocked_by:
- nuzlocke-tracker-5svj
@@ -15,14 +15,24 @@ Use the existing \`ProtectedRoute\` component (currently unused) and create an \
## Checklist
- [ ] Wrap \`/runs/new\` and \`/genlockes/new\` with \`ProtectedRoute\` (requires login)
- [ ] Create \`AdminRoute\` component that checks \`isAdmin\` from \`useAuth()\`, redirects to \`/\` with a toast/message if not admin
- [ ] Wrap all \`/admin/*\` routes with \`AdminRoute\`
- [ ] Ensure \`/runs\` and \`/runs/:runId\` remain accessible to everyone (public run viewing)
- [ ] Verify deep-linking works (e.g., visiting \`/admin/games\` while logged out redirects to login, then back to \`/admin/games\` after auth)
- [x] Wrap \`/runs/new\` and \`/genlockes/new\` with \`ProtectedRoute\` (requires login)
- [x] Create \`AdminRoute\` component that checks \`isAdmin\` from \`useAuth()\`, redirects to \`/\` with a toast/message if not admin
- [x] Wrap all \`/admin/*\` routes with \`AdminRoute\`
- [x] Ensure \`/runs\` and \`/runs/:runId\` remain accessible to everyone (public run viewing)
- [x] Verify deep-linking works (e.g., visiting \`/admin/games\` while logged out redirects to login, then back to \`/admin/games\` after auth)
## Files to change
- \`frontend/src/App.tsx\` — wrap routes
- \`frontend/src/components/ProtectedRoute.tsx\` — already exists, verify it works
- \`frontend/src/components/AdminRoute.tsx\` — new file
## Summary of Changes
Implemented frontend route protection:
- **ProtectedRoute**: Wraps `/runs/new` and `/genlockes/new` - redirects unauthenticated users to `/login` with return location preserved
- **AdminRoute**: New component that checks `isAdmin` from `useAuth()`, redirects non-admins to `/` with a toast notification
- **Admin routes**: Wrapped `AdminLayout` with `AdminRoute` to protect all `/admin/*` routes
- **Public routes**: `/runs` and `/runs/:runId` remain accessible to everyone
- **Deep-linking**: Location state preserved so users return to original route after login

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-5svj
title: Expose admin status to frontend via user API
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z
updated_at: 2026-03-21T10:23:04Z
parent: nuzlocke-tracker-ce4o
blocked_by:
- nuzlocke-tracker-dwah
@@ -15,13 +15,21 @@ The frontend needs to know if the current user is an admin so it can show/hide t
## Checklist
- [ ] Add `is_admin` field to the user response schema (`/api/users/me` endpoint)
- [ ] Update `AuthContext` to fetch `/api/users/me` after login and store `isAdmin` in context
- [ ] Expose `isAdmin` boolean from `useAuth()` hook
- [ ] Handle edge case: user exists in Supabase but not yet in local DB (first login creates user row with `is_admin=false`)
- [x] Add `is_admin` field to the user response schema (`/api/users/me` endpoint)
- [x] Update `AuthContext` to fetch `/api/users/me` after login and store `isAdmin` in context
- [x] Expose `isAdmin` boolean from `useAuth()` hook
- [x] Handle edge case: user exists in Supabase but not yet in local DB (first login creates user row with `is_admin=false`)
## Files to change
- `backend/src/app/schemas/user.py` or equivalent — add `is_admin` to response
- `backend/src/app/api/users.py` — ensure `/me` returns `is_admin`
- `frontend/src/contexts/AuthContext.tsx` — fetch and store admin status
## Summary of Changes
Added `isAdmin` field to frontend auth system:
- **Backend**: Added `is_admin: bool = False` to `UserResponse` schema in `backend/src/app/api/users.py`
- **Frontend**: Updated `AuthContext` to fetch `/api/users/me` after login and expose `isAdmin` boolean
- Edge case handled: `syncUserProfile` returns `false` if API call fails (new user auto-created with `is_admin=false` by backend)

View File

@@ -1,10 +1,11 @@
---
# nuzlocke-tracker-ce4o
title: Auth-aware UI and role-based access control
status: todo
status: completed
type: epic
priority: normal
created_at: 2026-03-21T10:05:52Z
updated_at: 2026-03-21T10:05:52Z
updated_at: 2026-03-21T10:18:47Z
---
The app currently shows the same navigation menu to all users regardless of auth state. Logged-out users can navigate to protected pages (e.g., /runs/new, /admin) even though the backend rejects their requests. The admin interface has no role restriction — any authenticated user can access it.
@@ -19,9 +20,9 @@ The app currently shows the same navigation menu to all users regardless of auth
## Success Criteria
- [ ] Logged-out users see only: Home, Runs (public list), Genlockes, Stats, Sign In
- [ ] Logged-out users cannot navigate to /runs/new, /genlockes/new, or /admin/*
- [x] Logged-out users cannot navigate to /runs/new, /genlockes/new, or /admin/*
- [ ] Logged-in non-admin users see: New Run, My Runs, Genlockes, Stats (no Admin link)
- [ ] Admin users see the full menu including Admin
- [ ] Backend admin endpoints return 403 for non-admin authenticated users
- [x] Backend admin endpoints return 403 for non-admin authenticated users
- [ ] Admin role is stored in the `users` table (`is_admin` boolean column)
- [ ] Admin status is exposed to the frontend via the user API or auth context
- [x] Admin status is exposed to the frontend via the user API or auth context

View File

@@ -1,10 +1,11 @@
---
# nuzlocke-tracker-dwah
title: Add is_admin column to users table
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-21T10:06:19Z
updated_at: 2026-03-21T10:06:19Z
updated_at: 2026-03-21T10:10:38Z
parent: nuzlocke-tracker-ce4o
---
@@ -12,12 +13,31 @@ Add an `is_admin` boolean column (default `false`) to the `users` table via an A
## Checklist
- [ ] Create Alembic migration adding `is_admin: Mapped[bool]` column with `server_default="false"`
- [ ] Update `User` model in `backend/src/app/models/user.py`
- [ ] Run migration and verify column exists
- [ ] Seed a test admin user (or document how to set `is_admin=true` via SQL)
- [x] Create Alembic migration adding `is_admin: Mapped[bool]` column with `server_default="false"`
- [x] Update `User` model in `backend/src/app/models/user.py`
- [x] Run migration and verify column exists
- [x] Seed a test admin user (or document how to set `is_admin=true` via SQL)
## Files to change
- `backend/src/app/models/user.py` — add `is_admin` field
- `backend/src/app/alembic/versions/` — new migration
## Summary of Changes
Added `is_admin` boolean column to the `users` table:
- **Migration**: `p7e8f9a0b1c2_add_is_admin_to_users.py` adds the column with `server_default='false'`
- **Model**: Updated `User` model with `is_admin: Mapped[bool]` field
### Setting admin via SQL
To promote a user to admin:
```sql
UPDATE users SET is_admin = true WHERE email = 'admin@example.com';
```
Or by user ID:
```sql
UPDATE users SET is_admin = true WHERE id = '<uuid>';
```

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-f4d0
title: Add require_admin dependency and protect admin endpoints
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-21T10:06:19Z
updated_at: 2026-03-21T10:06:24Z
updated_at: 2026-03-21T10:15:14Z
parent: nuzlocke-tracker-ce4o
blocked_by:
- nuzlocke-tracker-dwah
@@ -15,13 +15,13 @@ Add a `require_admin` FastAPI dependency that checks the `is_admin` column on th
## Checklist
- [ ] Add `require_admin` dependency in `backend/src/app/core/auth.py` that:
- [x] Add `require_admin` dependency in `backend/src/app/core/auth.py` that:
- Requires authentication (reuses `require_auth`)
- Looks up the user in the `users` table by `AuthUser.id`
- Returns 403 if `is_admin` is not `True`
- [ ] Apply `require_admin` to write endpoints in: `games.py`, `pokemon.py`, `evolutions.py`, `bosses.py` (all POST/PUT/PATCH/DELETE)
- [ ] Keep read endpoints (GET) accessible to all authenticated users
- [ ] Add tests for 403 response when non-admin user hits admin endpoints
- [x] Apply `require_admin` to write endpoints in: `games.py`, `pokemon.py`, `evolutions.py`, `bosses.py` (all POST/PUT/PATCH/DELETE)
- [x] Keep read endpoints (GET) accessible to all authenticated users
- [x] Add tests for 403 response when non-admin user hits admin endpoints
## Files to change
@@ -30,3 +30,20 @@ Add a `require_admin` FastAPI dependency that checks the `is_admin` column on th
- `backend/src/app/api/pokemon.py` — same
- `backend/src/app/api/evolutions.py` — same
- `backend/src/app/api/bosses.py` — same
## Summary of Changes
Added `require_admin` FastAPI dependency to `backend/src/app/core/auth.py`:
- Depends on `require_auth` (returns 401 if not authenticated)
- Looks up user in `users` table by UUID
- Returns 403 if user not found or `is_admin` is not True
Applied `require_admin` to all admin-facing write endpoints:
- `games.py`: POST/PUT/DELETE for games and routes
- `pokemon.py`: POST/PUT/DELETE for pokemon and route encounters
- `evolutions.py`: POST/PUT/DELETE for evolutions
- `bosses.py`: POST/PUT/DELETE for game-scoped boss operations (run-scoped endpoints kept with `require_auth`)
Added tests in `test_auth.py`:
- Unit tests for `require_admin` (admin user, non-admin user, user not in DB)
- Integration tests for admin endpoint access (403 for non-admin, 201 for admin)

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-h205
title: Auth-aware navigation menu
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z
updated_at: 2026-03-21T10:22:34Z
parent: nuzlocke-tracker-ce4o
blocked_by:
- nuzlocke-tracker-5svj
@@ -15,13 +15,24 @@ Update the Layout component to show different nav links based on auth state and
## Checklist
- [ ] Replace static \`navLinks\` array with dynamic links based on \`useAuth()\` state
- [ ] **Logged out**: Home, Runs, Genlockes, Stats (no New Run, no Admin)
- [ ] **Logged in (non-admin)**: New Run, My Runs, Genlockes, Stats
- [ ] **Logged in (admin)**: New Run, My Runs, Genlockes, Stats, Admin
- [ ] Update both desktop and mobile nav (they share the same \`navLinks\` array, so this should be automatic)
- [ ] Verify menu updates reactively on login/logout
- [x] Replace static \`navLinks\` array with dynamic links based on \`useAuth()\` state
- [x] **Logged out**: Home, Runs, Genlockes, Stats (no New Run, no Admin)
- [x] **Logged in (non-admin)**: New Run, My Runs, Genlockes, Stats
- [x] **Logged in (admin)**: New Run, My Runs, Genlockes, Stats, Admin
- [x] Update both desktop and mobile nav (they share the same \`navLinks\` array, so this should be automatic)
- [x] Verify menu updates reactively on login/logout
## Files to change
- \`frontend/src/components/Layout.tsx\` — make \`navLinks\` dynamic based on auth state
## Summary of Changes
- Removed static `navLinks` array from module scope
- Added dynamic `navLinks` computation inside `Layout` component using `useMemo`
- Navigation now depends on `user` and `isAdmin` from `useAuth()`:
- Logged out: Home, Runs, Genlockes, Stats
- Logged in (non-admin): New Run, My Runs, Genlockes, Stats
- Logged in (admin): New Run, My Runs, Genlockes, Stats, Admin
- Updated `isActive` function to handle Home route (`/`) correctly
- Both desktop and mobile nav automatically use the same dynamic `navLinks` array

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-he1n
title: Add local GoTrue container for dev auth testing
status: todo
status: completed
type: feature
priority: normal
created_at: 2026-03-20T20:57:04Z
updated_at: 2026-03-20T21:13:18Z
updated_at: 2026-03-21T10:07:40Z
---
## Problem

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()
)

View File

@@ -1,12 +1,14 @@
import time
from uuid import UUID
import jwt
import pytest
from httpx import ASGITransport, AsyncClient
from app.core.auth import AuthUser, get_current_user, require_auth
from app.core.auth import AuthUser, get_current_user, require_admin, require_auth
from app.core.config import settings
from app.main import app
from app.models.user import User
@pytest.fixture
@@ -177,3 +179,140 @@ async def test_read_endpoint_without_token(db_session):
) as ac:
response = await ac.get("/runs")
assert response.status_code == 200
async def test_require_admin_valid_admin_user(db_session):
"""Test require_admin passes through for admin user."""
user_id = "11111111-1111-1111-1111-111111111111"
admin_user = User(
id=UUID(user_id),
email="admin@example.com",
is_admin=True,
)
db_session.add(admin_user)
await db_session.commit()
auth_user = AuthUser(id=user_id, email="admin@example.com")
result = await require_admin(user=auth_user, session=db_session)
assert result is auth_user
async def test_require_admin_non_admin_user(db_session):
"""Test require_admin raises 403 for non-admin user."""
from fastapi import HTTPException
user_id = "22222222-2222-2222-2222-222222222222"
regular_user = User(
id=UUID(user_id),
email="user@example.com",
is_admin=False,
)
db_session.add(regular_user)
await db_session.commit()
auth_user = AuthUser(id=user_id, email="user@example.com")
with pytest.raises(HTTPException) as exc_info:
await require_admin(user=auth_user, session=db_session)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "Admin access required"
async def test_require_admin_user_not_in_db(db_session):
"""Test require_admin raises 403 for user not in database."""
from fastapi import HTTPException
auth_user = AuthUser(
id="33333333-3333-3333-3333-333333333333", email="ghost@example.com"
)
with pytest.raises(HTTPException) as exc_info:
await require_admin(user=auth_user, session=db_session)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "Admin access required"
async def test_admin_endpoint_returns_403_for_non_admin(
db_session, jwt_secret, monkeypatch
):
"""Test that admin endpoint returns 403 for authenticated non-admin user."""
user_id = "44444444-4444-4444-4444-444444444444"
regular_user = User(
id=UUID(user_id),
email="nonadmin@example.com",
is_admin=False,
)
db_session.add(regular_user)
await db_session.commit()
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
token = jwt.encode(
{
"sub": user_id,
"email": "nonadmin@example.com",
"role": "authenticated",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
},
jwt_secret,
algorithm="HS256",
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
response = await ac.post(
"/games",
json={
"name": "Test Game",
"slug": "test-game",
"generation": 1,
"region": "Kanto",
"category": "core",
},
)
assert response.status_code == 403
assert response.json()["detail"] == "Admin access required"
async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeypatch):
"""Test that admin endpoint succeeds for authenticated admin user."""
user_id = "55555555-5555-5555-5555-555555555555"
admin_user = User(
id=UUID(user_id),
email="admin@example.com",
is_admin=True,
)
db_session.add(admin_user)
await db_session.commit()
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
token = jwt.encode(
{
"sub": user_id,
"email": "admin@example.com",
"role": "authenticated",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
},
jwt_secret,
algorithm="HS256",
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
response = await ac.post(
"/games",
json={
"name": "Test Game",
"slug": "test-game",
"generation": 1,
"region": "Kanto",
"category": "core",
},
)
assert response.status_code == 201
assert response.json()["name"] == "Test Game"

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

@@ -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()