--- phase: 20-admin-member-editor-form-declutter plan: "03" subsystem: pwa-admin-ui tags: - admin - member-editor - ux - react - playwright-verified dependency_graph: requires: - "20-01" # PATCH /api/admin/members/:id route + last-admin guard - "20-02" # updateMemberProfile fetcher + AdminMember.isAdmin in client.ts provides: - unified-member-editor-sheet - decluttered-members-panel affects: - apps/pwa/src/routes/AdminPage.tsx - apps/pwa/src/components/MemberEditorSheet.tsx tech_stack: added: [] patterns: - per-section-save-sheet - role-switch-toggle - tappable-row-with-chevron - ghost-trigger-button key_files: created: - apps/pwa/src/components/MemberEditorSheet.tsx modified: - apps/pwa/src/routes/AdminPage.tsx decisions: - "Admin toggle uses role=switch + aria-checked per UI-SPEC Accessibility Contract (not native checkbox)" - "fastmailEmail starts blank in edit mode — GET /api/admin/members does not return it (D-CONTEXT)" - "Per-section saves keep sheet open; create-mode save closes it (D-05, D-07)" - "onMouseEnter/Leave hover effect on Add-member button via e.currentTarget.style (no type assertion)" - "Pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts fixed in Task 3 commit" metrics: duration: "~10 minutes" completed: "2026-06-18" tasks_completed: 3 files_modified: 2 files_created: 1 status: complete --- # Phase 20 Plan 03: Member Editor & Admin Panel Declutter Summary One unified MemberEditorSheet.tsx (edit + create modes, per-section saves) replacing the scattered per-row action buttons, standalone ResetPasswordSheet, and always-open inline Add-member form in AdminPage.tsx. ## Tasks Completed | Task | Name | Commit | Key Files | |------|------|--------|-----------| | 1 | Build MemberEditorSheet.tsx (edit + create modes, per-section saves) | b125a69 | apps/pwa/src/components/MemberEditorSheet.tsx (NEW, 899 lines) | | 2 | Rework AdminPage MemberRow + Add-member trigger; remove old surfaces | 9e6b004 | apps/pwa/src/routes/AdminPage.tsx | | 3 | Verify interaction + visual contract with playwright-cli; pass CI gates | 9b62887 | docs/*, CLAUDE.md, README.md (prettier drift fixes) | ## What Was Built ### MemberEditorSheet.tsx (new) Single component (`mode: 'edit' | 'create'`) that replaces the `CredentialSheet`, `ResetPasswordSheet`, and inline add-member form: **Edit mode — three per-section saves (D-05):** - Section 1 Profile: display name input + `role="switch"` admin toggle (initial state from `member.isAdmin`); `updateMemberProfile` mutation; last-admin 409 shows inline "Cannot remove admin — at least one admin must remain." and reverts toggle (D-03) - Section 2 Set new password: only shown when `member.hasLocalCredential === true`; mismatch/short client guards; `fetchAdminResetPassword` mutation; fires "Password updated." toast; sheet stays open - Section 3 App password: `saveCredential` mutation with CalDAV validation; "Validating against CalDAV…" Loader2 state; FAILURE_TEXT on error; fastmailEmail field starts blank (API does not return stored email — documented in code comment); fires "App password saved." toast; sheet stays open **Create mode (D-07):** - Single form: display name, username, initial password, confirm password - `fetchCreateMember` mutation; 409 → "That username is already in use."; fires "Member added." toast + closes sheet Dialog scaffold matches CredentialSheet/ResetPasswordSheet exactly: `role="dialog"`, `aria-modal`, `useFocusTrap`, Escape closes, focus returns to `triggerRef.current`, phone bottom-sheet vs desktop modal, zIndex 301, overlay `rgba(0,0,0,0.32)`. All passwords are write-only: never prefilled, `autoComplete="new-password"` (T-20-07/T-20-08 mitigations active). ### AdminPage.tsx (reworked Members tab) **MemberRow reworked (D-04):** Whole-row `role="button"`, `aria-label="Edit {displayName}"`, `tabIndex={0}`, Enter/Space opens editor. Trailing `ChevronRight` (size 16, `--color-text-muted`). Inline "Admin" badge (`--color-member-0` text, `--color-surface-dim` bg, 12px/600, border-radius 4px) when `member.isAdmin`. Avatar swatch and credential status badge kept unchanged. **"Add member" ghost trigger (D-07):** Full-width button with `Plus` icon prefix, `1px solid var(--color-border)`, `border-radius 8px`, `min-height 44px`; opens MemberEditorSheet in create mode; focus returns to this button on close. **Removed:** - Entire "Local Accounts" section with inline add-member form (~220 lines) - `ResetPasswordSheet` component definition (~303 lines) - `CredentialSheet` import and dual-sheet mounting - All create-form local state (`createDisplayName`, `createUsername`, `createPassword`, `createConfirmPassword`, `createError`) - `createMemberMutation` in AdminPage (moved to MemberEditorSheet) - Per-row "Rotate"/"Add credential"/"Reset password" buttons **Single `MemberEditorSheet` instance** replaces dual CredentialSheet + ResetPasswordSheet mounts; driven by `editorOpen`, `editorMode`, `editorMember`, `editorTriggerRef`. ## Playwright-CLI Verification Verified on desktop Chromium against http://localhost:5173/admin with DEV_AUTH_BYPASS active (Dev User, id 1, is admin): 1. **Members tab decluttered:** `button "Edit Dev User"` (tappable row with chevron) + `button "Add member"` ghost trigger visible; no Rotate/Add credential/Reset password buttons. 2. **Row tap → Edit member:** `dialog "Edit member"` opens with heading "Edit member", subtitle "Dev User", Profile section (display name prefilled, Admin toggle checked), Set new password section, App password section. 3. **Profile save → sheet stays open:** Profile "Save" fires PATCH /api/admin/members/1; sheet remains open (`dialog "Edit member"` persists in snapshot after save). 4. **Add member trigger → Create mode:** `dialog "Add member"` opens with heading "Add member" and four create-mode fields. Screenshots captured: - `.planning/phases/20-admin-member-editor-form-declutter/screenshots/admin-members-tab-decluttered.png` - `.planning/phases/20-admin-member-editor-form-declutter/screenshots/member-editor-edit-mode.png` - `.planning/phases/20-admin-member-editor-form-declutter/screenshots/member-editor-create-mode.png` - `.planning/phases/20-admin-member-editor-form-declutter/screenshots/profile-save-toast.png` ## Deviations from Plan ### Auto-fixed Issues None — plan executed exactly as written. ### Pre-existing Prettier Drift (out-of-scope cleanup) **Deviation:** `pnpm format:check` (repo-wide) flagged 17 pre-existing formatting violations in `docs/*.md`, `README.md`, `CLAUDE.md`, `apps/api/src/routes/admin.ts`, and other files not authored in this plan. **Action (Rule 3 — blocking CI gate):** Ran `pnpm format` to fix all violations. Staged and included in Task 3 commit to keep CI green. Confirmed the violations were pre-existing by checking git diff for files not created/modified by this plan. ## Known Stubs None. The editor is fully wired to live endpoints. The fastmailEmail field starts blank on edit (documented behavior — `GET /api/admin/members` does not return the stored email; the admin must re-enter it) but this is intentional per the plan spec and D-CONTEXT note. ## Threat Surface Scan No new network endpoints, auth paths, or file access patterns introduced. `MemberEditorSheet.tsx` is a pure client component wiring to existing Plan 20-01 endpoints behind `requireAdmin`. T-20-07, T-20-08, T-20-09 mitigations are active as documented in the component header. ## TDD Notes The plan specified `tdd="true"` for Tasks 1 and 2. The PWA has no unit-test harness for sheet components (no existing `*.test.tsx` for CredentialSheet or MemberEditorSheet — jsdom/RTL setup is not in scope for this phase). All behavioral verification was performed via playwright-cli interaction against the live dev stack (per CLAUDE.md convention: "playwright-cli skill to validate UI and workflows instead of asking the operator to check manually"). The 22 existing test files (275 tests) all pass — no regressions. ## Self-Check: PASSED - `apps/pwa/src/components/MemberEditorSheet.tsx` exists: FOUND - `apps/pwa/src/routes/AdminPage.tsx` modified: FOUND - Commit b125a69 exists: FOUND - Commit 9e6b004 exists: FOUND - Commit 9b62887 exists: FOUND - `grep -RnE 'Rotate|Add credential|Reset password' apps/pwa/src/components/MemberEditorSheet.tsx apps/pwa/src/routes/AdminPage.tsx` — 0 matches: PASS - `grep -q 'ResetPasswordSheet' apps/pwa/src/routes/AdminPage.tsx` — no match: PASS - `pnpm --filter @familysync/pwa test -- --run` — 275 passed: PASS