Release: MFA, JWKS auth, run ownership, and dependency updates #79

Merged
TheFurya merged 36 commits from develop into main 2026-03-22 11:43:03 +01:00
3 changed files with 171 additions and 53 deletions
Showing only changes of commit 3bd24fcdb0 - Show all commits

View File

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

View File

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

View File

@@ -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() {
&#10022; 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() {
&#x1F95A; 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,6 +1096,7 @@ export function RunEncounters() {
<span className="text-xs font-medium text-text-link uppercase tracking-wider">
Hall of Fame
</span>
{canEdit && (
<button
type="button"
onClick={() => setShowHofModal(true)}
@@ -1070,6 +1104,7 @@ export function RunEncounters() {
>
{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 &#10003;
</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,12 +1635,15 @@ export function RunEncounters() {
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
{/* Player team snapshot */}
{isDefeated && (() => {
{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>
<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)
@@ -1607,14 +1652,20 @@ export function RunEncounters() {
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" />
<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>
<span className="text-[10px] text-text-muted">
Lv.{tm.level}
</span>
</div>
)
})}