diff --git a/src/App.tsx b/src/App.tsx index d393f5c..06052df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import axios from 'axios' import { useState, useCallback } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Plus, LayoutGrid, Table2, ShoppingBag } from 'lucide-react' @@ -13,6 +14,62 @@ import { DeleteConfirm } from './components/DeleteConfirm' import { Pagination } from './components/Pagination' 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 ( +
+
+
+ +
+
+

Session expired

+

+ Your login timed out. Sign in again to continue. +

+
+ +
+
+ ) +} + const DEFAULT_FILTERS: StoreFilters = { search: '', category: '', @@ -52,7 +109,7 @@ export default function App() { })) } - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, error } = useQuery({ queryKey: ['stores', filters], queryFn: () => fetchStores({ page: filters.page, @@ -126,6 +183,15 @@ export default function App() { const routes = filterOptions?.routes ?? [] 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 + } + return (
{/* Header */} diff --git a/vite.config.ts b/vite.config.ts index 0b756ad..21b80c2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,6 +41,10 @@ export default defineConfig({ }, workbox: { 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: [ { urlPattern: /^\/api\//,