feat(10-04): extend client.ts — MeUser.isAdmin+needsProviderSetup + admin/self-service fetchers

- Add isAdmin and needsProviderSetup to MeUser interface (D-03 UX gating)
- Add AdminMember, SaveCredentialPayload, AdminCalendar, SaveMyCredentialPayload types
- Add fetchAdminMembers, saveCredential, fetchAdminCalendars, setSharedCalendar fetchers
- Add saveMyCredential (self-service, no userId field — T-10-12/Pitfall 6)
- All fetchers use credentials:'include', redirect:'manual', handleAuthResponse
- Password never logged or stored beyond in-flight request body (T-10-15)
This commit is contained in:
Lucas Berger
2026-06-13 15:02:06 -04:00
parent 0f41993a95
commit bfe1eff5a3
+136
View File
@@ -63,6 +63,8 @@ export interface MeUser {
id: number;
displayName: string | null;
color: string;
isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/*
needsProviderSetup: boolean; // true when no member_credentials row exists for this user
}
export interface MeResponse {
@@ -325,3 +327,137 @@ export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
const body = (await res.json()) as { calendars: WritableCalendar[] };
return body.calendars;
}
// ── /api/admin/* (Phase 10, Plan 04) ─────────────────────────────────────────
/**
* A member row as returned by GET /api/admin/members.
* Matches the Plan-03 shape: { id, displayName, color, hasCredential }.
*/
export interface AdminMember {
id: number;
displayName: string | null;
color: string;
hasCredential: boolean;
}
export interface AdminMembersResponse {
members: AdminMember[];
}
/**
* Payload for POST /api/admin/credentials (admin-managed rotation).
* NOTE: includes userId (the member being rotated) — admin-scoped.
* The app password MUST NOT be logged or stored beyond the in-flight request body (T-10-15).
*/
export interface SaveCredentialPayload {
userId: number;
providerType: 'caldav';
fastmailEmail: string;
appPassword: string;
}
/**
* A synced calendar row as returned by GET /api/admin/calendars.
* Matches the Plan-03 shape: { id, displayName, isShared }.
*/
export interface AdminCalendar {
id: number;
displayName: string;
isShared: boolean;
}
export interface AdminCalendarsResponse {
calendars: AdminCalendar[];
}
/**
* Payload for POST /api/me/credential (self-service, member-scoped).
* NOTE: no userId field — the server resolves userId from the session (Pitfall 6 / T-10-12).
* The app password MUST NOT be logged or stored beyond the in-flight request body (T-10-15).
*/
export interface SaveMyCredentialPayload {
providerType: 'caldav';
fastmailEmail: string;
appPassword: string;
}
/**
* Fetch the list of all members with their credential status.
* Admin-only: the server enforces requireAdmin (403 for non-admins).
*/
export async function fetchAdminMembers(): Promise<AdminMembersResponse> {
const res = await fetch('/api/admin/members', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/members');
return res.json() as Promise<AdminMembersResponse>;
}
/**
* Save (add or rotate) a credential for any member.
* Admin-only: requires userId in payload; server enforces requireAdmin.
* The app password is sent in the request body and NEVER stored client-side.
*/
export async function saveCredential(payload: SaveCredentialPayload): Promise<void> {
const res = await fetch('/api/admin/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(payload),
});
handleAuthResponse(res, 'POST /api/admin/credentials');
}
/**
* Fetch the list of synced calendars with their shared status.
* Admin-only: the server enforces requireAdmin (403 for non-admins).
*/
export async function fetchAdminCalendars(): Promise<AdminCalendarsResponse> {
const res = await fetch('/api/admin/calendars', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/calendars');
return res.json() as Promise<AdminCalendarsResponse>;
}
/**
* Set the shared calendar (ADMIN-02: exclusive single-select, D-06).
* Admin-only: the server enforces requireAdmin.
* Clears is_shared on all other calendars and sets it on the given id.
*/
export async function setSharedCalendar(calendarId: number): Promise<void> {
const res = await fetch(`/api/admin/calendars/${calendarId}/shared`, {
method: 'PUT',
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, `PUT /api/admin/calendars/${calendarId}/shared`);
}
/**
* Self-service: save (add) the current member's own credential.
* Member-scoped: NO userId in payload — server resolves from session (Pitfall 6 / T-10-12).
* The app password is sent in the request body and NEVER stored client-side.
* On success, /api/me re-fetched via ['me'] cache invalidation → needsProviderSetup becomes false.
*/
export async function saveMyCredential(payload: SaveMyCredentialPayload): Promise<void> {
const res = await fetch('/api/me/credential', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(payload),
});
handleAuthResponse(res, 'POST /api/me/credential');
}