diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 2f42a1b..c0c3206 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -122,4 +122,4 @@ This document evolves at phase transitions and milestone boundaries. --- -_Last updated: 2026-06-14 — Phase 11 (Per-Event Reminders) complete; CAL-13/14 + NOTIF-04/05/06 validated (live round-trip deferred, 999.19)_ +_Last updated: 2026-06-18 — Phase 20 (Admin Member Editor & Form Declutter) complete; single member-editor sheet (D-01..D-07) over new PATCH /api/admin/members/:id with last-admin guard; "Rotate"/"Reset password" copy retired._ diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 201938f..a973106 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -453,6 +453,7 @@ Plans: | 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 | | 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 | | 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 | +| 20. Admin Member Editor & Declutter | v1.1 | 3/3 | Complete | 2026-06-18 | ## Backlog @@ -752,3 +753,20 @@ Plans: - [x] 19-04-PLAN.md — PWA: LoginPage + BrandSlot + App.tsx gate + client.ts + AdminPage + SettingsSheet (AUTH-LOCAL-12/13/14/15) - [x] 19-05-PLAN.md — Dev-bypass Option C + break-glass CLI + harness/CI rework + login.spec.ts (AUTH-LOCAL-11/16) + +### 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 — clicking a member's name or an edit button opens a member-detail editor where an admin modifies all of that member's details in one place: display name, local-login password, and the Fastmail/CalDAV app password (calendar credential) — using clear, non-jargon labels that retire the confusing "Rotate" term. Also collapse the "Add member" section so its input fields are hidden behind a single "Add member" trigger by default, decluttering the panel. Client-side AdminPage + CredentialSheet rework over the existing `/api/admin` endpoints; no new auth/authorization boundary (seeded by the gripe that "Rotate" for the app password is not intuitive). +**Requirements**: TBD (refine in /gsd-discuss-phase 20 — open scope: which fields count as "all" (color swatch? admin toggle? OIDC link?), whether to keep any standalone reset-password flow, and the exact edit affordance — clickable name vs. row edit button) +**Depends on:** Phase 19 +**Plans:** 3/3 plans complete + +Plans: +**Wave 1** + +- [x] 20-01-PLAN.md — Server: PATCH /api/admin/members/:id (displayName + is_admin) with last-admin demotion guard (TDD) + isAdmin in GET /members +- [x] 20-02-PLAN.md — PWA API client: AdminMember.isAdmin field + updateMemberProfile fetcher (last-admin sentinel) + +**Wave 2** *(blocked on Wave 1 completion)* + +- [x] 20-03-PLAN.md — PWA: unified MemberEditorSheet (edit/create, per-section saves) + decluttered tappable Members panel; retire Rotate/Reset-password buttons diff --git a/.planning/STATE.md b/.planning/STATE.md index 7c20b3b..b9e0a75 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,18 +2,18 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: Operability & Polish -current_phase: 18 -current_phase_name: Auto timezone detection and ability to change timezone -status: "Phase 17 shipped — PR #24" -stopped_at: Phase 17 UI-SPEC approved -last_updated: "2026-06-18T19:19:22.951Z" +current_phase: 999.1 +current_phase_name: BACKLOG +status: "Phase 20 shipped — PR #25" +stopped_at: Phase 20 UI-SPEC approved +last_updated: "2026-06-19T00:26:05.257Z" last_activity: 2026-06-18 progress: - total_phases: 26 - completed_phases: 12 - total_plans: 55 - completed_plans: 54 - percent: 46 + total_phases: 27 + completed_phases: 13 + total_plans: 58 + completed_plans: 57 + percent: 48 --- # Project State @@ -23,13 +23,13 @@ progress: See: .planning/PROJECT.md (updated 2026-06-16) **Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store -**Current focus:** Phase 17 — ui-optimization-polish +**Current focus:** Phase 20 — admin-member-editor-form-declutter ## Current Position -Phase: 18 — Auto timezone detection and ability to change timezone +Phase: 999.1 — Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG) Plan: Not started -Status: Phase 17 shipped — PR #24 +Status: Phase 20 shipped — PR #25 Last activity: 2026-06-18 ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) @@ -40,7 +40,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa **Velocity:** -- Total plans completed: 66 +- Total plans completed: 69 - Average duration: - - Total execution time: 0 hours @@ -61,6 +61,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa | 12 | 7 | - | - | | 19 | 5 | - | - | | 17 | 6 | - | - | +| 20 | 3 | - | - | **Recent Trend:** @@ -120,6 +121,7 @@ _Updated after each plan completion_ | Phase 12 P02 | 15 | 3 tasks | 6 files | | Phase 12 P03 | 8 | 1 tasks | 2 files | | Phase 12 P06 | 8 | 2 tasks tasks | 3 files files | +| Phase 20 P03 | 10 | 3 tasks | 3 files | ## Accumulated Context @@ -215,6 +217,7 @@ Recent decisions affecting current work: - **Phase 16 added (2026-06-12, /gsd-phase):** CI Dependency Audit, Security Checks & Image Hygiene — extend the Phase 8 Gitea CI workflow with outdated-dependency reporting (`pnpm outdated`), a vulnerability audit (`pnpm audit` at a chosen severity), and a baseline of additional security checks (secret scan / image scan). User requested a 16 integer phase (not a decimal insert) — they've been running independent/CI phases ahead of the admin chain. **Depends on Phase 8; independent of the admin chain (10–12).** Scope still needs definition — run /gsd-discuss-phase 16. Milestone window now Phases 7–16. - **Backlog 999.17 folded into Phase 16 + removed (2026-06-12, /gsd-phase):** the dev/prod image-boundary item (confine `DEV_AUTH_BYPASS` to dev via build/boot-time enforcement; ensure no `.env`/secrets/encryption keys/`DEV_USER` seed/family data ship in published images; CI assertion in the publish job) was pulled into Phase 16 — shared CI surface and overlapping secret/image scanning made a separate phase redundant. The 999.17 backlog entry + its phase dir were **deleted** (not retained-for-history) since the scope now lives in an active phase; this also clears the recycled-number collision with Phase 15's historical "promoted from 999.17" provenance (the markdown-lint item that became Phase 15 had reused 999.17 first). - **Phase 18 added (2026-06-13, /gsd-phase):** Auto timezone detection and ability to change timezone — let the app auto-detect the household timezone and allow changing it. User invoked `/gsd-phase --insert 18` but Phase 18 didn't exist (17 was the last integer phase), so after confirmation it was added as an integer phase at the end of the milestone, not a decimal insert. Motivated by the Phase 11 all-day-reminder dependency on a correct server `TZ` (all-day reminders fire at 9 AM local, computed from `process.env.TZ`). Scope still needs definition — run /gsd-discuss-phase 18. Milestone window now Phases 7–18. +- **Phase 20 added (2026-06-18, /gsd-phase):** Admin Member Editor & Form Declutter — replace the per-member-row action buttons (Rotate/Add credential + Reset password) with a single edit affordance (click member name or an edit button) opening a member-detail editor for all of a member's details (display name, local-login password, Fastmail/CalDAV app password) with clear non-jargon labels that retire "Rotate"; and collapse the "Add member" form behind a single trigger by default. Seeded by a UX gripe during Phase 17 verification that "Rotate" for the app password is unintuitive. Client-side AdminPage + CredentialSheet rework over existing `/api/admin` endpoints; no new authorization boundary. Scope still needs definition — run /gsd-discuss-phase 20. Milestone window now Phases 7–20. ### Pending Todos @@ -272,9 +275,9 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-18T15:00:30.986Z -Stopped at: Phase 17 UI-SPEC approved -Resume file: .planning/phases/17-ui-optimization-polish/17-UI-SPEC.md +Last session: 2026-06-18T21:40:44.704Z +Stopped at: Phase 20 UI-SPEC approved +Resume file: .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md ## Operator Next Steps diff --git a/.planning/phases/20-admin-member-editor-form-declutter/.gitkeep b/.planning/phases/20-admin-member-editor-form-declutter/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/.gitkeep @@ -0,0 +1 @@ + diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-01-PLAN.md b/.planning/phases/20-admin-member-editor-form-declutter/20-01-PLAN.md new file mode 100644 index 0000000..821489f --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-01-PLAN.md @@ -0,0 +1,184 @@ +--- +phase: 20-admin-member-editor-form-declutter +plan: 01 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - apps/api/src/routes/admin.ts + - apps/api/tests/routes/admin.test.ts +autonomous: true +requirements: [] +must_haves: + truths: + - "An admin can update a member's display name and admin flag through one route behind requireAdmin" + - "Demoting the only remaining admin is rejected with a 409 and the member stays admin" + - "Self-demotion succeeds while another admin exists" + - "GET /members returns each member's isAdmin so the editor toggle has correct initial state" + artifacts: + - path: "apps/api/src/routes/admin.ts" + provides: "PATCH /api/admin/members/:id member-profile update route + isAdmin in GET /members select" + contains: "members/:id" + - path: "apps/api/tests/routes/admin.test.ts" + provides: "RED tests for last-admin guard + happy-path profile update + isAdmin in GET /members" + contains: "last-admin" + key_links: + - from: "apps/api/src/routes/admin.ts PATCH /members/:id" + to: "apps/api/src/db/schema.ts users.isAdmin" + via: "last-admin count query + partial update set()" + pattern: "users\\.isAdmin" +--- + + +Add the one new server route this phase needs: `PATCH /api/admin/members/:id`, accepting `displayName` and/or `isAdmin`, behind the existing `requireAdmin` boundary (D-02), and enforce the D-03 last-admin demotion guard (reject 409 when demoting the only admin). Also surface `isAdmin` from `GET /api/admin/members` so the PWA editor's admin toggle has a correct initial state. + +Purpose: This is the only genuinely new backend logic in Phase 20. The last-admin guard is a lockout-safety invariant with a defined request/response contract — written test-first (RED -> GREEN -> REFACTOR). +Output: A tested `PATCH /members/:id` route + `isAdmin` field on `GET /members`, both reachable only by admins. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.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 + + + +## Artifacts this phase produces (Plan 20-01) +- `PATCH /api/admin/members/:id` route handler in `apps/api/src/routes/admin.ts` (member-profile update: displayName and/or isAdmin) +- `updateMemberSchema` Zod schema in `apps/api/src/routes/admin.ts` +- `isAdmin` field added to the `GET /api/admin/members` select + mapped member object in `apps/api/src/routes/admin.ts` +- New `describe` block for `PATCH /members/:id` in `apps/api/tests/routes/admin.test.ts` (last-admin guard, happy path, auth boundary, validation, 404) + + + + + + Task 1: RED — failing tests for the member-profile route + isAdmin read + apps/api/tests/routes/admin.test.ts + + - apps/api/tests/routes/admin.test.ts (full — copy the jsonRequest helper, admin/non-admin session setup, member-create flow at lines ~920-990, and the existing password-reset test at line ~956 as the structural analog) + - apps/api/src/routes/admin.ts (lines 95-130 GET /members handler; lines 225-269 POST /members/:id/password as the route analog; lines 75-92 noEchoHook + parsePositiveIntParam) + - apps/api/src/auth/user.ts (lines 140-156 — the admin-count query to adapt for the guard) + - apps/api/src/db/schema.ts (line ~56 users.isAdmin column) + - .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md (D-02, D-03) + + + - Test A (happy path displayName): PATCH /api/admin/members/:id with { displayName: 'New Name' } as an admin -> 200; GET /members reflects the new displayName. + - Test B (happy path isAdmin promote): a non-admin member PATCH'd with { isAdmin: true } -> 200; GET /members shows isAdmin true for that member. + - Test C (last-admin guard): with exactly ONE admin in the DB, PATCH that admin with { isAdmin: false } -> 409, body has an `error` string; GET /members still shows that member isAdmin true (unchanged). + - Test D (self-demotion allowed when another admin exists): seed two admins, PATCH one with { isAdmin: false } -> 200; GET /members shows one admin remaining. + - Test E (auth boundary): PATCH /api/admin/members/:id as a non-admin session -> 403 (inherits requireAdmin; no second guard). + - Test F (validation): PATCH with { isAdmin: 'yes' } (wrong type) -> 400 { error: 'Invalid request' } via noEchoHook; malformed :id (e.g. '1abc') -> 400. + - Test G (not found): PATCH a non-existent member id -> 404. + - Test H (GET isAdmin field): GET /api/admin/members as admin -> each member object includes a boolean `isAdmin` field. + + + Add a new `describe` block to apps/api/tests/routes/admin.test.ts for `PATCH /api/admin/members/:id` plus one assertion in the existing GET /members test for the `isAdmin` field. Reuse the file's existing `jsonRequest('PATCH', path, body)` helper, admin/non-admin session injection, and the member-create helper used by the password-reset test (~line 956). Seed admins by inserting `users` rows with `isAdmin: true`. Assert response status codes and that `GET /members` reflects (or does NOT reflect, for the guard case) the change. Use the real-DB integration pattern already in this file (DB_HOST=127.0.0.1). Run the suite and confirm these new tests FAIL because neither the route nor the `isAdmin` field exists yet. Do NOT implement the route in this task. Commit: `test(20-01): add failing tests for member-profile update + last-admin guard + isAdmin read`. + + + cd apps/api && DB_HOST=127.0.0.1 pnpm vitest run tests/routes/admin.test.ts 2>&1 | grep -Ei 'fail|members/:id' | head + + + - New tests for PATCH /members/:id exist in apps/api/tests/routes/admin.test.ts and reference both `displayName` and `isAdmin`. + - Running the suite shows the new tests FAILING (route 404 / no isAdmin field) — RED confirmed. + - A test asserts a 409 for last-admin demotion and a separate test asserts 200 self-demotion with a second admin present. + - Commit message starts with `test(20-01):`. + + The new tests are committed and fail for the right reason (route + field not implemented). + + + + Task 2: GREEN — implement PATCH /members/:id with last-admin guard + isAdmin in GET /members + apps/api/src/routes/admin.ts + + - apps/api/src/routes/admin.ts (lines 95-130 GET /members; lines 225-269 POST /members/:id/password analog; line 47 requireAdmin mount; lines 75-92 noEchoHook + parsePositiveIntParam; line 28 eq/sql imports) + - apps/api/src/auth/user.ts (lines 140-156 admin-count query) + - .planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md ("NEW PATCH /members/:id" section — route handler shape + last-admin guard excerpt) + + + In apps/api/src/routes/admin.ts: + (1) Add `isAdmin: users.isAdmin` to the `GET /members` select and `isAdmin: row.isAdmin` to the mapped member object (no join change — `users.isAdmin` is a base-table column). + (2) Add a Zod schema `updateMemberSchema` = object with `displayName` (string min 1 max 256, optional) and `isAdmin` (boolean, optional). + (3) Register `adminRouter.patch('/members/:id', zValidator('json', updateMemberSchema, noEchoHook), handler)`. The router-wide `requireAdmin` (line 47) already protects it — add NO second guard (D-02). + (4) Handler: parse the id with the existing `parsePositiveIntParam` (400 on null). Verify the target `users` row exists (404 if not). For the last-admin guard (D-03): when `isAdmin === false` is requested AND the target is currently an admin, run the admin-count query (`COUNT(*)` over `users` WHERE `users.isAdmin` is true, adapted from auth/user.ts:151-156) and return 409 `{ error: 'Cannot remove the last admin' }` when count is at most 1. Otherwise build a partial `set({ ... })` from whichever of `displayName`/`isAdmin` is present and `db.update(users)...where(eq(users.id, targetId))`. Mirror the password route's try/catch -> 503 fallback. Return 200 `{ ok: true }`. Apply `noEchoHook` for consistency. Do NOT log the request body. + Run the suite; all Task 1 tests must pass. Commit: `feat(20-01): add PATCH /members/:id member-profile update with last-admin guard`. + + + cd apps/api && DB_HOST=127.0.0.1 pnpm vitest run tests/routes/admin.test.ts 2>&1 | tail -20 + + + - `grep -n "patch('/members/:id'" apps/api/src/routes/admin.ts` returns the new route registration. + - `grep -nE "isAdmin: *users\.isAdmin" apps/api/src/routes/admin.ts` confirms isAdmin added to GET /members select. + - All new Task 1 tests pass (GREEN); the last-admin demotion test returns 409 and the member stays admin. + - No second `requireAdmin` call added in the PATCH handler (boundary inherited per D-02). + - Commit message starts with `feat(20-01):`. + + PATCH /members/:id and the isAdmin read field both implemented; full admin.test.ts suite green. + + + + Task 3: REFACTOR — tidy + pass CI gates + apps/api/src/routes/admin.ts + + - apps/api/src/routes/admin.ts (the new route + GET /members edits from Task 2) + - /home/luc/.claude/projects/-home-luc-projects-familysync/memory/MEMORY.md ("CI checks conformance" entry) + + + Review the new route for duplication with the password route (the shared id-parse / existence-check shape is fine to keep inline — do not over-extract). Ensure the route's doc-header banner comment matches the style of the sibling routes' headers (the file documents each route in a banner comment). Run the API CI gates locally: typecheck, eslint, prettier. Fix any violations. Commit (only if changes): `refactor(20-01): tidy member-profile route + pass api gates`. + + + cd /home/luc/projects/familysync && pnpm --filter @familysync/api exec tsc --noEmit && pnpm --filter @familysync/api exec eslint src/routes/admin.ts && pnpm exec prettier --check apps/api/src/routes/admin.ts apps/api/tests/routes/admin.test.ts + + + - typecheck passes (tsc --noEmit exit 0). + - eslint passes on apps/api/src/routes/admin.ts (exit 0). + - prettier --check passes on both modified files. + - Full admin.test.ts suite still green. + + All API CI gates pass locally for the modified files; suite green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client -> /api/admin | Untrusted admin-session input crosses here; already guarded by router-wide `requireAdmin` (line 47). No NEW boundary added (D-02). | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-20-01 | Elevation of Privilege | PATCH /members/:id isAdmin toggle | mitigate | Route inherits router-wide `requireAdmin`; no second/weaker guard added. Test E asserts 403 for non-admin. | +| T-20-02 | Denial of Service (self-lockout) | last-admin demotion | mitigate | D-03 guard: count admins, reject 409 when demoting the only admin (Test C). Break-glass CLI (19-D-13) remains true recovery path. | +| T-20-03 | Tampering | malformed :id / wrong-type body | mitigate | `parsePositiveIntParam` rejects non-positive-int ids (400); `updateMemberSchema` + `noEchoHook` reject wrong types as 400 `{ error: 'Invalid request' }` (Test F). | +| T-20-04 | Information Disclosure | error echo on invalid input | mitigate | `noEchoHook` returns only `{ error: 'Invalid request' }`; request body never logged (preserves T-10-15/16 posture). | + + + +- `cd apps/api && DB_HOST=127.0.0.1 pnpm vitest run tests/routes/admin.test.ts` — full admin suite green including new PATCH tests. +- `grep -n "patch('/members/:id'" apps/api/src/routes/admin.ts` — route registered. +- Last-admin demotion returns 409; member remains admin in a follow-up GET. +- API typecheck + eslint + prettier gates pass. + + + +- PATCH /api/admin/members/:id updates displayName and/or isAdmin behind requireAdmin. +- Demoting the only admin returns 409 and leaves the admin flag set. +- Self-demotion with a second admin present returns 200. +- GET /api/admin/members returns a boolean `isAdmin` per member. +- All API CI gates pass for the modified files. + + + +Create `.planning/phases/20-admin-member-editor-form-declutter/20-01-SUMMARY.md` when done. + diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-01-SUMMARY.md b/.planning/phases/20-admin-member-editor-form-declutter/20-01-SUMMARY.md new file mode 100644 index 0000000..3e36069 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-01-SUMMARY.md @@ -0,0 +1,120 @@ +--- +phase: 20-admin-member-editor-form-declutter +plan: "01" +subsystem: api/admin +status: complete +tags: [tdd, backend, admin, member-profile, last-admin-guard] +dependency_graph: + requires: [] + provides: + - "PATCH /api/admin/members/:id (member-profile update: displayName and/or isAdmin)" + - "isAdmin field on GET /api/admin/members response" + affects: + - apps/api/src/routes/admin.ts + - apps/api/tests/routes/admin.test.ts +tech_stack: + added: [] + patterns: + - "Last-admin guard via COUNT(*) query before demoting the only admin (D-03)" + - "Partial update via whichever fields are present in updateMemberSchema" + - "noEchoHook + parsePositiveIntParam reuse for new PATCH route" +key_files: + created: [] + modified: + - apps/api/src/routes/admin.ts + - apps/api/tests/routes/admin.test.ts +decisions: + - "Use PATCH verb for the member-profile update route (idiomatic REST for partial update)" + - "D-03 guard uses COUNT(*) on users.isAdmin — adapted from auth/user.ts:151-156 pattern" + - "noEchoHook applied to PATCH route for consistency even though body has no sensitive data" + - "Test D: switch currentDevUserId to adminId2 for GET verification after self-demotion (adminId1 is no longer admin post-PATCH)" +metrics: + duration: "4m" + completed: "2026-06-18" + tasks_completed: 3 + files_changed: 2 +--- + +# Phase 20 Plan 01: Member-profile update route + isAdmin read Summary + +PATCH /api/admin/members/:id with displayName/isAdmin partial update, D-03 last-admin guard (409), and isAdmin added to GET /members — implemented test-first. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | RED — failing tests for member-profile route + isAdmin read | a0a82ac | apps/api/tests/routes/admin.test.ts | +| 2 | GREEN — implement PATCH /members/:id + isAdmin in GET /members | bc48632 | apps/api/src/routes/admin.ts, apps/api/tests/routes/admin.test.ts | +| 3 | REFACTOR — tidy + pass CI gates | (no changes needed) | — | + +## What Was Built + +- **`PATCH /api/admin/members/:id`** route in `apps/api/src/routes/admin.ts`: + - Accepts `{ displayName?: string; isAdmin?: boolean }` via `updateMemberSchema` + - Protected by router-wide `requireAdmin` (no second guard — D-02) + - `parsePositiveIntParam` rejects malformed ids → 400 + - Existence check → 404 for unknown member ids + - D-03 last-admin guard: when demoting the only admin → 409 `{ error: 'Cannot remove the last admin' }` + - Self-demotion with a second admin present → 200 + - Partial `set()` from whichever fields are present; try/catch 503 fallback + - `noEchoHook` applied per T-20-04 consistency posture +- **`isAdmin` field** added to `GET /api/admin/members` select and mapped response object + +## Test Coverage (8 scenarios, all passing) + +| Test | Scenario | Status | +|------|----------|--------| +| A | displayName update → 200; GET reflects change | GREEN | +| B | isAdmin promote → 200; GET shows isAdmin true | GREEN | +| C | Last-admin demotion → 409; member stays admin | GREEN | +| D | Self-demotion with second admin → 200; one admin remains | GREEN | +| E | Non-admin PATCH → 403 (requireAdmin boundary) | GREEN | +| F | Wrong-type body → 400 Invalid request; malformed :id → 400 | GREEN | +| G | Non-existent member id → 404 | GREEN | +| H | GET /members includes boolean isAdmin per member | GREEN | + +Full suite: **44 tests passed, 0 failed**. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Test D GET called with demoted user** +- **Found during:** Task 2 (GREEN run) +- **Issue:** Test D called `GET /members` while `currentDevUserId` was still `adminId1`, who had just been demoted — resulting in 403 instead of 200 for the verification GET +- **Fix:** Switched `currentDevUserId = adminId2` before the GET call so the verification uses the remaining admin's session +- **Files modified:** apps/api/tests/routes/admin.test.ts +- **Commit:** bc48632 + +## CI Gates + +All gates pass for modified files: +- `tsc --noEmit`: pass +- `eslint src/routes/admin.ts`: pass +- `prettier --check`: pass (both files) + +## TDD Gate Compliance + +- RED gate commit: `a0a82ac` (`test(20-01): ...`) — 7 tests failing for right reasons +- GREEN gate commit: `bc48632` (`feat(20-01): ...`) — all 44 tests passing +- REFACTOR: no code changes needed — code was already clean from GREEN + +## Known Stubs + +None. + +## Threat Flags + +None — no new network surfaces beyond the planned PATCH route. All T-20-xx mitigations applied as specified. + +## Self-Check: PASSED + +| Check | Result | +|-------|--------| +| apps/api/src/routes/admin.ts | FOUND | +| apps/api/tests/routes/admin.test.ts | FOUND | +| 20-01-SUMMARY.md | FOUND | +| Commit a0a82ac (RED) | FOUND | +| Commit bc48632 (GREEN) | FOUND | +| PATCH route registered | FOUND (line 230) | +| isAdmin in GET /members select | FOUND (line 109) | diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-02-PLAN.md b/.planning/phases/20-admin-member-editor-form-declutter/20-02-PLAN.md new file mode 100644 index 0000000..326bed2 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-02-PLAN.md @@ -0,0 +1,141 @@ +--- +phase: 20-admin-member-editor-form-declutter +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/pwa/src/api/client.ts +autonomous: true +requirements: [] +must_haves: + truths: + - "The PWA can call the member-profile update route and receive a typed result" + - "AdminMember carries isAdmin so the editor toggle can show the correct initial state" + - "A last-admin demotion 409/422 from the server is surfaced as a distinguishable sentinel error" + artifacts: + - path: "apps/pwa/src/api/client.ts" + provides: "updateMemberProfile fetcher + isAdmin on AdminMember" + contains: "updateMemberProfile" + key_links: + - from: "apps/pwa/src/api/client.ts updateMemberProfile" + to: "apps/api/src/routes/admin.ts PATCH /members/:id" + via: "fetch PATCH /api/admin/members/:id" + pattern: "api/admin/members/" +--- + + +Extend the PWA API client (`apps/pwa/src/api/client.ts`) with: the `isAdmin: boolean` field on the `AdminMember` type, and a new `updateMemberProfile(memberId, { displayName?, isAdmin? })` fetcher that calls `PATCH /api/admin/members/:id` (the route created in Plan 20-01) and maps the D-03 last-admin 409/422 to a distinguishable sentinel error. + +Purpose: Decouples the PWA editor (Plan 20-03) from the wire shape. This is a thin, single-file, glue-code change with no business logic of its own — standard (non-TDD) execution. +Output: A typed `updateMemberProfile` fetcher + `AdminMember.isAdmin` field consumed by Plan 20-03. + + + +@$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 + + + +## Artifacts this phase produces (Plan 20-02) +- `isAdmin: boolean` field added to the `AdminMember` interface in `apps/pwa/src/api/client.ts` +- `updateMemberProfile(memberId, body)` fetcher in `apps/pwa/src/api/client.ts` calling `PATCH /api/admin/members/:id` +- A `'last-admin'` sentinel `Error` thrown on 409/422 (mirrors the existing `'conflict'` sentinel pattern) + + + + + + Task 1: Add AdminMember.isAdmin + updateMemberProfile fetcher + apps/pwa/src/api/client.ts + + - apps/pwa/src/api/client.ts (lines ~560-575 AdminMember interface; lines ~184-234 fetchCreateMember + fetchAdminResetPassword as the fetcher analog; the SessionExpiredError + handleAuthResponse conventions used by every fetcher; the existing 'conflict' sentinel at line ~206) + - .planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md ("apps/pwa/src/api/client.ts" section — type + fetcher excerpts) + - .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md (D-02, D-03) + + + - `AdminMember` now has a required `isAdmin: boolean` field. + - `updateMemberProfile(7, { displayName: 'X' })` issues `PATCH /api/admin/members/7` with a JSON body, `credentials: 'include'`, `redirect: 'manual'`. + - A 401 or `opaqueredirect` response throws `SessionExpiredError` (existing convention). + - A 409 or 422 response throws `new Error('last-admin')` (sentinel the editor branches on). + - Any other non-ok response throws a generic error. + - A 200 resolves void. + + + In apps/pwa/src/api/client.ts: + (1) Add `isAdmin: boolean;` to the `AdminMember` interface (after `color`, before `hasCredential`). + (2) Add an exported async function `updateMemberProfile(memberId: number, body: { displayName?: string; isAdmin?: boolean }): Promise`. Use the same `fetch` shape as `fetchAdminResetPassword`: method `'PATCH'` to `/api/admin/members/${memberId}`, `Content-Type: application/json`, `credentials: 'include'`, `redirect: 'manual'`, JSON-stringified body. Reuse the file's existing session-expiry handling (`res.type === 'opaqueredirect' || res.status === 401` -> `SessionExpiredError`). Map `res.status === 409 || res.status === 422` to `throw new Error('last-admin')`. Throw a generic error on any other non-ok status. The verb MUST be `PATCH` to match the Plan 20-01 route. + + + cd /home/luc/projects/familysync && grep -n "updateMemberProfile" apps/pwa/src/api/client.ts && grep -nE "isAdmin: *boolean" apps/pwa/src/api/client.ts && pnpm --filter @familysync/pwa exec tsc --noEmit + + + - `grep -n "updateMemberProfile" apps/pwa/src/api/client.ts` returns the exported fetcher. + - `grep -n "PATCH" apps/pwa/src/api/client.ts` shows the new fetcher uses the PATCH verb on `/api/admin/members/`. + - `AdminMember` interface includes `isAdmin: boolean`. + - The 409/422 branch throws an Error whose message is the literal `last-admin` sentinel. + - `pnpm --filter @familysync/pwa exec tsc --noEmit` exits 0. + + AdminMember.isAdmin + updateMemberProfile exist, typed, and the PWA typechecks. + + + + Task 2: Pass PWA CI gates + apps/pwa/src/api/client.ts + + - apps/pwa/src/api/client.ts (the edits from Task 1) + - /home/luc/.claude/projects/-home-luc-projects-familysync/memory/MEMORY.md ("CI checks conformance" entry) + + + Run the PWA eslint + prettier gates on the modified file and fix any violations. Commit: `feat(20-02): add updateMemberProfile fetcher + AdminMember.isAdmin`. + + + cd /home/luc/projects/familysync && pnpm --filter @familysync/pwa exec eslint src/api/client.ts && pnpm exec prettier --check apps/pwa/src/api/client.ts + + + - eslint passes on apps/pwa/src/api/client.ts (exit 0). + - prettier --check passes on apps/pwa/src/api/client.ts. + - Change committed with a `feat(20-02):` message. + + PWA lint + format gates pass for client.ts; change committed. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| PWA -> /api/admin | Client fetch crosses into the admin surface; server-side `requireAdmin` is the real boundary (unchanged). The client merely calls it. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-20-05 | Spoofing (stale session) | updateMemberProfile fetch | mitigate | Reuses the file's `SessionExpiredError` on 401/opaqueredirect, triggering the existing re-auth flow — no silent failure. | +| T-20-06 | Elevation of Privilege (client trust) | last-admin sentinel | accept | The 409/422 guard is enforced server-side (Plan 20-01); the client only surfaces it. Client-side toggle state is non-authoritative by design (existing pattern: "isAdmin drives nav visibility; real boundary is server-side"). | + + + +- `grep -n "updateMemberProfile" apps/pwa/src/api/client.ts` — fetcher present. +- `AdminMember` has `isAdmin: boolean`. +- PWA typecheck + eslint + prettier pass on client.ts. + + + +- `updateMemberProfile` calls `PATCH /api/admin/members/:id` and maps 409/422 to a `last-admin` sentinel. +- `AdminMember.isAdmin` exists and is typed boolean. +- PWA CI gates pass for the modified file. + + + +Create `.planning/phases/20-admin-member-editor-form-declutter/20-02-SUMMARY.md` when done. + diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-02-SUMMARY.md b/.planning/phases/20-admin-member-editor-form-declutter/20-02-SUMMARY.md new file mode 100644 index 0000000..0e80e8f --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-02-SUMMARY.md @@ -0,0 +1,109 @@ +--- +phase: 20-admin-member-editor-form-declutter +plan: "02" +subsystem: pwa-api-client +tags: [api-client, types, fetcher, admin, tdd] +status: complete + +dependency_graph: + requires: + - "20-01: PATCH /api/admin/members/:id route (Plan 20-01, parallel worktree)" + provides: + - "updateMemberProfile fetcher consumed by Plan 20-03 MemberEditorSheet" + - "AdminMember.isAdmin field for editor toggle initial state" + affects: + - "apps/pwa/src/api/client.ts" + - "apps/pwa/src/api/client.test.ts" + +tech_stack: + added: [] + patterns: + - "SessionExpiredError sentinel (existing convention) extended to new fetcher" + - "last-admin sentinel error (new: mirrors existing 'conflict' pattern at line 206)" + - "TDD RED→GREEN cycle on client.ts behavior" + +key_files: + modified: + - path: apps/pwa/src/api/client.ts + change: "Added isAdmin: boolean to AdminMember interface; added updateMemberProfile fetcher" + - path: apps/pwa/src/api/client.test.ts + change: "Added 9 TDD tests: 8 for updateMemberProfile behavior + 1 for AdminMember.isAdmin shape" + +decisions: + - "Followed PATCH verb for the member-profile update (idiomatic REST, consistent with updateEvent at line 496)" + - "last-admin sentinel maps both 409 and 422 — PATTERNS.md notes server may return either; both handled" + - "isAdmin placed after color and before hasCredential in AdminMember — matches PATTERNS.md excerpt" + +metrics: + duration_minutes: 4 + completed_date: "2026-06-18" + tasks_completed: 2 + files_modified: 2 +--- + +# Phase 20 Plan 02: PWA API Client — updateMemberProfile Fetcher + AdminMember.isAdmin Summary + +**One-liner:** Thin API client glue: `updateMemberProfile` PATCH fetcher with `last-admin` 409/422 sentinel + `isAdmin: boolean` on `AdminMember`, enabling the Plan 20-03 editor. + +## What Was Built + +Added two changes to `apps/pwa/src/api/client.ts`: + +1. **`AdminMember.isAdmin: boolean`** — new required field on the `AdminMember` interface (after `color`, before `hasCredential`). Feeds the editor toggle's initial state once Plan 20-01 lands (the `GET /api/admin/members` route already returns it after that plan's changes). No consumer code changes needed; Plan 20-03 reads it directly. + +2. **`updateMemberProfile(memberId, body)`** — exported `async function` that issues `PATCH /api/admin/members/${memberId}` with `credentials: 'include'`, `redirect: 'manual'`, `Content-Type: application/json`, and JSON-stringified body `{ displayName?, isAdmin? }`. Error mapping: + - `opaqueredirect` or `401` → `SessionExpiredError` (existing re-auth flow convention) + - `409` or `422` → `new Error('last-admin')` (D-03 sentinel; editor branches on this message) + - other non-ok → generic `Error` + - `200` → resolves `void` + +## TDD Gate Compliance + +| Gate | Commit | Notes | +|------|--------|-------| +| RED | `18da7e9` | 8 `updateMemberProfile` behavior tests + 1 `AdminMember.isAdmin` shape test — all fail with `updateMemberProfile is not a function`; 42 existing tests pass | +| GREEN | `5bcd818` | All 50 tests pass after implementation; eslint + prettier + tsc --noEmit exit 0 | +| REFACTOR | N/A | No refactor needed — the implementation was minimal and clean on the first pass | + +## Task Summary + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| RED | Add failing tests for updateMemberProfile + AdminMember.isAdmin | `18da7e9` | `client.test.ts` | +| GREEN | Add updateMemberProfile fetcher + AdminMember.isAdmin | `5bcd818` | `client.ts`, `client.test.ts` | + +## Verification + +- `grep -n "updateMemberProfile" apps/pwa/src/api/client.ts` → line 249 (export), line 263 (error throw) +- `grep -n "PATCH" apps/pwa/src/api/client.ts` → line 254 (`method: 'PATCH'`) +- `grep -nE "isAdmin: boolean" apps/pwa/src/api/client.ts` → line 597 (AdminMember) +- `grep -n "last-admin" apps/pwa/src/api/client.ts` → line 262 (the sentinel throw) +- `pnpm --filter @familysync/pwa exec tsc --noEmit` → exit 0 +- `pnpm --filter @familysync/pwa exec eslint src/api/client.ts src/api/client.test.ts` → exit 0 +- `pnpm exec prettier --check apps/pwa/src/api/client.ts apps/pwa/src/api/client.test.ts` → exit 0 +- 50/50 tests pass + +## Deviations from Plan + +None — plan executed exactly as written. + +- The PATTERNS.md excerpt was followed verbatim for the function shape. +- The eslint `require-await` issue in the test file was caught and fixed during Task 2 CI gates (test function did not need `async` — removed it). Not a plan deviation; it was a CI gate finding during Task 2 as specified. + +## Known Stubs + +None. This plan delivers typed wire code only; no UI rendering or data display. + +## Threat Flags + +No new security-relevant surface introduced. `updateMemberProfile` reuses the existing session-expiry path (T-20-05 mitigated) and does not introduce new trust boundaries (T-20-06 accepted per plan threat model). + +## Self-Check: PASSED + +| Check | Result | +|-------|--------| +| `apps/pwa/src/api/client.ts` exists | FOUND | +| `apps/pwa/src/api/client.test.ts` exists | FOUND | +| `20-02-SUMMARY.md` exists | FOUND | +| RED commit `18da7e9` | FOUND | +| GREEN commit `5bcd818` | FOUND | diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-03-PLAN.md b/.planning/phases/20-admin-member-editor-form-declutter/20-03-PLAN.md new file mode 100644 index 0000000..ec424e8 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-03-PLAN.md @@ -0,0 +1,208 @@ +--- +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. + diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-03-SUMMARY.md b/.planning/phases/20-admin-member-editor-form-declutter/20-03-SUMMARY.md new file mode 100644 index 0000000..1bcb7ed --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-03-SUMMARY.md @@ -0,0 +1,143 @@ +--- +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 diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md b/.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md new file mode 100644 index 0000000..fe9e36e --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md @@ -0,0 +1,112 @@ +# Phase 20: Admin Member Editor & Form Declutter - Context + +**Gathered:** 2026-06-18 +**Status:** Ready for planning + + +## Phase Boundary + +Rework the **admin Members panel** (`apps/pwa/src/routes/AdminPage.tsx`, "Members & Accounts" tab) so an admin edits all of a member's details from **one editor** instead of scattered per-row action buttons: + +- Replace the per-row `Rotate` / `Add credential` button **and** the separate `Reset password` button with a single member editor opened from the row. +- The editor changes: **display name**, **local-login password**, the **Fastmail/CalDAV app password** (calendar credential), and the member's **admin flag (`is_admin`)** — using clear, non-jargon labels that **retire the "Rotate" term**. +- Collapse the always-open inline **Add member** form behind a single "Add member" trigger. + +Primarily a client-side `AdminPage` + `CredentialSheet` rework over the existing `/api/admin` surface. **No new auth/authorization boundary** — everything stays behind `requireAdmin`. One small new *route* (member-profile update) is in scope; it is not a new boundary. Seeded by the gripe that "Rotate" for the app password is unintuitive. + +**Out of scope (deferred):** editable member color, admin-driven OIDC link/unlink, member deletion/removal. + + + +## Implementation Decisions + +### Editor field scope +- **D-01:** The editor exposes **four** things: display name, local-login password, Fastmail/CalDAV app password, and the **admin toggle (`is_admin`)**. Color, OIDC link/unlink, and remove-member are explicitly deferred (see Deferred Ideas). +- **D-02:** **Editing display name + `is_admin` needs one new route** within the existing `requireAdmin` boundary — today `displayName` is only written at member-create (`POST /members`) and there is no member-update route. Recommended shape: a single `PATCH /api/admin/members/:id` (or `POST`) accepting `displayName` and/or `is_admin`; exact verb/shape is the planner's call. `AdminMember` (`apps/pwa/src/api/client.ts:563`) and the `GET /members` select (`apps/api/src/routes/admin.ts:102`) must surface `isAdmin` for the toggle's initial state. + +### Admin toggle safety +- **D-03:** **Server blocks demoting the last admin.** Toggling `is_admin` off is rejected (409/422) when the target is the only remaining admin; self-demotion is permitted only when another admin exists. The client surfaces this as a clear inline error. The Phase 19 break-glass CLI/host command remains the true lockout-recovery path ([[19-CONTEXT]] D-13) — no new role/capability model. + +### Edit affordance +- **D-04:** **Whole-row tap opens the editor**, with a trailing chevron / edit icon as the affordance signal. The current per-row action buttons (`Rotate`/`Add credential`, `Reset password`) are removed from `MemberRow`. Big mobile tap target; matches the low-friction, warm aesthetic. + +### Editor layout & save model +- **D-05:** **One sheet, per-section save** — not a single combined Save. Sections: + 1. **Profile** — display name input + admin toggle, with one Save (writes the new member-profile route; subject to D-03 guard). + 2. **Set new password** — optional, **write-only** (blank = unchanged), with confirm; maps to existing `POST /members/:id/password` (`admin.ts:225`). Only shown for members with a local credential (`hasLocalCredential`). + 3. **Set app password** — optional, **write-only**; collects Fastmail email + app password, **CalDAV-validated** before store; maps to existing `POST /credentials` (`admin.ts:279`). + Each section maps 1:1 to an endpoint, avoiding partial-failure ambiguity when CalDAV validation fails. Passwords are never prefilled/returned to the client (preserve T-10-15/T-10-16). +- **D-06:** **Retire "Rotate" copy** everywhere; use plain labels (e.g. "Set app password" / "Update calendar password"). The standalone `ResetPasswordSheet` (currently in `AdminPage.tsx`) is **folded into** the editor's "Set new password" section — no separate reset sheet remains. + +### Add-member declutter +- **D-07:** **"Add member" opens a sheet**, not an inline-expanded form. Preferred: the **same Member sheet in a create mode** (compose_event-style — one component, create vs edit), so the panel collapses to a clean member list + a single "Add member" trigger. Create mode keeps today's fields (display name, username, initial password + confirm → `POST /members`). + +### Claude's Discretion +- Exact new-route verb/path/shape for the member-profile update (D-02). +- Whether the Member editor and Add-member sheet are literally one component with a mode prop vs two siblings sharing a base — planner's call, but D-07 prefers unification. +- In edit mode, whether the app-password section prefills/display the stored Fastmail email (read-only) or requires re-entry — minor UX detail for planning; note the stored `fastmailEmail` exists on the credential. +- Icon choice for the row chevron/edit affordance (lucide, consistent with existing `CheckCircle`/`AlertCircle` usage). + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Phase definition +- `.planning/ROADMAP.md` §"Phase 20: Admin Member Editor & Form Declutter" — goal + the open-scope note this discussion resolved. + +### Code being reworked +- `apps/pwa/src/routes/AdminPage.tsx` — the Members panel, `MemberRow`, the inline Add-member form, and the standalone `ResetPasswordSheet` being consolidated. +- `apps/pwa/src/components/CredentialSheet.tsx` — dialog/focus-trap/Escape + CalDAV-validation sheet to generalize into the Member editor (and Add-member create mode). +- `apps/pwa/src/api/client.ts` — `AdminMember` type (`:563`), admin fetchers (`fetchAdminMembers`, `fetchCreateMember`, `fetchAdminResetPassword`, `saveCredential`); add the new member-profile fetcher + `isAdmin` field here. +- `apps/api/src/routes/admin.ts` — existing endpoints: `GET /members` (`:102`), `POST /members` (`:143`), `POST /members/:id/password` (`:225`), `POST /credentials` (`:279`); add the member-profile update route here behind the same `requireAdmin`. + +### Prior decisions that constrain this phase +- `.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md` — D-11 (password lifecycle = self-change + admin-reset, no email reset), D-12 (OIDC link is self-service only, deletes local credential), D-13 (single `is_admin`, break-glass = CLI/host, no role split). + + + +## Existing Code Insights + +### Reusable Assets +- **`CredentialSheet`**: full dialog scaffold (role=dialog, `aria-modal`, `useFocusTrap`, Escape-to-close, focus-return-to-trigger, phone bottom-sheet vs desktop modal, CalDAV-validating mutation). Generalize into the Member editor + Add-member create mode. +- **`ResetPasswordSheet`** (in `AdminPage.tsx`): password + confirm + mismatch validation logic — folds into the editor's "Set new password" section (D-06). +- **`useFocusTrap`, `useIsPhone`** hooks — reuse for the new sheet. +- **Existing endpoints** cover login-password reset and app-password set; only the member-profile (displayName + is_admin) write is new. + +### Established Patterns +- Two-tab `AdminPage` ("Members & Accounts" / "Settings") with roving-tabindex tabs — keep; this phase only restructures the Members tab body. +- Mutations invalidate `['admin','members']` (+ `['me']` for credential changes) on success; success toast via `showToast` (D-08 pattern). Reuse for editor saves. +- Write-only password handling: never prefill, never log, `autoComplete="new-password"` (T-10-15/16). + +### Integration Points +- New `PATCH/POST /api/admin/members/:id` mounts on `adminRouter` behind `requireAdmin` (no new boundary). +- `GET /members` select + `AdminMember` type gain `isAdmin` so the editor's toggle has initial state. +- `MemberRow` becomes a single tappable row (chevron affordance), dropping its action-button cluster. + + + +## Specific Ideas + +- "Same sheet, create vs edit mode" is explicitly modeled on the Fastmail `compose_event` pattern (one widget, `id` present = edit, absent = create) — apply that shape to the Member sheet. +- Labels must read for a non-technical household member: retire "Rotate"; prefer "Set app password" / "Set new password" / plain "Save". + + + +## Deferred Ideas + +- **Editable member color** — colors are currently derived by row index (`var(--color-member-N)`); there is no stored per-member color to edit. Would need schema + assignment UX. → backlog / future phase. +- **Admin-driven OIDC link/unlink** — Phase 19 D-12 makes OIDC linking a self-service action performed *as that user*, never by an admin. Admin-side link/unlink is a different security model. → out of scope. +- **Remove / delete member** — destructive, with cascade concerns (events, lists, credentials, last-admin). Not part of the gripe-seeded scope. → backlog / future phase. + +### Reviewed Todos (not folded) +- "Gitea CI — full regression + Docker publish" (score 0.6) — stale keyword match (already delivered as Phase 8); unrelated to this UI phase. +- "PWA phone layout — BottomTabBar overlaps FAB + legend" (score 0.4) — already addressed in Phase 17; unrelated. + + + +--- + +*Phase: 20-admin-member-editor-form-declutter* +*Context gathered: 2026-06-18* diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-DISCUSSION-LOG.md b/.planning/phases/20-admin-member-editor-form-declutter/20-DISCUSSION-LOG.md new file mode 100644 index 0000000..7a684e5 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-DISCUSSION-LOG.md @@ -0,0 +1,83 @@ +# Phase 20: Admin Member Editor & Form Declutter - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-06-18 +**Phase:** 20-admin-member-editor-form-declutter +**Areas discussed:** Editor field scope, Edit affordance, Editor layout & save model, Add-member declutter, Admin toggle safety + +--- + +## Editor field scope + +| Option | Description | Selected | +|--------|-------------|----------| +| Core 3 only | Display name + local login password + app password; defer admin toggle/color/OIDC/remove. | | +| Core 3 + admin toggle | Also flip `is_admin` from the editor, with a last-admin guard. | ✓ | + +**User's choice:** "The entire scope plus admin toggle" — core 3 fields plus the `is_admin` toggle. +**Notes:** Color, OIDC link/unlink, and remove-member stay deferred. Display name + admin toggle require one new within-`requireAdmin` route (no member-update route exists today). + +--- + +## Edit affordance + +| Option | Description | Selected | +|--------|-------------|----------| +| Whole-row tap + chevron | Tapping anywhere on the member row opens the editor; trailing chevron signals it. | ✓ | +| Name link + pencil button | Literal roadmap wording — name link + dedicated edit icon. | | + +**User's choice:** Whole-row tap + chevron. +**Notes:** Removes the per-row `Rotate`/`Add credential` + `Reset password` button cluster. + +--- + +## Editor layout & save model + +| Option | Description | Selected | +|--------|-------------|----------| +| One sheet, per-section save | Name+toggle save; "Set new password"; "Set app password" — each independent, maps 1:1 to an endpoint. | ✓ | +| One sheet, single combined Save | One Save writes every changed field; needs partial-failure handling for CalDAV validation. | | + +**User's choice:** One sheet, per-section save. +**Notes:** Avoids partial-failure ambiguity when CalDAV validation fails mid-save. Passwords stay write-only (blank = unchanged). + +--- + +## Add-member declutter + +| Option | Description | Selected | +|--------|-------------|----------| +| Open as a sheet | "Add member" opens a sheet — ideally the same Member sheet in create mode. | ✓ | +| Expand inline form | Button toggles the existing inline form visible/hidden in place. | | + +**User's choice:** Open as a sheet. +**Notes:** Prefer the compose_event-style one-component create-vs-edit pattern so the panel collapses to a clean list + one button. + +--- + +## Admin toggle safety + +| Option | Description | Selected | +|--------|-------------|----------| +| Block demoting last admin | Server rejects toggling `is_admin` off when they're the only admin; self-demotion only if another admin exists. | ✓ | +| Warn but allow | Confirm dialog when demoting the last admin/yourself, but permit it; rely on break-glass CLI. | | + +**User's choice:** Block demoting the last admin (server-enforced). +**Notes:** Phase 19 break-glass CLI/host command remains the true lockout-recovery path; no new role model. + +--- + +## Claude's Discretion + +- Exact verb/path/shape of the new member-profile update route (`displayName` + `is_admin`). +- Whether the Member editor and Add-member sheet are literally one component (mode prop) vs two siblings on a shared base. +- Whether the app-password section prefills the stored Fastmail email (read-only) or requires re-entry in edit mode. +- Chevron/edit icon choice for the row affordance. + +## Deferred Ideas + +- Editable member color (no stored per-member color — derived by row index). +- Admin-driven OIDC link/unlink (Phase 19 D-12: self-service only). +- Remove / delete member (destructive, cascade concerns). diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md b/.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md new file mode 100644 index 0000000..e2e2dc8 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md @@ -0,0 +1,276 @@ +# Phase 20: Admin Member Editor & Form Declutter - Pattern Map + +**Mapped:** 2026-06-18 +**Files analyzed:** 5 (2 new surfaces, 3 modified) +**Analogs found:** 5 / 5 (all in-repo, recent) + +> No RESEARCH.md for this phase — this is a client-side rework over existing `/api/admin` endpoints. Every new file copies a concrete in-repo analog; no external pattern is needed. + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `apps/pwa/src/components/MemberEditorSheet.tsx` (NEW) | component (dialog/sheet) | request-response (form → mutation) | `apps/pwa/src/components/CredentialSheet.tsx` + `ResetPasswordSheet` (in `AdminPage.tsx`) | exact | +| `apps/api/src/routes/admin.ts` — new `PATCH /members/:id` (NEW route, MODIFIED file) | route (member-profile update) | CRUD (update) | `admin.ts` `POST /members/:id/password` (`:225`) + last-admin count in `auth/user.ts:151` | exact | +| `apps/pwa/src/routes/AdminPage.tsx` (MODIFIED) | route/page (MemberRow + triggers) | request-response | `ListCard.tsx` (tappable row + ChevronRight) + existing `MemberRow` (`:1151`) | exact | +| `apps/pwa/src/api/client.ts` (MODIFIED) | api client (type + fetcher) | request-response | `fetchAdminResetPassword` (`:218`) / `fetchCreateMember` (`:184`) | exact | +| `apps/api/src/routes/admin.ts` — `GET /members` adds `isAdmin` (MODIFIED) | route | CRUD (read) | `admin.ts` `GET /members` (`:102`) | exact (same handler) | + +--- + +## Pattern Assignments + +### `apps/pwa/src/components/MemberEditorSheet.tsx` (component, dialog/sheet) + +**Primary analog:** `apps/pwa/src/components/CredentialSheet.tsx` +**Secondary analog:** `ResetPasswordSheet` inside `apps/pwa/src/routes/AdminPage.tsx:1391` (password+confirm+mismatch logic to fold into the "Set new password" section per D-06). + +This is the central new file. Copy the **entire dialog scaffold** from CredentialSheet, then compose the three edit-mode sections (Profile / Set new password / App password) + the create-mode form from existing field/mutation snippets. + +**Imports pattern** (`CredentialSheet.tsx:25-35`): +```typescript +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { + saveCredential, + type SaveCredentialPayload, +} from '../api/client.js'; +import { useIsPhone } from '../hooks/useIsPhone.js'; +import { useFocusTrap } from '../hooks/useFocusTrap.js'; +``` +For Phase 20 also import the new `updateMemberProfile` fetcher + existing `fetchAdminResetPassword`, and add `ChevronRight`/`Plus` are NOT needed here (those go in AdminPage). + +**`mode` prop pattern** — model the create/edit discriminator on `CredentialSheetMode` (`CredentialSheet.tsx:37`). UI-SPEC §Surface B wants a single `mode: 'edit' | 'create'` prop (id present = edit). Mirror the `headingFor(mode)` switch (`CredentialSheet.tsx:54-58`) for "Edit member" / "Add member" copy. + +**Dialog scaffold — copy verbatim** (`CredentialSheet.tsx`): +- `useFocusTrap(dialogRef)` wiring (`:88-89`) + `onKeyDown={handleDialogKeyDown}` on the dialog div (`:186`) +- `handleClose` via `useCallback` that clears form state, calls `onClose()`, and returns focus to `triggerRef.current` (`:94-103`) +- Escape-to-close effect (`:106-115`) +- Focus-heading-on-open effect (`:118-122`) +- Backdrop div (`:169-178`) — note UI-SPEC §Surface B wants `rgba(0,0,0,0.32)` / `--color-overlay`, which matches `ResetPasswordSheet:1459` (`var(--color-overlay, rgba(0,0,0,0.32))`), NOT CredentialSheet's `rgba(0,0,0,0.4)`. Prefer the ResetPasswordSheet overlay token. +- Phone-vs-desktop sheet style object (`:187-217`) — copy exactly (borderRadius `12px 12px 0 0` phone / `12px` desktop, `maxWidth 480px`, zIndex 301, `padding var(--space-6)`, desktop `maxHeight: calc(100dvh - var(--space-8)); overflowY: auto`) +- `h2 ref={headingRef} tabIndex={-1}` heading (`:220-233`) +- Member subtitle block (`:236-247`) — edit mode only + +**Section divider** (UI-SPEC §Surface B): `border-top: 1px solid var(--color-border-subtle)`, `margin: var(--space-6) 0`. Section headings reuse `sectionLabelStyle` from `AdminPage.tsx:46-53` (13px/600/uppercase/`--color-text-muted`) — UI-SPEC Accessibility note says use `
` not `

