Release: auth system, admin RBAC, and production Supabase setup #70

Merged
TheFurya merged 34 commits from develop into main 2026-03-21 12:21:11 +01:00
10 changed files with 194 additions and 7 deletions
Showing only changes of commit f7731b0497 - Show all commits

View File

@@ -0,0 +1,28 @@
---
# nuzlocke-tracker-2zwg
title: Protect frontend routes with ProtectedRoute and AdminRoute
status: todo
type: task
priority: normal
created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z
parent: nuzlocke-tracker-ce4o
blocked_by:
- nuzlocke-tracker-5svj
---
Use the existing \`ProtectedRoute\` component (currently unused) and create an \`AdminRoute\` component to guard routes in \`App.tsx\`.
## 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)
## 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

View File

@@ -0,0 +1,27 @@
---
# nuzlocke-tracker-5svj
title: Expose admin status to frontend via user API
status: todo
type: task
priority: normal
created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z
parent: nuzlocke-tracker-ce4o
blocked_by:
- nuzlocke-tracker-dwah
---
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.
## 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`)
## 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

View File

@@ -0,0 +1,27 @@
---
# nuzlocke-tracker-ce4o
title: Auth-aware UI and role-based access control
status: todo
type: epic
created_at: 2026-03-21T10:05:52Z
updated_at: 2026-03-21T10:05:52Z
---
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.
## Goals
1. **Auth-aware navigation**: Menu items change based on login state (logged-out users only see public browsing options)
2. **Route protection**: Protected routes redirect to login, admin routes require admin role
3. **Admin role system**: Define which users are admins via a database field, enforce on both frontend and backend
4. **Backend admin enforcement**: Admin API endpoints (games, pokemon, evolutions, bosses, routes) require admin role, not just authentication
## 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/*
- [ ] 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
- [ ] 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

View File

@@ -0,0 +1,23 @@
---
# nuzlocke-tracker-dwah
title: Add is_admin column to users table
status: todo
type: task
created_at: 2026-03-21T10:06:19Z
updated_at: 2026-03-21T10:06:19Z
parent: nuzlocke-tracker-ce4o
---
Add an `is_admin` boolean column (default `false`) to the `users` table via an Alembic migration.
## 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)
## Files to change
- `backend/src/app/models/user.py` — add `is_admin` field
- `backend/src/app/alembic/versions/` — new migration

View File

@@ -0,0 +1,32 @@
---
# nuzlocke-tracker-f4d0
title: Add require_admin dependency and protect admin endpoints
status: todo
type: task
priority: normal
created_at: 2026-03-21T10:06:19Z
updated_at: 2026-03-21T10:06:24Z
parent: nuzlocke-tracker-ce4o
blocked_by:
- nuzlocke-tracker-dwah
---
Add a `require_admin` FastAPI dependency that checks the `is_admin` column on the `users` table. Apply it to all admin-facing API endpoints (games CRUD, pokemon CRUD, evolutions CRUD, bosses CRUD, route CRUD).
## Checklist
- [ ] 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
## Files to change
- `backend/src/app/core/auth.py` — add `require_admin`
- `backend/src/app/api/games.py` — replace `require_auth` with `require_admin` on mutations
- `backend/src/app/api/pokemon.py` — same
- `backend/src/app/api/evolutions.py` — same
- `backend/src/app/api/bosses.py` — same

View File

@@ -0,0 +1,27 @@
---
# nuzlocke-tracker-h205
title: Auth-aware navigation menu
status: todo
type: task
priority: normal
created_at: 2026-03-21T10:06:20Z
updated_at: 2026-03-21T10:06:24Z
parent: nuzlocke-tracker-ce4o
blocked_by:
- nuzlocke-tracker-5svj
---
Update the Layout component to show different nav links based on auth state and admin role.
## 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
## Files to change
- \`frontend/src/components/Layout.tsx\` — make \`navLinks\` dynamic based on auth state

View File

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

View File

@@ -37,7 +37,12 @@ class NuzlockeRun(Base):
String(20), index=True String(20), index=True
) # active, completed, failed ) # active, completed, failed
visibility: Mapped[RunVisibility] = mapped_column( visibility: Mapped[RunVisibility] = mapped_column(
Enum(RunVisibility, name="run_visibility", create_constraint=False), Enum(
RunVisibility,
name="run_visibility",
create_constraint=False,
values_callable=lambda e: [m.value for m in e],
),
default=RunVisibility.PUBLIC, default=RunVisibility.PUBLIC,
server_default="public", server_default="public",
) )

View File

@@ -72,7 +72,7 @@ services:
- GOTRUE_SITE_URL=http://localhost:5173 - GOTRUE_SITE_URL=http://localhost:5173
# Database # Database
- GOTRUE_DB_DRIVER=postgres - GOTRUE_DB_DRIVER=postgres
- GOTRUE_DB_DATABASE_URL=postgres://postgres:postgres@db:5432/nuzlocke?sslmode=disable - GOTRUE_DB_DATABASE_URL=postgres://postgres:postgres@db:5432/nuzlocke?sslmode=disable&search_path=auth
# JWT - must match backend's SUPABASE_JWT_SECRET # JWT - must match backend's SUPABASE_JWT_SECRET
- GOTRUE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long - GOTRUE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
- GOTRUE_JWT_AUD=authenticated - GOTRUE_JWT_AUD=authenticated

View File

@@ -2,14 +2,32 @@ import { createClient, type SupabaseClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env['VITE_SUPABASE_URL'] ?? '' const supabaseUrl = import.meta.env['VITE_SUPABASE_URL'] ?? ''
const supabaseAnonKey = import.meta.env['VITE_SUPABASE_ANON_KEY'] ?? '' const supabaseAnonKey = import.meta.env['VITE_SUPABASE_ANON_KEY'] ?? ''
const isLocalDev = supabaseUrl.includes('localhost')
// supabase-js hardcodes /auth/v1 as the auth path prefix, but GoTrue
// serves at the root when accessed directly (no API gateway).
// This custom fetch strips the prefix for local dev.
function localGoTrueFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const url = input instanceof Request ? input.url : String(input)
const rewritten = url.replace('/auth/v1/', '/')
if (input instanceof Request) {
return fetch(new Request(rewritten, input), init)
}
return fetch(rewritten, init)
}
function createSupabaseClient(): SupabaseClient { function createSupabaseClient(): SupabaseClient {
if (!supabaseUrl || !supabaseAnonKey) { if (!supabaseUrl || !supabaseAnonKey) {
// Return a stub client for tests/dev without Supabase configured
// Uses port 9999 to match local GoTrue container
return createClient('http://localhost:9999', 'stub-key') return createClient('http://localhost:9999', 'stub-key')
} }
return createClient(supabaseUrl, supabaseAnonKey) return createClient(supabaseUrl, supabaseAnonKey, {
...(isLocalDev && {
global: { fetch: localGoTrueFetch },
}),
})
} }
export const supabase = createSupabaseClient() export const supabase = createSupabaseClient()