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- {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 (Shown on the club page so shooters can find and reach you. Leave anything blank to hide it.
@@ -266,6 +291,7 @@ function LocationContactSection({ clubId, club }: { clubId: string; club: Club } type={f.type ?? 'text'} value={form[f.key]} onChange={e => set(f.key, e.target.value)} + disabled={!canManage} className={inputCls} placeholder={f.placeholder} /> @@ -279,11 +305,11 @@ function LocationContactSection({ clubId, club }: { clubId: string; club: Club } {coords ? `${coords.lat}, ${coords.lng}` : 'Not set'} -Disciplines drive the club directory filter — pick every one you shoot.
@@ -394,8 +425,9 @@ function DisciplinesSection({ clubId, club }: { clubId: string; club: Club }) { key={value} type="button" onClick={() => toggleDiscipline(value)} + disabled={!canManage} aria-pressed={disciplines.includes(value)} - className={`px-3 py-1.5 rounded border text-[11px] tracking-widest uppercase transition-colors ${ + className={`px-3 py-1.5 rounded border text-[11px] tracking-widest uppercase transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${ disciplines.includes(value) ? 'border-[var(--brass)] bg-[var(--brass)] text-inverse' : 'border-subtle text-muted hover:text-secondary' @@ -413,6 +445,7 @@ function DisciplinesSection({ clubId, club }: { clubId: string; club: Club }) { placeholder="e.g. 25 yards" values={distances} onChange={setDistances} + disabled={!canManage} />Add a slot per session — a day can have more than one. Mark a day closed to say so explicitly.
@@ -608,6 +652,7 @@ function OpeningHoursSection({ clubId }: { clubId: string }) {