` to avoid heading-hierarchy issues under the `

`. + +**Text input field pattern** (`CredentialSheet.tsx:250-283` email field; `AdminPage.tsx:477-495` display-name field) — label (13px/600) + input (`min-height 44px`, `border-radius var(--space-1)`, `padding var(--space-3) var(--space-4)`, `border` flips to `--color-destructive` on error). + +**Password section (Section 2)** — copy `ResetPasswordSheet`'s new-password + confirm fields and mismatch logic: +```typescript +// AdminPage.tsx:1426-1443 — mutation with client-side mismatch guard +const resetMutation = useMutation({ + mutationFn: async () => { + if (newPassword !== confirmPassword) throw new Error('mismatch'); + await fetchAdminResetPassword(member.id, newPassword); + }, + onSuccess: () => { handleClose(); onSuccess?.(); }, + onError: (err) => { + const msg = err instanceof Error ? err.message : 'server'; + if (msg === 'mismatch') setError('Passwords do not match.'); + else setError('Something went wrong. Please try again.'); + }, +}); +``` +UI-SPEC adds a `< 8 chars` guard ("Password must be at least 8 characters.") — mirror the create-member length check at `AdminPage.tsx:267-269`. Fields use `autoComplete="new-password"`, never prefilled (T-10-15/16). **Per-section** save (not handleClose-on-success) — Section 2 success keeps the sheet open and fires toast "Password updated." Only render this section when `member.hasLocalCredential === true`. + +**App-password section (Section 3)** — copy CredentialSheet's email + password fields (`:249-320`), the "Validating against CalDAV…" `Loader2` inline state (`:337-345`), the helper link to Fastmail device tokens (`:351-362`), and the FAILURE_TEXT copy (`:65-66`). The mutation maps to `saveCredential` (`:124-153`) — admin mode requires `userId: memberId`. Prefill `fastmailEmail` read-only convenience in edit mode (Claude's-discretion D — the stored `fastmailEmail` exists on `memberCredentials.fastmail_email`, but is NOT currently returned by `GET /members`; the password field is never prefilled). + +**Create-mode form (single form, no dividers)** — copy the four fields + mutation from `AdminPage.tsx:260-306` (`createMemberMutation`): display name / username / initial password / confirm. Maps to `fetchCreateMember` (`client.ts:184`). Reuse the exact error mapping (`mismatch` / `short` / `conflict` → "That username is already in use."). + +**Per-section Save button** (`CredentialSheet.tsx:397-419`): accent `--color-member-0` background when enabled, `--color-border` when disabled, white text, `min-height 44px`, `border-radius var(--space-1)`, inline `Loader2 size={14}` while pending (pattern at `AdminPage.tsx:651-657`). + +**Cancel button** (`CredentialSheet.tsx:375-395`): no background, `--color-text-secondary`, `min-height 44px`. + +--- + +### `apps/api/src/routes/admin.ts` — NEW `PATCH /members/:id` (route, CRUD update) + +**Primary analog:** `POST /members/:id/password` (`admin.ts:225-269`) — same `:id` param shape, same `requireAdmin` boundary, same noEchoHook posture, same existence-check-then-update flow. +**Secondary analog:** the admin-count query in `auth/user.ts:151-156` — reuse for the D-03 last-admin guard. + +**Route handler shape — copy** (`admin.ts:225-269`): +```typescript +const updateMemberSchema = z.object({ + displayName: z.string().min(1).max(256).optional(), + isAdmin: z.boolean().optional(), +}); + +adminRouter.patch( + '/members/:id', + zValidator('json', updateMemberSchema, noEchoHook), + async (c) => { + const targetId = parsePositiveIntParam(c.req.param('id')); // :87 + if (targetId === null) return c.json({ error: 'Invalid member id' }, 400); + const { displayName, isAdmin } = c.req.valid('json'); + // ... existence check + last-admin guard + update + }, +); +``` +- `parsePositiveIntParam` already exists (`admin.ts:87-92`) — reuse, do NOT re-implement. +- `requireAdmin` is already mounted router-wide (`admin.ts:47`) — the new route inherits it automatically; **no new boundary** (D-02). +- `noEchoHook` (`admin.ts:75-79`) — apply even though no password is in this body, for consistency with the other admin write routes. + +**Last-admin guard (D-03) — adapt from `auth/user.ts:151-156`:** +```typescript +// Count remaining admins; reject demotion of the only admin. +const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(users) + .where(eq(users.isAdmin, true)) + .limit(1); +``` +When `isAdmin === false` is requested for a target that is currently an admin AND `Number(count) <= 1`, return 409 (or 422) `{ error: ... }`. UI-SPEC client maps this to "Cannot remove admin — at least one admin must remain." Note: `sql` and `eq` are already imported (`admin.ts:28`). + +**Update + error handling** — mirror the try/catch + 503 fallback of the password route (`admin.ts:249-268`). Build a partial `set({ ... })` from whichever of `displayName` / `isAdmin` is present. Verify the target user exists (404 if not), matching the password route's `if (!credRow) return 404` shape (`:245`). + +**Verb choice (Claude's discretion, D-02):** Hono supports `adminRouter.patch(...)`. The repo's existing admin writes use `POST` (`/members`, `/credentials`) and `PUT` (`/calendars/:id/shared`, `/config/timezone`); a `PATCH` for partial member update is idiomatic and consistent with REST, but `POST /members/:id` is equally acceptable — planner's call. + +--- + +### `apps/api/src/routes/admin.ts` — `GET /members` adds `isAdmin` (route, CRUD read) + +**Analog:** the existing handler itself (`admin.ts:102-124`). Add `isAdmin` to the select and the mapped object: +```typescript +.select({ + id: users.id, + displayName: users.displayName, + color: users.color, + isAdmin: users.isAdmin, // NEW — feeds the editor toggle initial state (D-02) + credentialId: memberCredentials.id, + localCredId: localCredentials.id, +}) +// ... +const members = rows.map((row) => ({ + id: row.id, + displayName: row.displayName, + color: row.color, + isAdmin: row.isAdmin, // NEW + hasCredential: row.credentialId !== null, + hasLocalCredential: row.localCredId !== null, +})); +``` +`users.isAdmin` already exists in the schema (`db/schema.ts:56`, `boolean('is_admin')`). No join change needed — it's a column on the base `users` table already in the FROM. + +--- + +### `apps/pwa/src/api/client.ts` (api client — type + fetcher) + +**Analog for the type:** `AdminMember` interface (`client.ts:563-569`). Add `isAdmin: boolean`: +```typescript +export interface AdminMember { + id: number; + displayName: string | null; + color: string; + isAdmin: boolean; // NEW — Phase 20 (drives editor admin toggle initial state) + hasCredential: boolean; + hasLocalCredential: boolean; +} +``` + +**Analog for the new fetcher:** `fetchAdminResetPassword` (`client.ts:218-234`) — same `:id` path, same POST/PATCH-with-json shape, same error handling. New `updateMemberProfile`: +```typescript +export async function updateMemberProfile( + memberId: number, + body: { displayName?: string; isAdmin?: boolean }, +): Promise { + const res = await fetch(`/api/admin/members/${memberId}`, { + method: 'PATCH', // match the chosen route verb + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + redirect: 'manual', + body: JSON.stringify(body), + }); + if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); + if (res.status === 409 || res.status === 422) throw new Error('last-admin'); // D-03 guard + if (!res.ok) throw new Error(`updateMemberProfile failed: ${res.status}`); +} +``` +The `SessionExpiredError` + `handleAuthResponse` conventions are already in this file (used by every fetcher). Map the last-admin 409/422 to a sentinel the editor's onError can branch on (mirrors the `conflict` sentinel pattern at `client.ts:206`). + +--- + +### `apps/pwa/src/routes/AdminPage.tsx` (route/page — MemberRow rework + triggers) + +**MemberRow → tappable row analog:** `apps/pwa/src/components/ListCard.tsx` — a whole-row ` +

