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:
co-authored by
Claude Sonnet 4.6
parent
18da7e9476
commit
5bcd8180c1
@@ -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 = {
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user