Compare commits
14 Commits
177c02006a
...
d23e24b826
| Author | SHA1 | Date | |
|---|---|---|---|
| d23e24b826 | |||
| e9eccc5b21 | |||
| 79ad7b9133 | |||
| 50ed370d24 | |||
| 8be9718293 | |||
| 38b1156a95 | |||
| 596393d5b8 | |||
| c064a1b8d4 | |||
| f17687d2fa | |||
| 7a828d7215 | |||
| a4fa5bf1e4 | |||
| a3f332f82b | |||
| 3bd24fcdb0 | |||
| eeb1609452 |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
# nuzlocke-tracker-h8zw
|
||||
title: 'Crash: Hide edit controls for non-owners in frontend'
|
||||
status: completed
|
||||
type: bug
|
||||
priority: high
|
||||
created_at: 2026-03-21T12:49:42Z
|
||||
updated_at: 2026-03-21T12:50:37Z
|
||||
parent: nuzlocke-tracker-bw1m
|
||||
blocking:
|
||||
- nuzlocke-tracker-i2va
|
||||
---
|
||||
|
||||
Bean was found in 'in-progress' status on startup but no agent was running.
|
||||
This likely indicates a crash or unexpected termination.
|
||||
|
||||
Manual review required before retrying.
|
||||
|
||||
Bean: nuzlocke-tracker-i2va
|
||||
Title: Hide edit controls for non-owners in frontend
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
Investigation shows commit `3bd24fc` already implemented all required changes:
|
||||
- Added `useAuth` and `canEdit = isOwner` to both `RunEncounters.tsx` and `RunDashboard.tsx`
|
||||
- All mutation UI guarded behind `canEdit` (Log Shiny/Egg, End Run, Randomize All, HoF Edit, Boss Battle, route clicks, visibility, naming scheme)
|
||||
- Read-only banners displayed for non-owners
|
||||
- No code changes needed — work was already complete
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# nuzlocke-tracker-wwnu
|
||||
title: Auth hardening, admin ownership display, and MFA
|
||||
status: completed
|
||||
type: epic
|
||||
priority: high
|
||||
created_at: 2026-03-21T12:18:09Z
|
||||
updated_at: 2026-03-21T12:38:27Z
|
||||
---
|
||||
|
||||
Harden authentication and authorization across the app after the initial auth integration went live.
|
||||
|
||||
## Goals
|
||||
|
||||
- [x] Runs are only editable by their owner (encounters, deaths, bosses, settings)
|
||||
- [x] Frontend hides edit controls for non-owners and logged-out users
|
||||
- [x] Admin pages show owner info for runs and genlockes
|
||||
- [ ] Genlocke visibility/ownership inferred from first leg's run
|
||||
- [ ] Optional TOTP MFA for email/password signups
|
||||
|
||||
## Context
|
||||
|
||||
Auth is live with Google/Discord OAuth + email/password. Backend has `require_auth` on mutations but doesn't check ownership on encounters or genlockes. Frontend `RunEncounters.tsx` has zero auth checks. Admin pages lack owner columns. Genlocke model has no `owner_id` or `visibility`.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
# nuzlocke-tracker-wwwq
|
||||
title: 'Crash: Show owner info in admin pages'
|
||||
status: completed
|
||||
type: bug
|
||||
priority: high
|
||||
created_at: 2026-03-21T12:49:42Z
|
||||
updated_at: 2026-03-21T12:51:18Z
|
||||
parent: nuzlocke-tracker-bw1m
|
||||
blocking:
|
||||
- nuzlocke-tracker-2fp1
|
||||
---
|
||||
|
||||
Bean was found in 'in-progress' status on startup but no agent was running.
|
||||
This likely indicates a crash or unexpected termination.
|
||||
|
||||
Manual review required before retrying.
|
||||
|
||||
Bean: nuzlocke-tracker-2fp1
|
||||
Title: Show owner info in admin pages
|
||||
|
||||
## Reasons for Scrapping
|
||||
|
||||
The original bean (nuzlocke-tracker-2fp1) had all work completed and committed before the crash occurred. The agent crashed after completing the implementation but before marking the bean as completed. No additional work was needed - just updated the original bean's status to completed.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
# nuzlocke-tracker-2fp1
|
||||
title: Show owner info in admin pages
|
||||
status: in-progress
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-03-21T12:18:51Z
|
||||
updated_at: 2026-03-21T12:37:36Z
|
||||
parent: nuzlocke-tracker-wwnu
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Admin pages (`AdminRuns.tsx`, `AdminGenlockes.tsx`) don't show which user owns each run or genlocke. This makes it hard for admins to manage content.
|
||||
|
||||
## Approach
|
||||
|
||||
### Backend
|
||||
- The `/api/runs` list endpoint already returns run data — verify it includes `owner` (id + email). If not, add it to the response schema.
|
||||
- For genlockes, ownership is inferred from the first leg's run owner. Add an `owner` field to the genlocke list response that resolves from the first leg's run.
|
||||
|
||||
### Frontend
|
||||
- `AdminRuns.tsx`: Add an "Owner" column showing the owner's email (or "No owner" for legacy runs)
|
||||
- `AdminGenlockes.tsx`: Add an "Owner" column showing the inferred owner from the first leg's run
|
||||
- Add owner filter dropdown to both pages
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `backend/src/app/api/runs.py` — verify owner is included in list response
|
||||
- `backend/src/app/api/genlockes.py` — add owner resolution to list endpoint
|
||||
- `backend/src/app/schemas/genlocke.py` — add owner field to `GenlockeListItem`
|
||||
- `frontend/src/pages/admin/AdminRuns.tsx` — add Owner column + filter
|
||||
- `frontend/src/pages/admin/AdminGenlockes.tsx` — add Owner column + filter
|
||||
- `frontend/src/types/game.ts` — update types if needed
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Verify runs list API includes owner info; add if missing
|
||||
- [x] Add owner resolution to genlocke list endpoint (from first leg's run)
|
||||
- [x] Update `GenlockeListItem` schema to include owner
|
||||
- [x] Add Owner column to `AdminRuns.tsx`
|
||||
- [x] Add Owner column to `AdminGenlockes.tsx`
|
||||
- [x] Add owner filter to both admin pages
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
# nuzlocke-tracker-532i
|
||||
title: 'UX: Make level field optional in boss defeat modal'
|
||||
status: todo
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-03-21T21:50:48Z
|
||||
updated_at: 2026-03-21T22:04:08Z
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
When recording which team members beat a boss, users must manually enter a level for each pokemon. Since the app does not track levels anywhere else, this is unnecessary friction with no payoff.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
- Level input in `BossDefeatModal.tsx:200-211`
|
||||
- DB column `boss_result_team.level` is `SmallInteger NOT NULL` (in `models.py`)
|
||||
- Level is required in the API schema
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Remove the level field entirely from the UI and make it optional in the backend:
|
||||
|
||||
- [ ] Remove level input from `BossDefeatModal.tsx`
|
||||
- [ ] Make `level` column nullable in the database (alembic migration)
|
||||
- [ ] Update the API schema to make level optional (default to null)
|
||||
- [ ] Update any backend validation that requires level
|
||||
- [ ] Verify boss result display still works without level data
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
# nuzlocke-tracker-73ba
|
||||
title: Enforce run ownership on all mutation endpoints
|
||||
status: completed
|
||||
type: bug
|
||||
priority: critical
|
||||
created_at: 2026-03-21T12:18:27Z
|
||||
updated_at: 2026-03-21T12:28:35Z
|
||||
parent: nuzlocke-tracker-wwnu
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Backend mutation endpoints for encounters, bosses, and run updates use `require_auth` but do NOT verify the authenticated user is the run's owner. Any authenticated user can modify any run's encounters, mark bosses as defeated, or change run settings.
|
||||
|
||||
Additionally, `_check_run_access` in `runs.py:184` allows anyone to edit unowned (legacy) runs when `require_owner=False`.
|
||||
|
||||
### Affected endpoints
|
||||
|
||||
**encounters.py** — all mutations use `require_auth` with no ownership check:
|
||||
- `POST /runs/{run_id}/encounters` (line 35)
|
||||
- `PATCH /runs/{run_id}/encounters/{encounter_id}` (line 142)
|
||||
- `DELETE /runs/{run_id}/encounters/{encounter_id}` (line 171)
|
||||
- `POST /runs/{run_id}/encounters/bulk-randomize` (line 203)
|
||||
|
||||
**bosses.py** — boss result mutations:
|
||||
- `POST /runs/{run_id}/boss-results` (line 347)
|
||||
- `DELETE /runs/{run_id}/boss-results/{result_id}` (line 428)
|
||||
|
||||
**runs.py** — run updates/deletion:
|
||||
- `PATCH /runs/{run_id}` (line 379) — uses `_check_run_access(run, user, require_owner=run.owner_id is not None)` which skips check for unowned runs
|
||||
- `DELETE /runs/{run_id}` (line 488) — same conditional check
|
||||
|
||||
**genlockes.py** — genlocke mutations:
|
||||
- `POST /genlockes` (line 439) — no owner assigned to created genlocke or its first run
|
||||
- `PATCH /genlockes/{id}` (line 824) — no ownership check
|
||||
- `DELETE /genlockes/{id}` (line 862) — no ownership check
|
||||
- `POST /genlockes/{id}/legs/{leg_order}/advance` (line 569) — no ownership check
|
||||
- `POST /genlockes/{id}/legs` (line 894) — no ownership check
|
||||
- `DELETE /genlockes/{id}/legs/{leg_id}` (line 936) — no ownership check
|
||||
|
||||
## Approach
|
||||
|
||||
1. Add a reusable `_check_run_owner(run, user)` helper in `auth.py` or `runs.py` that raises 403 if `user.id != str(run.owner_id)` (no fallback for unowned runs — they should be read-only)
|
||||
2. Apply ownership check to ALL encounter/boss/run mutation endpoints
|
||||
3. For genlocke mutations, load the first leg's run and verify ownership against that
|
||||
4. Update `_check_run_access` to always require ownership for mutations (remove the `require_owner` conditional)
|
||||
5. When creating runs (standalone or via genlocke), set `owner_id` from the authenticated user
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Add `_check_run_owner` helper that rejects non-owners (including unowned/legacy runs)
|
||||
- [x] Apply ownership check to all 4 encounter mutation endpoints
|
||||
- [x] Apply ownership check to both boss result mutation endpoints
|
||||
- [x] Fix `_check_run_access` to always require ownership on mutations
|
||||
- [x] Set `owner_id` on run creation in `runs.py` and `genlockes.py` (create_genlocke, advance_leg)
|
||||
- [x] Apply ownership check to all genlocke mutation endpoints (via first leg's run owner)
|
||||
- [x] Add tests for ownership enforcement (403 for non-owner, 401 for unauthenticated)
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
Added `require_run_owner` helper in `auth.py` that enforces ownership on mutation endpoints:
|
||||
- Returns 403 for unowned (legacy) runs - they are now read-only
|
||||
- Returns 403 if authenticated user is not the run's owner
|
||||
|
||||
Applied ownership checks to:
|
||||
- All 4 encounter mutation endpoints (create, update, delete, bulk-randomize)
|
||||
- Both boss result mutation endpoints (create, delete)
|
||||
- Run update and delete endpoints (via `require_run_owner`)
|
||||
- All 5 genlocke mutation endpoints (update, delete, advance_leg, add_leg, remove_leg via `_check_genlocke_owner`)
|
||||
|
||||
Added `owner_id` on run creation:
|
||||
- `runs.py`: create_run already sets owner_id (verified)
|
||||
- `genlockes.py`: create_genlocke now sets owner_id on the first run
|
||||
- `genlockes.py`: advance_leg preserves owner_id from current run to new run
|
||||
|
||||
Renamed `_check_run_access` to `_check_run_read_access` (read-only visibility check) for clarity.
|
||||
|
||||
Added 22 comprehensive tests in `test_ownership.py` covering:
|
||||
- Owner can perform mutations
|
||||
- Non-owner gets 403 on mutations
|
||||
- Unauthenticated user gets 401
|
||||
- Unowned (legacy) runs reject all mutations
|
||||
- Read access preserved for public runs
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
# nuzlocke-tracker-8b25
|
||||
title: 'UX: Allow editing caught pokemon details on run page'
|
||||
status: draft
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-03-21T22:00:55Z
|
||||
updated_at: 2026-03-21T22:04:08Z
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Users can mistype catch level, nickname, or other details when recording an encounter, but there's no way to correct mistakes from the run page. The only option is to go through admin — which doesn't even support editing encounters for a specific run.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Backend `EncounterUpdate` schema** (`backend/src/app/schemas/encounter.py:18-23`): Supports `nickname`, `status`, `faint_level`, `death_cause`, `current_pokemon_id` — but NOT `catch_level`
|
||||
- **Frontend `UpdateEncounterInput`** (`frontend/src/types/game.ts:169-175`): Same fields as backend, missing `catch_level`
|
||||
- **Run page encounter modal**: Clicking a route with an existing encounter opens the modal in "edit" mode, but only allows changing pokemon/nickname/status — no catch_level editing
|
||||
- The encounter modal is the create/edit modal — editing is done by re-opening it on an existing encounter
|
||||
|
||||
## Approach
|
||||
|
||||
### Backend
|
||||
- [ ] Add `catch_level: int | None = None` to `EncounterUpdate` schema
|
||||
- [ ] Verify the PATCH `/encounters/{id}` endpoint applies `catch_level` updates (check `encounters.py` update handler)
|
||||
|
||||
### Frontend
|
||||
- [ ] Add `catchLevel?: number` to `UpdateEncounterInput` type
|
||||
- [ ] Ensure the encounter modal shows catch_level as editable when editing an existing encounter
|
||||
- [ ] Add catch_level field to the encounter edit modal (shown when editing existing encounters)
|
||||
|
||||
### Testing
|
||||
- [ ] Test updating catch_level via API
|
||||
- [ ] Test that the frontend sends catch_level in update requests
|
||||
- [ ] Verify existing create/update flows still work
|
||||
38
.beans/nuzlocke-tracker-9i9m--admin-interface-overhaul.md
Normal file
38
.beans/nuzlocke-tracker-9i9m--admin-interface-overhaul.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
# nuzlocke-tracker-9i9m
|
||||
title: Admin interface overhaul
|
||||
status: draft
|
||||
type: epic
|
||||
created_at: 2026-03-21T21:58:48Z
|
||||
updated_at: 2026-03-21T21:58:48Z
|
||||
---
|
||||
|
||||
Overhaul the admin interface to reduce navigation depth for game data management and add proper run administration.
|
||||
|
||||
## Problems
|
||||
|
||||
1. **Game data navigation is too deep** — Adding an encounter requires navigating Games → Game Detail → Route Detail (3 levels). This is rare but painful when needed.
|
||||
2. **No way to search across admin entities** — You have to manually drill down through the hierarchy to find anything.
|
||||
3. **Run admin is view+delete only** — Clicking a run row immediately opens a delete confirmation. No way to edit name, status, owner, visibility, or other metadata.
|
||||
|
||||
## Solution
|
||||
|
||||
### Game Data Navigation
|
||||
- Add a **global search bar** to the admin layout header that lets you jump directly to any game, route, encounter, or pokemon by name
|
||||
- Add **flattened views** for routes (`/admin/routes`) and encounters (`/admin/encounters`) as top-level admin pages with game/region filters, so you don't have to drill down through the game hierarchy
|
||||
|
||||
### Run Administration
|
||||
- Add a **slide-over panel** that opens when clicking a run row (replacing the current delete-on-click behavior)
|
||||
- Panel shows editable metadata: name, status, owner, visibility, rules, naming scheme
|
||||
- Add admin-only backend endpoint for owner reassignment
|
||||
- Keep delete as a button inside the panel (not the primary action)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Global admin search bar in layout header
|
||||
- [ ] Flattened routes page (`/admin/routes`) with game filter
|
||||
- [ ] Flattened encounters page (`/admin/encounters`) with game/route filters
|
||||
- [ ] Admin nav updated with new pages
|
||||
- [ ] Run slide-over panel with metadata editing
|
||||
- [ ] Admin endpoint for owner reassignment
|
||||
- [ ] Delete moved inside slide-over panel
|
||||
37
.beans/nuzlocke-tracker-b4d8--flattened-admin-routes-page.md
Normal file
37
.beans/nuzlocke-tracker-b4d8--flattened-admin-routes-page.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
# nuzlocke-tracker-b4d8
|
||||
title: Flattened admin routes page
|
||||
status: draft
|
||||
type: feature
|
||||
created_at: 2026-03-21T21:59:20Z
|
||||
updated_at: 2026-03-21T21:59:20Z
|
||||
parent: nuzlocke-tracker-9i9m
|
||||
---
|
||||
|
||||
Add a top-level `/admin/routes` page that shows all routes across all games, with filters for game and region. Eliminates the need to drill into a specific game just to find a route.
|
||||
|
||||
## Approach
|
||||
|
||||
- New page at `/admin/routes` showing all routes in a table
|
||||
- Columns: Route Name, Game, Region/Area, Order, Pokemon Count
|
||||
- Filters: game dropdown, text search
|
||||
- Clicking a route navigates to the existing `/admin/games/:gameId/routes/:routeId` detail page
|
||||
- Reuse existing `useRoutes` or add a new hook that fetches all routes across games
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `frontend/src/pages/admin/AdminRoutes.tsx` — New page
|
||||
- `frontend/src/pages/admin/index.ts` — Export new page
|
||||
- `frontend/src/App.tsx` — Add route
|
||||
- `frontend/src/components/admin/AdminLayout.tsx` — Add nav item
|
||||
- Possibly `frontend/src/hooks/` — Hook for fetching all routes
|
||||
- Possibly `backend/app/routes/` — Endpoint for listing all routes (if not already available)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] AdminRoutes page with table of all routes
|
||||
- [ ] Game filter dropdown
|
||||
- [ ] Text search filter
|
||||
- [ ] Click navigates to route detail page
|
||||
- [ ] Nav item added to admin sidebar
|
||||
- [ ] Route registered in App.tsx
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
# nuzlocke-tracker-e372
|
||||
title: Flattened admin encounters page
|
||||
status: draft
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-03-21T21:59:20Z
|
||||
updated_at: 2026-03-21T22:04:08Z
|
||||
parent: nuzlocke-tracker-9i9m
|
||||
---
|
||||
|
||||
Add a top-level `/admin/encounters` page that shows all encounters across all games and routes, with filters. This is the deepest entity in the current hierarchy and the most painful to reach.
|
||||
|
||||
## Approach
|
||||
|
||||
- New page at `/admin/encounters` showing all encounters in a table
|
||||
- Columns: Pokemon, Route, Game, Encounter Rate, Method
|
||||
- Filters: game dropdown, route dropdown (filtered by selected game), pokemon search
|
||||
- Clicking an encounter navigates to the route detail page where it can be edited
|
||||
- Requires new backend endpoint: GET /admin/encounters returning encounters joined with route name, game name, and pokemon name. Response shape: `{ id, pokemon_name, route_name, game_name, encounter_rate, method }`
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `frontend/src/pages/admin/AdminEncounters.tsx` — New page
|
||||
- `frontend/src/pages/admin/index.ts` — Export new page
|
||||
- `frontend/src/App.tsx` — Add route
|
||||
- `frontend/src/components/admin/AdminLayout.tsx` — Add nav item
|
||||
- `backend/app/routes/` — Endpoint for listing all encounters with game/route context
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] AdminEncounters page with table of all encounters
|
||||
- [ ] Game filter dropdown
|
||||
- [ ] Route filter dropdown (cascading from game)
|
||||
- [ ] Pokemon name search
|
||||
- [ ] Click navigates to route detail page
|
||||
- [ ] Nav item added to admin sidebar
|
||||
- [ ] Route registered in App.tsx
|
||||
- [ ] Backend endpoint for listing all encounters
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
# nuzlocke-tracker-f2hs
|
||||
title: Optional TOTP MFA for email/password accounts
|
||||
status: in-progress
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-03-21T12:19:18Z
|
||||
updated_at: 2026-03-21T12:56:34Z
|
||||
parent: nuzlocke-tracker-wwnu
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Users who sign up with email/password have no MFA option. Google/Discord OAuth users get their provider's MFA, but email-only users have a weaker security posture.
|
||||
|
||||
## Approach
|
||||
|
||||
Supabase has built-in TOTP MFA support via the `supabase.auth.mfa` API. This should be optional — users can enable it from their profile/settings page.
|
||||
|
||||
### Backend
|
||||
- No backend changes needed — Supabase handles MFA enrollment and verification at the auth layer
|
||||
- JWT tokens from MFA-enrolled users include an `aal` (authenticator assurance level) claim; optionally validate `aal2` for sensitive operations in the future
|
||||
|
||||
### Frontend
|
||||
1. Add MFA setup flow to user profile/settings page:
|
||||
- "Enable MFA" button → calls `supabase.auth.mfa.enroll({ factorType: 'totp' })`
|
||||
- Show QR code from enrollment response
|
||||
- Verify with TOTP code → `supabase.auth.mfa.challengeAndVerify()`
|
||||
2. Add MFA challenge during login:
|
||||
- After email/password sign-in, check `supabase.auth.mfa.getAuthenticatorAssuranceLevel()`
|
||||
- If `currentLevel === 'aal1'` and `nextLevel === 'aal2'`, show TOTP input
|
||||
- Verify → `supabase.auth.mfa.challengeAndVerify()`
|
||||
3. Add "Disable MFA" option with re-verification
|
||||
4. Only show MFA options for email/password users (not OAuth)
|
||||
|
||||
### UX
|
||||
- Settings page: toggle to enable/disable MFA
|
||||
- Login flow: TOTP input step after password for enrolled users
|
||||
- Recovery: Supabase provides recovery codes during enrollment — display them
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `frontend/src/pages/` — new MFA settings component or add to existing profile page
|
||||
- `frontend/src/pages/Login.tsx` — add MFA challenge step
|
||||
- `frontend/src/contexts/AuthContext.tsx` — handle AAL levels
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Add MFA enrollment UI (QR code, verification) to profile/settings
|
||||
- [x] Display backup secret code after enrollment (Supabase TOTP doesn't provide recovery codes)
|
||||
- [x] Add TOTP challenge step to login flow
|
||||
- [x] Check AAL after login and redirect to TOTP if needed
|
||||
- [x] Add "Disable MFA" with re-verification
|
||||
- [x] Only show MFA options for email/password users
|
||||
- [ ] Test: full enrollment → login → TOTP flow
|
||||
- [N/A] Test: recovery code works when TOTP unavailable (Supabase doesn't provide recovery codes; users save their secret key instead)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
# nuzlocke-tracker-i0rn
|
||||
title: Infer genlocke visibility from first leg's run
|
||||
status: completed
|
||||
type: feature
|
||||
created_at: 2026-03-21T12:46:56Z
|
||||
updated_at: 2026-03-21T12:46:56Z
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Genlockes are always public — they have no visibility setting. They should inherit visibility from their first leg's run, so if a user makes their run private, the genlocke is also hidden from public listings.
|
||||
|
||||
## Approach
|
||||
|
||||
Rather than adding a `visibility` column to the `genlockes` table, infer it from the first leg's run at query time. This avoids sync issues and keeps the first leg's run as the source of truth.
|
||||
|
||||
### Backend
|
||||
- `list_genlockes` endpoint: filter out genlockes whose first leg's run is private (unless the requesting user is the owner)
|
||||
- `get_genlocke` endpoint: return 404 if the first leg's run is private and the user is not the owner
|
||||
- Add optional auth (not required) to genlocke read endpoints to check ownership
|
||||
|
||||
### Frontend
|
||||
- No changes needed — private genlockes simply won't appear in listings for non-owners
|
||||
|
||||
## Files modified
|
||||
|
||||
- `backend/src/app/api/genlockes.py` — add visibility filtering to all read endpoints
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Add `get_current_user` (optional auth) dependency to genlocke read endpoints
|
||||
- [x] Filter private genlockes from `list_genlockes` for non-owners
|
||||
- [x] Return 404 for private genlockes in `get_genlocke` for non-owners
|
||||
- [x] Apply same filtering to graveyard, lineages, survivors, and retired-families endpoints
|
||||
- [x] Test: private run's genlocke hidden from unauthenticated users
|
||||
- [x] Test: owner can still see their private genlocke
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
- Added `_is_genlocke_visible()` helper function to check visibility based on first leg's run
|
||||
- Added optional auth (`get_current_user`) to all genlocke read endpoints:
|
||||
- `list_genlockes`: filters out private genlockes for non-owners
|
||||
- `get_genlocke`: returns 404 for private genlockes to non-owners
|
||||
- `get_genlocke_graveyard`: returns 404 for private genlockes
|
||||
- `get_genlocke_lineages`: returns 404 for private genlockes
|
||||
- `get_leg_survivors`: returns 404 for private genlockes
|
||||
- `get_retired_families`: returns 404 for private genlockes
|
||||
- Added 9 new tests in `TestGenlockeVisibility` class covering:
|
||||
- Private genlockes hidden from unauthenticated list
|
||||
- Private genlockes visible to owner in list
|
||||
- 404 for all detail endpoints when accessed by unauthenticated users
|
||||
- 404 for private genlockes when accessed by different authenticated user
|
||||
- Owner can still access their private genlocke
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
# nuzlocke-tracker-i2va
|
||||
title: Hide edit controls for non-owners in frontend
|
||||
status: in-progress
|
||||
type: bug
|
||||
priority: critical
|
||||
created_at: 2026-03-21T12:18:38Z
|
||||
updated_at: 2026-03-21T12:32:45Z
|
||||
parent: nuzlocke-tracker-wwnu
|
||||
blocked_by:
|
||||
- nuzlocke-tracker-73ba
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
`RunEncounters.tsx` has NO auth checks — all edit buttons (encounter modals, boss defeat, status changes, end run, shiny encounters, egg encounters, transfers, HoF team) are always visible, even to logged-out users viewing a public run.
|
||||
|
||||
`RunDashboard.tsx` has `canEdit = isOwner || !run?.owner` (line 70) which means unowned legacy runs are editable by anyone, including logged-out users.
|
||||
|
||||
## Approach
|
||||
|
||||
1. Add `useAuth` and `canEdit` logic to `RunEncounters.tsx`, matching the pattern from `RunDashboard.tsx` but stricter: `canEdit = isOwner` (no fallback for unowned runs)
|
||||
2. Update `RunDashboard.tsx` line 70 to `canEdit = isOwner` (remove `|| !run?.owner`)
|
||||
3. Conditionally render all mutation UI elements based on `canEdit`:
|
||||
- Encounter create/edit modals and triggers
|
||||
- Boss defeat buttons
|
||||
- Status change / End run buttons
|
||||
- Shiny encounter / Egg encounter modals
|
||||
- Transfer modal
|
||||
- HoF team modal
|
||||
- Visibility settings toggle
|
||||
4. Show a read-only banner when viewing someone else's run
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Add `useAuth` import and `canEdit` logic to `RunEncounters.tsx`
|
||||
- [x] Guard all mutation triggers in `RunEncounters.tsx` behind `canEdit`
|
||||
- [x] Update `RunDashboard.tsx` `canEdit` to be `isOwner` only (no unowned fallback)
|
||||
- [x] Guard all mutation triggers in `RunDashboard.tsx` behind `canEdit`
|
||||
- [x] Add read-only indicator/banner for non-owner viewers
|
||||
- [x] Verify logged-out users see no edit controls on public runs
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
# nuzlocke-tracker-lkro
|
||||
title: 'UX: Make team section a floating sidebar on desktop'
|
||||
status: todo
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-03-21T21:50:48Z
|
||||
<<<<<<< Updated upstream
|
||||
updated_at: 2026-03-21T22:04:08Z
|
||||
=======
|
||||
updated_at: 2026-03-22T08:08:13Z
|
||||
>>>>>>> Stashed changes
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
During a run, the team section is rendered inline at the top of the encounters page. When scrolling through routes and bosses, the team disappears and users must scroll back up to evolve pokemon or check their team. This creates constant friction during gameplay.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
- Team section rendered in `RunEncounters.tsx:1214-1288`
|
||||
- Inline in the page flow, above the encounters list
|
||||
- No sticky/floating behavior
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Make the team section a sticky sidebar on desktop viewports (2-column layout):
|
||||
- **Desktop (lg breakpoint, ≥1024px — Tailwind v4 default):** Encounters on the left, team pinned in a right sidebar that scrolls with the page
|
||||
- **Mobile:** Keep current stacked layout (team above encounters)
|
||||
|
||||
Alternative: A floating action button (FAB) that opens the team in a slide-over panel.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Add responsive 2-column layout to RunEncounters page (desktop only)
|
||||
- [ ] Move team section into a sticky sidebar column
|
||||
- [ ] Ensure sidebar scrolls independently if team is taller than viewport
|
||||
- [ ] Keep current stacked layout on mobile/tablet
|
||||
- [ ] Test with various team sizes (0-6 pokemon)
|
||||
- [ ] Test evolution/nickname editing still works from sidebar
|
||||
36
.beans/nuzlocke-tracker-mmre--admin-global-search-bar.md
Normal file
36
.beans/nuzlocke-tracker-mmre--admin-global-search-bar.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
# nuzlocke-tracker-mmre
|
||||
title: Admin global search bar
|
||||
status: draft
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-03-21T21:59:20Z
|
||||
updated_at: 2026-03-21T22:04:08Z
|
||||
parent: nuzlocke-tracker-9i9m
|
||||
---
|
||||
|
||||
Add a search bar to the admin layout header that searches across all admin entities (games, routes, encounters, pokemon, evolutions, runs) and lets you jump directly to the relevant page.
|
||||
|
||||
## Approach
|
||||
|
||||
- Add a search input to `AdminLayout.tsx` above the nav
|
||||
- Use a debounced search that queries multiple endpoints (or a single backend search endpoint)
|
||||
- Show results in a dropdown grouped by entity type (Games, Routes, Encounters, Pokemon, Runs)
|
||||
- Each result links directly to the relevant admin page (e.g., clicking a route goes to `/admin/games/:gameId/routes/:routeId`)
|
||||
- Keyboard shortcut (Cmd/Ctrl+K) to focus the search bar
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `frontend/src/components/admin/AdminLayout.tsx` — Add search bar UI
|
||||
- `frontend/src/components/admin/AdminSearchBar.tsx` — New component
|
||||
- `frontend/src/hooks/useAdminSearch.ts` — New hook for search logic
|
||||
- `backend/src/app/api/search.py` — New unified search endpoint (required — client-side search across 5+ entity types is too slow)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Search bar component with debounced input
|
||||
- [ ] Search across games, routes, encounters, pokemon, runs
|
||||
- [ ] Results dropdown grouped by entity type
|
||||
- [ ] Click result navigates to correct admin page
|
||||
- [ ] Keyboard shortcut (Cmd/Ctrl+K) to focus
|
||||
- [ ] Empty state and loading state
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
# nuzlocke-tracker-ru96
|
||||
title: Admin run slide-over panel with metadata editing
|
||||
status: draft
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-03-21T21:59:20Z
|
||||
updated_at: 2026-03-21T22:04:08Z
|
||||
parent: nuzlocke-tracker-9i9m
|
||||
---
|
||||
|
||||
Replace the current click-to-delete behavior on the runs page with a slide-over panel that shows run details and allows editing metadata.
|
||||
|
||||
## Current problem
|
||||
|
||||
Clicking any run row in AdminRuns immediately opens a delete confirmation modal. There is no way to view or edit run metadata (name, status, owner, visibility).
|
||||
|
||||
## Approach
|
||||
|
||||
- Replace `onRowClick` from opening delete modal to opening a slide-over panel
|
||||
- Panel slides in from the right over the runs list
|
||||
- Panel shows all run metadata with inline editing:
|
||||
- Name (text input)
|
||||
- Status (dropdown: active/completed/failed — matches `RunStatus` type)
|
||||
- Owner (user search/select — requires new admin endpoint)
|
||||
- Visibility (dropdown: public/private/unlisted)
|
||||
- Rules, Naming Scheme (if applicable)
|
||||
- Started At, Completed At (read-only)
|
||||
- Save button to persist changes
|
||||
- Delete button at bottom of panel (with confirmation)
|
||||
- New admin-only backend endpoint: PUT /admin/runs/:id for owner reassignment and other admin-only fields\n- New admin-only endpoint: GET /admin/users for user search/select (currently no list-users endpoint exists — only /users/me)
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `frontend/src/pages/admin/AdminRuns.tsx` — Replace delete-on-click with slide-over
|
||||
- `frontend/src/components/admin/RunSlideOver.tsx` — New slide-over component
|
||||
- `frontend/src/hooks/useRuns.ts` — Add admin update mutation
|
||||
- `backend/app/routes/admin.py` — Add admin run update endpoint
|
||||
- `backend/app/schemas/run.py` — Add admin-specific update schema (with owner_id)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] SlideOver component (reusable, slides from right)
|
||||
- [ ] RunSlideOver with editable fields
|
||||
- [ ] AdminRuns opens slide-over on row click (not delete modal)
|
||||
- [ ] Save functionality with optimistic updates
|
||||
- [ ] Delete button inside slide-over with confirmation
|
||||
- [ ] Admin backend endpoint for run updates (including owner reassignment)
|
||||
- [ ] Admin run update schema with owner_id field
|
||||
- [ ] User search/select for owner reassignment
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# nuzlocke-tracker-tatg
|
||||
title: 'Bug: Intermittent 401 errors / failed save-load requiring page reload'
|
||||
status: todo
|
||||
type: bug
|
||||
priority: high
|
||||
created_at: 2026-03-21T21:50:48Z
|
||||
updated_at: 2026-03-21T21:50:48Z
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
During gameplay, the app intermittently fails to load or save data. A page reload fixes the issue. Likely caused by expired Supabase JWT tokens not being refreshed automatically before API calls.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
- Auth uses Supabase JWTs verified with HS256 (`backend/auth.py:39-44`)
|
||||
- Frontend gets token via `supabase.auth.getSession()` in `client.ts:16-21`
|
||||
- `getAuthHeaders()` returns the cached session token without checking expiry
|
||||
- When the token expires between interactions, API calls return 401
|
||||
- Page reload triggers a fresh `getSession()` which refreshes the token
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
`getSession()` returns the cached token. If it's expired, the frontend sends an expired JWT to the backend, which rejects it with 401. The frontend doesn't call `refreshSession()` or handle token refresh before API calls.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
- [ ] Add token refresh logic before API calls (check expiry, call `refreshSession()` if needed)
|
||||
- [ ] Add 401 response interceptor that automatically refreshes token and retries the request
|
||||
- [ ] Verify Supabase client `autoRefreshToken` option is enabled
|
||||
- [ ] Test with short-lived tokens to confirm refresh works
|
||||
- [ ] Check if there's a race condition when multiple API calls trigger refresh simultaneously
|
||||
@@ -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_admin, require_auth
|
||||
from app.core.auth import AuthUser, require_admin, require_auth, require_run_owner
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.boss_pokemon import BossPokemon
|
||||
@@ -344,12 +344,14 @@ async def create_boss_result(
|
||||
run_id: int,
|
||||
data: BossResultCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
require_run_owner(run, user)
|
||||
|
||||
boss = await session.get(BossBattle, data.boss_battle_id)
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
@@ -425,8 +427,14 @@ async def delete_boss_result(
|
||||
run_id: int,
|
||||
result_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
require_run_owner(run, user)
|
||||
|
||||
result = await session.execute(
|
||||
select(BossResult).where(
|
||||
BossResult.id == result_id, BossResult.run_id == run_id
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.core.auth import AuthUser, require_auth
|
||||
from app.core.auth import AuthUser, require_auth, require_run_owner
|
||||
from app.core.database import get_session
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
@@ -36,13 +36,15 @@ async def create_encounter(
|
||||
run_id: int,
|
||||
data: EncounterCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
# Validate run exists
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
require_run_owner(run, user)
|
||||
|
||||
# Validate route exists and load its children
|
||||
result = await session.execute(
|
||||
select(Route)
|
||||
@@ -139,12 +141,17 @@ async def update_encounter(
|
||||
encounter_id: int,
|
||||
data: EncounterUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
encounter = await session.get(Encounter, encounter_id)
|
||||
if encounter is None:
|
||||
raise HTTPException(status_code=404, detail="Encounter not found")
|
||||
|
||||
run = await session.get(NuzlockeRun, encounter.run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
require_run_owner(run, user)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(encounter, field, value)
|
||||
@@ -168,12 +175,17 @@ async def update_encounter(
|
||||
async def delete_encounter(
|
||||
encounter_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
encounter = await session.get(Encounter, encounter_id)
|
||||
if encounter is None:
|
||||
raise HTTPException(status_code=404, detail="Encounter not found")
|
||||
|
||||
run = await session.get(NuzlockeRun, encounter.run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
require_run_owner(run, user)
|
||||
|
||||
# Block deletion if encounter is referenced by a genlocke transfer
|
||||
transfer_result = await session.execute(
|
||||
select(GenlockeTransfer.id).where(
|
||||
@@ -200,12 +212,15 @@ async def delete_encounter(
|
||||
async def bulk_randomize_encounters(
|
||||
run_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
# 1. Validate run
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
require_run_owner(run, user)
|
||||
|
||||
if run.status != "active":
|
||||
raise HTTPException(status_code=400, detail="Run is not active")
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete as sa_delete
|
||||
@@ -6,16 +8,17 @@ from sqlalchemy import update as sa_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, get_current_user, require_auth, require_run_owner
|
||||
from app.core.database import get_session
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
from app.models.game import Game
|
||||
from app.models.genlocke import Genlocke, GenlockeLeg
|
||||
from app.models.genlocke_transfer import GenlockeTransfer
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.user import User
|
||||
from app.schemas.genlocke import (
|
||||
AddLegRequest,
|
||||
AdvanceLegRequest,
|
||||
@@ -25,6 +28,7 @@ from app.schemas.genlocke import (
|
||||
GenlockeLegDetailResponse,
|
||||
GenlockeLineageResponse,
|
||||
GenlockeListItem,
|
||||
GenlockeOwnerResponse,
|
||||
GenlockeResponse,
|
||||
GenlockeStatsResponse,
|
||||
GenlockeUpdate,
|
||||
@@ -41,12 +45,72 @@ from app.services.families import build_families, resolve_base_form
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _check_genlocke_owner(
|
||||
genlocke_id: int,
|
||||
user: AuthUser,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
Verify user owns the genlocke via the first leg's run.
|
||||
Raises 404 if the genlocke doesn't exist.
|
||||
Raises 403 if the first leg has a run with a different owner.
|
||||
Raises 403 if the first leg has an unowned run (read-only legacy data).
|
||||
"""
|
||||
# First check if genlocke exists
|
||||
genlocke = await session.get(Genlocke, genlocke_id)
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
leg_result = await session.execute(
|
||||
select(GenlockeLeg)
|
||||
.where(GenlockeLeg.genlocke_id == genlocke_id, GenlockeLeg.leg_order == 1)
|
||||
.options(selectinload(GenlockeLeg.run))
|
||||
)
|
||||
first_leg = leg_result.scalar_one_or_none()
|
||||
|
||||
if first_leg is None or first_leg.run is None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Cannot modify genlocke: no run found for first leg",
|
||||
)
|
||||
|
||||
require_run_owner(first_leg.run, user)
|
||||
|
||||
|
||||
def _is_genlocke_visible(genlocke: Genlocke, user: AuthUser | None) -> bool:
|
||||
"""
|
||||
Check if a genlocke is visible to the given user.
|
||||
Visibility is inferred from the first leg's run:
|
||||
- Public runs are visible to everyone
|
||||
- Private runs are only visible to the owner
|
||||
"""
|
||||
first_leg = next((leg for leg in genlocke.legs if leg.leg_order == 1), None)
|
||||
if not first_leg or not first_leg.run:
|
||||
# No first leg or run - treat as visible (legacy data)
|
||||
return True
|
||||
|
||||
if first_leg.run.visibility == RunVisibility.PUBLIC:
|
||||
return True
|
||||
|
||||
# Private run - only visible to owner
|
||||
if user is None:
|
||||
return False
|
||||
if first_leg.run.owner_id is None:
|
||||
return False
|
||||
return str(first_leg.run.owner_id) == user.id
|
||||
|
||||
|
||||
@router.get("", response_model=list[GenlockeListItem])
|
||||
async def list_genlockes(session: AsyncSession = Depends(get_session)):
|
||||
async def list_genlockes(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser | None = Depends(get_current_user),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
.options(
|
||||
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
|
||||
selectinload(Genlocke.legs)
|
||||
.selectinload(GenlockeLeg.run)
|
||||
.selectinload(NuzlockeRun.owner),
|
||||
)
|
||||
.order_by(Genlocke.created_at.desc())
|
||||
)
|
||||
@@ -54,8 +118,22 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
|
||||
|
||||
items = []
|
||||
for g in genlockes:
|
||||
# Filter out private genlockes for non-owners
|
||||
if not _is_genlocke_visible(g, user):
|
||||
continue
|
||||
|
||||
completed_legs = 0
|
||||
current_leg_order = None
|
||||
owner = None
|
||||
|
||||
# Find first leg (leg_order == 1) to get owner
|
||||
first_leg = next((leg for leg in g.legs if leg.leg_order == 1), None)
|
||||
if first_leg and first_leg.run and first_leg.run.owner:
|
||||
owner = GenlockeOwnerResponse(
|
||||
id=first_leg.run.owner.id,
|
||||
display_name=first_leg.run.owner.display_name,
|
||||
)
|
||||
|
||||
for leg in g.legs:
|
||||
if leg.run and leg.run.status == "completed":
|
||||
completed_legs += 1
|
||||
@@ -71,13 +149,18 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
|
||||
total_legs=len(g.legs),
|
||||
completed_legs=completed_legs,
|
||||
current_leg_order=current_leg_order,
|
||||
owner=owner,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{genlocke_id}", response_model=GenlockeDetailResponse)
|
||||
async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_session)):
|
||||
async def get_genlocke(
|
||||
genlocke_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser | None = Depends(get_current_user),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
.where(Genlocke.id == genlocke_id)
|
||||
@@ -90,6 +173,10 @@ async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_ses
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
# Check visibility - return 404 for private genlockes to non-owners
|
||||
if not _is_genlocke_visible(genlocke, user):
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
# Collect run IDs for aggregate query
|
||||
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
|
||||
|
||||
@@ -173,20 +260,26 @@ async def get_genlocke(genlocke_id: int, session: AsyncSession = Depends(get_ses
|
||||
response_model=GenlockeGraveyardResponse,
|
||||
)
|
||||
async def get_genlocke_graveyard(
|
||||
genlocke_id: int, session: AsyncSession = Depends(get_session)
|
||||
genlocke_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser | None = Depends(get_current_user),
|
||||
):
|
||||
# Load genlocke with legs + game
|
||||
# Load genlocke with legs + game + run (for visibility check)
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
.where(Genlocke.id == genlocke_id)
|
||||
.options(
|
||||
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
|
||||
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
|
||||
)
|
||||
)
|
||||
genlocke = result.scalar_one_or_none()
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
if not _is_genlocke_visible(genlocke, user):
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
# Build run_id → (leg_order, game_name) lookup
|
||||
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
|
||||
run_lookup: dict[int, tuple[int, str]] = {}
|
||||
@@ -274,20 +367,26 @@ async def get_genlocke_graveyard(
|
||||
response_model=GenlockeLineageResponse,
|
||||
)
|
||||
async def get_genlocke_lineages(
|
||||
genlocke_id: int, session: AsyncSession = Depends(get_session)
|
||||
genlocke_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser | None = Depends(get_current_user),
|
||||
):
|
||||
# Load genlocke with legs + game
|
||||
# Load genlocke with legs + game + run (for visibility check)
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
.where(Genlocke.id == genlocke_id)
|
||||
.options(
|
||||
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
|
||||
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
|
||||
)
|
||||
)
|
||||
genlocke = result.scalar_one_or_none()
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
if not _is_genlocke_visible(genlocke, user):
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
# Query all transfers for this genlocke
|
||||
transfer_result = await session.execute(
|
||||
select(GenlockeTransfer).where(GenlockeTransfer.genlocke_id == genlocke_id)
|
||||
@@ -440,7 +539,7 @@ async def get_genlocke_lineages(
|
||||
async def create_genlocke(
|
||||
data: GenlockeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
if not data.game_ids:
|
||||
raise HTTPException(status_code=400, detail="At least one game is required")
|
||||
@@ -455,6 +554,13 @@ async def create_genlocke(
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"Games not found: {missing}")
|
||||
|
||||
# Ensure user exists in local DB
|
||||
user_id = UUID(user.id)
|
||||
db_user = await session.get(User, user_id)
|
||||
if db_user is None:
|
||||
db_user = User(id=user_id, email=user.email or "")
|
||||
session.add(db_user)
|
||||
|
||||
# Create genlocke
|
||||
genlocke = Genlocke(
|
||||
name=data.name.strip(),
|
||||
@@ -481,6 +587,7 @@ async def create_genlocke(
|
||||
first_game = found_games[data.game_ids[0]]
|
||||
first_run = NuzlockeRun(
|
||||
game_id=first_game.id,
|
||||
owner_id=user_id,
|
||||
name=f"{data.name.strip()} \u2014 Leg 1",
|
||||
status="active",
|
||||
rules=data.nuzlocke_rules,
|
||||
@@ -513,15 +620,23 @@ async def get_leg_survivors(
|
||||
genlocke_id: int,
|
||||
leg_order: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser | None = Depends(get_current_user),
|
||||
):
|
||||
# Find the leg
|
||||
result = await session.execute(
|
||||
select(GenlockeLeg).where(
|
||||
GenlockeLeg.genlocke_id == genlocke_id,
|
||||
GenlockeLeg.leg_order == leg_order,
|
||||
)
|
||||
# Load genlocke with legs + run for visibility check
|
||||
genlocke_result = await session.execute(
|
||||
select(Genlocke)
|
||||
.where(Genlocke.id == genlocke_id)
|
||||
.options(selectinload(Genlocke.legs).selectinload(GenlockeLeg.run))
|
||||
)
|
||||
leg = result.scalar_one_or_none()
|
||||
genlocke = genlocke_result.scalar_one_or_none()
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
if not _is_genlocke_visible(genlocke, user):
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
# Find the leg
|
||||
leg = next((leg for leg in genlocke.legs if leg.leg_order == leg_order), None)
|
||||
if leg is None:
|
||||
raise HTTPException(status_code=404, detail="Leg not found")
|
||||
|
||||
@@ -571,8 +686,10 @@ async def advance_leg(
|
||||
leg_order: int,
|
||||
data: AdvanceLegRequest | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
await _check_genlocke_owner(genlocke_id, user, session)
|
||||
|
||||
# Load genlocke with legs
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
@@ -653,9 +770,10 @@ async def advance_leg(
|
||||
else:
|
||||
current_leg.retired_pokemon_ids = []
|
||||
|
||||
# Create a new run for the next leg
|
||||
# Create a new run for the next leg, preserving owner from current run
|
||||
new_run = NuzlockeRun(
|
||||
game_id=next_leg.game_id,
|
||||
owner_id=current_run.owner_id,
|
||||
name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}",
|
||||
status="active",
|
||||
rules=genlocke.nuzlocke_rules,
|
||||
@@ -786,12 +904,21 @@ class RetiredFamiliesResponse(BaseModel):
|
||||
async def get_retired_families(
|
||||
genlocke_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser | None = Depends(get_current_user),
|
||||
):
|
||||
# Verify genlocke exists
|
||||
genlocke = await session.get(Genlocke, genlocke_id)
|
||||
# Load genlocke with legs + run for visibility check
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
.where(Genlocke.id == genlocke_id)
|
||||
.options(selectinload(Genlocke.legs).selectinload(GenlockeLeg.run))
|
||||
)
|
||||
genlocke = result.scalar_one_or_none()
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
if not _is_genlocke_visible(genlocke, user):
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
# Query all legs with retired_pokemon_ids
|
||||
result = await session.execute(
|
||||
select(GenlockeLeg)
|
||||
@@ -826,8 +953,10 @@ async def update_genlocke(
|
||||
genlocke_id: int,
|
||||
data: GenlockeUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
await _check_genlocke_owner(genlocke_id, user, session)
|
||||
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
.where(Genlocke.id == genlocke_id)
|
||||
@@ -863,8 +992,10 @@ async def update_genlocke(
|
||||
async def delete_genlocke(
|
||||
genlocke_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
await _check_genlocke_owner(genlocke_id, user, session)
|
||||
|
||||
genlocke = await session.get(Genlocke, genlocke_id)
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
@@ -895,8 +1026,10 @@ async def add_leg(
|
||||
genlocke_id: int,
|
||||
data: AddLegRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
await _check_genlocke_owner(genlocke_id, user, session)
|
||||
|
||||
genlocke = await session.get(Genlocke, genlocke_id)
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
@@ -938,8 +1071,10 @@ async def remove_leg(
|
||||
genlocke_id: int,
|
||||
leg_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
await _check_genlocke_owner(genlocke_id, user, session)
|
||||
|
||||
result = await session.execute(
|
||||
select(GenlockeLeg).where(
|
||||
GenlockeLeg.id == leg_id,
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.core.auth import AuthUser, get_current_user, require_auth
|
||||
from app.core.auth import AuthUser, get_current_user, require_auth, require_run_owner
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.encounter import Encounter
|
||||
@@ -181,31 +181,17 @@ def _build_run_response(run: NuzlockeRun) -> RunResponse:
|
||||
)
|
||||
|
||||
|
||||
def _check_run_access(
|
||||
run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False
|
||||
) -> None:
|
||||
def _check_run_read_access(run: NuzlockeRun, user: AuthUser | None) -> None:
|
||||
"""
|
||||
Check if user can access the run.
|
||||
Check if user can read the run.
|
||||
Raises 403 for private runs if user is not owner.
|
||||
If require_owner=True, always requires ownership (for mutations).
|
||||
Unowned runs are readable by everyone (legacy).
|
||||
"""
|
||||
if run.owner_id is None:
|
||||
# Unowned runs are accessible by everyone (legacy)
|
||||
if require_owner:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Only the run owner can perform this action"
|
||||
)
|
||||
return
|
||||
|
||||
user_id = UUID(user.id) if user else None
|
||||
|
||||
if require_owner:
|
||||
if user_id != run.owner_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Only the run owner can perform this action"
|
||||
)
|
||||
return
|
||||
|
||||
if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id:
|
||||
raise HTTPException(status_code=403, detail="This run is private")
|
||||
|
||||
@@ -301,7 +287,7 @@ async def get_run(
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check visibility access
|
||||
_check_run_access(run, user)
|
||||
_check_run_read_access(run, user)
|
||||
|
||||
# Check if this run belongs to a genlocke
|
||||
genlocke_context = None
|
||||
@@ -375,8 +361,7 @@ async def update_run(
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check ownership for mutations (unowned runs allow anyone for backwards compat)
|
||||
_check_run_access(run, user, require_owner=run.owner_id is not None)
|
||||
require_run_owner(run, user)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
@@ -484,8 +469,7 @@ async def delete_run(
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check ownership for deletion (unowned runs allow anyone for backwards compat)
|
||||
_check_run_access(run, user, require_owner=run.owner_id is not None)
|
||||
require_run_owner(run, user)
|
||||
|
||||
# Block deletion if run is linked to a genlocke leg
|
||||
leg_result = await session.execute(
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_session
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.user import User
|
||||
|
||||
_jwks_client: PyJWKClient | None = None
|
||||
@@ -121,3 +122,20 @@ async def require_admin(
|
||||
detail="Admin access required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_run_owner(run: NuzlockeRun, user: AuthUser) -> None:
|
||||
"""
|
||||
Verify user owns the run. Raises 403 if not owner.
|
||||
Unowned (legacy) runs are read-only and reject all mutations.
|
||||
"""
|
||||
if run.owner_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="This run has no owner and cannot be modified",
|
||||
)
|
||||
if UUID(user.id) != run.owner_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the run owner can perform this action",
|
||||
)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
from app.schemas.game import GameResponse
|
||||
from app.schemas.pokemon import PokemonResponse
|
||||
|
||||
|
||||
class GenlockeOwnerResponse(CamelModel):
|
||||
id: UUID
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class GenlockeCreate(CamelModel):
|
||||
name: str
|
||||
game_ids: list[int]
|
||||
@@ -92,6 +98,7 @@ class GenlockeListItem(CamelModel):
|
||||
total_legs: int
|
||||
completed_legs: int
|
||||
current_leg_order: int | None = None
|
||||
owner: GenlockeOwnerResponse | None = None
|
||||
|
||||
|
||||
class GenlockeDetailResponse(CamelModel):
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Integration tests for the Genlockes & Bosses API."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import AuthUser, get_current_user
|
||||
from app.main import app
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.version_group import VersionGroup
|
||||
@@ -55,7 +58,9 @@ async def games_ctx(db_session: AsyncSession) -> dict:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ctx(db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict) -> dict:
|
||||
async def ctx(
|
||||
db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict
|
||||
) -> dict:
|
||||
"""Full context: routes + pokemon + genlocke + encounter for advance/transfer tests."""
|
||||
route1 = Route(name="GT Route 1", version_group_id=games_ctx["vg1_id"], order=1)
|
||||
route2 = Route(name="GT Route 2", version_group_id=games_ctx["vg2_id"], order=1)
|
||||
@@ -116,6 +121,178 @@ class TestListGenlockes:
|
||||
assert "Test Genlocke" in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Genlockes — visibility (inferred from first leg's run)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenlockeVisibility:
|
||||
"""Test that genlocke visibility is inferred from the first leg's run."""
|
||||
|
||||
@pytest.fixture
|
||||
async def private_genlocke_ctx(
|
||||
self, db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict
|
||||
) -> dict:
|
||||
"""Create a genlocke and make its first leg's run private."""
|
||||
r = await admin_client.post(
|
||||
GENLOCKES_BASE,
|
||||
json={
|
||||
"name": "Private Genlocke",
|
||||
"gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
genlocke = r.json()
|
||||
leg1 = next(leg for leg in genlocke["legs"] if leg["legOrder"] == 1)
|
||||
run_id = leg1["runId"]
|
||||
|
||||
# Make the run private
|
||||
run = await db_session.get(NuzlockeRun, run_id)
|
||||
assert run is not None
|
||||
run.visibility = RunVisibility.PRIVATE
|
||||
await db_session.commit()
|
||||
|
||||
return {
|
||||
**games_ctx,
|
||||
"genlocke_id": genlocke["id"],
|
||||
"run_id": run_id,
|
||||
"owner_id": str(run.owner_id),
|
||||
}
|
||||
|
||||
async def test_private_genlocke_hidden_from_unauthenticated_list(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should not see private genlockes in the list."""
|
||||
# Temporarily remove auth override to simulate unauthenticated request
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
try:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(GENLOCKES_BASE)
|
||||
assert response.status_code == 200
|
||||
names = [g["name"] for g in response.json()]
|
||||
assert "Private Genlocke" not in names
|
||||
finally:
|
||||
pass
|
||||
|
||||
async def test_private_genlocke_visible_to_owner_in_list(
|
||||
self, admin_client: AsyncClient, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Owner should still see their private genlocke in the list."""
|
||||
response = await admin_client.get(GENLOCKES_BASE)
|
||||
assert response.status_code == 200
|
||||
names = [g["name"] for g in response.json()]
|
||||
assert "Private Genlocke" in names
|
||||
|
||||
async def test_private_genlocke_404_for_unauthenticated_get(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private genlocke details."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_accessible_to_owner(
|
||||
self, admin_client: AsyncClient, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Owner should still be able to access their private genlocke."""
|
||||
response = await admin_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Private Genlocke"
|
||||
|
||||
async def test_private_genlocke_graveyard_404_for_unauthenticated(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private genlocke graveyard."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/graveyard"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_lineages_404_for_unauthenticated(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private genlocke lineages."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/lineages"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_survivors_404_for_unauthenticated(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private genlocke survivors."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/legs/1/survivors"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_retired_families_404_for_unauthenticated(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private retired-families."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/retired-families"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_404_for_different_user(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""A different authenticated user should get 404 for private genlockes."""
|
||||
# Create a different user's auth
|
||||
different_user = AuthUser(
|
||||
id="00000000-0000-4000-a000-000000000099",
|
||||
email="other@example.com",
|
||||
role="authenticated",
|
||||
)
|
||||
|
||||
def _override():
|
||||
return different_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = _override
|
||||
try:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as other_client:
|
||||
response = await other_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Also check list
|
||||
list_response = await other_client.get(GENLOCKES_BASE)
|
||||
assert list_response.status_code == 200
|
||||
names = [g["name"] for g in list_response.json()]
|
||||
assert "Private Genlocke" not in names
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Genlockes — create
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -259,14 +436,18 @@ class TestGenlockeLegs:
|
||||
|
||||
|
||||
class TestAdvanceLeg:
|
||||
async def test_uncompleted_run_returns_400(self, admin_client: AsyncClient, ctx: dict):
|
||||
async def test_uncompleted_run_returns_400(
|
||||
self, admin_client: AsyncClient, ctx: dict
|
||||
):
|
||||
"""Cannot advance when leg 1's run is still active."""
|
||||
response = await admin_client.post(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_no_next_leg_returns_400(self, admin_client: AsyncClient, games_ctx: dict):
|
||||
async def test_no_next_leg_returns_400(
|
||||
self, admin_client: AsyncClient, games_ctx: dict
|
||||
):
|
||||
"""A single-leg genlocke cannot be advanced."""
|
||||
r = await admin_client.post(
|
||||
GENLOCKES_BASE,
|
||||
@@ -283,7 +464,9 @@ class TestAdvanceLeg:
|
||||
|
||||
async def test_advances_to_next_leg(self, admin_client: AsyncClient, ctx: dict):
|
||||
"""Completing the current run allows advancing to the next leg."""
|
||||
await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
|
||||
await admin_client.patch(
|
||||
f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}
|
||||
)
|
||||
|
||||
response = await admin_client.post(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
|
||||
@@ -295,7 +478,9 @@ class TestAdvanceLeg:
|
||||
|
||||
async def test_advances_with_transfers(self, admin_client: AsyncClient, ctx: dict):
|
||||
"""Advancing with transfer_encounter_ids creates egg encounters in the next leg."""
|
||||
await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
|
||||
await admin_client.patch(
|
||||
f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}
|
||||
)
|
||||
|
||||
response = await admin_client.post(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance",
|
||||
@@ -319,30 +504,40 @@ class TestAdvanceLeg:
|
||||
|
||||
class TestGenlockeGraveyard:
|
||||
async def test_returns_empty_graveyard(self, admin_client: AsyncClient, ctx: dict):
|
||||
response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard")
|
||||
response = await admin_client.get(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["entries"] == []
|
||||
assert data["totalDeaths"] == 0
|
||||
|
||||
async def test_not_found_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404
|
||||
assert (
|
||||
await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard")
|
||||
).status_code == 404
|
||||
|
||||
|
||||
class TestGenlockeLineages:
|
||||
async def test_returns_empty_lineages(self, admin_client: AsyncClient, ctx: dict):
|
||||
response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages")
|
||||
response = await admin_client.get(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["lineages"] == []
|
||||
assert data["totalLineages"] == 0
|
||||
|
||||
async def test_not_found_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404
|
||||
assert (
|
||||
await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages")
|
||||
).status_code == 404
|
||||
|
||||
|
||||
class TestGenlockeRetiredFamilies:
|
||||
async def test_returns_empty_retired_families(self, admin_client: AsyncClient, ctx: dict):
|
||||
async def test_returns_empty_retired_families(
|
||||
self, admin_client: AsyncClient, ctx: dict
|
||||
):
|
||||
response = await admin_client.get(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families"
|
||||
)
|
||||
@@ -365,9 +560,13 @@ class TestLegSurvivors:
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
|
||||
async def test_leg_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict):
|
||||
async def test_leg_not_found_returns_404(
|
||||
self, admin_client: AsyncClient, ctx: dict
|
||||
):
|
||||
assert (
|
||||
await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors")
|
||||
await admin_client.get(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors"
|
||||
)
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -386,7 +585,9 @@ BOSS_PAYLOAD = {
|
||||
|
||||
class TestBossCRUD:
|
||||
async def test_empty_list(self, admin_client: AsyncClient, games_ctx: dict):
|
||||
response = await admin_client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
|
||||
response = await admin_client.get(
|
||||
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@@ -441,7 +642,9 @@ class TestBossCRUD:
|
||||
async def test_invalid_game_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404
|
||||
|
||||
async def test_game_without_version_group_returns_400(self, admin_client: AsyncClient):
|
||||
async def test_game_without_version_group_returns_400(
|
||||
self, admin_client: AsyncClient
|
||||
):
|
||||
game = (
|
||||
await admin_client.post(
|
||||
GAMES_BASE,
|
||||
@@ -480,7 +683,9 @@ class TestBossResults:
|
||||
return {"boss_id": boss["id"], "run_id": run["id"]}
|
||||
|
||||
async def test_empty_list(self, admin_client: AsyncClient, boss_ctx: dict):
|
||||
response = await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
|
||||
response = await admin_client.get(
|
||||
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@@ -495,7 +700,9 @@ class TestBossResults:
|
||||
assert data["attempts"] == 1
|
||||
assert data["completedAt"] is not None
|
||||
|
||||
async def test_upserts_existing_result(self, admin_client: AsyncClient, boss_ctx: dict):
|
||||
async def test_upserts_existing_result(
|
||||
self, admin_client: AsyncClient, boss_ctx: dict
|
||||
):
|
||||
"""POSTing the same boss twice updates the result (upsert)."""
|
||||
await admin_client.post(
|
||||
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
|
||||
@@ -530,10 +737,16 @@ class TestBossResults:
|
||||
await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
|
||||
).json() == []
|
||||
|
||||
async def test_invalid_run_returns_404(self, admin_client: AsyncClient, boss_ctx: dict):
|
||||
assert (await admin_client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404
|
||||
async def test_invalid_run_returns_404(
|
||||
self, admin_client: AsyncClient, boss_ctx: dict
|
||||
):
|
||||
assert (
|
||||
await admin_client.get(f"{RUNS_BASE}/9999/boss-results")
|
||||
).status_code == 404
|
||||
|
||||
async def test_invalid_boss_returns_404(self, admin_client: AsyncClient, boss_ctx: dict):
|
||||
async def test_invalid_boss_returns_404(
|
||||
self, admin_client: AsyncClient, boss_ctx: dict
|
||||
):
|
||||
response = await admin_client.post(
|
||||
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
|
||||
json={"bossBattleId": 9999, "result": "won"},
|
||||
@@ -587,8 +800,16 @@ class TestExport:
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
async def test_export_game_routes_not_found_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404
|
||||
async def test_export_game_routes_not_found_returns_404(
|
||||
self, admin_client: AsyncClient
|
||||
):
|
||||
assert (
|
||||
await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")
|
||||
).status_code == 404
|
||||
|
||||
async def test_export_game_bosses_not_found_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404
|
||||
async def test_export_game_bosses_not_found_returns_404(
|
||||
self, admin_client: AsyncClient
|
||||
):
|
||||
assert (
|
||||
await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")
|
||||
).status_code == 404
|
||||
|
||||
447
backend/tests/test_ownership.py
Normal file
447
backend/tests/test_ownership.py
Normal file
@@ -0,0 +1,447 @@
|
||||
"""Tests for run ownership enforcement on mutation endpoints."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import AuthUser, get_current_user, require_run_owner
|
||||
from app.main import app
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.user import User
|
||||
|
||||
RUNS_BASE = "/api/v1/runs"
|
||||
ENC_BASE = "/api/v1/encounters"
|
||||
|
||||
OWNER_ID = "00000000-0000-4000-a000-000000000001"
|
||||
OTHER_USER_ID = "00000000-0000-4000-a000-000000000002"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def game(db_session: AsyncSession) -> Game:
|
||||
"""Create a test game."""
|
||||
game = Game(name="Test Game", slug="test-game", generation=1, region="kanto")
|
||||
db_session.add(game)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(game)
|
||||
return game
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owner_user(db_session: AsyncSession) -> User:
|
||||
"""Create the owner user."""
|
||||
user = User(id=UUID(OWNER_ID), email="owner@example.com")
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_user(db_session: AsyncSession) -> User:
|
||||
"""Create another user who is not the owner."""
|
||||
user = User(id=UUID(OTHER_USER_ID), email="other@example.com")
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owned_run(
|
||||
db_session: AsyncSession, game: Game, owner_user: User
|
||||
) -> NuzlockeRun:
|
||||
"""Create a run owned by the test owner."""
|
||||
run = NuzlockeRun(
|
||||
game_id=game.id,
|
||||
owner_id=owner_user.id,
|
||||
name="Owned Run",
|
||||
status="active",
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def unowned_run(db_session: AsyncSession, game: Game) -> NuzlockeRun:
|
||||
"""Create a legacy run with no owner."""
|
||||
run = NuzlockeRun(
|
||||
game_id=game.id,
|
||||
owner_id=None,
|
||||
name="Unowned Run",
|
||||
status="active",
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def owner_auth_override(owner_user):
|
||||
"""Override auth to return the owner user."""
|
||||
|
||||
def _override():
|
||||
return AuthUser(id=OWNER_ID, email="owner@example.com")
|
||||
|
||||
app.dependency_overrides[get_current_user] = _override
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_auth_override(other_user):
|
||||
"""Override auth to return a different user (not the owner)."""
|
||||
|
||||
def _override():
|
||||
return AuthUser(id=OTHER_USER_ID, email="other@example.com")
|
||||
|
||||
app.dependency_overrides[get_current_user] = _override
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owner_client(db_session, owner_auth_override):
|
||||
"""Client authenticated as the owner."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_client(db_session, other_auth_override):
|
||||
"""Client authenticated as a different user."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
class TestRequireRunOwnerHelper:
|
||||
"""Tests for the require_run_owner helper function."""
|
||||
|
||||
async def test_owner_passes(self, owner_user, owned_run):
|
||||
"""Owner can access their own run."""
|
||||
auth_user = AuthUser(id=str(owner_user.id), email="owner@example.com")
|
||||
require_run_owner(owned_run, auth_user)
|
||||
|
||||
async def test_non_owner_raises_403(self, other_user, owned_run):
|
||||
"""Non-owner is rejected with 403."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
auth_user = AuthUser(id=str(other_user.id), email="other@example.com")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_run_owner(owned_run, auth_user)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Only the run owner" in exc_info.value.detail
|
||||
|
||||
async def test_unowned_run_raises_403(self, owner_user, unowned_run):
|
||||
"""Unowned runs reject all mutations with 403."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
auth_user = AuthUser(id=str(owner_user.id), email="owner@example.com")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_run_owner(unowned_run, auth_user)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "no owner" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestRunUpdateOwnership:
|
||||
"""Tests for run PATCH ownership enforcement."""
|
||||
|
||||
async def test_owner_can_update(self, owner_client: AsyncClient, owned_run):
|
||||
"""Owner can update their own run."""
|
||||
response = await owner_client.patch(
|
||||
f"{RUNS_BASE}/{owned_run.id}", json={"name": "Updated Name"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated Name"
|
||||
|
||||
async def test_non_owner_cannot_update(self, other_client: AsyncClient, owned_run):
|
||||
"""Non-owner gets 403 when trying to update."""
|
||||
response = await other_client.patch(
|
||||
f"{RUNS_BASE}/{owned_run.id}", json={"name": "Stolen"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "Only the run owner" in response.json()["detail"]
|
||||
|
||||
async def test_unauthenticated_cannot_update(self, client: AsyncClient, owned_run):
|
||||
"""Unauthenticated user gets 401."""
|
||||
response = await client.patch(
|
||||
f"{RUNS_BASE}/{owned_run.id}", json={"name": "Stolen"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_unowned_run_rejects_all_updates(
|
||||
self, owner_client: AsyncClient, unowned_run
|
||||
):
|
||||
"""Unowned (legacy) runs cannot be updated by anyone."""
|
||||
response = await owner_client.patch(
|
||||
f"{RUNS_BASE}/{unowned_run.id}", json={"name": "Stolen"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "no owner" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestRunDeleteOwnership:
|
||||
"""Tests for run DELETE ownership enforcement."""
|
||||
|
||||
async def test_owner_can_delete(self, owner_client: AsyncClient, owned_run):
|
||||
"""Owner can delete their own run."""
|
||||
response = await owner_client.delete(f"{RUNS_BASE}/{owned_run.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_non_owner_cannot_delete(self, other_client: AsyncClient, owned_run):
|
||||
"""Non-owner gets 403 when trying to delete."""
|
||||
response = await other_client.delete(f"{RUNS_BASE}/{owned_run.id}")
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_unauthenticated_cannot_delete(self, client: AsyncClient, owned_run):
|
||||
"""Unauthenticated user gets 401."""
|
||||
response = await client.delete(f"{RUNS_BASE}/{owned_run.id}")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_unowned_run_rejects_all_deletes(
|
||||
self, owner_client: AsyncClient, unowned_run
|
||||
):
|
||||
"""Unowned (legacy) runs cannot be deleted by anyone."""
|
||||
response = await owner_client.delete(f"{RUNS_BASE}/{unowned_run.id}")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestEncounterCreateOwnership:
|
||||
"""Tests for encounter POST ownership enforcement."""
|
||||
|
||||
@pytest.fixture
|
||||
async def pokemon(self, db_session: AsyncSession):
|
||||
"""Create a test Pokemon."""
|
||||
from app.models.pokemon import Pokemon
|
||||
|
||||
pokemon = Pokemon(
|
||||
pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"]
|
||||
)
|
||||
db_session.add(pokemon)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(pokemon)
|
||||
return pokemon
|
||||
|
||||
@pytest.fixture
|
||||
async def route(self, db_session: AsyncSession, game: Game):
|
||||
"""Create a test route."""
|
||||
from app.models.route import Route
|
||||
from app.models.version_group import VersionGroup
|
||||
|
||||
vg = VersionGroup(name="Test VG", slug="test-vg")
|
||||
db_session.add(vg)
|
||||
await db_session.flush()
|
||||
|
||||
game.version_group_id = vg.id
|
||||
await db_session.flush()
|
||||
|
||||
route = Route(name="Test Route", version_group_id=vg.id, order=1)
|
||||
db_session.add(route)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(route)
|
||||
return route
|
||||
|
||||
async def test_owner_can_create_encounter(
|
||||
self, owner_client: AsyncClient, owned_run, pokemon, route
|
||||
):
|
||||
"""Owner can create encounters on their own run."""
|
||||
response = await owner_client.post(
|
||||
f"{RUNS_BASE}/{owned_run.id}/encounters",
|
||||
json={
|
||||
"routeId": route.id,
|
||||
"pokemonId": pokemon.id,
|
||||
"status": "caught",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
async def test_non_owner_cannot_create_encounter(
|
||||
self, other_client: AsyncClient, owned_run, pokemon, route
|
||||
):
|
||||
"""Non-owner gets 403 when trying to create encounters."""
|
||||
response = await other_client.post(
|
||||
f"{RUNS_BASE}/{owned_run.id}/encounters",
|
||||
json={
|
||||
"routeId": route.id,
|
||||
"pokemonId": pokemon.id,
|
||||
"status": "caught",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_unauthenticated_cannot_create_encounter(
|
||||
self, client: AsyncClient, owned_run, pokemon, route
|
||||
):
|
||||
"""Unauthenticated user gets 401."""
|
||||
response = await client.post(
|
||||
f"{RUNS_BASE}/{owned_run.id}/encounters",
|
||||
json={
|
||||
"routeId": route.id,
|
||||
"pokemonId": pokemon.id,
|
||||
"status": "caught",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestEncounterUpdateOwnership:
|
||||
"""Tests for encounter PATCH ownership enforcement."""
|
||||
|
||||
@pytest.fixture
|
||||
async def encounter(self, db_session: AsyncSession, owned_run):
|
||||
"""Create a test encounter."""
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.version_group import VersionGroup
|
||||
|
||||
pokemon = Pokemon(
|
||||
pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"]
|
||||
)
|
||||
db_session.add(pokemon)
|
||||
await db_session.flush()
|
||||
|
||||
game = await db_session.get(Game, owned_run.game_id)
|
||||
if game.version_group_id is None:
|
||||
vg = VersionGroup(name="Test VG", slug="test-vg")
|
||||
db_session.add(vg)
|
||||
await db_session.flush()
|
||||
game.version_group_id = vg.id
|
||||
await db_session.flush()
|
||||
else:
|
||||
vg = await db_session.get(VersionGroup, game.version_group_id)
|
||||
|
||||
route = Route(name="Test Route", version_group_id=vg.id, order=1)
|
||||
db_session.add(route)
|
||||
await db_session.flush()
|
||||
|
||||
encounter = Encounter(
|
||||
run_id=owned_run.id,
|
||||
route_id=route.id,
|
||||
pokemon_id=pokemon.id,
|
||||
status="caught",
|
||||
)
|
||||
db_session.add(encounter)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(encounter)
|
||||
return encounter
|
||||
|
||||
async def test_owner_can_update_encounter(
|
||||
self, owner_client: AsyncClient, encounter
|
||||
):
|
||||
"""Owner can update encounters on their own run."""
|
||||
response = await owner_client.patch(
|
||||
f"{ENC_BASE}/{encounter.id}", json={"nickname": "Sparky"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nickname"] == "Sparky"
|
||||
|
||||
async def test_non_owner_cannot_update_encounter(
|
||||
self, other_client: AsyncClient, encounter
|
||||
):
|
||||
"""Non-owner gets 403 when trying to update encounters."""
|
||||
response = await other_client.patch(
|
||||
f"{ENC_BASE}/{encounter.id}", json={"nickname": "Stolen"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_unauthenticated_cannot_update_encounter(
|
||||
self, client: AsyncClient, encounter
|
||||
):
|
||||
"""Unauthenticated user gets 401."""
|
||||
response = await client.patch(
|
||||
f"{ENC_BASE}/{encounter.id}", json={"nickname": "Stolen"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestEncounterDeleteOwnership:
|
||||
"""Tests for encounter DELETE ownership enforcement."""
|
||||
|
||||
@pytest.fixture
|
||||
async def encounter(self, db_session: AsyncSession, owned_run):
|
||||
"""Create a test encounter."""
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.version_group import VersionGroup
|
||||
|
||||
pokemon = Pokemon(
|
||||
pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"]
|
||||
)
|
||||
db_session.add(pokemon)
|
||||
await db_session.flush()
|
||||
|
||||
game = await db_session.get(Game, owned_run.game_id)
|
||||
if game.version_group_id is None:
|
||||
vg = VersionGroup(name="Test VG", slug="test-vg")
|
||||
db_session.add(vg)
|
||||
await db_session.flush()
|
||||
game.version_group_id = vg.id
|
||||
await db_session.flush()
|
||||
else:
|
||||
vg = await db_session.get(VersionGroup, game.version_group_id)
|
||||
|
||||
route = Route(name="Test Route", version_group_id=vg.id, order=1)
|
||||
db_session.add(route)
|
||||
await db_session.flush()
|
||||
|
||||
encounter = Encounter(
|
||||
run_id=owned_run.id,
|
||||
route_id=route.id,
|
||||
pokemon_id=pokemon.id,
|
||||
status="caught",
|
||||
)
|
||||
db_session.add(encounter)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(encounter)
|
||||
return encounter
|
||||
|
||||
async def test_owner_can_delete_encounter(
|
||||
self, owner_client: AsyncClient, encounter
|
||||
):
|
||||
"""Owner can delete encounters on their own run."""
|
||||
response = await owner_client.delete(f"{ENC_BASE}/{encounter.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_non_owner_cannot_delete_encounter(
|
||||
self, other_client: AsyncClient, encounter
|
||||
):
|
||||
"""Non-owner gets 403 when trying to delete encounters."""
|
||||
response = await other_client.delete(f"{ENC_BASE}/{encounter.id}")
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_unauthenticated_cannot_delete_encounter(
|
||||
self, client: AsyncClient, encounter
|
||||
):
|
||||
"""Unauthenticated user gets 401."""
|
||||
response = await client.delete(f"{ENC_BASE}/{encounter.id}")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestRunVisibilityPreserved:
|
||||
"""Verify read access still works for public runs."""
|
||||
|
||||
async def test_non_owner_can_read_public_run(
|
||||
self, other_client: AsyncClient, owned_run
|
||||
):
|
||||
"""Non-owner can read (but not modify) a public run."""
|
||||
response = await other_client.get(f"{RUNS_BASE}/{owned_run.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == owned_run.id
|
||||
|
||||
async def test_unauthenticated_can_read_public_run(
|
||||
self, client: AsyncClient, owned_run
|
||||
):
|
||||
"""Unauthenticated user can read a public run."""
|
||||
response = await client.get(f"{RUNS_BASE}/{owned_run.id}")
|
||||
assert response.status_code == 200
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Integration tests for the Runs & Encounters API."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -8,8 +10,11 @@ from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.user import User
|
||||
from app.models.version_group import VersionGroup
|
||||
|
||||
MOCK_AUTH_USER_ID = UUID("00000000-0000-4000-a000-000000000001")
|
||||
|
||||
RUNS_BASE = "/api/v1/runs"
|
||||
ENC_BASE = "/api/v1/encounters"
|
||||
|
||||
@@ -42,6 +47,11 @@ async def run(auth_client: AsyncClient, game_id: int) -> dict:
|
||||
@pytest.fixture
|
||||
async def enc_ctx(db_session: AsyncSession) -> dict:
|
||||
"""Full context for encounter tests: game, run, pokemon, standalone and grouped routes."""
|
||||
# Create the mock auth user to own the run
|
||||
user = User(id=MOCK_AUTH_USER_ID, email="test@example.com")
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
|
||||
vg = VersionGroup(name="Enc Test VG", slug="enc-test-vg")
|
||||
db_session.add(vg)
|
||||
await db_session.flush()
|
||||
@@ -83,6 +93,7 @@ async def enc_ctx(db_session: AsyncSession) -> dict:
|
||||
|
||||
run = NuzlockeRun(
|
||||
game_id=game.id,
|
||||
owner_id=user.id,
|
||||
name="Enc Run",
|
||||
status="active",
|
||||
rules={"shinyClause": True, "giftClause": False},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NewRun,
|
||||
RunList,
|
||||
RunEncounters,
|
||||
Settings,
|
||||
Signup,
|
||||
Stats,
|
||||
} from './pages'
|
||||
@@ -42,6 +43,7 @@ function App() {
|
||||
<Route path="genlockes/new" element={<ProtectedRoute><NewGenlocke /></ProtectedRoute>} />
|
||||
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
|
||||
<Route path="stats" element={<Stats />} />
|
||||
<Route path="settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
<Route
|
||||
path="runs/:runId/encounters"
|
||||
element={<Navigate to=".." relative="path" replace />}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
@@ -67,6 +67,7 @@ function ThemeToggle() {
|
||||
function UserMenu({ onAction }: { onAction?: () => void }) {
|
||||
const { user, loading, signOut } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (loading) {
|
||||
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
||||
@@ -106,6 +107,17 @@ function UserMenu({ onAction }: { onAction?: () => void }) {
|
||||
<p className="text-sm text-text-primary truncate">{email}</p>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onAction?.()
|
||||
navigate('/settings')
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import type { User, Session, AuthError } from '@supabase/supabase-js'
|
||||
import type { User, Session, AuthError, Factor } from '@supabase/supabase-js'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { api } from '../api/client'
|
||||
|
||||
@@ -10,19 +10,42 @@ interface UserProfile {
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
interface MfaState {
|
||||
requiresMfa: boolean
|
||||
factorId: string | null
|
||||
enrolledFactors: Factor[]
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
isAdmin: boolean
|
||||
mfa: MfaState
|
||||
}
|
||||
|
||||
interface MfaEnrollResult {
|
||||
factorId: string
|
||||
qrCode: string
|
||||
secret: string
|
||||
recoveryCodes?: string[]
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
signInWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
||||
signInWithEmail: (
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<{ error: AuthError | null; requiresMfa?: boolean }>
|
||||
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
||||
signInWithGoogle: () => Promise<{ error: AuthError | null }>
|
||||
signInWithDiscord: () => Promise<{ error: AuthError | null }>
|
||||
signOut: () => Promise<void>
|
||||
verifyMfa: (code: string) => Promise<{ error: AuthError | null }>
|
||||
enrollMfa: () => Promise<{ data: MfaEnrollResult | null; error: AuthError | null }>
|
||||
verifyMfaEnrollment: (factorId: string, code: string) => Promise<{ error: AuthError | null }>
|
||||
unenrollMfa: (factorId: string) => Promise<{ error: AuthError | null }>
|
||||
isOAuthUser: boolean
|
||||
refreshMfaState: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
@@ -37,25 +60,49 @@ async function syncUserProfile(session: Session | null): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getMfaState(): Promise<MfaState> {
|
||||
const defaultState: MfaState = { requiresMfa: false, factorId: null, enrolledFactors: [] }
|
||||
try {
|
||||
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||
if (!aalData) return defaultState
|
||||
|
||||
const { data: factorsData } = await supabase.auth.mfa.listFactors()
|
||||
const verifiedFactors = factorsData?.totp?.filter((f) => f.status === 'verified') ?? []
|
||||
|
||||
const requiresMfa = aalData.currentLevel === 'aal1' && aalData.nextLevel === 'aal2'
|
||||
const factorId = requiresMfa ? (verifiedFactors[0]?.id ?? null) : null
|
||||
|
||||
return { requiresMfa, factorId, enrolledFactors: verifiedFactors }
|
||||
} catch {
|
||||
return defaultState
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
session: null,
|
||||
loading: true,
|
||||
isAdmin: false,
|
||||
mfa: { requiresMfa: false, factorId: null, enrolledFactors: [] },
|
||||
})
|
||||
|
||||
const refreshMfaState = useCallback(async () => {
|
||||
const mfa = await getMfaState()
|
||||
setState((prev) => ({ ...prev, mfa }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
||||
const isAdmin = await syncUserProfile(session)
|
||||
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
|
||||
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
|
||||
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
|
||||
})
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||
const isAdmin = await syncUserProfile(session)
|
||||
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
|
||||
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
|
||||
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
@@ -63,7 +110,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const signInWithEmail = useCallback(async (email: string, password: string) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
return { error }
|
||||
if (error) return { error }
|
||||
|
||||
const mfa = await getMfaState()
|
||||
setState((prev) => ({ ...prev, mfa }))
|
||||
return { error: null, requiresMfa: mfa.requiresMfa }
|
||||
}, [])
|
||||
|
||||
const signUpWithEmail = useCallback(async (email: string, password: string) => {
|
||||
@@ -91,6 +142,79 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
await supabase.auth.signOut()
|
||||
}, [])
|
||||
|
||||
const verifyMfa = useCallback(
|
||||
async (code: string) => {
|
||||
const factorId = state.mfa.factorId
|
||||
if (!factorId) {
|
||||
return { error: { message: 'No MFA factor found' } as AuthError }
|
||||
}
|
||||
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
||||
factorId,
|
||||
})
|
||||
if (challengeError) return { error: challengeError }
|
||||
|
||||
const { error } = await supabase.auth.mfa.verify({
|
||||
factorId,
|
||||
challengeId: challengeData.id,
|
||||
code,
|
||||
})
|
||||
if (!error) {
|
||||
const mfa = await getMfaState()
|
||||
setState((prev) => ({ ...prev, mfa }))
|
||||
}
|
||||
return { error }
|
||||
},
|
||||
[state.mfa.factorId]
|
||||
)
|
||||
|
||||
const enrollMfa = useCallback(async () => {
|
||||
const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
|
||||
if (error || !data) {
|
||||
return { data: null, error }
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
factorId: data.id,
|
||||
qrCode: data.totp.qr_code,
|
||||
secret: data.totp.secret,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const verifyMfaEnrollment = useCallback(async (factorId: string, code: string) => {
|
||||
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
||||
factorId,
|
||||
})
|
||||
if (challengeError) return { error: challengeError }
|
||||
|
||||
const { error } = await supabase.auth.mfa.verify({
|
||||
factorId,
|
||||
challengeId: challengeData.id,
|
||||
code,
|
||||
})
|
||||
if (!error) {
|
||||
const mfa = await getMfaState()
|
||||
setState((prev) => ({ ...prev, mfa }))
|
||||
}
|
||||
return { error }
|
||||
}, [])
|
||||
|
||||
const unenrollMfa = useCallback(async (factorId: string) => {
|
||||
const { error } = await supabase.auth.mfa.unenroll({ factorId })
|
||||
if (!error) {
|
||||
const mfa = await getMfaState()
|
||||
setState((prev) => ({ ...prev, mfa }))
|
||||
}
|
||||
return { error }
|
||||
}, [])
|
||||
|
||||
const isOAuthUser = useMemo(() => {
|
||||
if (!state.user) return false
|
||||
const provider = state.user.app_metadata?.['provider']
|
||||
return provider === 'google' || provider === 'discord'
|
||||
}, [state.user])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
@@ -99,8 +223,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
signInWithGoogle,
|
||||
signInWithDiscord,
|
||||
signOut,
|
||||
verifyMfa,
|
||||
enrollMfa,
|
||||
verifyMfaEnrollment,
|
||||
unenrollMfa,
|
||||
isOAuthUser,
|
||||
refreshMfaState,
|
||||
}),
|
||||
[state, signInWithEmail, signUpWithEmail, signInWithGoogle, signInWithDiscord, signOut]
|
||||
[
|
||||
state,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
signInWithGoogle,
|
||||
signInWithDiscord,
|
||||
signOut,
|
||||
verifyMfa,
|
||||
enrollMfa,
|
||||
verifyMfaEnrollment,
|
||||
unenrollMfa,
|
||||
isOAuthUser,
|
||||
refreshMfaState,
|
||||
]
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
|
||||
@@ -7,9 +7,11 @@ const isLocalDev = import.meta.env['VITE_SUPABASE_URL']?.includes('localhost') ?
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { signInWithEmail, signInWithGoogle, signInWithDiscord } = useAuth()
|
||||
const [showMfaChallenge, setShowMfaChallenge] = useState(false)
|
||||
const { signInWithEmail, signInWithGoogle, signInWithDiscord, verifyMfa } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
@@ -20,11 +22,29 @@ export function Login() {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const { error } = await signInWithEmail(email, password)
|
||||
const { error, requiresMfa } = await signInWithEmail(email, password)
|
||||
setLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else if (requiresMfa) {
|
||||
setShowMfaChallenge(true)
|
||||
} else {
|
||||
navigate(from, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMfaSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const { error } = await verifyMfa(totpCode)
|
||||
setLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setTotpCode('')
|
||||
} else {
|
||||
navigate(from, { replace: true })
|
||||
}
|
||||
@@ -42,6 +62,68 @@ export function Login() {
|
||||
if (error) setError(error.message)
|
||||
}
|
||||
|
||||
if (showMfaChallenge) {
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Two-Factor Authentication</h1>
|
||||
<p className="text-text-secondary mt-1">Enter the code from your authenticator app</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleMfaSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="totp-code"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Authentication code
|
||||
</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={totpCode.length !== 6 || loading}
|
||||
className="w-full py-2 px-4 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMfaChallenge(false)
|
||||
setTotpCode('')
|
||||
setError(null)
|
||||
}}
|
||||
className="w-full text-center text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
|
||||
@@ -67,7 +67,7 @@ export function RunDashboard() {
|
||||
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
||||
|
||||
const isOwner = user && run?.owner?.id === user.id
|
||||
const canEdit = isOwner || !run?.owner
|
||||
const canEdit = isOwner
|
||||
|
||||
const encounters = run?.encounters ?? []
|
||||
const alive = useMemo(
|
||||
@@ -143,6 +143,32 @@ export function RunDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only Banner */}
|
||||
{!canEdit && run.owner && (
|
||||
<div className="rounded-lg p-3 mb-6 bg-surface-2 border border-border-default">
|
||||
<div className="flex items-center gap-2 text-text-secondary">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">
|
||||
Viewing {run.owner.displayName ? `${run.owner.displayName}'s` : "another player's"}{' '}
|
||||
run (read-only)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion Banner */}
|
||||
{!isActive && (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
||||
import { useAdvanceLeg } from '../hooks/useGenlockes'
|
||||
import { useGameRoutes } from '../hooks/useGames'
|
||||
@@ -286,7 +287,7 @@ interface RouteGroupProps {
|
||||
giftEncounterByRoute: Map<number, EncounterDetail>
|
||||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onRouteClick: (route: Route) => void
|
||||
onRouteClick: ((route: Route) => void) | undefined
|
||||
filter: 'all' | RouteStatus
|
||||
pinwheelClause: boolean
|
||||
}
|
||||
@@ -438,10 +439,12 @@ function RouteGroup({
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => !isDisabled && onRouteClick(child)}
|
||||
disabled={isDisabled}
|
||||
onClick={() => !isDisabled && onRouteClick?.(child)}
|
||||
disabled={isDisabled || !onRouteClick}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 pl-8 text-left transition-colors ${
|
||||
isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-surface-2/50'
|
||||
isDisabled || !onRouteClick
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-surface-2/50'
|
||||
} ${childSi.bg}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
|
||||
@@ -501,6 +504,10 @@ export function RunEncounters() {
|
||||
const { data: bosses } = useGameBosses(run?.gameId ?? null)
|
||||
const { data: bossResults } = useBossResults(runIdNum)
|
||||
const createBossResult = useCreateBossResult(runIdNum)
|
||||
const { user } = useAuth()
|
||||
|
||||
const isOwner = user && run?.owner?.id === user.id
|
||||
const canEdit = isOwner
|
||||
|
||||
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
||||
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
|
||||
@@ -944,7 +951,7 @@ export function RunEncounters() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isActive && run.rules?.shinyClause && (
|
||||
{isActive && canEdit && run.rules?.shinyClause && (
|
||||
<button
|
||||
onClick={() => setShowShinyModal(true)}
|
||||
className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 light:text-amber-700 light:border-amber-600 rounded-full font-medium hover:bg-yellow-900/20 light:hover:bg-amber-50 transition-colors"
|
||||
@@ -952,7 +959,7 @@ export function RunEncounters() {
|
||||
✦ Log Shiny
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
{isActive && canEdit && (
|
||||
<button
|
||||
onClick={() => setShowEggModal(true)}
|
||||
className="px-3 py-1 text-sm border border-green-600 text-status-active rounded-full font-medium hover:bg-green-900/20 transition-colors"
|
||||
@@ -960,7 +967,7 @@ export function RunEncounters() {
|
||||
🥚 Log Egg
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
{isActive && canEdit && (
|
||||
<button
|
||||
onClick={() => setShowEndRun(true)}
|
||||
className="px-3 py-1 text-sm border border-border-default rounded-full font-medium hover:bg-surface-2 transition-colors"
|
||||
@@ -977,6 +984,32 @@ export function RunEncounters() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only Banner */}
|
||||
{!canEdit && run.owner && (
|
||||
<div className="rounded-lg p-3 mb-6 bg-surface-2 border border-border-default">
|
||||
<div className="flex items-center gap-2 text-text-secondary">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">
|
||||
Viewing {run.owner.displayName ? `${run.owner.displayName}'s` : "another player's"}{' '}
|
||||
run (read-only)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion Banner */}
|
||||
{!isActive && (
|
||||
<div
|
||||
@@ -1025,7 +1058,7 @@ export function RunEncounters() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
|
||||
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && canEdit && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (hofTeam && hofTeam.length > 0) {
|
||||
@@ -1063,13 +1096,15 @@ export function RunEncounters() {
|
||||
<span className="text-xs font-medium text-text-link uppercase tracking-wider">
|
||||
Hall of Fame
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHofModal(true)}
|
||||
className="text-xs text-blue-400 hover:text-accent-300"
|
||||
>
|
||||
{hofTeam ? 'Edit' : 'Select team'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHofModal(true)}
|
||||
className="text-xs text-blue-400 hover:text-accent-300"
|
||||
>
|
||||
{hofTeam ? 'Edit' : 'Select team'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hofTeam ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@@ -1262,7 +1297,9 @@ export function RunEncounters() {
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
onClick={
|
||||
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1276,7 +1313,9 @@ export function RunEncounters() {
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
showFaintLevel
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
onClick={
|
||||
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1292,7 +1331,9 @@ export function RunEncounters() {
|
||||
<div className="mb-6">
|
||||
<ShinyBox
|
||||
encounters={shinyEncounters}
|
||||
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
|
||||
onEncounterClick={
|
||||
isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1306,7 +1347,7 @@ export function RunEncounters() {
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1318,7 +1359,7 @@ export function RunEncounters() {
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
|
||||
{isActive && completedCount < totalLocations && (
|
||||
{isActive && canEdit && completedCount < totalLocations && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkRandomize.isPending}
|
||||
@@ -1409,7 +1450,7 @@ export function RunEncounters() {
|
||||
giftEncounterByRoute={giftEncounterByRoute}
|
||||
isExpanded={expandedGroups.has(route.id)}
|
||||
onToggleExpand={() => toggleGroup(route.id)}
|
||||
onRouteClick={handleRouteClick}
|
||||
onRouteClick={canEdit ? handleRouteClick : undefined}
|
||||
filter={filter}
|
||||
pinwheelClause={pinwheelClause}
|
||||
/>
|
||||
@@ -1425,8 +1466,9 @@ export function RunEncounters() {
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => handleRouteClick(route)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-surface-2/50 ${si.bg}`}
|
||||
onClick={canEdit ? () => handleRouteClick(route) : undefined}
|
||||
disabled={!canEdit}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${!canEdit ? 'cursor-default' : 'hover:bg-surface-2/50'} ${si.bg}`}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -1578,7 +1620,7 @@ export function RunEncounters() {
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
|
||||
Defeated ✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
) : isActive && canEdit ? (
|
||||
<button
|
||||
onClick={() => setSelectedBoss(boss)}
|
||||
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
|
||||
@@ -1593,35 +1635,44 @@ export function RunEncounters() {
|
||||
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
||||
)}
|
||||
{/* Player team snapshot */}
|
||||
{isDefeated && (() => {
|
||||
const result = bossResultByBattleId.get(boss.id)
|
||||
if (!result || result.team.length === 0) return null
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-border-default">
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{result.team.map((tm: BossResultTeamMember) => {
|
||||
const enc = encounterById.get(tm.encounterId)
|
||||
if (!enc) return null
|
||||
const dp = enc.currentPokemon ?? enc.pokemon
|
||||
return (
|
||||
<div key={tm.id} className="flex flex-col items-center">
|
||||
{dp.spriteUrl ? (
|
||||
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
||||
)}
|
||||
<span className="text-[10px] text-text-tertiary capitalize">
|
||||
{enc.nickname ?? dp.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{isDefeated &&
|
||||
(() => {
|
||||
const result = bossResultByBattleId.get(boss.id)
|
||||
if (!result || result.team.length === 0) return null
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-border-default">
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||
Your Team
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{result.team.map((tm: BossResultTeamMember) => {
|
||||
const enc = encounterById.get(tm.encounterId)
|
||||
if (!enc) return null
|
||||
const dp = enc.currentPokemon ?? enc.pokemon
|
||||
return (
|
||||
<div key={tm.id} className="flex flex-col items-center">
|
||||
{dp.spriteUrl ? (
|
||||
<img
|
||||
src={dp.spriteUrl}
|
||||
alt={dp.name}
|
||||
className="w-10 h-10"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
||||
)}
|
||||
<span className="text-[10px] text-text-tertiary capitalize">
|
||||
{enc.nickname ?? dp.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">
|
||||
Lv.{tm.level}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{sectionAfter && (
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
|
||||
303
frontend/src/pages/Settings.tsx
Normal file
303
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
type MfaStep = 'idle' | 'enrolling' | 'verifying' | 'success' | 'disabling'
|
||||
|
||||
interface EnrollmentData {
|
||||
factorId: string
|
||||
qrCode: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { user, loading, mfa, isOAuthUser, enrollMfa, verifyMfaEnrollment, unenrollMfa } = useAuth()
|
||||
const [step, setStep] = useState<MfaStep>('idle')
|
||||
const [enrollmentData, setEnrollmentData] = useState<EnrollmentData | null>(null)
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [disableFactorId, setDisableFactorId] = useState<string | null>(null)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-4 border-accent-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const hasMfa = mfa.enrolledFactors.length > 0
|
||||
|
||||
async function handleEnroll() {
|
||||
setError(null)
|
||||
setActionLoading(true)
|
||||
const { data, error: enrollError } = await enrollMfa()
|
||||
setActionLoading(false)
|
||||
|
||||
if (enrollError || !data) {
|
||||
setError(enrollError?.message ?? 'Failed to start MFA enrollment')
|
||||
return
|
||||
}
|
||||
|
||||
setEnrollmentData(data)
|
||||
setStep('enrolling')
|
||||
}
|
||||
|
||||
async function handleVerifyEnrollment(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!enrollmentData) return
|
||||
|
||||
setError(null)
|
||||
setActionLoading(true)
|
||||
const { error: verifyError } = await verifyMfaEnrollment(enrollmentData.factorId, code)
|
||||
setActionLoading(false)
|
||||
|
||||
if (verifyError) {
|
||||
setError(verifyError.message)
|
||||
return
|
||||
}
|
||||
|
||||
setStep('success')
|
||||
setCode('')
|
||||
}
|
||||
|
||||
async function handleStartDisable(factorId: string) {
|
||||
setDisableFactorId(factorId)
|
||||
setStep('disabling')
|
||||
setCode('')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
async function handleConfirmDisable(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!disableFactorId) return
|
||||
|
||||
setError(null)
|
||||
setActionLoading(true)
|
||||
const { error: unenrollError } = await unenrollMfa(disableFactorId)
|
||||
setActionLoading(false)
|
||||
|
||||
if (unenrollError) {
|
||||
setError(unenrollError.message)
|
||||
return
|
||||
}
|
||||
|
||||
setStep('idle')
|
||||
setDisableFactorId(null)
|
||||
setCode('')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setStep('idle')
|
||||
setEnrollmentData(null)
|
||||
setDisableFactorId(null)
|
||||
setCode('')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
|
||||
<section className="bg-surface-1 rounded-lg border border-border-default p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Two-Factor Authentication</h2>
|
||||
|
||||
{isOAuthUser ? (
|
||||
<p className="text-text-secondary text-sm">
|
||||
You signed in with an OAuth provider (Google/Discord). MFA is managed by your provider.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{step === 'idle' && (
|
||||
<>
|
||||
{hasMfa ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">MFA is enabled</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Your account is protected with two-factor authentication.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartDisable(mfa.enrolledFactors[0]?.id ?? '')}
|
||||
className="px-4 py-2 border border-red-500/50 text-red-400 rounded-lg hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Disable MFA
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-text-secondary text-sm">
|
||||
Add an extra layer of security to your account by enabling two-factor
|
||||
authentication with an authenticator app.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnroll}
|
||||
disabled={actionLoading}
|
||||
className="px-4 py-2 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{actionLoading ? 'Setting up...' : 'Enable MFA'}
|
||||
</button>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'enrolling' && enrollmentData && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-text-secondary text-sm">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):
|
||||
</p>
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<img src={enrollmentData.qrCode} alt="MFA QR Code" className="w-48 h-48" />
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg p-4 space-y-2">
|
||||
<p className="text-xs text-text-tertiary">
|
||||
Manual entry code (save this as a backup):
|
||||
</p>
|
||||
<code className="block text-sm font-mono bg-surface-3 px-3 py-2 rounded select-all text-center break-all">
|
||||
{enrollmentData.secret}
|
||||
</code>
|
||||
<p className="text-xs text-yellow-500">
|
||||
Save this code securely. You can use it to restore your authenticator if you
|
||||
lose access to your device.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleVerifyEnrollment} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="totp-code"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Enter the 6-digit code from your app
|
||||
</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={code.length !== 6 || actionLoading}
|
||||
className="flex-1 px-4 py-2 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{actionLoading ? 'Verifying...' : 'Verify & Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 border border-border-default rounded-lg hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-semibold">MFA enabled successfully!</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Your account is now protected with two-factor authentication. You'll need to
|
||||
enter a code from your authenticator app each time you sign in.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('idle')}
|
||||
className="px-4 py-2 bg-accent-600 hover:bg-accent-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'disabling' && (
|
||||
<form onSubmit={handleConfirmDisable} className="space-y-4">
|
||||
<p className="text-text-secondary text-sm">
|
||||
To disable MFA, enter a code from your authenticator app to confirm.
|
||||
</p>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="disable-code"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Authentication code
|
||||
</label>
|
||||
<input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={code.length !== 6 || actionLoading}
|
||||
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{actionLoading ? 'Disabling...' : 'Disable MFA'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 border border-border-default rounded-lg hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,14 +13,46 @@ export function AdminGenlockes() {
|
||||
|
||||
const [deleting, setDeleting] = useState<GenlockeListItem | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [ownerFilter, setOwnerFilter] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!statusFilter) return genlockes
|
||||
return genlockes.filter((g) => g.status === statusFilter)
|
||||
}, [genlockes, statusFilter])
|
||||
let result = genlockes
|
||||
if (statusFilter) result = result.filter((g) => g.status === statusFilter)
|
||||
if (ownerFilter) {
|
||||
if (ownerFilter === '__none__') {
|
||||
result = result.filter((g) => !g.owner)
|
||||
} else {
|
||||
result = result.filter((g) => g.owner?.id === ownerFilter)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [genlockes, statusFilter, ownerFilter])
|
||||
|
||||
const genlockeOwners = useMemo(() => {
|
||||
const owners = new Map<string, string>()
|
||||
let hasUnowned = false
|
||||
for (const g of genlockes) {
|
||||
if (g.owner) {
|
||||
owners.set(g.owner.id, g.owner.displayName ?? g.owner.id)
|
||||
} else {
|
||||
hasUnowned = true
|
||||
}
|
||||
}
|
||||
const sorted = [...owners.entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
return { owners: sorted, hasUnowned }
|
||||
}, [genlockes])
|
||||
|
||||
const columns: Column<GenlockeListItem>[] = [
|
||||
{ header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name },
|
||||
{
|
||||
header: 'Owner',
|
||||
accessor: (g) => (
|
||||
<span className={g.owner ? '' : 'text-text-tertiary'}>
|
||||
{g.owner?.displayName ?? g.owner?.id ?? 'No owner'}
|
||||
</span>
|
||||
),
|
||||
sortKey: (g) => g.owner?.displayName ?? g.owner?.id ?? '',
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessor: (g) => (
|
||||
@@ -67,9 +99,25 @@ export function AdminGenlockes() {
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
{statusFilter && (
|
||||
<select
|
||||
value={ownerFilter}
|
||||
onChange={(e) => setOwnerFilter(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||
>
|
||||
<option value="">All owners</option>
|
||||
{genlockeOwners.hasUnowned && <option value="__none__">No owner</option>}
|
||||
{genlockeOwners.owners.map(([id, name]) => (
|
||||
<option key={id} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(statusFilter || ownerFilter) && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
onClick={() => {
|
||||
setStatusFilter('')
|
||||
setOwnerFilter('')
|
||||
}}
|
||||
className="text-sm text-text-tertiary hover:text-text-primary"
|
||||
>
|
||||
Clear filters
|
||||
|
||||
@@ -13,6 +13,7 @@ export function AdminRuns() {
|
||||
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [gameFilter, setGameFilter] = useState('')
|
||||
const [ownerFilter, setOwnerFilter] = useState('')
|
||||
|
||||
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games])
|
||||
|
||||
@@ -20,8 +21,15 @@ export function AdminRuns() {
|
||||
let result = runs
|
||||
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
|
||||
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
|
||||
if (ownerFilter) {
|
||||
if (ownerFilter === '__none__') {
|
||||
result = result.filter((r) => !r.owner)
|
||||
} else {
|
||||
result = result.filter((r) => r.owner?.id === ownerFilter)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [runs, statusFilter, gameFilter])
|
||||
}, [runs, statusFilter, gameFilter, ownerFilter])
|
||||
|
||||
const runGames = useMemo(
|
||||
() =>
|
||||
@@ -33,6 +41,20 @@ export function AdminRuns() {
|
||||
[runs, gameMap]
|
||||
)
|
||||
|
||||
const runOwners = useMemo(() => {
|
||||
const owners = new Map<string, string>()
|
||||
let hasUnowned = false
|
||||
for (const r of runs) {
|
||||
if (r.owner) {
|
||||
owners.set(r.owner.id, r.owner.displayName ?? r.owner.id)
|
||||
} else {
|
||||
hasUnowned = true
|
||||
}
|
||||
}
|
||||
const sorted = [...owners.entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
return { owners: sorted, hasUnowned }
|
||||
}, [runs])
|
||||
|
||||
const columns: Column<NuzlockeRun>[] = [
|
||||
{ header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name },
|
||||
{
|
||||
@@ -40,6 +62,15 @@ export function AdminRuns() {
|
||||
accessor: (r) => gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
|
||||
sortKey: (r) => gameMap.get(r.gameId) ?? '',
|
||||
},
|
||||
{
|
||||
header: 'Owner',
|
||||
accessor: (r) => (
|
||||
<span className={r.owner ? '' : 'text-text-tertiary'}>
|
||||
{r.owner?.displayName ?? r.owner?.id ?? 'No owner'}
|
||||
</span>
|
||||
),
|
||||
sortKey: (r) => r.owner?.displayName ?? r.owner?.id ?? '',
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessor: (r) => (
|
||||
@@ -93,11 +124,25 @@ export function AdminRuns() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(statusFilter || gameFilter) && (
|
||||
<select
|
||||
value={ownerFilter}
|
||||
onChange={(e) => setOwnerFilter(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||
>
|
||||
<option value="">All owners</option>
|
||||
{runOwners.hasUnowned && <option value="__none__">No owner</option>}
|
||||
{runOwners.owners.map(([id, name]) => (
|
||||
<option key={id} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(statusFilter || gameFilter || ownerFilter) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusFilter('')
|
||||
setGameFilter('')
|
||||
setOwnerFilter('')
|
||||
}}
|
||||
className="text-sm text-text-tertiary hover:text-text-primary"
|
||||
>
|
||||
|
||||
@@ -8,5 +8,6 @@ export { NewGenlocke } from './NewGenlocke'
|
||||
export { NewRun } from './NewRun'
|
||||
export { RunList } from './RunList'
|
||||
export { RunEncounters } from './RunEncounters'
|
||||
export { Settings } from './Settings'
|
||||
export { Signup } from './Signup'
|
||||
export { Stats } from './Stats'
|
||||
|
||||
@@ -320,6 +320,11 @@ export interface GenlockeStats {
|
||||
totalLegs: number
|
||||
}
|
||||
|
||||
export interface GenlockeOwner {
|
||||
id: string
|
||||
displayName: string | null
|
||||
}
|
||||
|
||||
export interface GenlockeListItem {
|
||||
id: number
|
||||
name: string
|
||||
@@ -328,6 +333,7 @@ export interface GenlockeListItem {
|
||||
totalLegs: number
|
||||
completedLegs: number
|
||||
currentLegOrder: number | null
|
||||
owner: GenlockeOwner | null
|
||||
}
|
||||
|
||||
export interface RetiredPokemon {
|
||||
|
||||
Reference in New Issue
Block a user