Phase 20: Admin Member Editor & Form Declutter Goal: Replace the per-member-row action buttons (Rotate/Add credential + Reset password) in the admin Members panel with a single edit affordance — tapping a member opens one member-detail editor where an admin changes all of that member's details (display name, local-login password, Fastmail/CalDAV app password) using clear non-jargon labels, retiring the confusing "Rotate" term. Also collapses the "Add member" section behind a single trigger. Status: Verified ✓ (9/9 must-haves)
Adds a server-side PATCH /api/admin/members/:id partial-update route (displayName + isAdmin) guarded by the router-wide requireAdmin, with an atomic last-admin demotion guard (409). Surfaces isAdmin on GET /members. On the PWA, a new MemberEditorSheet consolidates profile, local password, and app-password into one sheet with per-section saves, and the Members panel becomes tappable rows with an "Add member" ghost trigger that opens the same sheet in create mode. The old Rotate / Add-credential / standalone Reset-password surfaces are removed.
Changes
Plan 20-01: Server PATCH /api/admin/members/:id (TDD)
PATCH route for member-profile partial update with last-admin demotion guard; isAdmin added to GET /members. Key files:apps/api/src/routes/admin.ts, apps/api/tests/routes/admin.test.ts
PATCH verb for idiomatic partial update; last-admin guard via COUNT(*) on users.isAdmin, wrapped in a transaction with a FOR UPDATE locking read to defeat a concurrent double-demotion race.
Password/app-password fields are write-only (never prefilled, autoComplete="new-password", never logged); app-password email starts blank on edit.
Admin toggle is role="switch" (cosmetic) — the real boundary is the server 409; on rejection the toggle reverts with an inline error.
Per-section saves keep the sheet open; create-mode save closes it.
TDD Audit
Code-bearing commits (docs/chore omitted — no gate applies). This project does not emit gate_status: trailers, so all rows are missing (informational; non-blocking).
Test commit
Impl commit
gate_status
a0a82ac test(20-01): failing tests for member-profile update + last-admin guard
- Test A: PATCH displayName happy path → 200, GET reflects change
- Test B: PATCH isAdmin promote → 200, GET shows isAdmin true
- Test C: last-admin guard → 409 when only admin demotes self
- Test D: self-demotion → 200 when second admin exists
- Test E: non-admin PATCH → 403 (requireAdmin boundary)
- Test F: wrong-type body → 400 { error: "Invalid request" }; malformed :id → 400
- Test G: non-existent member id → 404
- Test H: GET /members includes boolean isAdmin per member
All 7 new tests fail RED for the right reasons (route 404 / isAdmin missing)
- 8 RED tests covering: PATCH URL contract, credentials/redirect shape,
void on 200, SessionExpiredError on 401/opaqueredirect, last-admin
sentinel on 409 and 422, generic error on 500
- 1 compile-time shape test for AdminMember.isAdmin: boolean
- All new tests fail (updateMemberProfile is not a function); 42 existing pass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add isAdmin field to GET /members select and mapped member object (D-02)
- Add updateMemberSchema (displayName optional string, isAdmin optional boolean)
- Register adminRouter.patch('/members/:id') with noEchoHook and requireAdmin (inherited)
- Handler: parsePositiveIntParam id validation (400), existence check (404),
D-03 last-admin guard via COUNT(*) query (409), partial set() update (200)
- Fix Test D: switch to adminId2 for GET after self-demotion (adminId1 no longer admin)
- All 44 tests green including 7 new PATCH/isAdmin tests
- MemberRow: whole-row role=button, aria-label='Edit {name}', trailing ChevronRight
- Admin badge on member.isAdmin (12px/600, --color-member-0 on --color-surface-dim)
- 'Add member' ghost trigger with Plus icon opens MemberEditorSheet in create mode
- Single MemberEditorSheet replaces CredentialSheet + ResetPasswordSheet mounts
- Removed: inline Local Accounts add-form, ResetPasswordSheet definition, create-form state
- Empty state: 'No members yet' / 'Add a member to get started.' (UI-SPEC copy)
Wrap the last-admin check and UPDATE in a db.transaction with a
SELECT...FOR UPDATE locking read so concurrent PATCH demotions
serialise and cannot both pass the guard, eliminating the TOCTOU
race (CR-01).
Add a .refine() to updateMemberSchema requiring at least one field,
returning 400 via noEchoHook instead of crashing Drizzle with an
empty SET clause (WR-06).
Add Test H asserting empty {} -> 400 { error: 'Invalid request' }.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Store only the member id in editorMemberId state rather than the full
AdminMember object. Derive editorMember from membersQuery.data after each
render, so query invalidations following a per-section save always give
MemberEditorSheet fresh server state — eliminating the stale-reference
silent-overwrite race.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WR-01: Send only changed fields in profileMutation so admin-toggle-only
saves don't re-send displayName (blocking members with null displayName
from ever having their admin flag toggled).
WR-02: Revert the toggle to member!.isAdmin explicitly instead of
`member?.isAdmin ?? true` — the prior fallback was correct by coincidence
but semantically wrong for any error path where member is non-null.
WR-04: Remove member-derived field resets (displayName, isAdmin) from
handleClose — those belong to the useEffect sync that already tracks
member?.id/displayName/isAdmin. Only ephemeral fields (passwords, errors)
are reset on close, eliminating the stale-closure Cancel regression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add maxHeight:90dvh + overflowY:auto to the phone bottom-sheet branch
so content overflowing a short viewport (iPhone SE 667px) is scrollable
rather than clipped and the Save buttons remain reachable.
Add paddingBottom with env(safe-area-inset-bottom) to clear the iOS home
indicator / Android gesture navigation bar (IN-03).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Link the admin role="switch" toggle button to the profile-error region
via aria-describedby so screen-reader users who activate the toggle
hear the last-admin error announcement — matching the existing link on
the display-name input.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Render an explanatory helper line when displayName is empty and no
other error is showing, so admins editing an OIDC-provisioned member
with a null displayName understand why Save is disabled rather than
seeing a greyed-out button with no explanation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add maxLength={256} to the edit-mode and create-mode display-name inputs,
and maxLength={128} to the create-mode username input, matching the server-
side Zod schema limits. This surfaces the constraint client-side instead of
letting a long entry reach the server and return a generic 400.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Phase 20: Admin Member Editor & Form Declutter
Goal: Replace the per-member-row action buttons (Rotate/Add credential + Reset password) in the admin Members panel with a single edit affordance — tapping a member opens one member-detail editor where an admin changes all of that member's details (display name, local-login password, Fastmail/CalDAV app password) using clear non-jargon labels, retiring the confusing "Rotate" term. Also collapses the "Add member" section behind a single trigger.
Status: Verified ✓ (9/9 must-haves)
Adds a server-side
PATCH /api/admin/members/:idpartial-update route (displayName + isAdmin) guarded by the router-widerequireAdmin, with an atomic last-admin demotion guard (409). SurfacesisAdminonGET /members. On the PWA, a newMemberEditorSheetconsolidates profile, local password, and app-password into one sheet with per-section saves, and the Members panel becomes tappable rows with an "Add member" ghost trigger that opens the same sheet in create mode. The old Rotate / Add-credential / standalone Reset-password surfaces are removed.Changes
Plan 20-01: Server PATCH /api/admin/members/:id (TDD)
PATCH route for member-profile partial update with last-admin demotion guard;
isAdminadded toGET /members.Key files:
apps/api/src/routes/admin.ts,apps/api/tests/routes/admin.test.tsPlan 20-02: PWA API client fetcher
updateMemberProfilefetcher (last-admin sentinel + session-expiry reuse) andAdminMember.isAdminfield.Key files:
apps/pwa/src/api/client.ts,apps/pwa/src/api/client.test.tsPlan 20-03: Unified MemberEditorSheet + decluttered panel
New
MemberEditorSheet(edit/create, per-section saves, write-only password fields, role=switch admin toggle); tappable Members rows + Add-member ghost trigger; retired Rotate/Reset-password buttons.Key files (created):
apps/pwa/src/components/MemberEditorSheet.tsxKey files (modified):
apps/pwa/src/routes/AdminPage.tsxRequirements Addressed
No formal REQ-IDs were mapped (phase requirements were TBD/goal-level per ROADMAP). Verified against the 9 observable truths in
20-VERIFICATION.md.Verification
20-VERIFICATION.md)playwright-verified)20-REVIEW-FIX.md)threats_open: 0(20-SECURITY.md)Key Decisions
COUNT(*)onusers.isAdmin, wrapped in a transaction with aFOR UPDATElocking read to defeat a concurrent double-demotion race.autoComplete="new-password", never logged); app-password email starts blank on edit.role="switch"(cosmetic) — the real boundary is the server 409; on rejection the toggle reverts with an inline error.TDD Audit
Code-bearing commits (docs/chore omitted — no gate applies). This project does not emit
gate_status:trailers, so all rows aremissing(informational; non-blocking).a0a82actest(20-01): failing tests for member-profile update + last-admin guardbc48632feat(20-01): PATCH /members/:id + last-admin guard18da7e9test(20-02): failing tests for updateMemberProfile + AdminMember.isAdmin5bcd818feat(20-02): updateMemberProfile fetcher + isAdminb125a69feat(20-03): MemberEditorSheet edit/create + per-section saves9e6b004feat(20-03): rework AdminPage MemberRow + Add-member trigger9b62887feat(20-03): unify member editor + declutter panel7297733fix(20): CR-01 WR-06 atomic last-admin guard + empty-body 400ee04aeefix(20): CR-02 derive editorMember from live query data527d855fix(20): WR-01/02/04 profile mutation + handleClose2fd253efix(20): WR-03 IN-03 phone sheet scroll + safe-area paddingd2e9862fix(20): WR-05 aria-describedby on admin toggle400733ffix(20): IN-01 helper text when display name empty182ba1dfix(20): IN-02 maxLength on display-name/username41a4faefix(20): prettier format admin.test.ts5161bd3fix(20): IN-04 skip no-op profile-saved toast + refetchAggregate (code commits): 0 skill, 0 fallback, 0 exempt — 14 missing.
🤖 Generated with Claude Code
gate_status: skill=0, fallback=0, exempt=0, missing=14
- Test A: PATCH displayName happy path → 200, GET reflects change - Test B: PATCH isAdmin promote → 200, GET shows isAdmin true - Test C: last-admin guard → 409 when only admin demotes self - Test D: self-demotion → 200 when second admin exists - Test E: non-admin PATCH → 403 (requireAdmin boundary) - Test F: wrong-type body → 400 { error: "Invalid request" }; malformed :id → 400 - Test G: non-existent member id → 404 - Test H: GET /members includes boolean isAdmin per member All 7 new tests fail RED for the right reasons (route 404 / isAdmin missing)- Add isAdmin field to GET /members select and mapped member object (D-02) - Add updateMemberSchema (displayName optional string, isAdmin optional boolean) - Register adminRouter.patch('/members/:id') with noEchoHook and requireAdmin (inherited) - Handler: parsePositiveIntParam id validation (400), existence check (404), D-03 last-admin guard via COUNT(*) query (409), partial set() update (200) - Fix Test D: switch to adminId2 for GET after self-demotion (adminId1 no longer admin) - All 44 tests green including 7 new PATCH/isAdmin tests- Add isAdmin: boolean to AdminMember interface (after color, before hasCredential) — feeds the Phase 20 editor toggle initial state (D-02) - Add updateMemberProfile(memberId, body) fetcher: PATCH /api/admin/members/:id, credentials:include, redirect:manual, JSON body - Maps 401/opaqueredirect → SessionExpiredError (existing convention) - Maps 409/422 → Error('last-admin') sentinel (D-03 last-admin guard) - Maps other non-ok → generic error - All 50 tests pass; eslint + prettier + tsc --noEmit exit 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>- MemberRow: whole-row role=button, aria-label='Edit {name}', trailing ChevronRight - Admin badge on member.isAdmin (12px/600, --color-member-0 on --color-surface-dim) - 'Add member' ghost trigger with Plus icon opens MemberEditorSheet in create mode - Single MemberEditorSheet replaces CredentialSheet + ResetPasswordSheet mounts - Removed: inline Local Accounts add-form, ResetPasswordSheet definition, create-form state - Empty state: 'No members yet' / 'Add a member to get started.' (UI-SPEC copy)Wrap the last-admin check and UPDATE in a db.transaction with a SELECT...FOR UPDATE locking read so concurrent PATCH demotions serialise and cannot both pass the guard, eliminating the TOCTOU race (CR-01). Add a .refine() to updateMemberSchema requiring at least one field, returning 400 via noEchoHook instead of crashing Drizzle with an empty SET clause (WR-06). Add Test H asserting empty {} -> 400 { error: 'Invalid request' }. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Add maxLength={256} to the edit-mode and create-mode display-name inputs, and maxLength={128} to the create-mode username input, matching the server- side Zod schema limits. This surfaces the constraint client-side instead of letting a long entry reach the server and return a generic 400. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>