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:
+74
-28
@@ -11,8 +11,52 @@
|
|||||||
* (see apps/pwa/src/lib/loginRedirect.ts). fetchMe and other fetch calls here
|
* (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
|
* are pure data fetches; they throw on non-ok responses and leave the redirect
|
||||||
* decision to the caller (CalendarShell via maybeRedirectToLogin).
|
* 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 ────────────────────────────────────────────────────────────────
|
// ── /api/me ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MeUser {
|
export interface MeUser {
|
||||||
@@ -38,16 +82,7 @@ export async function fetchMe(): Promise<MeResponse> {
|
|||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.type === 'opaqueredirect' || res.status === 401) {
|
handleAuthResponse(res, 'GET /api/me')
|
||||||
// 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}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<MeResponse>
|
return res.json() as Promise<MeResponse>
|
||||||
}
|
}
|
||||||
@@ -88,6 +123,12 @@ export interface CalendarOccurrence {
|
|||||||
allDay: boolean
|
allDay: boolean
|
||||||
location: string | null
|
location: string | null
|
||||||
description: 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 {
|
export interface OccurrencesResponse {
|
||||||
@@ -109,11 +150,10 @@ export async function fetchEvents(
|
|||||||
): Promise<OccurrencesResponse> {
|
): Promise<OccurrencesResponse> {
|
||||||
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
|
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
handleAuthResponse(res, 'GET /api/events')
|
||||||
throw new Error(`GET /api/events failed: ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<OccurrencesResponse>
|
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 event's existing RRULE (the occurrence contract does not expose recurrence, so
|
||||||
// the form cannot echo it back without silently resetting it to 'none').
|
// the form cannot echo it back without silently resetting it to 'none').
|
||||||
recurrence?: RecurrencePreset
|
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
|
location?: string
|
||||||
description?: string
|
description?: string
|
||||||
calendarUrl?: string // omit to use the member's default writable calendar (D-01)
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
handleAuthResponse(res, 'POST /api/events/create')
|
||||||
throw new Error(`POST /api/events/create failed: ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<CreateEventResponse>
|
return res.json() as Promise<CreateEventResponse>
|
||||||
}
|
}
|
||||||
@@ -199,12 +249,11 @@ export async function updateEvent(
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
handleAuthResponse(res, `PATCH /api/events/${uid}/edit`)
|
||||||
throw new Error(`PATCH /api/events/${uid}/edit failed: ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<CreateEventResponse>
|
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}`, {
|
const res = await fetch(`/api/events/${uid}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
handleAuthResponse(res, `DELETE /api/events/${uid}`)
|
||||||
throw new Error(`DELETE /api/events/${uid} failed: ${res.status}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── /api/events/sync-status (Plan 03-06) ─────────────────────────────────────
|
// ── /api/events/sync-status (Plan 03-06) ─────────────────────────────────────
|
||||||
@@ -254,11 +302,10 @@ export interface SyncStatus {
|
|||||||
export async function fetchSyncStatus(uid: string): Promise<SyncStatus> {
|
export async function fetchSyncStatus(uid: string): Promise<SyncStatus> {
|
||||||
const res = await fetch(`/api/events/sync-status?uid=${uid}`, {
|
const res = await fetch(`/api/events/sync-status?uid=${uid}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
handleAuthResponse(res, 'GET /api/events/sync-status')
|
||||||
throw new Error(`GET /api/events/sync-status failed: ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<SyncStatus>
|
return res.json() as Promise<SyncStatus>
|
||||||
}
|
}
|
||||||
@@ -273,11 +320,10 @@ export async function fetchSyncStatus(uid: string): Promise<SyncStatus> {
|
|||||||
export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
|
export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
|
||||||
const res = await fetch('/api/events/writable-calendars', {
|
const res = await fetch('/api/events/writable-calendars', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
handleAuthResponse(res, 'GET /api/events/writable-calendars')
|
||||||
throw new Error(`GET /api/events/writable-calendars failed: ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await res.json()) as { calendars: WritableCalendar[] }
|
const body = (await res.json()) as { calendars: WritableCalendar[] }
|
||||||
return body.calendars
|
return body.calendars
|
||||||
|
|||||||
Reference in New Issue
Block a user