Release: MFA, JWKS auth, run ownership, and dependency updates #79
@@ -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
|
||||||
@@ -28,6 +28,7 @@ from app.schemas.genlocke import (
|
|||||||
GenlockeLegDetailResponse,
|
GenlockeLegDetailResponse,
|
||||||
GenlockeLineageResponse,
|
GenlockeLineageResponse,
|
||||||
GenlockeListItem,
|
GenlockeListItem,
|
||||||
|
GenlockeOwnerResponse,
|
||||||
GenlockeResponse,
|
GenlockeResponse,
|
||||||
GenlockeStatsResponse,
|
GenlockeStatsResponse,
|
||||||
GenlockeUpdate,
|
GenlockeUpdate,
|
||||||
@@ -81,7 +82,9 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Genlocke)
|
select(Genlocke)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
|
selectinload(Genlocke.legs)
|
||||||
|
.selectinload(GenlockeLeg.run)
|
||||||
|
.selectinload(NuzlockeRun.owner),
|
||||||
)
|
)
|
||||||
.order_by(Genlocke.created_at.desc())
|
.order_by(Genlocke.created_at.desc())
|
||||||
)
|
)
|
||||||
@@ -91,6 +94,16 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
|
|||||||
for g in genlockes:
|
for g in genlockes:
|
||||||
completed_legs = 0
|
completed_legs = 0
|
||||||
current_leg_order = None
|
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:
|
for leg in g.legs:
|
||||||
if leg.run and leg.run.status == "completed":
|
if leg.run and leg.run.status == "completed":
|
||||||
completed_legs += 1
|
completed_legs += 1
|
||||||
@@ -106,6 +119,7 @@ async def list_genlockes(session: AsyncSession = Depends(get_session)):
|
|||||||
total_legs=len(g.legs),
|
total_legs=len(g.legs),
|
||||||
completed_legs=completed_legs,
|
completed_legs=completed_legs,
|
||||||
current_leg_order=current_leg_order,
|
current_leg_order=current_leg_order,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from app.schemas.base import CamelModel
|
from app.schemas.base import CamelModel
|
||||||
from app.schemas.game import GameResponse
|
from app.schemas.game import GameResponse
|
||||||
from app.schemas.pokemon import PokemonResponse
|
from app.schemas.pokemon import PokemonResponse
|
||||||
|
|
||||||
|
|
||||||
|
class GenlockeOwnerResponse(CamelModel):
|
||||||
|
id: UUID
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GenlockeCreate(CamelModel):
|
class GenlockeCreate(CamelModel):
|
||||||
name: str
|
name: str
|
||||||
game_ids: list[int]
|
game_ids: list[int]
|
||||||
@@ -92,6 +98,7 @@ class GenlockeListItem(CamelModel):
|
|||||||
total_legs: int
|
total_legs: int
|
||||||
completed_legs: int
|
completed_legs: int
|
||||||
current_leg_order: int | None = None
|
current_leg_order: int | None = None
|
||||||
|
owner: GenlockeOwnerResponse | None = None
|
||||||
|
|
||||||
|
|
||||||
class GenlockeDetailResponse(CamelModel):
|
class GenlockeDetailResponse(CamelModel):
|
||||||
|
|||||||
@@ -13,14 +13,46 @@ export function AdminGenlockes() {
|
|||||||
|
|
||||||
const [deleting, setDeleting] = useState<GenlockeListItem | null>(null)
|
const [deleting, setDeleting] = useState<GenlockeListItem | null>(null)
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [ownerFilter, setOwnerFilter] = useState('')
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!statusFilter) return genlockes
|
let result = genlockes
|
||||||
return genlockes.filter((g) => g.status === statusFilter)
|
if (statusFilter) result = result.filter((g) => g.status === statusFilter)
|
||||||
}, [genlockes, 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>[] = [
|
const columns: Column<GenlockeListItem>[] = [
|
||||||
{ header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name },
|
{ 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',
|
header: 'Status',
|
||||||
accessor: (g) => (
|
accessor: (g) => (
|
||||||
@@ -67,9 +99,25 @@ export function AdminGenlockes() {
|
|||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="failed">Failed</option>
|
<option value="failed">Failed</option>
|
||||||
</select>
|
</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
|
<button
|
||||||
onClick={() => setStatusFilter('')}
|
onClick={() => {
|
||||||
|
setStatusFilter('')
|
||||||
|
setOwnerFilter('')
|
||||||
|
}}
|
||||||
className="text-sm text-text-tertiary hover:text-text-primary"
|
className="text-sm text-text-tertiary hover:text-text-primary"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function AdminRuns() {
|
|||||||
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
|
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
const [gameFilter, setGameFilter] = useState('')
|
const [gameFilter, setGameFilter] = useState('')
|
||||||
|
const [ownerFilter, setOwnerFilter] = useState('')
|
||||||
|
|
||||||
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games])
|
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games])
|
||||||
|
|
||||||
@@ -20,8 +21,15 @@ export function AdminRuns() {
|
|||||||
let result = runs
|
let result = runs
|
||||||
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
|
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
|
||||||
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
|
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
|
return result
|
||||||
}, [runs, statusFilter, gameFilter])
|
}, [runs, statusFilter, gameFilter, ownerFilter])
|
||||||
|
|
||||||
const runGames = useMemo(
|
const runGames = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -33,6 +41,20 @@ export function AdminRuns() {
|
|||||||
[runs, gameMap]
|
[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>[] = [
|
const columns: Column<NuzlockeRun>[] = [
|
||||||
{ header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name },
|
{ 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}`,
|
accessor: (r) => gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
|
||||||
sortKey: (r) => gameMap.get(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',
|
header: 'Status',
|
||||||
accessor: (r) => (
|
accessor: (r) => (
|
||||||
@@ -93,11 +124,25 @@ export function AdminRuns() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatusFilter('')
|
setStatusFilter('')
|
||||||
setGameFilter('')
|
setGameFilter('')
|
||||||
|
setOwnerFilter('')
|
||||||
}}
|
}}
|
||||||
className="text-sm text-text-tertiary hover:text-text-primary"
|
className="text-sm text-text-tertiary hover:text-text-primary"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -320,6 +320,11 @@ export interface GenlockeStats {
|
|||||||
totalLegs: number
|
totalLegs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenlockeOwner {
|
||||||
|
id: string
|
||||||
|
displayName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenlockeListItem {
|
export interface GenlockeListItem {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -328,6 +333,7 @@ export interface GenlockeListItem {
|
|||||||
totalLegs: number
|
totalLegs: number
|
||||||
completedLegs: number
|
completedLegs: number
|
||||||
currentLegOrder: number | null
|
currentLegOrder: number | null
|
||||||
|
owner: GenlockeOwner | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RetiredPokemon {
|
export interface RetiredPokemon {
|
||||||
|
|||||||
Reference in New Issue
Block a user