From 2f5349bd1ce4a0f683eb9fde9bd7dc9e51a933fb Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Fri, 24 Jul 2026 17:07:15 +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 kitchen/kds: AuthGate unconditionally hard-navigated window.top to the central /login on session expiry, even when not embedded in the portal iframe — dropping an installed/directly-opened forecasting session into the portal's framed browser view instead of staying in its own window. 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 inactivity auto-logout timer. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/AuthGate.tsx | 141 +++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index 7f0e793..c13ebe7 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState } from 'react' +import { createContext, useContext, useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { User } from '../types' @@ -10,6 +10,26 @@ 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 +} + +function verify(): Promise { + return fetch('/forecasting/api/auth/verify?app=forecasting', { credentials: 'include' }) + .then(r => { + if (!r.ok) throw new Error('unauth') + return r.json() + }) + .then(data => ({ + email: data.email || data.sub || '', + name: data.name || data.display_name || '', + is_admin: data.is_admin ?? false, + caps: data.caps ?? [], + })) +} interface AuthCtx { user: User } const Ctx = createContext(null) @@ -21,30 +41,72 @@ export function useAuth() { } export default function AuthGate({ children }: { children: ReactNode }) { + const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') const [user, setUser] = useState(null) - const [checking, setChecking] = useState(true) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const timerRef = useRef | null>(null) useEffect(() => { - fetch('/forecasting/api/auth/verify?app=forecasting', { credentials: 'include' }) - .then(r => { - if (!r.ok) throw new Error('unauth') - return r.json() - }) - .then(data => setUser({ - email: data.email || data.sub || '', - name: data.name || data.display_name || '', - is_admin: data.is_admin ?? false, - caps: data.caps ?? [], - })) + verify() + .then(data => { setUser(data); setState('authed') }) .catch(() => { - // Redirect the top-level window, not the iframe, so the portal - // navigates to login rather than loading inside itself (EmbeddedFallback). - ;(window.top ?? window).location.href = '/login' + if (isEmbedded()) window.top!.location.href = `/login?from=${encodeURIComponent('/app/forecasting')}` + else setState('login') }) - .finally(() => setChecking(false)) }, []) - if (checking) { + // 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('/forecasting/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('/forecasting/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 } + setUser(await verify()) + setState('authed') + } catch { + setError('Connection error — please try again') + } finally { + setLoading(false) + } + } + + if (state === 'checking') { return (
@@ -52,7 +114,46 @@ export default function AuthGate({ children }: { children: ReactNode }) { ) } - if (!user) return null + if (state === 'login') { + return ( +
+
+

+ Forecasting +

+
+ 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} + 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', }