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 [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const isOwner = user && run?.owner?.id === user.id const isOwner = user && run?.owner?.id === user.id
const canEdit = isOwner || !run?.owner const canEdit = isOwner
const encounters = run?.encounters ?? [] const encounters = run?.encounters ?? []
const alive = useMemo( const alive = useMemo(
@@ -143,6 +143,32 @@ export function RunDashboard() {
</div> </div>
</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 */} {/* Completion Banner */}
{!isActive && ( {!isActive && (
<div <div

View File

@@ -1,5 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react' import { useState, useMemo, useEffect, useCallback } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom' import { useParams, Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useRun, useUpdateRun } from '../hooks/useRuns' import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useAdvanceLeg } from '../hooks/useGenlockes' import { useAdvanceLeg } from '../hooks/useGenlockes'
import { useGameRoutes } from '../hooks/useGames' import { useGameRoutes } from '../hooks/useGames'
@@ -286,7 +287,7 @@ interface RouteGroupProps {
giftEncounterByRoute: Map<number, EncounterDetail> giftEncounterByRoute: Map<number, EncounterDetail>
isExpanded: boolean isExpanded: boolean
onToggleExpand: () => void onToggleExpand: () => void
onRouteClick: (route: Route) => void onRouteClick: ((route: Route) => void) | undefined
filter: 'all' | RouteStatus filter: 'all' | RouteStatus
pinwheelClause: boolean pinwheelClause: boolean
} }
@@ -438,10 +439,12 @@ function RouteGroup({
<button <button
key={child.id} key={child.id}
type="button" type="button"
onClick={() => !isDisabled && onRouteClick(child)} onClick={() => !isDisabled && onRouteClick?.(child)}
disabled={isDisabled} disabled={isDisabled || !onRouteClick}
className={`w-full flex items-center gap-3 px-4 py-2 pl-8 text-left transition-colors ${ 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}`} } ${childSi.bg}`}
> >
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} /> <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: bosses } = useGameBosses(run?.gameId ?? null)
const { data: bossResults } = useBossResults(runIdNum) const { data: bossResults } = useBossResults(runIdNum)
const createBossResult = useCreateBossResult(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 [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null) const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
@@ -944,7 +951,7 @@ export function RunEncounters() {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isActive && run.rules?.shinyClause && ( {isActive && canEdit && run.rules?.shinyClause && (
<button <button
onClick={() => setShowShinyModal(true)} 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" 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 &#10022; Log Shiny
</button> </button>
)} )}
{isActive && ( {isActive && canEdit && (
<button <button
onClick={() => setShowEggModal(true)} 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" 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 &#x1F95A; Log Egg
</button> </button>
)} )}
{isActive && ( {isActive && canEdit && (
<button <button
onClick={() => setShowEndRun(true)} onClick={() => setShowEndRun(true)}
className="px-3 py-1 text-sm border border-border-default rounded-full font-medium hover:bg-surface-2 transition-colors" 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>
</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 */} {/* Completion Banner */}
{!isActive && ( {!isActive && (
<div <div
@@ -1025,7 +1058,7 @@ export function RunEncounters() {
</p> </p>
</div> </div>
</div> </div>
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && ( {run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && canEdit && (
<button <button
onClick={() => { onClick={() => {
if (hofTeam && hofTeam.length > 0) { if (hofTeam && hofTeam.length > 0) {
@@ -1063,6 +1096,7 @@ export function RunEncounters() {
<span className="text-xs font-medium text-text-link uppercase tracking-wider"> <span className="text-xs font-medium text-text-link uppercase tracking-wider">
Hall of Fame Hall of Fame
</span> </span>
{canEdit && (
<button <button
type="button" type="button"
onClick={() => setShowHofModal(true)} onClick={() => setShowHofModal(true)}
@@ -1070,6 +1104,7 @@ export function RunEncounters() {
> >
{hofTeam ? 'Edit' : 'Select team'} {hofTeam ? 'Edit' : 'Select team'}
</button> </button>
)}
</div> </div>
{hofTeam ? ( {hofTeam ? (
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -1262,7 +1297,9 @@ export function RunEncounters() {
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined} onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/> />
))} ))}
</div> </div>
@@ -1276,7 +1313,9 @@ export function RunEncounters() {
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
showFaintLevel showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined} onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/> />
))} ))}
</div> </div>
@@ -1292,7 +1331,9 @@ export function RunEncounters() {
<div className="mb-6"> <div className="mb-6">
<ShinyBox <ShinyBox
encounters={shinyEncounters} encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined} onEncounterClick={
isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined
}
/> />
</div> </div>
)} )}
@@ -1306,7 +1347,7 @@ export function RunEncounters() {
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined} onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined}
/> />
))} ))}
</div> </div>
@@ -1318,7 +1359,7 @@ export function RunEncounters() {
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2> <h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && completedCount < totalLocations && ( {isActive && canEdit && completedCount < totalLocations && (
<button <button
type="button" type="button"
disabled={bulkRandomize.isPending} disabled={bulkRandomize.isPending}
@@ -1409,7 +1450,7 @@ export function RunEncounters() {
giftEncounterByRoute={giftEncounterByRoute} giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)} isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)} onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick} onRouteClick={canEdit ? handleRouteClick : undefined}
filter={filter} filter={filter}
pinwheelClause={pinwheelClause} pinwheelClause={pinwheelClause}
/> />
@@ -1425,8 +1466,9 @@ export function RunEncounters() {
<button <button
key={route.id} key={route.id}
type="button" type="button"
onClick={() => handleRouteClick(route)} onClick={canEdit ? () => handleRouteClick(route) : undefined}
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}`} 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}`} /> <span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0"> <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"> <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; Defeated &#10003;
</span> </span>
) : isActive ? ( ) : isActive && canEdit ? (
<button <button
onClick={() => setSelectedBoss(boss)} 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" 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} /> <BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)} )}
{/* Player team snapshot */} {/* Player team snapshot */}
{isDefeated && (() => { {isDefeated &&
(() => {
const result = bossResultByBattleId.get(boss.id) const result = bossResultByBattleId.get(boss.id)
if (!result || result.team.length === 0) return null if (!result || result.team.length === 0) return null
return ( return (
<div className="mt-3 pt-3 border-t border-border-default"> <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"> <div className="flex gap-2 flex-wrap">
{result.team.map((tm: BossResultTeamMember) => { {result.team.map((tm: BossResultTeamMember) => {
const enc = encounterById.get(tm.encounterId) const enc = encounterById.get(tm.encounterId)
@@ -1607,14 +1652,20 @@ export function RunEncounters() {
return ( return (
<div key={tm.id} className="flex flex-col items-center"> <div key={tm.id} className="flex flex-col items-center">
{dp.spriteUrl ? ( {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" /> <div className="w-10 h-10 bg-surface-3 rounded-full" />
)} )}
<span className="text-[10px] text-text-tertiary capitalize"> <span className="text-[10px] text-text-tertiary capitalize">
{enc.nickname ?? dp.name} {enc.nickname ?? dp.name}
</span> </span>
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span> <span className="text-[10px] text-text-muted">
Lv.{tm.level}
</span>
</div> </div>
) )
})} })}