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');
|
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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user