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 }) {