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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-24 17:06:55 +00:00
parent 9f402bd2d9
commit 3740727950
2 changed files with 126 additions and 12 deletions

View file

@ -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 type { User } from '../types'
import { usePushSubscription } from '../hooks/usePushSubscription' import { usePushSubscription } from '../hooks/usePushSubscription'
@ -10,6 +10,12 @@ function getInactivityMs(): number | null {
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000 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 } interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null) const Ctx = createContext<AuthCtx | null>(null)
@ -26,22 +32,83 @@ function PushSubscriber({ user }: { user: User }) {
} }
export default function AuthGate({ children }: { children: React.ReactNode }) { export default function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
fetch('/api/auth/verify?app=maintenance', { credentials: 'include' }) fetch('/api/auth/verify?app=maintenance', { credentials: 'include' })
.then(r => { .then(async r => {
if (!r.ok) { if (r.ok) {
;(window.top ?? window).location.href = '/login' setUser(await r.json())
return null 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(() => { if (!isEmbedded()) setState('login') })
.catch(() => { ;(window.top ?? window).location.href = '/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 ( return (
<div style={{ <div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
@ -52,10 +119,57 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
) )
} }
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '1.5rem', color: 'var(--gold)' }}>
Maintenance
</h1>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email"
style={inputStyle}
/>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password"
style={inputStyle}
/>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '6px', padding: '0.625rem',
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return ( return (
<Ctx.Provider value={{ user }}> <Ctx.Provider value={{ user: user! }}>
<PushSubscriber user={user} /> <PushSubscriber user={user!} />
{children} {children}
</Ctx.Provider> </Ctx.Provider>
) )
} }
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',
}

View file

@ -15,7 +15,7 @@ export default defineConfig({
name: 'Maintenance', name: 'Maintenance',
short_name: 'Maint.', short_name: 'Maint.',
start_url: '/maintenance/', start_url: '/maintenance/',
scope: '/maintenance/', scope: '/',
display: 'standalone', display: 'standalone',
theme_color: '#b45309', theme_color: '#b45309',
background_color: '#b45309', background_color: '#b45309',