feat(06-05): centralize session-expiry detection and extend client types

- Add SessionExpiredError class with Object.setPrototypeOf for correct instanceof
- Add handleAuthResponse helper: throws SessionExpiredError on 401/opaqueredirect, generic Error on other non-ok
- Add redirect:'manual' + handleAuthResponse to all six fetch wrappers (fetchEvents, createEvent, updateEvent, deleteEvent, fetchSyncStatus, fetchWritableCalendars)
- Unify fetchMe: now throws SessionExpiredError instead of generic Error
- Add recurrenceUntil? and recurrenceCount? to CreateEventPayload (D-06)
- Add hasRrule: boolean to CalendarOccurrence client mirror (D-08, Pitfall 4)
This commit is contained in:
Lucas Berger
2026-06-10 11:29:37 -04:00
parent e5072ff663
commit d7d4023cf9
+74 -28
View File
@@ -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<MeResponse> {
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<MeResponse>
}
@@ -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<OccurrencesResponse> {
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<OccurrencesResponse>
}
@@ -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<CreateEv
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(payload),
})
if (!res.ok) {
throw new Error(`POST /api/events/create failed: ${res.status}`)
}
handleAuthResponse(res, 'POST /api/events/create')
return res.json() as Promise<CreateEventResponse>
}
@@ -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<CreateEventResponse>
}
@@ -219,11 +268,10 @@ export async function deleteEvent(uid: string): Promise<void> {
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<SyncStatus> {
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<SyncStatus>
}
@@ -273,11 +320,10 @@ export async function fetchSyncStatus(uid: string): Promise<SyncStatus> {
export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
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