--- phase: 20-admin-member-editor-form-declutter plan: 03 type: execute wave: 2 depends_on: - "20-01" - "20-02" files_modified: - apps/pwa/src/components/MemberEditorSheet.tsx - apps/pwa/src/routes/AdminPage.tsx autonomous: true requirements: [] user_setup: [] must_haves: truths: - "Tapping a member row opens one editor sheet for all of that member's details" - "The editor has per-section saves: Profile (name + admin), Set new password, App password" - "The admin toggle initial state reflects the member's isAdmin; a last-admin demotion shows an inline error and reverts" - "Add member is collapsed behind a single trigger that opens the same sheet in create mode" - "The terms Rotate, Add credential, and the standalone Reset password button no longer appear" artifacts: - path: "apps/pwa/src/components/MemberEditorSheet.tsx" provides: "Member editor sheet (edit + create modes) with per-section saves" min_lines: 200 contains: "MemberEditorSheet" - path: "apps/pwa/src/routes/AdminPage.tsx" provides: "Tappable MemberRow with chevron + single Add member trigger; ResetPasswordSheet + inline add-form removed" contains: "MemberEditorSheet" key_links: - from: "apps/pwa/src/routes/AdminPage.tsx MemberRow" to: "apps/pwa/src/components/MemberEditorSheet.tsx" via: "row tap opens sheet in edit mode; Add member trigger opens create mode" pattern: "MemberEditorSheet" - from: "apps/pwa/src/components/MemberEditorSheet.tsx Profile save" to: "apps/pwa/src/api/client.ts updateMemberProfile" via: "updateMemberProfile(memberId, { displayName, isAdmin })" pattern: "updateMemberProfile" --- Rework the admin Members panel into the single-editor experience (D-01..D-07). Build a new `MemberEditorSheet.tsx` (one component, edit/create modes per D-07) with per-section saves (D-05), folding the standalone `ResetPasswordSheet` into a "Set new password" section (D-06) and retiring "Rotate" copy (D-06). Rework `AdminPage.tsx` so each `MemberRow` is a whole-row tap target with a trailing chevron (D-04), the per-row action-button cluster and the always-open inline Add-member form are removed, and a single "Add member" trigger opens the sheet in create mode (D-07). Verify the visual + interaction contract in 20-UI-SPEC.md with playwright-cli. Purpose: This is the user-facing payload of the phase — UI/glue work over the route from Plan 20-01 and the fetcher from Plan 20-02. Standard execution, verified in a real Chromium browser via the playwright-cli skill (project convention for desktop-runnable UI checks). Output: A unified member editor + decluttered Members panel matching the UI-SPEC copy and interaction contracts. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md @.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md @.planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md @apps/pwa/src/components/CredentialSheet.tsx @apps/pwa/src/routes/AdminPage.tsx ## Artifacts this phase produces (Plan 20-03) - NEW component `apps/pwa/src/components/MemberEditorSheet.tsx` — single sheet with a `mode: 'edit' | 'create'` prop; edit mode renders Profile / Set new password / App password sections with per-section saves; create mode renders the four-field add-member form - Reworked `MemberRow` in `apps/pwa/src/routes/AdminPage.tsx` — whole-row `role="button"` tap target, trailing `ChevronRight` affordance, inline "Admin" badge when `member.isAdmin`, action-button cluster removed - New "Add member" ghost trigger (lucide `Plus` prefix) in `AdminPage.tsx` - New lucide imports: `ChevronRight`, `Plus` (AdminPage); the sheet imports `Loader2` (existing) - REMOVED: `ResetPasswordSheet` component, the inline Add-member form + its local state, and the dual CredentialSheet/ResetPasswordSheet mounting from `AdminPage.tsx` Task 1: Build MemberEditorSheet.tsx (edit + create modes, per-section saves) apps/pwa/src/components/MemberEditorSheet.tsx - apps/pwa/src/components/CredentialSheet.tsx (full — copy the dialog scaffold: useFocusTrap wiring, handleClose+focus-return, Escape effect, focus-heading-on-open, phone/desktop sheet style object, h2 heading, email+password fields, "Validating against CalDAV…" Loader2 state, Fastmail device-tokens helper link, FAILURE_TEXT copy, saveCredential mutation; note the mode-discriminator + headingFor pattern) - apps/pwa/src/routes/AdminPage.tsx (the ResetPasswordSheet component ~lines 1375-1677: password+confirm+mismatch+length logic; the createMemberMutation + create-form fields ~lines 260-306 and ~443-662; the sectionLabelStyle ~lines 46-53; showToast usage ~lines 66-76; the openSheet/triggerRef capture pattern ~lines 252-258) - apps/pwa/src/hooks/useFocusTrap.ts and apps/pwa/src/hooks/useIsPhone.ts - apps/pwa/src/api/client.ts (updateMemberProfile + AdminMember from Plan 20-02; fetchCreateMember; fetchAdminResetPassword; saveCredential; the 'last-admin' / 'conflict' / 'mismatch' / 'short' sentinels) - .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md (Surface B, Copywriting Contract, Interaction Contracts, Accessibility Contract — the authoritative visual + copy contract) - .planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md ("MemberEditorSheet.tsx" section) - .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md (D-05, D-06, D-07; Claude's-discretion item on fastmailEmail prefill) Create apps/pwa/src/components/MemberEditorSheet.tsx as ONE component with a `mode: 'edit' | 'create'` prop (D-07 unification; `member` present implies edit). Props: `member?: AdminMember`, `mode`, `onClose`, `triggerRef`, and an `onToast(message)` callback (lift toast in AdminPage; pass success copy up). Copy the entire dialog scaffold from CredentialSheet (role="dialog", aria-modal, useFocusTrap on the dialog div, handleClose clearing form state + returning focus to triggerRef.current, Escape-to-close, focus the h2 on open, phone-vs-desktop sheet style with borderRadius 12px, maxWidth 480px, zIndex 301, padding var(--space-6), desktop maxHeight calc(100dvh - var(--space-8)) + overflowY auto). Use the backdrop overlay token `var(--color-overlay, rgba(0,0,0,0.32))` (per UI-SPEC, matching ResetPasswordSheet — NOT CredentialSheet's 0.4). headingFor(mode): edit -> "Edit member", create -> "Add member"; aria-label matches the h2. EDIT MODE — three sections separated by `border-top: 1px solid var(--color-border-subtle); margin: var(--space-6) 0`. Section headings use a `
` with sectionLabelStyle (13px/600/uppercase, --color-text-muted) NOT `

