From fe095d08919c5c13f8dbe68a0189b59bb9cebaf2 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Fri, 24 Jul 2026 17:07:10 +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 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 kitchen 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 previously-dead inactivity auto-logout timer (disabled for installed PWAs, configurable per device otherwise). The legacy token/restrictedPages/login/logout compat shim is unchanged. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/AuthGate.tsx | 158 +++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index c6014ed..82871c0 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -1,13 +1,42 @@ -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' +function getInactivityMs(): number | null { + if (window.matchMedia('(display-mode: standalone)').matches) return null + const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins=')) + if (!c) return null + const mins = parseInt(c.split('=')[1]) + 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('/kitchen/api/auth/verify?app=kitchen', { credentials: 'include' }) + .then(r => { + if (!r.ok) throw new Error('unauth') + return r.json() + }) + .then((data): User => ({ + email: data.email || data.sub || '', + name: data.name || data.display_name || '', + is_admin: data.is_admin ?? false, + caps: data.caps ?? [], + })) +} + // Compatibility shim: archive components destructure token/login/logout/restrictedPages // from useAuth(). These stubs keep the TypeScript build clean. Runtime behaviour: // token = "__session__" (truthy so component guards pass; backends ignore the // Authorization header and use the hnf_session cookie — see log B5b for full migration). // restrictedPages = [] (replaced by cap-based access control). -// logout = redirect to /login portal. +// logout = clears the session and returns to the sign-in screen. // login = no-op (cookie auth, no local token). interface AuthCtx { user: User @@ -25,30 +54,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('/kitchen/api/auth/verify?app=kitchen', { 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(() => { - ;(window.top ?? window).location.href = '/login' + if (isEmbedded()) window.top!.location.href = `/login?from=${encodeURIComponent('/app/kitchen')}` + 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('/kitchen/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('/kitchen/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 (
@@ -56,15 +127,58 @@ export default function AuthGate({ children }: { children: ReactNode }) { ) } - if (!user) return null + if (state === 'login') { + return ( +
+
+

+ Kitchen +

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

} + +
+
+
+ ) + } const ctx: AuthCtx = { - user, + user: user!, token: '__session__', restrictedPages: [], login: () => {}, - logout: () => { (window.top ?? window).location.href = '/login' }, + logout: () => { + fetch('/kitchen/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) + if (isEmbedded()) window.top!.location.href = '/login' + else { setUser(null); setState('login') } + }, } 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', +}