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

@@ -17,9 +17,9 @@ GAME_PAYLOAD = {
@pytest.fixture
async def game(auth_client: AsyncClient) -> dict:
async def game(admin_client: AsyncClient) -> dict:
"""A game created via the API (no version_group_id)."""
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
response = await admin_client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201
return response.json()
@@ -68,8 +68,8 @@ class TestListGames:
class TestCreateGame:
async def test_creates_and_returns_game(self, auth_client: AsyncClient):
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
async def test_creates_and_returns_game(self, admin_client: AsyncClient):
response = await admin_client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Pokemon Red"
@@ -77,15 +77,15 @@ class TestCreateGame:
assert isinstance(data["id"], int)
async def test_duplicate_slug_returns_409(
self, auth_client: AsyncClient, game: dict
self, admin_client: AsyncClient, game: dict
):
response = await auth_client.post(
response = await admin_client.post(
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
)
assert response.status_code == 409
async def test_missing_required_field_returns_422(self, auth_client: AsyncClient):
response = await auth_client.post(BASE, json={"name": "Pokemon Red"})
async def test_missing_required_field_returns_422(self, admin_client: AsyncClient):
response = await admin_client.post(BASE, json={"name": "Pokemon Red"})
assert response.status_code == 422
@@ -115,35 +115,35 @@ class TestGetGame:
class TestUpdateGame:
async def test_updates_name(self, auth_client: AsyncClient, game: dict):
response = await auth_client.put(
async def test_updates_name(self, admin_client: AsyncClient, game: dict):
response = await admin_client.put(
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
)
assert response.status_code == 200
assert response.json()["name"] == "Pokemon Blue"
async def test_slug_unchanged_on_partial_update(
self, auth_client: AsyncClient, game: dict
self, admin_client: AsyncClient, game: dict
):
response = await auth_client.put(
response = await admin_client.put(
f"{BASE}/{game['id']}", json={"name": "New Name"}
)
assert response.json()["slug"] == "red"
async def test_not_found_returns_404(self, auth_client: AsyncClient):
async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (
await auth_client.put(f"{BASE}/9999", json={"name": "x"})
await admin_client.put(f"{BASE}/9999", json={"name": "x"})
).status_code == 404
async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient):
await auth_client.post(
async def test_duplicate_slug_returns_409(self, admin_client: AsyncClient):
await admin_client.post(
BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}
)
r1 = await auth_client.post(
r1 = await admin_client.post(
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
)
game_id = r1.json()["id"]
response = await auth_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
response = await admin_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
assert response.status_code == 409
@@ -153,13 +153,13 @@ class TestUpdateGame:
class TestDeleteGame:
async def test_deletes_game(self, auth_client: AsyncClient, game: dict):
response = await auth_client.delete(f"{BASE}/{game['id']}")
async def test_deletes_game(self, admin_client: AsyncClient, game: dict):
response = await admin_client.delete(f"{BASE}/{game['id']}")
assert response.status_code == 204
assert (await auth_client.get(f"{BASE}/{game['id']}")).status_code == 404
assert (await admin_client.get(f"{BASE}/{game['id']}")).status_code == 404
async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404
async def test_not_found_returns_404(self, admin_client: AsyncClient):
assert (await admin_client.delete(f"{BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
@@ -195,9 +195,9 @@ class TestListByRegion:
class TestCreateRoute:
async def test_creates_route(self, auth_client: AsyncClient, game_with_vg: tuple):
async def test_creates_route(self, admin_client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
response = await auth_client.post(
response = await admin_client.post(
f"{BASE}/{game_id}/routes",
json={"name": "Pallet Town", "order": 1},
)
@@ -208,35 +208,35 @@ class TestCreateRoute:
assert isinstance(data["id"], int)
async def test_game_detail_includes_route(
self, auth_client: AsyncClient, game_with_vg: tuple
self, admin_client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
await auth_client.post(
await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
response = await auth_client.get(f"{BASE}/{game_id}")
response = await admin_client.get(f"{BASE}/{game_id}")
routes = response.json()["routes"]
assert len(routes) == 1
assert routes[0]["name"] == "Route 1"
async def test_game_without_version_group_returns_400(
self, auth_client: AsyncClient, game: dict
self, admin_client: AsyncClient, game: dict
):
response = await auth_client.post(
response = await admin_client.post(
f"{BASE}/{game['id']}/routes",
json={"name": "Route 1", "order": 1},
)
assert response.status_code == 400
async def test_list_routes_excludes_routes_without_encounters(
self, auth_client: AsyncClient, game_with_vg: tuple
self, admin_client: AsyncClient, game_with_vg: tuple
):
"""list_game_routes only returns routes that have Pokemon encounters."""
game_id, _ = game_with_vg
await auth_client.post(
await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
response = await auth_client.get(f"{BASE}/{game_id}/routes?flat=true")
response = await admin_client.get(f"{BASE}/{game_id}/routes?flat=true")
assert response.status_code == 200
assert response.json() == []
@@ -248,15 +248,15 @@ class TestCreateRoute:
class TestUpdateRoute:
async def test_updates_route_name(
self, auth_client: AsyncClient, game_with_vg: tuple
self, admin_client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
r = (
await auth_client.post(
await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
)
).json()
response = await auth_client.put(
response = await admin_client.put(
f"{BASE}/{game_id}/routes/{r['id']}",
json={"name": "New Name"},
)
@@ -264,11 +264,11 @@ class TestUpdateRoute:
assert response.json()["name"] == "New Name"
async def test_route_not_found_returns_404(
self, auth_client: AsyncClient, game_with_vg: tuple
self, admin_client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
assert (
await auth_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
await admin_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
).status_code == 404
@@ -278,26 +278,26 @@ class TestUpdateRoute:
class TestDeleteRoute:
async def test_deletes_route(self, auth_client: AsyncClient, game_with_vg: tuple):
async def test_deletes_route(self, admin_client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
r = (
await auth_client.post(
await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
).json()
assert (
await auth_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
await admin_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
).status_code == 204
# No longer in game detail
detail = (await auth_client.get(f"{BASE}/{game_id}")).json()
detail = (await admin_client.get(f"{BASE}/{game_id}")).json()
assert all(route["id"] != r["id"] for route in detail["routes"])
async def test_route_not_found_returns_404(
self, auth_client: AsyncClient, game_with_vg: tuple
self, admin_client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
assert (
await auth_client.delete(f"{BASE}/{game_id}/routes/9999")
await admin_client.delete(f"{BASE}/{game_id}/routes/9999")
).status_code == 404
@@ -307,20 +307,20 @@ class TestDeleteRoute:
class TestReorderRoutes:
async def test_reorders_routes(self, auth_client: AsyncClient, game_with_vg: tuple):
async def test_reorders_routes(self, admin_client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
r1 = (
await auth_client.post(
await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
)
).json()
r2 = (
await auth_client.post(
await admin_client.post(
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
)
).json()
response = await auth_client.put(
response = await admin_client.put(
f"{BASE}/{game_id}/routes/reorder",
json={
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]