From 5bcd8180c1f5e9d4933d981bae574ecf57a395ba Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 17:20:13 -0400 Subject: [PATCH] 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) }