From 21224c0ebeb384e0b931daeb6f98fe3c4a85ec24 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Fri, 24 Jul 2026 17:07:00 +0000 Subject: [PATCH] Fix session-expiry redirect breaking standalone PWA out of its shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as maintenance/room-planner: manifest scope was the app's own base path instead of "/", and AuthGate unconditionally hard- navigated window.top to the central /login on session expiry even when not embedded in the portal iframe — together these dropped an installed/standalone reports PWA into the portal's framed browser view instead of staying in its own window. AuthGate now only bounces to central login when actually embedded (passing ?from= so it returns here 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. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/AuthGate.tsx | 128 ++++++++++++++++++++++++--- frontend/vite.config.ts | 2 +- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index 632fd3cf..6fc0137f 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' function getInactivityMs(): number | null { @@ -9,6 +9,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) @@ -20,22 +26,83 @@ export function useAuth() { } 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('/reports/api/auth/verify?app=reports', { 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/reports')}` + } 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('/reports/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('/reports/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('/reports/api/auth/verify?app=reports', { 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 (
{children} + if (state === 'login') { + return ( +
+
+

+ Reports +

+
+ 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: '#0f0f20', border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '6px', color: 'rgba(255,255,255,0.88)', padding: '0.625rem 0.75rem', + fontSize: '1rem', width: '100%', outline: 'none', } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 841b4be9..5fee9cdd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ name: 'Reports', short_name: 'Reports', start_url: '/reports/', - scope: '/reports/', + scope: '/', display: 'standalone', theme_color: '#1d4ed8', background_color: '#1d4ed8',