feat(20-02): add updateMemberProfile fetcher + AdminMember.isAdmin

- 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 <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-18 17:20:13 -04:00
co-authored by Claude Sonnet 4.6
parent 18da7e9476
commit 5bcd8180c1
2 changed files with 33 additions and 4 deletions
+2 -4
View File
@@ -801,15 +801,13 @@ describe('updateMemberProfile — URL + verb contract (Phase 20, Plan 20-02)', (
const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); const { updateMemberProfile, SessionExpiredError } = await import('./client.js');
await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toSatisfy( await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toSatisfy(
(e: unknown) => (e: unknown) =>
e instanceof Error && e instanceof Error && !(e instanceof SessionExpiredError) && e.message !== 'last-admin',
!(e instanceof SessionExpiredError) &&
e.message !== 'last-admin',
); );
}); });
}); });
describe('AdminMember.isAdmin field (Phase 20, Plan 20-02)', () => { 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 // Construct a conforming object — TypeScript will error at compile time if
// isAdmin is missing from the AdminMember interface (caught by tsc --noEmit). // isAdmin is missing from the AdminMember interface (caught by tsc --noEmit).
const member: import('./client.js').AdminMember = { const member: import('./client.js').AdminMember = {
+31
View File
@@ -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<void> {
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). * 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; id: number;
displayName: string | null; displayName: string | null;
color: string; color: string;
isAdmin: boolean; // Phase 20 — drives the editor admin toggle initial state (D-02)
hasCredential: boolean; hasCredential: boolean;
hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19) hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19)
} }