fix: hide edit controls for non-owners in frontend
- Add useAuth and canEdit logic to RunEncounters.tsx - Guard all mutation triggers (Log Shiny, Log Egg, End Run, Randomize All, HoF Edit, Boss Battle, route/team clicks, Advance to Next Leg) - Update RunDashboard.tsx canEdit to be isOwner only (no unowned fallback) - Add read-only banner for non-owner viewers in both pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 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">
|
||||
|
||||
Reference in New Issue
Block a user