Fix session-expiry redirect losing return-to-app + wire up inactivity timer

Login.tsx only read the return path from router state, which a hard
cross-document navigation (a child app bouncing the whole shell here
on session expiry) can never carry — so re-authenticating always
landed on the Dashboard instead of back in whatever app you were
using. Login.tsx now also accepts a ?from= query param for that case.

Also wired up AuthGate's inactivity auto-logout timer, which was
defined (getInactivityMs) but never actually called — so the
per-device timeout configurable in Admin Settings had no effect.
Disabled for installed PWAs as intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-24 17:07:21 +00:00
parent 58b64c5450
commit d2603bca99
2 changed files with 36 additions and 1 deletions

View file

@ -19,6 +19,7 @@ export function AuthGate({ children }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const userRef = useRef<User | null>(null) const userRef = useRef<User | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function fetchMe(isInitial = false) { function fetchMe(isInitial = false) {
fetch('/api/auth/me', { credentials: 'include' }) fetch('/api/auth/me', { credentials: 'include' })
@ -58,6 +59,35 @@ export function AuthGate({ children }: Props) {
} }
}, []) }, [])
// Inactivity auto-logout — disabled for installed PWAs; configurable per
// device (Admin Settings → Device) for shared/front-desk browser sessions.
useEffect(() => {
const ms = getInactivityMs()
if (!user || !ms) return
const timeoutMs: number = ms
async function forceLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
userRef.current = null
setUser(null)
navigate('/login', { replace: true, state: { from: location.pathname } })
}
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))
}
}, [user])
if (checking) { if (checking) {
return ( return (
<div style={{ height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>

View file

@ -9,7 +9,12 @@ export function Login() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const from = (location.state as { from?: string })?.from || '/' // `state.from` covers portal-internal redirects (client-side nav preserves
// router state); `?from=` covers a child app bouncing the whole shell here
// via a hard page navigation, which can't carry router state.
const from = (location.state as { from?: string })?.from
|| new URLSearchParams(location.search).get('from')
|| '/'
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()