Milestone v1.0: FamilySync MVP #1
+74
-28
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user