Release: MFA, JWKS auth, run ownership, and dependency updates #79
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
|||||||
✦ Log Shiny
|
✦ 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() {
|
|||||||
🥚 Log Egg
|
🥚 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,13 +1096,15 @@ 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>
|
||||||
<button
|
{canEdit && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setShowHofModal(true)}
|
type="button"
|
||||||
className="text-xs text-blue-400 hover:text-accent-300"
|
onClick={() => setShowHofModal(true)}
|
||||||
>
|
className="text-xs text-blue-400 hover:text-accent-300"
|
||||||
{hofTeam ? 'Edit' : 'Select team'}
|
>
|
||||||
</button>
|
{hofTeam ? 'Edit' : 'Select team'}
|
||||||
|
</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 ✓
|
Defeated ✓
|
||||||
</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,35 +1635,44 @@ 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)
|
(() => {
|
||||||
if (!result || result.team.length === 0) return null
|
const result = bossResultByBattleId.get(boss.id)
|
||||||
return (
|
if (!result || result.team.length === 0) return null
|
||||||
<div className="mt-3 pt-3 border-t border-border-default">
|
return (
|
||||||
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
|
<div className="mt-3 pt-3 border-t border-border-default">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||||
{result.team.map((tm: BossResultTeamMember) => {
|
Your Team
|
||||||
const enc = encounterById.get(tm.encounterId)
|
</p>
|
||||||
if (!enc) return null
|
<div className="flex gap-2 flex-wrap">
|
||||||
const dp = enc.currentPokemon ?? enc.pokemon
|
{result.team.map((tm: BossResultTeamMember) => {
|
||||||
return (
|
const enc = encounterById.get(tm.encounterId)
|
||||||
<div key={tm.id} className="flex flex-col items-center">
|
if (!enc) return null
|
||||||
{dp.spriteUrl ? (
|
const dp = enc.currentPokemon ?? enc.pokemon
|
||||||
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
|
return (
|
||||||
) : (
|
<div key={tm.id} className="flex flex-col items-center">
|
||||||
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
{dp.spriteUrl ? (
|
||||||
)}
|
<img
|
||||||
<span className="text-[10px] text-text-tertiary capitalize">
|
src={dp.spriteUrl}
|
||||||
{enc.nickname ?? dp.name}
|
alt={dp.name}
|
||||||
</span>
|
className="w-10 h-10"
|
||||||
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span>
|
/>
|
||||||
</div>
|
) : (
|
||||||
)
|
<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>
|
)
|
||||||
)
|
})()}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
{sectionAfter && (
|
{sectionAfter && (
|
||||||
<div className="flex items-center gap-3 my-4">
|
<div className="flex items-center gap-3 my-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user