Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
3 changed files with 144 additions and 4 deletions
Showing only changes of commit 6dc9ccd2e9 - Show all commits
+9 -4
View File
@@ -5,8 +5,12 @@
* every cross-origin request (Vite dev proxy routes to :3000; production is
* same-origin via Pangolin).
*
* 401 responses mean the session has expired — the browser will follow the
* 302 redirect to Authelia on the next API call automatically (full-page nav).
* Auth note: the OIDC guard's 302 to Authelia is CORS-blocked for fetch/XHR —
* browsers do not follow cross-origin redirects from XHR to an external IdP.
* Re-authentication therefore requires a TOP-LEVEL navigation to /api/login
* (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).
*/
// ── /api/me ────────────────────────────────────────────────────────────────
@@ -27,8 +31,9 @@ export async function fetchMe(): Promise<MeResponse> {
})
if (!res.ok) {
// 302 → browser follows redirect to Authelia automatically.
// For 4xx/5xx, throw so React Query can surface the error.
// Throw on any non-ok response. The OIDC guard's 302 to Authelia cannot be
// followed by fetch (CORS-blocked for XHR). Callers that handle auth errors
// should use maybeRedirectToLogin() for a top-level navigation to /api/login.
throw new Error(`GET /api/me failed: ${res.status}`)
}
+71
View File
@@ -0,0 +1,71 @@
/**
* loginRedirect — one-shot sessionStorage-guarded redirect helper.
*
* Covers:
* - maybeRedirectToLogin() redirects to /api/login on first call and returns true
* - maybeRedirectToLogin() does NOT redirect on second call (one-shot guard) and returns false
* - clearLoginRedirect() removes the flag so a subsequent call redirects again
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Stub window.location with a writable href before importing the module.
// jsdom sets window.location to a read-only getter backed by a Location object;
// we need to replace it with a plain object so href assignment is detectable.
const locationStub = { href: '' }
Object.defineProperty(window, 'location', {
value: locationStub,
writable: true,
})
import { maybeRedirectToLogin, clearLoginRedirect } from './loginRedirect.js'
describe('maybeRedirectToLogin', () => {
beforeEach(() => {
sessionStorage.clear()
locationStub.href = ''
vi.clearAllMocks()
})
it('sets window.location.href to /api/login and returns true on first call', () => {
const result = maybeRedirectToLogin()
expect(result).toBe(true)
expect(locationStub.href).toBe('/api/login')
})
it('sets the sessionStorage flag after first call', () => {
maybeRedirectToLogin()
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBe('1')
})
it('does NOT change href on second call and returns false (one-shot guard)', () => {
maybeRedirectToLogin() // first call — redirects
locationStub.href = '' // reset the stub to detect a second assignment
const result = maybeRedirectToLogin() // second call — should NOT redirect
expect(result).toBe(false)
expect(locationStub.href).toBe('') // href must NOT have been reassigned
})
})
describe('clearLoginRedirect', () => {
beforeEach(() => {
sessionStorage.clear()
locationStub.href = ''
vi.clearAllMocks()
})
it('removes the sessionStorage flag', () => {
sessionStorage.setItem('familysync.loginRedirectAttempted', '1')
clearLoginRedirect()
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBeNull()
})
it('allows maybeRedirectToLogin to redirect again after the flag is cleared', () => {
maybeRedirectToLogin() // first call — sets flag
clearLoginRedirect() // clear the flag
locationStub.href = '' // reset stub
const result = maybeRedirectToLogin() // should redirect again
expect(result).toBe(true)
expect(locationStub.href).toBe('/api/login')
})
})
+64
View File
@@ -0,0 +1,64 @@
/**
* One-shot login-redirect helper.
*
* Problem: the OIDC guard issues a 302 to Authelia, but fetch()/XHR cannot follow a
* cross-origin 302 to Authelia — the browser enforces CORS on XHR redirects, so the
* response is opaque and the SPA never reaches Authelia. Re-auth requires a TOP-LEVEL
* browser navigation to /api/login, where the guard's redirect is followed at the
* document level (no CORS restriction).
*
* Solution: when /api/me fails (indicating an unauthenticated session), perform a
* full-page navigation to /api/login. A sessionStorage flag prevents infinite loops:
* if the navigation completes and /api/me still fails (genuine error), the second
* maybeRedirectToLogin() call is a no-op and the "Sign-in required" UI is shown.
*/
export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted'
/**
* Navigate to /api/login if this is the first attempt.
*
* Returns true when a redirect was triggered (caller can bail early — the page
* is navigating away). Returns false when the flag was already set (already
* bounced through login once; let the caller render the error UI instead).
*
* Safe under SSR/test environments: no-ops and returns false when window or
* sessionStorage is unavailable.
*/
export function maybeRedirectToLogin(): boolean {
if (typeof window === 'undefined') return false
if (typeof sessionStorage === 'undefined') return false
try {
if (sessionStorage.getItem(LOGIN_REDIRECT_KEY) !== null) {
// Already attempted — do not redirect again (loop guard).
return false
}
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1')
window.location.href = '/api/login'
return true
} catch {
// sessionStorage access can throw in private-browsing mode or with storage quota
// exceeded. Fail open: do not redirect, let the caller render the error.
return false
}
}
/**
* Clear the one-shot flag.
*
* Call on a successful /api/me load so that a later session expiry can trigger
* another redirect instead of silently showing "Sign-in required".
*
* Safe under SSR/test environments (guarded).
*/
export function clearLoginRedirect(): void {
if (typeof window === 'undefined') return
if (typeof sessionStorage === 'undefined') return
try {
sessionStorage.removeItem(LOGIN_REDIRECT_KEY)
} catch {
// Ignore storage errors — clearing the flag is best-effort.
}
}