fix: recover from expired Authentik session without clearing site data

Store Tracker is a PWA behind the shared Authentik forward-auth provider.
When the session expires (Android Chrome evicts the PWA cookie), the same-origin
/api XHR is 302'd cross-origin and CORS-blocked, the app showed only a generic
"backend connection" error, and the service worker served the cached shell for
any navigation — so re-auth was impossible without clearing all site data.

Fix:
- App swaps to a "Session expired" screen on an auth error (axios error with no
  response, or 401/403) while online. Its "Sign in" button unregisters the
  service worker + deletes all caches before navigating (programmatic "clear
  site data"), so the re-auth navigation reaches forward-auth -> Authentik.
- vite.config: navigateFallbackDenylist [/[?&]reauth=/] keeps the SW from
  serving the cached shell for the re-auth navigation.

Same bug class fixed in books (#19), speedracer, and vpn-stats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rXJ49eToZ6YFZzDYH8GXd
This commit is contained in:
claude 2026-06-27 09:10:09 -04:00
parent 9f901ec50d
commit df951ff2ab
2 changed files with 71 additions and 1 deletions

View file

@ -1,3 +1,4 @@
import axios from 'axios'
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, LayoutGrid, Table2, ShoppingBag } from 'lucide-react' import { Plus, LayoutGrid, Table2, ShoppingBag } from 'lucide-react'
@ -13,6 +14,62 @@ import { DeleteConfirm } from './components/DeleteConfirm'
import { Pagination } from './components/Pagination' import { Pagination } from './components/Pagination'
import { ToastContainer, makeToast, type ToastMessage } from './components/Toast' import { ToastContainer, makeToast, type ToastMessage } from './components/Toast'
// The Authentik forward-auth session expires (Android Chrome evicts the PWA
// cookie). When it does, the same-origin /api XHR is 302'd cross-origin to
// Authentik and CORS-blocked, so axios sees no response (or a 401/403). A 5xx
// is a backend problem, not a login problem, and must not trigger the screen.
function isSessionExpired(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false
if (!error.response) return true
return error.response.status === 401 || error.response.status === 403
}
function SessionExpiredScreen() {
async function handleSignIn() {
// The PWA service worker serves the precached app shell for every
// navigation, so a plain reload never reaches the network — Authentik's
// forward-auth redirect never fires and re-auth is impossible without
// clearing site data. Do that programmatically: drop the service worker +
// caches, then navigate so the request reaches forward-auth → login.
try {
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))
}
if ('caches' in window) {
const keys = await caches.keys()
await Promise.all(keys.map(k => caches.delete(k)))
}
} catch {
// best-effort — navigate regardless of cleanup failures
}
window.location.href = `${window.location.origin}/?reauth=${Date.now()}`
}
return (
<div className="min-h-screen bg-surface-900 grid place-items-center px-6">
<div className="glass rounded-2xl px-6 py-8 max-w-xs w-full text-center flex flex-col items-center gap-4">
<div className="w-11 h-11 rounded-xl bg-blue-500/20 flex items-center justify-center">
<ShoppingBag size={20} className="text-blue-400" />
</div>
<div>
<p className="font-semibold text-slate-200">Session expired</p>
<p className="mt-1 text-sm text-slate-500">
Your login timed out. Sign in again to continue.
</p>
</div>
<button
type="button"
onClick={handleSignIn}
className="w-full bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-white text-sm font-medium px-4 py-2.5 rounded-xl transition-colors"
>
Sign in
</button>
</div>
</div>
)
}
const DEFAULT_FILTERS: StoreFilters = { const DEFAULT_FILTERS: StoreFilters = {
search: '', search: '',
category: '', category: '',
@ -52,7 +109,7 @@ export default function App() {
})) }))
} }
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ['stores', filters], queryKey: ['stores', filters],
queryFn: () => fetchStores({ queryFn: () => fetchStores({
page: filters.page, page: filters.page,
@ -126,6 +183,15 @@ export default function App() {
const routes = filterOptions?.routes ?? [] const routes = filterOptions?.routes ?? []
const availableTags = filterOptions?.tags ?? [] const availableTags = filterOptions?.tags ?? []
// An expired Authentik session can't be recovered from inside the SPA (the
// service worker would serve the cached shell), so swap the app for a re-auth
// screen that forces a fresh forward-auth navigation. Gate on `online` so an
// offline blip doesn't demand re-login.
const online = typeof navigator === 'undefined' ? true : navigator.onLine
if (isError && online && isSessionExpired(error)) {
return <SessionExpiredScreen />
}
return ( return (
<div className="min-h-screen bg-surface-900"> <div className="min-h-screen bg-surface-900">
{/* Header */} {/* Header */}

View file

@ -41,6 +41,10 @@ export default defineConfig({
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico}'], globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
// Re-auth navigations (?reauth=) must reach the network so Authentik's
// forward-auth redirect can fire — never serve the cached app shell for
// them (the default NavigationRoute otherwise blocks re-login).
navigateFallbackDenylist: [/[?&]reauth=/],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /^\/api\//, urlPattern: /^\/api\//,