diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 87037a5..75b7a46 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -11,8 +11,52 @@ * (see apps/pwa/src/lib/loginRedirect.ts). fetchMe and other fetch calls here * are pure data fetches; they throw on non-ok responses and leave the redirect * decision to the caller (CalendarShell via maybeRedirectToLogin). + * + * D-11 (Plan 06-05): All fetch wrappers now use redirect:'manual' and throw a + * typed SessionExpiredError on 401 / opaqueredirect. The global QueryCache / + * MutationCache error handler in main.tsx catches this class and arms the + * session-expiry interstitial. A generic Error is still thrown for other non-ok + * statuses so error UI can distinguish auth failures from transient errors. */ +// ── Auth error ──────────────────────────────────────────────────────────── + +/** + * Typed error thrown by all fetch wrappers when the server returns a 401 or + * an opaque redirect (the OIDC guard's 302 to Authelia surfaced as + * res.type==='opaqueredirect' via redirect:'manual'). + * + * Object.setPrototypeOf is required so instanceof checks work correctly after + * TypeScript compilation to ES5 / CommonJS, where extending built-in Error + * breaks the prototype chain. + */ +export class SessionExpiredError extends Error { + readonly name = 'SessionExpiredError' + constructor() { + super('Session expired — re-authentication required') + Object.setPrototypeOf(this, SessionExpiredError.prototype) + } +} + +/** + * Unified auth-response checker applied to every fetch in this module. + * + * - 401 or opaqueredirect → throws SessionExpiredError (caught by global QueryCache handler) + * - other non-ok → throws a generic Error (distinguishable from auth failures) + * - ok → no-op (caller proceeds to parse body) + * + * @param res The fetch Response object + * @param label A short human-readable description for the generic error message + */ +function handleAuthResponse(res: Response, label: string): void { + if (res.type === 'opaqueredirect' || res.status === 401) { + throw new SessionExpiredError() + } + if (!res.ok) { + throw new Error(`${label} failed: ${res.status}`) + } +} + // ── /api/me ──────────────────────────────────────────────────────────────── export interface MeUser { @@ -38,16 +82,7 @@ export async function fetchMe(): Promise { redirect: 'manual', }) - if (res.type === 'opaqueredirect' || res.status === 401) { - // Session missing/expired → the guard wants us at Authelia. Signal the caller - // (CalendarShell) to perform a TOP-LEVEL navigation to /api/login via - // maybeRedirectToLogin() — a document navigation is not CORS-restricted. - throw new Error('GET /api/me: authentication required') - } - - if (!res.ok) { - throw new Error(`GET /api/me failed: ${res.status}`) - } + handleAuthResponse(res, 'GET /api/me') return res.json() as Promise } @@ -88,6 +123,12 @@ export interface CalendarOccurrence { allDay: boolean location: string | null description: string | null + /** + * True when this occurrence belongs to a recurring series (has an RRULE). + * Mirrors CalendarOccurrence.hasRrule in apps/api/src/broker/expand.ts — must + * stay in sync with the server type (Pitfall 4 — atomic mirror, Plan 06-05). + */ + hasRrule: boolean } export interface OccurrencesResponse { @@ -109,11 +150,10 @@ export async function fetchEvents( ): Promise { const res = await fetch(`/api/events?start=${start}&end=${end}`, { credentials: 'include', + redirect: 'manual', }) - if (!res.ok) { - throw new Error(`GET /api/events failed: ${res.status}`) - } + handleAuthResponse(res, 'GET /api/events') return res.json() as Promise } @@ -142,6 +182,17 @@ export interface CreateEventPayload { // the event's existing RRULE (the occurrence contract does not expose recurrence, so // the form cannot echo it back without silently resetting it to 'none'). recurrence?: RecurrencePreset + /** + * RRULE UNTIL date (D-06). ISO 'YYYY-MM-DD' string. Only sent when recurrence !== 'none' + * and the user selects the "On date" bound. Mutually exclusive with recurrenceCount. + * The outbox worker converts this to RRULE UNTIL format (DATE for all-day, DATETIME UTC for timed). + */ + recurrenceUntil?: string + /** + * RRULE COUNT (D-06). Integer >= 1. Only sent when recurrence !== 'none' and the user + * selects the "After N times" bound. Mutually exclusive with recurrenceUntil. + */ + recurrenceCount?: number location?: string description?: string calendarUrl?: string // omit to use the member's default writable calendar (D-01) @@ -175,12 +226,11 @@ export async function createEvent(payload: CreateEventPayload): Promise } @@ -199,12 +249,11 @@ export async function updateEvent( method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + redirect: 'manual', body: JSON.stringify(payload), }) - if (!res.ok) { - throw new Error(`PATCH /api/events/${uid}/edit failed: ${res.status}`) - } + handleAuthResponse(res, `PATCH /api/events/${uid}/edit`) return res.json() as Promise } @@ -219,11 +268,10 @@ export async function deleteEvent(uid: string): Promise { const res = await fetch(`/api/events/${uid}`, { method: 'DELETE', credentials: 'include', + redirect: 'manual', }) - if (!res.ok) { - throw new Error(`DELETE /api/events/${uid} failed: ${res.status}`) - } + handleAuthResponse(res, `DELETE /api/events/${uid}`) } // ── /api/events/sync-status (Plan 03-06) ───────────────────────────────────── @@ -254,11 +302,10 @@ export interface SyncStatus { export async function fetchSyncStatus(uid: string): Promise { const res = await fetch(`/api/events/sync-status?uid=${uid}`, { credentials: 'include', + redirect: 'manual', }) - if (!res.ok) { - throw new Error(`GET /api/events/sync-status failed: ${res.status}`) - } + handleAuthResponse(res, 'GET /api/events/sync-status') return res.json() as Promise } @@ -273,11 +320,10 @@ export async function fetchSyncStatus(uid: string): Promise { export async function fetchWritableCalendars(): Promise { const res = await fetch('/api/events/writable-calendars', { credentials: 'include', + redirect: 'manual', }) - if (!res.ok) { - throw new Error(`GET /api/events/writable-calendars failed: ${res.status}`) - } + handleAuthResponse(res, 'GET /api/events/writable-calendars') const body = (await res.json()) as { calendars: WritableCalendar[] } return body.calendars