` (UI-SPEC Accessibility note). - Section 1 Profile (always): "Display name" text input (prefilled with member.displayName, min-height 44px) + an Admin toggle row rendered as `role="switch"` with `aria-checked`, `aria-label="Admin"`, label "Admin" + sub-label "Can access admin settings"; pill 44x24, --color-member-0 checked / --color-border unchecked, white thumb; initial state from `member.isAdmin`. Save button "Save". Mutation calls `updateMemberProfile(member.id, { displayName, isAdmin })`, invalidates `['admin','members']`, fires onToast("Profile saved."), KEEPS the sheet open (per-section, D-05). onError: if the error message is the `last-admin` sentinel, show inline "Cannot remove admin — at least one admin must remain." below the toggle AND revert the toggle to its previous value; otherwise "Something went wrong. Please try again." - Section 2 Set new password (render ONLY when `member.hasLocalCredential === true`): heading "Set new password", helper "Leave blank to keep the current password.", "New password" + "Confirm new password" fields (type=password, autoComplete="new-password", never prefilled). Save button "Set password". Client guards: mismatch -> "Passwords do not match."; < 8 chars -> "Password must be at least 8 characters." Mutation calls `fetchAdminResetPassword(member.id, newPassword)`, invalidates `['admin','members']`, fires onToast("Password updated."), keeps sheet open. Loader2 size 14 inline while pending. - Section 3 App password (always in edit mode): heading "App password", helper "Fastmail app password scoped to Calendars & Contacts (CalDAV)." with inline link "Get an app password" -> https://app.fastmail.com/settings/security/devicetokens (target=_blank rel=noopener noreferrer, --color-member-0). "Fastmail email" field (type=email, autoComplete="email"); "App password" field (type=password, autoComplete="new-password", never prefilled). For fastmailEmail prefill: `GET /members` does NOT currently return fastmailEmail, so the email field starts BLANK on edit (admin re-enters it) — document this in a code comment; do not invent a read of a field the API does not return. "Validating against CalDAV…" Loader2 16px in-flight state; failure copy "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." Save button "Save app password". Mutation calls `saveCredential` with `userId: member.id`, invalidates `['admin','members']` AND `['me']`, fires onToast("App password saved."), keeps sheet open. CREATE MODE — a single form, no dividers: Display name, Username, Initial password, Confirm password fields. Save button "Add member". Validate passwords match + >= 8 chars; map a 409/username conflict to "That username is already in use. Choose a different one." Mutation calls `fetchCreateMember`, invalidates `['admin','members']`, fires onToast("Member added."), then closes the sheet (create success closes; edit per-section saves do not). All save buttons: accent --color-member-0 background enabled / --color-border disabled, white text, min-height 44px, border-radius var(--space-1), Loader2 inline while pending. Cancel button: no background, --color-text-secondary, min-height 44px, calls handleClose. Inline errors: --color-destructive 13px, wired via aria-describedby on the relevant input. RETIRE "Rotate"/"Add credential"/"Reset password" — none of those literals appear in this file. cd /home/luc/projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -c 'role="switch"' apps/pwa/src/components/MemberEditorSheet.tsx && grep -RnE 'Rotate|Add credential|Reset password' apps/pwa/src/components/MemberEditorSheet.tsx; test $? -eq 1 - `apps/pwa/src/components/MemberEditorSheet.tsx` exists and the PWA typechecks (tsc --noEmit exit 0). - The file contains the exact copy strings "Edit member", "Add member", "Profile", "Set new password", "App password", "Save app password", "Can access admin settings" (UI-SPEC Copywriting Contract). - `grep -RnE 'Rotate|Add credential|Reset password' apps/pwa/src/components/MemberEditorSheet.tsx` returns NO matches (retired copy, D-06). - The Profile save's onError branches on the `last-admin` sentinel and renders "Cannot remove admin — at least one admin must remain." - Section 2 is gated on `member.hasLocalCredential === true`. - The admin control uses `role="switch"` with `aria-checked` (Accessibility Contract). - Per-section saves keep the sheet open; create-mode save closes it. MemberEditorSheet.tsx implements both modes with per-section saves, correct copy, the last-admin inline error, and typechecks. Task 2: Rework AdminPage MemberRow + Add-member trigger; remove old surfaces apps/pwa/src/routes/AdminPage.tsx - apps/pwa/src/routes/AdminPage.tsx (full — MemberRow ~lines 1151-1285 incl. avatar swatch, credential status badge, and the action-button cluster to remove; the inline Add-member form ~lines 443-662; the ResetPasswordSheet definition ~lines 1375-1677; the dual sheet mounting ~lines 1111-1137; create-form local state ~lines 94-98; CalendarRadioRow ~lines 1298-1318 for the role+onKeyDown Enter/Space template; openSheet/triggerRef ~lines 252-258) - apps/pwa/src/components/ListCard.tsx (lines ~120-145 — the trailing ChevronRight + "Shared" badge pattern to mirror for the chevron + "Admin" badge) - apps/pwa/src/components/CalendarShell.tsx (lines ~451-456 — the Plus-prefixed ghost button pattern for the Add-member trigger) - apps/pwa/src/components/MemberEditorSheet.tsx (the component from Task 1 — its props contract) - .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md (Surface A, Interaction Contracts, Accessibility Contract) - .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md (D-04, D-07) In apps/pwa/src/routes/AdminPage.tsx: (1) Import `{ ChevronRight, Plus }` from lucide-react and `MemberEditorSheet` from ../components/MemberEditorSheet.js. (2) Rework MemberRow into a single tappable surface: `role="button"`, `aria-label={`Edit ${displayName}`}`, `tabIndex={0}`, `cursor: pointer`, min-height 44px, onClick + onKeyDown (Enter/Space -> open editor for that member, capturing the row element into triggerRef via the existing openSheet pattern). KEEP the avatar swatch (var(--color-member-${colorIndex})) and the CheckCircle/AlertCircle credential status badge ("Credential set"/"No credential"). ADD a trailing `ChevronRight` (size 16, color var(--color-text-muted), aria-hidden, flexShrink 0, marginLeft var(--space-2)). ADD an inline "Admin" badge when `member.isAdmin` (12px/600, --color-member-0 text on --color-surface-dim, border-radius 4px, padding 2px 6px), placed between the status badge and the chevron, mirroring ListCard's "Shared" badge. REMOVE the entire action-button cluster (the "Rotate"/"Add credential" button and the "Reset password" button). (3) Add an "Add member" ghost trigger button below the member list: `` prefix, label "Add member", 1px solid var(--color-border), border-radius 8px, padding var(--space-3) var(--space-4), min-height 44px, --color-surface bg / --color-surface-dim hover; capture its ref into an `addMemberTriggerRef`; on click open MemberEditorSheet in create mode. Focus returns to this button on cancel/close. (4) Replace the dual CredentialSheet + ResetPasswordSheet mounting with a SINGLE `MemberEditorSheet` instance driven by sheet state (mode + selected member + triggerRef). Keep `showToast` in AdminPage and pass it as the sheet's `onToast` callback. (5) REMOVE: the entire inline Add-member form body (the "Local Accounts" add-form ~lines 443-662), the `ResetPasswordSheet` component definition (~lines 1375-1677), and all create-form local state (createDisplayName … createError ~lines 94-98) — these now live in MemberEditorSheet. Keep the two-tab AdminPage shell + roving-tabindex tabs intact (only the Members tab body changes). Use the empty/loading/error member-state copy from the UI-SPEC ("Loading members…", "Could not load members.", "No members yet", "Add a member to get started.") if those states are touched. cd /home/luc/projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -RnE '"Rotate"|>Rotate<|Add credential|Reset password' apps/pwa/src/routes/AdminPage.tsx; test $? -eq 1 && ! grep -q 'ResetPasswordSheet' apps/pwa/src/routes/AdminPage.tsx - PWA typechecks (tsc --noEmit exit 0). - `grep -RnE '"Rotate"|>Rotate<|Add credential|Reset password' apps/pwa/src/routes/AdminPage.tsx` returns NO matches (retired, D-06). - `grep -q 'ResetPasswordSheet' apps/pwa/src/routes/AdminPage.tsx` returns nothing (component removed, folded into the editor). - `grep -n 'MemberEditorSheet' apps/pwa/src/routes/AdminPage.tsx` shows the single sheet mounted. - MemberRow has `role="button"` + `aria-label` starting with "Edit " and a trailing ChevronRight. - A single "Add member" ghost trigger with a `Plus` icon exists; the inline always-open add-form is gone. AdminPage Members tab is a tappable list + chevron + single Add-member trigger wired to MemberEditorSheet; old action buttons, inline add-form, and ResetPasswordSheet removed. Task 3: Verify the interaction + visual contract with playwright-cli; pass CI gates apps/pwa/src/components/MemberEditorSheet.tsx, apps/pwa/src/routes/AdminPage.tsx - .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md (Interaction Contracts, Copywriting Contract) - .claude/skills/playwright-cli/ (the playwright-cli skill index) - /home/luc/.claude/projects/-home-luc-projects-familysync/memory/dev-bypass-feature-gating.md (how to reach the admin UI under DEV_AUTH_BYPASS) - /home/luc/.claude/projects/-home-luc-projects-familysync/memory/familysync-dev-stack-setup.md (how the dev stack runs on this box) - /home/luc/.claude/projects/-home-luc-projects-familysync/memory/ci-checks-conformance.md Bring up (or reuse) the host-side dev stack with DEV_AUTH_BYPASS so the admin Members tab is reachable (see the dev-bypass + dev-stack memory notes — the bypass user must be admin to see the admin route). Use the playwright-cli skill to drive desktop Chromium and OBSERVE: (a) the Members tab shows the decluttered list — no "Rotate"/"Add credential"/"Reset password" buttons, a single "Add member" trigger present; (b) tapping a member row opens the editor sheet titled "Edit member"; (c) the Profile section save fires the "Profile saved." toast and the sheet stays open; (d) the "Add member" trigger opens the same sheet titled "Add member" in create mode. Capture a screenshot of the editor for the SUMMARY. (iOS-Safari standalone behavior is out of scope here — desktop Chromium is the right surface.) Then run the full PWA CI gates (eslint + prettier + typecheck + the existing pwa vitest suite) and fix any violations. Commit: `feat(20-03): unify member editor + declutter admin members panel`. cd /home/luc/projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa exec eslint src/components/MemberEditorSheet.tsx src/routes/AdminPage.tsx && pnpm exec prettier --check apps/pwa/src/components/MemberEditorSheet.tsx apps/pwa/src/routes/AdminPage.tsx && pnpm --filter @familysync/pwa test -- --run - playwright-cli observation confirms: no retired button labels in the Members tab; row tap opens an "Edit member" sheet; "Add member" trigger opens an "Add member" sheet; a Profile save shows the "Profile saved." toast (screenshot captured for the SUMMARY). - eslint + prettier + typecheck pass for both modified PWA files. - The existing PWA vitest suite passes (`pnpm --filter @familysync/pwa test -- --run` exit 0). - Change committed with a `feat(20-03):` message. The unified editor + decluttered panel are observed working in a real browser and all PWA CI gates pass. ## Trust Boundaries | Boundary | Description | |----------|-------------| | PWA admin UI -> /api/admin | The editor's saves cross into the admin surface; the server's `requireAdmin` + the Plan 20-01 last-admin guard are the real boundaries. Client toggle state is non-authoritative (existing pattern). | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-20-07 | Information Disclosure | password / app-password fields | mitigate | All password fields are write-only: never prefilled, `autoComplete="new-password"`, never logged (preserves T-10-15/16). The app-password email field starts blank on edit (API does not return it). | | T-20-08 | Tampering | CalDAV credential | mitigate | App-password save routes through the existing `saveCredential` -> server-side CalDAV validation before store; invalid password surfaces the CalDAV-failure copy, nothing stored. | | T-20-09 | Elevation of Privilege (UI bypass) | admin toggle | mitigate | Toggle is cosmetic; the demotion guard (409) is enforced server-side (Plan 20-01). On 409 the UI shows the inline error and reverts — no client-side override of the guard. | | T-20-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages installed; lucide-react `ChevronRight`/`Plus` are already project dependencies. No legitimacy checkpoint required. | - `pnpm --filter @familysync/pwa exec tsc --noEmit` — PWA typechecks. - `grep -RnE 'Rotate|Add credential|Reset password' apps/pwa/src/components/MemberEditorSheet.tsx apps/pwa/src/routes/AdminPage.tsx` — no matches (retired copy). - `grep -q 'ResetPasswordSheet' apps/pwa/src/routes/AdminPage.tsx` — empty (folded into editor). - playwright-cli: row tap opens "Edit member"; Add-member trigger opens "Add member"; Profile save -> "Profile saved." toast. - PWA eslint + prettier + vitest gates pass. - One sheet edits all of a member's details (name, admin, local password, app password) with per-section saves. - The standalone Reset password button and the "Rotate"/"Add credential" buttons are gone; the inline add-form is collapsed behind a single trigger. - The admin toggle reflects isAdmin and a last-admin demotion shows the inline error and reverts. - Verified in a real browser; all PWA CI gates pass. Create `.planning/phases/20-admin-member-editor-form-declutter/20-03-SUMMARY.md` when done.