fix(pwa): detect upstream proxy auth challenges and recover gracefully

Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.

New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.

axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
  probe also fails (proxy is intercepting all requests), trigger a guarded
  window.location.reload() so the edge proxy can intercept the top-level
  navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
  covering Pangolin header-auth extended compatibility mode which returns
  401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
  there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
  response so the guard resets after re-auth.

/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.

LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.

Fixes discussion #836.
This commit is contained in:
jubnl
2026-05-06 12:16:08 +02:00
parent 48c0f97ab9
commit 3ee4da9775
6 changed files with 119 additions and 13 deletions
+4 -1
View File
@@ -276,7 +276,10 @@ export function createApp(): express.Application {
app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
app.get('/api/health', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate')
res.json({ status: 'ok' })
});
app.use('/api/config', publicConfigRoutes);
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);