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