From 18da7e9476d46b980306121b41ed7bd61b72cae6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 17:18:21 -0400 Subject: [PATCH 1/3] test(20-02): add failing tests for updateMemberProfile + AdminMember.isAdmin - 8 RED tests covering: PATCH URL contract, credentials/redirect shape, void on 200, SessionExpiredError on 401/opaqueredirect, last-admin sentinel on 409 and 422, generic error on 500 - 1 compile-time shape test for AdminMember.isAdmin: boolean - All new tests fail (updateMemberProfile is not a function); 42 existing pass Co-Authored-By: Claude Sonnet 4.6 --- apps/pwa/src/api/client.test.ts | 139 ++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/apps/pwa/src/api/client.test.ts b/apps/pwa/src/api/client.test.ts index c4cae79..c370188 100644 --- a/apps/pwa/src/api/client.test.ts +++ b/apps/pwa/src/api/client.test.ts @@ -684,3 +684,142 @@ describe('fetchAdminResetPassword — URL contract (Phase 19, AUTH-LOCAL-08)', ( ); }); }); + +// ── Phase 20 (Plan 20-02): updateMemberProfile + AdminMember.isAdmin ───────── +// TDD RED: these tests MUST fail before the implementation is added to client.ts. + +describe('updateMemberProfile — URL + verb contract (Phase 20, Plan 20-02)', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('PATCHes /api/admin/members/:id (must match PATCH /members/:id in admin.ts)', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + type: 'basic', + status: 200, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + await updateMemberProfile(7, { displayName: 'Alice' }); + + expect(fetch).toHaveBeenCalledWith( + '/api/admin/members/7', + expect.objectContaining({ method: 'PATCH' }), + ); + }); + + it('sends credentials:include and redirect:manual', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + type: 'basic', + status: 200, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + await updateMemberProfile(3, { isAdmin: true }); + + expect(fetch).toHaveBeenCalledWith( + '/api/admin/members/3', + expect.objectContaining({ + credentials: 'include', + redirect: 'manual', + }), + ); + }); + + it('resolves void on 200', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + type: 'basic', + status: 200, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + const result = await updateMemberProfile(7, { displayName: 'Bob' }); + expect(result).toBeUndefined(); + }); + + it('throws SessionExpiredError on opaqueredirect', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'opaqueredirect', + status: 0, + } as unknown as Response); + + const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); + await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf( + SessionExpiredError, + ); + }); + + it('throws SessionExpiredError on 401', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 401, + } as unknown as Response); + + const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); + await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf( + SessionExpiredError, + ); + }); + + it('throws Error("last-admin") on 409 (last-admin demotion sentinel)', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 409, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin'); + }); + + it('throws Error("last-admin") on 422 as well (server may return either)', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 422, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin'); + }); + + it('throws a generic error on any other non-ok status (not last-admin sentinel)', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 500, + } as unknown as Response); + + const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); + await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toSatisfy( + (e: unknown) => + e instanceof Error && + !(e instanceof SessionExpiredError) && + e.message !== 'last-admin', + ); + }); +}); + +describe('AdminMember.isAdmin field (Phase 20, Plan 20-02)', () => { + it('AdminMember interface has isAdmin: boolean (compile-time type check via runtime shape)', async () => { + // Construct a conforming object — TypeScript will error at compile time if + // isAdmin is missing from the AdminMember interface (caught by tsc --noEmit). + const member: import('./client.js').AdminMember = { + id: 1, + displayName: 'Test', + color: '#abc', + isAdmin: true, + hasCredential: false, + hasLocalCredential: false, + }; + expect(member.isAdmin).toBe(true); + }); +}); From 5bcd8180c1f5e9d4933d981bae574ecf57a395ba Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 17:20:13 -0400 Subject: [PATCH 2/3] feat(20-02): add updateMemberProfile fetcher + AdminMember.isAdmin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isAdmin: boolean to AdminMember interface (after color, before hasCredential) — feeds the Phase 20 editor toggle initial state (D-02) - Add updateMemberProfile(memberId, body) fetcher: PATCH /api/admin/members/:id, credentials:include, redirect:manual, JSON body - Maps 401/opaqueredirect → SessionExpiredError (existing convention) - Maps 409/422 → Error('last-admin') sentinel (D-03 last-admin guard) - Maps other non-ok → generic error - All 50 tests pass; eslint + prettier + tsc --noEmit exit 0 Co-Authored-By: Claude Sonnet 4.6 --- apps/pwa/src/api/client.test.ts | 6 ++---- apps/pwa/src/api/client.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/pwa/src/api/client.test.ts b/apps/pwa/src/api/client.test.ts index c370188..e2eb6d8 100644 --- a/apps/pwa/src/api/client.test.ts +++ b/apps/pwa/src/api/client.test.ts @@ -801,15 +801,13 @@ describe('updateMemberProfile — URL + verb contract (Phase 20, Plan 20-02)', ( const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toSatisfy( (e: unknown) => - e instanceof Error && - !(e instanceof SessionExpiredError) && - e.message !== 'last-admin', + e instanceof Error && !(e instanceof SessionExpiredError) && e.message !== 'last-admin', ); }); }); describe('AdminMember.isAdmin field (Phase 20, Plan 20-02)', () => { - it('AdminMember interface has isAdmin: boolean (compile-time type check via runtime shape)', async () => { + it('AdminMember interface has isAdmin: boolean (compile-time type check via runtime shape)', () => { // Construct a conforming object — TypeScript will error at compile time if // isAdmin is missing from the AdminMember interface (caught by tsc --noEmit). const member: import('./client.js').AdminMember = { diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 426007f..4590581 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -233,6 +233,36 @@ export async function fetchAdminResetPassword( } } +/** + * PATCH /api/admin/members/:id — update a member's display name and/or admin flag (Phase 20, D-02). + * + * Admin-only; server enforces requireAdmin. Sends only the fields that are present in `body` + * (partial update — the server schema marks both fields optional). + * + * Status codes: + * 200 → success (resolves void) + * 401 / opaqueredirect → throws SessionExpiredError (session expired; existing convention) + * 409 / 422 → throws Error('last-admin') — the D-03 sentinel: demoting the last admin is + * rejected server-side; the editor branches on this message to show inline copy. + * other non-ok → generic error + */ +export async function updateMemberProfile( + memberId: number, + body: { displayName?: string; isAdmin?: boolean }, +): Promise { + const res = await fetch(`/api/admin/members/${memberId}`, { + method: 'PATCH', + 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'); + if (!res.ok) throw new Error(`updateMemberProfile failed: ${res.status}`); +} + /** * POST /api/me/link-oidc — initiate the OIDC-link flow for the current local user (Surface 13). * @@ -564,6 +594,7 @@ export interface AdminMember { id: number; displayName: string | null; color: string; + isAdmin: boolean; // Phase 20 — drives the editor admin toggle initial state (D-02) hasCredential: boolean; hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19) } From 618991cf13b2a87659c3d6fd341371323b41725b Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 17:21:26 -0400 Subject: [PATCH 3/3] docs(20-02): complete plan 20-02 summary Co-Authored-By: Claude Sonnet 4.6 --- .../20-02-SUMMARY.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .planning/phases/20-admin-member-editor-form-declutter/20-02-SUMMARY.md 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 |