From bfe1eff5a359207f79af376152ac780d2275edf3 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 15:02:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(10-04):=20extend=20client.ts=20=E2=80=94?= =?UTF-8?q?=20MeUser.isAdmin+needsProviderSetup=20+=20admin/self-service?= =?UTF-8?q?=20fetchers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/pwa/src/api/client.ts | 136 +++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 4024a3e..145816e 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -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 { 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 { + const res = await fetch('/api/admin/members', { + credentials: 'include', + redirect: 'manual', + }); + + handleAuthResponse(res, 'GET /api/admin/members'); + + return res.json() as Promise; +} + +/** + * 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 { + 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 { + const res = await fetch('/api/admin/calendars', { + credentials: 'include', + redirect: 'manual', + }); + + handleAuthResponse(res, 'GET /api/admin/calendars'); + + return res.json() as Promise; +} + +/** + * 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 { + 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 { + 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'); +}