+ + {/* IN-01: helper text when display name is empty (e.g. null-displayName + OIDC member) so the admin knows why Save is disabled */} + {displayName.trim().length === 0 && !profileError && ( +

Enter a display name to enable Save.

+ )} + + {/* Last-admin guard inline error */} + {profileError && ( +
+ {profileError} +
+ )} + +
+ + +
+

+ + {/* Section 2 — Set new password (only for members with local credential) */} + {member.hasLocalCredential && ( + <> +
+
+
Set new password
+
Leave blank to keep the current password.
+ + {/* New password */} +
+ + setNewPassword(e.target.value)} + aria-describedby={passwordError ? 'password-error' : undefined} + style={inputStyle(!!passwordError)} + /> +
+ + {/* Confirm new password */} +
+ + setConfirmPassword(e.target.value)} + aria-describedby={passwordError ? 'password-error' : undefined} + style={inputStyle(!!passwordError)} + /> +
+ + {/* Password error */} + {passwordError && ( +
+ {passwordError} +
+ )} + +
+ +
+
+ + )} + + {/* Section 3 — App password (always in edit mode) */} + <> +
+
+
App password
+ + {/* Helper text with link */} +
+ Fastmail app password scoped to Calendars & Contacts (CalDAV).{' '} + + {CALDAV_LINK_TEXT} + +
+ + {/* In-flight validating state */} + {appPasswordMutation.isPending && ( +
+
+ )} + + {/* Fastmail email */} +
+ + setFastmailEmail(e.target.value)} + placeholder="user@fastmail.com" + aria-describedby={appPasswordError ? 'app-password-error' : undefined} + style={inputStyle(!!appPasswordError)} + /> +
+ + {/* App password field — NEVER prefilled (T-20-07) */} +
+ + setAppPassword(e.target.value)} + aria-describedby={appPasswordError ? 'app-password-error' : undefined} + style={inputStyle(!!appPasswordError)} + /> +
+ + {/* CalDAV error */} + {appPasswordError && ( +
+ {appPasswordError} +
+ )} + +
+ +
+
+ + + )} + + {/* ── CREATE MODE ────────────────────────────────────────────────── */} + {mode === 'create' && ( +
+ {/* Display name */} +
+ + setCreateDisplayName(e.target.value)} + aria-describedby={createError ? 'create-error' : undefined} + style={inputStyle(!!createError)} + /> +
+ + {/* Username */} +
+ + setCreateUsername(e.target.value)} + aria-describedby={createError ? 'create-error' : undefined} + style={inputStyle(!!createError)} + /> +
+ + {/* Initial password */} +
+ + setCreatePassword(e.target.value)} + aria-describedby={createError ? 'create-error' : undefined} + style={inputStyle(!!createError)} + /> +
+ + {/* Confirm password */} +
+ + setCreateConfirmPassword(e.target.value)} + aria-describedby={createError ? 'create-error' : undefined} + style={inputStyle(!!createError)} + /> +
+ + {/* Create error */} + {createError && ( +
+ {createError} +
+ )} + +
+ + +
+
+ )} +
+ + ); +} diff --git a/apps/pwa/src/routes/AdminPage.tsx b/apps/pwa/src/routes/AdminPage.tsx index c953fcd..39851b3 100644 --- a/apps/pwa/src/routes/AdminPage.tsx +++ b/apps/pwa/src/routes/AdminPage.tsx @@ -9,10 +9,10 @@ * - Centered content column, maxWidth 640px on desktop * - var(--space-12) top/bottom padding, var(--space-6) horizontal padding * - * UI-SPEC §Surface 2 (MEMBERS section): - * - 32px avatar swatch (var(--color-member-N)) + member name + credential status badge - * - "Rotate" or "Add credential" action button per hasCredential - * - Opens CredentialSheet in admin-rotate or admin-add mode + * UI-SPEC §Surface A (MEMBERS section — Phase 20): + * - Tappable MemberRow with trailing ChevronRight + "Admin" badge when isAdmin + * - Single "Add member" ghost trigger at the bottom (opens MemberEditorSheet in create mode) + * - No per-row action-button cluster; no always-open inline add form * * UI-SPEC §Surface 5 (SHARED CALENDAR section): * - Radio group, one row per synced calendar @@ -25,21 +25,18 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; +import { CheckCircle, AlertCircle, ChevronRight, Plus } from 'lucide-react'; import { fetchAdminMembers, fetchAdminCalendars, setSharedCalendar, fetchAdminTimezone, setAdminTimezone, - fetchCreateMember, - fetchAdminResetPassword, type AdminMember, type AdminCalendar, } from '../api/client.js'; -import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js'; +import { MemberEditorSheet } from '../components/MemberEditorSheet.js'; import { useIsPhone } from '../hooks/useIsPhone.js'; -import { useFocusTrap } from '../hooks/useFocusTrap.js'; // ── Styles ───────────────────────────────────────────────────────────────── @@ -78,24 +75,20 @@ export function AdminPage() { // Two-tab navigation state (D-10) const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members'); - // Credential sheet state - const [sheetOpen, setSheetOpen] = useState(false); - const [sheetMode, setSheetMode] = useState('admin-add'); - const [sheetMember, setSheetMember] = useState(null); - const triggerRef = useRef(null); - - // Reset-password sheet state (Surface 11B) - const [resetSheetOpen, setResetSheetOpen] = useState(false); - const [resetTargetMember, setResetTargetMember] = useState(null); - // resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close - const resetTriggerRef = useRef(null); - - // Create-member form state (Surface 11A) - const [createDisplayName, setCreateDisplayName] = useState(''); - const [createUsername, setCreateUsername] = useState(''); - const [createPassword, setCreatePassword] = useState(''); - const [createConfirmPassword, setCreateConfirmPassword] = useState(''); - const [createError, setCreateError] = useState(null); + // ── Member editor sheet state ─────────────────────────────────────────── + // A single MemberEditorSheet instance handles both edit (row tap) and + // create ("Add member" trigger) modes (D-07 unification). + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState<'edit' | 'create'>('edit'); + // CR-02: store only the id, not the full member object. The full member is + // derived from live query data each render (see below, after membersQuery), + // so per-section saves + invalidations keep the editor's member prop fresh + // without a stale captured reference. + const [editorMemberId, setEditorMemberId] = useState(null); + // Ref to the element that opened the sheet — focus returns here on close (a11y) + const editorTriggerRef = useRef(null); + // Ref for the "Add member" ghost trigger button + const addMemberTriggerRef = useRef(null); // Shared calendar picker state const [selectedCalendarId, setSelectedCalendarId] = useState(null); @@ -126,6 +119,13 @@ export function AdminPage() { staleTime: 60 * 1000, }); + // CR-02: derive editorMember from live query data so saves + invalidations always + // reflect the latest server state rather than a snapshot from when the row was tapped. + const editorMember = + editorMemberId !== null + ? (membersQuery.data?.members.find((m) => m.id === editorMemberId) ?? null) + : null; + // Calendars query const calendarsQuery = useQuery({ queryKey: ['admin', 'calendars'], @@ -248,62 +248,21 @@ export function AdminPage() { )?.focus(); } - // Open credential sheet for a member - function openSheet(member: AdminMember, buttonRef: React.RefObject) { - // Capture the button so focus can return on close - (triggerRef as React.MutableRefObject).current = buttonRef.current; - setSheetMember(member); - setSheetMode(member.hasCredential ? 'admin-rotate' : 'admin-add'); - setSheetOpen(true); + // Open the editor for a specific member (row tap → edit mode) + function openEditorForMember(member: AdminMember, rowEl: HTMLElement) { + editorTriggerRef.current = rowEl; + setEditorMemberId(member.id); // CR-02: store id only; editorMember is derived from live data + setEditorMode('edit'); + setEditorOpen(true); } - // Create-member mutation (Surface 11A) - const createMemberMutation = useMutation({ - mutationFn: async () => { - // Client-side validation (server also validates; this is for UX) - if (createPassword !== createConfirmPassword) { - throw new Error('mismatch'); - } - if (createPassword.length < 8) { - throw new Error('short'); - } - await fetchCreateMember({ - displayName: createDisplayName.trim(), - username: createUsername.trim(), - password: createPassword, - }); - }, - onSuccess: () => { - // Clear form + refresh member list - setCreateDisplayName(''); - setCreateUsername(''); - setCreatePassword(''); - setCreateConfirmPassword(''); - setCreateError(null); - void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); - void queryClient.invalidateQueries({ queryKey: ['me'] }); - showToast('Member added.'); - }, - onError: (err) => { - const msg = err instanceof Error ? err.message : 'server'; - if (msg === 'mismatch') { - setCreateError('Passwords do not match.'); - } else if (msg === 'short') { - setCreateError('Password is too short. Use at least 8 characters.'); - } else if (msg === 'conflict' || msg.includes('409')) { - setCreateError('That username is already in use. Choose a different one.'); - } else { - setCreateError('Something went wrong. Please try again.'); - } - }, - }); - - const createSubmitDisabled = - createMemberMutation.isPending || - createDisplayName.trim().length === 0 || - createUsername.trim().length === 0 || - createPassword.length === 0 || - createConfirmPassword.length === 0; + // Open the editor in create mode ("Add member" trigger) + function openEditorForCreate() { + editorTriggerRef.current = addMemberTriggerRef.current; + setEditorMemberId(null); + setEditorMode('create'); + setEditorOpen(true); + } const saveDisabled = sharedCalMutation.isPending || @@ -416,248 +375,79 @@ export function AdminPage() {
)} - {membersQuery.data && ( + {membersQuery.data && membersQuery.data.members.length === 0 && ( +
+
+ No members yet +
+
+ Add a member to get started. +
+
+ )} + + {membersQuery.data && membersQuery.data.members.length > 0 && (
{membersQuery.data.members.map((member, idx) => ( openSheet(member, buttonRef)} - onResetPassword={(buttonRef) => { - // Capture trigger button so focus can return on close - resetTriggerRef.current = buttonRef.current; - setResetTargetMember(member); - setResetSheetOpen(true); - }} + onEdit={(rowEl) => openEditorForMember(member, rowEl)} /> ))}
)} - - {/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */} -
-
Local Accounts
- - {/* Surface 11A — Add member inline form */} -
-
+
- - {/* Display name */} -
- - setCreateDisplayName(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Username */} -
- - setCreateUsername(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Initial password */} -
- - setCreatePassword(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Confirm password */} -
- - setCreateConfirmPassword(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Inline error */} - {createError && ( -
- {createError} -
- )} - - {/* Action row */} -
- -
+
@@ -1108,33 +898,15 @@ export function AdminPage() { )} - {/* Credential sheet — admin-rotate or admin-add */} - {sheetMember && ( - setSheetOpen(false)} - mode={sheetMode} - memberName={sheetMember.displayName} - memberId={sheetMember.id} - triggerRef={triggerRef} - /> - )} - - {/* Surface 11B — Reset password sheet */} - {resetTargetMember && ( - { - setResetSheetOpen(false); - // Return focus to trigger - if (resetTriggerRef.current) { - resetTriggerRef.current.focus(); - } - }} - onSuccess={() => showToast('Password reset.')} - member={resetTargetMember} - /> - )} + {/* Single unified MemberEditorSheet — handles edit + create modes (D-07) */} + setEditorOpen(false)} + mode={editorMode} + member={editorMember ?? undefined} + triggerRef={editorTriggerRef} + onToast={showToast} + /> ); } @@ -1144,16 +916,37 @@ export function AdminPage() { interface MemberRowProps { member: AdminMember; colorIndex: number; - onAction: (buttonRef: React.RefObject) => void; - onResetPassword?: (buttonRef: React.RefObject) => void; + /** Called with the row element so focus can return to it on editor close */ + onEdit: (rowEl: HTMLElement) => void; } -function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) { - const buttonRef = useRef(null); - const resetBtnRef = useRef(null); +/** + * Tappable member row (D-04): whole-row role="button" target with trailing + * ChevronRight affordance. Opens MemberEditorSheet in edit mode on tap/click + * or Enter/Space. Admin badge displayed when member.isAdmin. + */ +function MemberRow({ member, colorIndex, onEdit }: MemberRowProps) { + const rowRef = useRef(null); + + function handleActivate() { + if (rowRef.current) { + onEdit(rowRef.current); + } + } return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleActivate(); + } + }} style={{ display: 'flex', alignItems: 'center', @@ -1161,6 +954,7 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP minHeight: '44px', padding: 'var(--space-2, 8px) 0', borderBottom: '1px solid var(--color-border-subtle, var(--color-border))', + cursor: 'pointer', }} > {/* Avatar swatch */} @@ -1175,7 +969,7 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP }} /> - {/* Name + status */} + {/* Name + credential status badge */}
- {/* Action button row */} -
- {/* Credential rotate/add button */} - + Admin + + )} - {/* Surface 11B — Reset password button (only for members with a local credential) */} - {member.hasLocalCredential && onResetPassword && ( - - )} -
+ {/* Trailing chevron affordance */} +
); } @@ -1372,310 +1141,6 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr ); } -// ── ResetPasswordSheet ────────────────────────────────────────────────────── - -/** - * Surface 11B — Admin password reset sheet. - * Opens as a bottom sheet (mobile) / centered modal (desktop). - * Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus returns to trigger). - * No current-password field — admin reset does not require knowing the old password. - */ - -interface ResetPasswordSheetProps { - isOpen: boolean; - onClose: () => void; - onSuccess?: () => void; - member: AdminMember; -} - -function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) { - // WR-05: resize-aware phone detection. - const sheetPhone = useIsPhone(); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [error, setError] = useState(null); - const headingRef = useRef(null); - // WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true"). - const dialogRef = useRef(null); - const handleDialogKeyDown = useFocusTrap(dialogRef); - - // Escape closes the sheet - useEffect(() => { - if (!isOpen) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [isOpen, onClose]); - - // Focus heading on open - useEffect(() => { - if (isOpen && headingRef.current) { - headingRef.current.focus(); - } - }, [isOpen]); - - function handleClose() { - setNewPassword(''); - setConfirmPassword(''); - setError(null); - onClose(); - } - - const resetMutation = useMutation({ - mutationFn: async () => { - if (newPassword !== confirmPassword) throw new Error('mismatch'); - await fetchAdminResetPassword(member.id, newPassword); - }, - onSuccess: () => { - handleClose(); - onSuccess?.(); - }, - onError: (err) => { - const msg = err instanceof Error ? err.message : 'server'; - if (msg === 'mismatch') { - setError('Passwords do not match.'); - } else { - setError('Something went wrong. Please try again.'); - } - }, - }); - - const isPending = resetMutation.isPending; - const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0; - - if (!isOpen) return null; - - return ( - <> - {/* Backdrop */} -