docs(20): create phase plan
This commit is contained in:
@@ -759,8 +759,10 @@ Plans:
|
|||||||
**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).
|
**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)
|
**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
|
**Depends on:** Phase 19
|
||||||
**Plans:** 0 plans
|
**Plans:** 3 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
|
|
||||||
- [ ] TBD (run /gsd-plan-phase 20 to break down)
|
- [ ] 20-01-PLAN.md — Server: PATCH /api/admin/members/:id (displayName + is_admin) with last-admin demotion guard (TDD) + isAdmin in GET /members
|
||||||
|
- [ ] 20-02-PLAN.md — PWA API client: AdminMember.isAdmin field + updateMemberProfile fetcher (last-admin sentinel)
|
||||||
|
- [ ] 20-03-PLAN.md — PWA: unified MemberEditorSheet (edit/create, per-section saves) + decluttered tappable Members panel; retire Rotate/Reset-password buttons
|
||||||
|
|||||||
@@ -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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<artifacts_produced>
|
||||||
|
## 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)
|
||||||
|
</artifacts_produced>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 1: RED — failing tests for the member-profile route + isAdmin read</name>
|
||||||
|
<files>apps/api/tests/routes/admin.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- 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.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- 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):`.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The new tests are committed and fail for the right reason (route + field not implemented).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 2: GREEN — implement PATCH /members/:id with last-admin guard + isAdmin in GET /members</name>
|
||||||
|
<files>apps/api/src/routes/admin.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && DB_HOST=127.0.0.1 pnpm vitest run tests/routes/admin.test.ts 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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):`.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>PATCH /members/:id and the isAdmin read field both implemented; full admin.test.ts suite green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 3: REFACTOR — tidy + pass CI gates</name>
|
||||||
|
<files>apps/api/src/routes/admin.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- 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.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>All API CI gates pass locally for the modified files; suite green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## 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). |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/20-admin-member-editor-form-declutter/20-01-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -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/"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<artifacts_produced>
|
||||||
|
## 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)
|
||||||
|
</artifacts_produced>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Add AdminMember.isAdmin + updateMemberProfile fetcher</name>
|
||||||
|
<files>apps/pwa/src/api/client.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- `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.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
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<void>`. 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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>AdminMember.isAdmin + updateMemberProfile exist, typed, and the PWA typechecks.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Pass PWA CI gates</name>
|
||||||
|
<files>apps/pwa/src/api/client.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Run the PWA eslint + prettier gates on the modified file and fix any violations. Commit: `feat(20-02): add updateMemberProfile fetcher + AdminMember.isAdmin`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- 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.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>PWA lint + format gates pass for client.ts; change committed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## 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"). |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `grep -n "updateMemberProfile" apps/pwa/src/api/client.ts` — fetcher present.
|
||||||
|
- `AdminMember` has `isAdmin: boolean`.
|
||||||
|
- PWA typecheck + eslint + prettier pass on client.ts.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `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.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/20-admin-member-editor-form-declutter/20-02-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<artifacts_produced>
|
||||||
|
## 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`
|
||||||
|
</artifacts_produced>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Build MemberEditorSheet.tsx (edit + create modes, per-section saves)</name>
|
||||||
|
<files>apps/pwa/src/components/MemberEditorSheet.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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 `<div>` with sectionLabelStyle (13px/600/uppercase, --color-text-muted) NOT `<h3>` (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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>MemberEditorSheet.tsx implements both modes with per-section saves, correct copy, the last-admin inline error, and typechecks.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Rework AdminPage MemberRow + Add-member trigger; remove old surfaces</name>
|
||||||
|
<files>apps/pwa/src/routes/AdminPage.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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: `<Plus size={16}>` 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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- 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.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>AdminPage Members tab is a tappable list + chevron + single Add-member trigger wired to MemberEditorSheet; old action buttons, inline add-form, and ResetPasswordSheet removed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Verify the interaction + visual contract with playwright-cli; pass CI gates</name>
|
||||||
|
<files>apps/pwa/src/components/MemberEditorSheet.tsx, apps/pwa/src/routes/AdminPage.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- .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
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- 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.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The unified editor + decluttered panel are observed working in a real browser and all PWA CI gates pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## 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. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/20-admin-member-editor-form-declutter/20-03-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user