From 37407279509ca48e6a7d9c7573a5217685ae3e76 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Fri, 24 Jul 2026 17:06:55 +0000 Subject: [PATCH] Fix session-expiry redirect breaking standalone PWA out of its shell Two compounding issues were kicking installed/standalone maintenance PWA sessions out into the portal's framed browser view on re-login: - manifest scope was the app's own base path instead of "/", so any same-origin navigation outside it (like the old redirect to /login) dropped the standalone window into a regular browser tab - AuthGate unconditionally hard-navigated window.top to the central /login on session expiry, even when not embedded in the portal iframe, which is exactly the navigation the doc warns against AuthGate now only bounces to central login when actually embedded (and passes ?from= so it returns to this app afterwards); standalone or directly-opened tabs get an in-app login form and never navigate away. Also wired up the previously-dead inactivity auto-logout timer (disabled for installed PWAs, configurable per device otherwise). Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/AuthGate.tsx | 136 ++++++++++++++++++++++++--- frontend/vite.config.ts | 2 +- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index 78f94d6..84080c3 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, createContext, useContext } from 'react' +import { useEffect, useRef, useState, createContext, useContext } from 'react' import type { User } from '../types' import { usePushSubscription } from '../hooks/usePushSubscription' @@ -10,6 +10,12 @@ function getInactivityMs(): number | null { return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000 } +// Only bounce to the central login when actually embedded in the portal shell. +// A standalone PWA or a directly-opened browser tab must never navigate away +// from its own start_url/scope — otherwise it loses its installed-app context. +function isEmbedded() { + return window.top !== window +} interface AuthCtx { user: User } const Ctx = createContext(null) @@ -26,22 +32,83 @@ function PushSubscriber({ user }: { user: User }) { } export default function AuthGate({ children }: { children: React.ReactNode }) { + const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') const [user, setUser] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const timerRef = useRef | null>(null) useEffect(() => { fetch('/api/auth/verify?app=maintenance', { credentials: 'include' }) - .then(r => { - if (!r.ok) { - ;(window.top ?? window).location.href = '/login' - return null + .then(async r => { + if (r.ok) { + setUser(await r.json()) + setState('authed') + } else if (isEmbedded()) { + window.top!.location.href = `/login?from=${encodeURIComponent('/app/maintenance')}` + } else { + setState('login') } - return r.json() }) - .then(data => { if (data) setUser(data) }) - .catch(() => { ;(window.top ?? window).location.href = '/login' }) + .catch(() => { if (!isEmbedded()) setState('login') }) }, []) - if (!user) { + // Inactivity auto-logout — disabled for installed PWAs; configurable per + // device (Admin Settings → Device) for shared/front-desk browser sessions. + useEffect(() => { + const ms = getInactivityMs() + if (state !== 'authed' || !ms) return + const timeoutMs: number = ms + + async function forceLogout() { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) + setUser(null) + setState('login') + } + + function reset() { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(forceLogout, timeoutMs) + } + + const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const + events.forEach(e => window.addEventListener(e, reset, { passive: true })) + reset() + + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + events.forEach(e => window.removeEventListener(e, reset)) + } + }, [state]) + + async function login(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + try { + const res = await fetch('/api/auth/login', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { setError('Invalid email or password'); return } + const verify = await fetch('/api/auth/verify?app=maintenance', { credentials: 'include' }) + if (verify.ok) { + setUser(await verify.json()) + setState('authed') + } else { + setError("You don't have access to this app.") + } + } catch { + setError('Connection error — please try again') + } finally { + setLoading(false) + } + } + + if (state === 'checking') { return (
+
+

+ Maintenance +

+
+ setEmail(e.target.value)} + placeholder="Email" required autoComplete="email" + style={inputStyle} + /> + setPassword(e.target.value)} + placeholder="Password" required autoComplete="current-password" + style={inputStyle} + /> + {error &&

{error}

} + +
+
+
+ ) + } + return ( - - + + {children} ) } + +const inputStyle: React.CSSProperties = { + background: 'var(--navy-dark)', border: '1px solid var(--surface-2)', + borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem', + fontSize: '1rem', width: '100%', outline: 'none', +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6d1643a..3d7ad57 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ name: 'Maintenance', short_name: 'Maint.', start_url: '/maintenance/', - scope: '/maintenance/', + scope: '/', display: 'standalone', theme_color: '#b45309', background_color: '#b45309',