From 33d7a6c5edc77c71c53c531cf597ff72685e0060 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 00:18:13 +0000 Subject: [PATCH] Gate ClubSettings sections on manage_settings, matching LeagueSettings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every club settings section (image, general info, location & contact, opening hours, disciplines, membership, privacy, regional defaults) saves through an endpoint the backend gates on manage_settings, but the frontend never threaded the viewer's permissions through to disable them — any club moderator saw fully live, editable forms and got an unexplained "Failed to save" on every attempt if they lacked the capability. LeagueSettings.tsx already fixed this exact bug for leagues; port the same canManage/ CapabilityNote pattern to its club counterpart. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016fa2ueKYTDTQCG2ZqKgzJa --- .jules/inspector.md | 39 +++++ frontend/src/pages/ClubSettings.tsx | 159 ++++++++++++------ .../src/pages/__tests__/ClubSettings.test.tsx | 67 ++++++++ 3 files changed, 213 insertions(+), 52 deletions(-) diff --git a/.jules/inspector.md b/.jules/inspector.md index 497a8d2..d0f1d7d 100644 --- a/.jules/inspector.md +++ b/.jules/inspector.md @@ -1,3 +1,42 @@ +## 2026-09-18 - LeagueSettings' Capability-Gating Fix Was Never Ported to ClubSettings + +**Finding:** CLAUDE.md's "Moderator roles" section documents, in detail, a fix +already shipped in `LeagueSettings.tsx`: every settings section threads a +`canManage` prop (`can(viewerRole, PERM.manageSettings)`), disables its inputs +and hides its Save button when the viewer lacks `manage_settings`, and shows a +`CapabilityNote` explaining which permission is missing and who grants it — +because rendering the form live for any moderator meant a delegated one could +type into it and get "Failed to save league" back with no explanation. The +backend enforces the identical `PermManageSettings` check for every club +mutation (`UpdateClub`, `UpdateImageURL`, `ReplaceOpeningHours`, +`RegenerateJoinCode` in `service/club.go`), but `ClubSettings.tsx` — the exact +structural sibling, built the same way, fetching the same +`getModeratorPermissions` response — never threaded `canManage` through any of +its eight sections (Image, General, Location & Contact, Opening Hours, +Disciplines, Membership, Privacy, Regional). `permissionData` was fetched at +the page level and used only for `MembersSection`'s `canDelegate` and the +announcement composer's `canSend` — every other section rendered fully live +and editable for **any** club moderator, reproducing the exact bug the League +side was already fixed for. A club moderator promoted with only +`manage_members` (the default promotion grant explicitly excludes +`manage_settings`) could edit the club's name, address, opening hours, image, +disciplines, privacy and regional defaults and have every save silently 403, +with a bare "Failed to save" toast and no indication a permission was missing. + +**Learning:** When CLAUDE.md documents a fix as "this exact bug, already +fixed here", always check every other file built to the same pattern — a +league/club (or any other) pair of structurally-parallel settings pages is a +prime place for a fix to have landed on only one twin. `grep -n "canManage"` +across sibling files is a fast litmus test: `LeagueSettings.tsx` had ~15 hits, +`ClubSettings.tsx` had zero, despite both fetching `getModeratorPermissions` +and rendering the identical section shape. +**Prevention:** Search for a permission-gating pattern's twin whenever a +CLAUDE.md doc singles out one page as the place a UX bug was fixed — the +prose describing the fix is usually written generally enough ("A control the +viewer's grant doesn't cover is drawn read-only") to sound like a project-wide +rule, but the code may only have been changed in the one file the bug report +named. + ## 2026-08-28 - A Documented, Unfixed Bug Sat in testsmith.md for Six Weeks **Finding:** `.jules/testsmith.md`'s 2026-07-17 entry named an exact, unguarded diff --git a/frontend/src/pages/ClubSettings.tsx b/frontend/src/pages/ClubSettings.tsx index 3e0ec19..fafd2d4 100644 --- a/frontend/src/pages/ClubSettings.tsx +++ b/frontend/src/pages/ClubSettings.tsx @@ -33,11 +33,27 @@ const labelCls = 't-section-title' const sectionCls = 'border border-subtle rounded bg-surface p-4 space-y-4' const btnPrimary = 'btn-brass disabled:opacity-50 disabled:cursor-not-allowed text-inverse font-medium text-[11px] tracking-widest uppercase py-2.5 px-4 rounded transition-all' +/** + * The line a section shows when the viewer helps run the club but the owner + * kept this part of it to themselves. Every save in these sections is gated on + * `manage_settings`, which is deliberately not part of the promotion grant — + * without this the fields render live and the save comes back "Failed to + * save" with no hint that a permission is missing. + */ +function CapabilityNote({ what }: { what: string }) { + return ( +

+ Read-only — {what} needs the “Manage settings” permission. The club owner + grants it from Members, below. +

+ ) +} + // --------------------------------------------------------------------------- // Club Image Section // --------------------------------------------------------------------------- -function ClubImageSection({ clubId, club }: { clubId: string; club: Club }) { +function ClubImageSection({ clubId, club, canManage }: { clubId: string; club: Club; canManage: boolean }) { const queryClient = useQueryClient() const fileInputRef = useRef(null) const [editingFile, setEditingFile] = useState(null) @@ -66,6 +82,7 @@ function ClubImageSection({ clubId, club }: { clubId: string; club: Club }) { return (

Club Image

+ {!canManage && }

- {club.image_url ? 'Click to change image' : 'Add a profile picture for this club'} + {!canManage + ? 'The club image is set by whoever manages the club’s settings.' + : club.image_url ? 'Click to change image' : 'Add a profile picture for this club'}

JPEG, PNG, or WebP. Max 5MB.

{mutation.isPending &&

Uploading…

} @@ -116,7 +135,7 @@ function ClubImageSection({ clubId, club }: { clubId: string; club: Club }) { // General Info Section // --------------------------------------------------------------------------- -function GeneralInfoSection({ clubId, club }: { clubId: string; club: Club }) { +function GeneralInfoSection({ clubId, club, canManage }: { clubId: string; club: Club; canManage: boolean }) { const queryClient = useQueryClient() const [name, setName] = useState(club.name) const [description, setDescription] = useState(club.description ?? '') @@ -148,6 +167,7 @@ function GeneralInfoSection({ clubId, club }: { clubId: string; club: Club }) { return (

General

+ {!canManage && }
@@ -155,6 +175,7 @@ function GeneralInfoSection({ clubId, club }: { clubId: string; club: Club }) { type="text" value={name} onChange={e => setName(e.target.value)} + disabled={!canManage} className={inputCls} placeholder="Club name" /> @@ -165,15 +186,18 @@ function GeneralInfoSection({ clubId, club }: { clubId: string; club: Club }) {