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) }