Phase 20: Admin Member Editor & Form Declutter #25

Merged
luckberg merged 37 commits from gsd/phase-20-admin-member-editor-form-declutter into main 2026-06-18 20:40:35 -04:00
Owner

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/: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

Plan 20-02: PWA API client fetcher

updateMemberProfile fetcher (last-admin sentinel + session-expiry reuse) and AdminMember.isAdmin field.
Key files: apps/pwa/src/api/client.ts, apps/pwa/src/api/client.test.ts

Plan 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.tsx
Key files (modified): apps/pwa/src/routes/AdminPage.tsx

Requirements 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

  • Automated verification: passed — 9/9 observable truths verified (20-VERIFICATION.md)
  • Playwright-verified UI (Plan 20-03 tagged playwright-verified)
  • All CI gates green (prettier, eslint, typecheck, tests)
  • Code review: 11/11 findings fixed, re-review clean (20-REVIEW-FIX.md)
  • Security: 10/10 STRIDE threats CLOSED, threats_open: 0 (20-SECURITY.md)

Key Decisions

  • 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 bc48632 feat(20-01): PATCH /members/:id + last-admin guard missing
18da7e9 test(20-02): failing tests for updateMemberProfile + AdminMember.isAdmin 5bcd818 feat(20-02): updateMemberProfile fetcher + isAdmin missing
b125a69 feat(20-03): MemberEditorSheet edit/create + per-section saves missing
9e6b004 feat(20-03): rework AdminPage MemberRow + Add-member trigger missing
9b62887 feat(20-03): unify member editor + declutter panel missing
7297733 fix(20): CR-01 WR-06 atomic last-admin guard + empty-body 400 missing
ee04aee fix(20): CR-02 derive editorMember from live query data missing
527d855 fix(20): WR-01/02/04 profile mutation + handleClose missing
2fd253e fix(20): WR-03 IN-03 phone sheet scroll + safe-area padding missing
d2e9862 fix(20): WR-05 aria-describedby on admin toggle missing
400733f fix(20): IN-01 helper text when display name empty missing
182ba1d fix(20): IN-02 maxLength on display-name/username missing
41a4fae fix(20): prettier format admin.test.ts missing
5161bd3 fix(20): IN-04 skip no-op profile-saved toast + refetch missing

Aggregate (code commits): 0 skill, 0 fallback, 0 exempt — 14 missing.

🤖 Generated with Claude Code

gate_status: skill=0, fallback=0, exempt=0, missing=14

## 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/: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` ### Plan 20-02: PWA API client fetcher `updateMemberProfile` fetcher (last-admin sentinel + session-expiry reuse) and `AdminMember.isAdmin` field. **Key files:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/api/client.test.ts` ### Plan 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.tsx` **Key files (modified):** `apps/pwa/src/routes/AdminPage.tsx` ## Requirements 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 - [x] Automated verification: **passed** — 9/9 observable truths verified (`20-VERIFICATION.md`) - [x] Playwright-verified UI (Plan 20-03 tagged `playwright-verified`) - [x] All CI gates green (prettier, eslint, typecheck, tests) - [x] Code review: 11/11 findings fixed, re-review clean (`20-REVIEW-FIX.md`) - [x] Security: 10/10 STRIDE threats CLOSED, `threats_open: 0` (`20-SECURITY.md`) ## Key Decisions - 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 | `bc48632` feat(20-01): PATCH /members/:id + last-admin guard | missing | | `18da7e9` test(20-02): failing tests for updateMemberProfile + AdminMember.isAdmin | `5bcd818` feat(20-02): updateMemberProfile fetcher + isAdmin | missing | | — | `b125a69` feat(20-03): MemberEditorSheet edit/create + per-section saves | missing | | — | `9e6b004` feat(20-03): rework AdminPage MemberRow + Add-member trigger | missing | | — | `9b62887` feat(20-03): unify member editor + declutter panel | missing | | — | `7297733` fix(20): CR-01 WR-06 atomic last-admin guard + empty-body 400 | missing | | — | `ee04aee` fix(20): CR-02 derive editorMember from live query data | missing | | — | `527d855` fix(20): WR-01/02/04 profile mutation + handleClose | missing | | — | `2fd253e` fix(20): WR-03 IN-03 phone sheet scroll + safe-area padding | missing | | — | `d2e9862` fix(20): WR-05 aria-describedby on admin toggle | missing | | — | `400733f` fix(20): IN-01 helper text when display name empty | missing | | — | `182ba1d` fix(20): IN-02 maxLength on display-name/username | missing | | — | `41a4fae` fix(20): prettier format admin.test.ts | missing | | — | `5161bd3` fix(20): IN-04 skip no-op profile-saved toast + refetch | missing | Aggregate (code commits): 0 skill, 0 fallback, 0 exempt — 14 missing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) gate_status: skill=0, fallback=0, exempt=0, missing=14
luckberg added 35 commits 2026-06-18 20:25:34 -04:00
- 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
- 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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Edit mode: Profile (display name + admin toggle), Set new password, App password sections
- Create mode: single form with display name, username, initial/confirm password
- Per-section saves keep sheet open; create success closes sheet (D-05, D-07)
- Admin toggle role=switch, aria-checked; last-admin 409 shows inline error + reverts (D-03)
- Section 2 gated on member.hasLocalCredential; passwords never prefilled (T-20-07)
- App password save routes through saveCredential -> CalDAV validation (T-20-08)
- No Rotate/Add credential/Reset password copy (D-06)
- 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)
- playwright-cli verified: Members tab shows tappable rows, no retired buttons
- Row tap opens 'Edit member' sheet; per-section saves keep sheet open
- 'Add member' trigger opens 'Add member' sheet in create mode
- Profile save fires 'Profile saved.' toast; sheet stays open (D-05)
- eslint + prettier + typecheck + vitest (275 tests) all pass
- Fix pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(phase-20): add security threat verification
CI / changes (pull_request) Successful in 4s
CI / api (pull_request) Successful in 2m8s
CI / fast-checks (pull_request) Successful in 2m26s
CI / security (pull_request) Successful in 1m1s
CI / harness (pull_request) Failing after 13m24s
CI / gate (pull_request) Failing after 2s
f0aa901f57
luckberg added 2 commits 2026-06-18 20:26:55 -04:00
Merge remote-tracking branch 'origin/main' into gsd/phase-20-admin-member-editor-form-declutter
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Successful in 2m26s
CI / api (pull_request) Successful in 1m56s
CI / harness (pull_request) Successful in 5m43s
CI / security (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 3s
c43bd314a1
# Conflicts:
#	.planning/STATE.md
luckberg merged commit cc76a32d0a into main 2026-06-18 20:40:35 -04:00
luckberg deleted branch gsd/phase-20-admin-member-editor-form-declutter 2026-06-18 20:40:36 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: luckberg/familysync#25