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:
parent
9f402bd2d9
commit
3740727950
2 changed files with 126 additions and 12 deletions
|
|
@ -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<AuthCtx | null>(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<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(() => {
|
||||
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 (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
|
|
@ -52,10 +119,57 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||
)
|
||||
}
|
||||
|
||||
if (state === 'login') {
|
||||
return (
|
||||
<Ctx.Provider value={{ user }}>
|
||||
<PushSubscriber user={user} />
|
||||
<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 (
|
||||
<Ctx.Provider value={{ user: user! }}>
|
||||
<PushSubscriber user={user!} />
|
||||
{children}
|
||||
</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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue