Kiosk: self-heal a busy device on fresh page load

A browser refresh mid-checkin bypasses the Cancel button entirely (no
React unmount cleanup runs), so a device stayed locked 'busy' with no
route back except a manual admin reset — hit for real 2026-09-01.
registerAndGoIdle() only runs on a genuine fresh mount, so if the device
comes back 'busy' at exactly that moment, nothing on this browser is
actually mid-checkin (state was just initialized) — self-correct via the
same reset the Cancel button already uses, rather than trusting stale
server-side status. Uses kioskReset (krequest, throws KioskSessionError
on 401) not resetDevice (request, redirects to the staff /login page on
401) — the latter would be wrong on a kiosk tablet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-09-01 19:20:10 +00:00
parent f4625e0860
commit 82f926d736

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { UserCheck } from 'lucide-react' import { UserCheck } from 'lucide-react'
import { kioskLogin, kioskRegisterDevice, kioskHeartbeat, kioskStreamUrl, KioskSessionError } from '../../api' import { kioskLogin, kioskRegisterDevice, kioskHeartbeat, kioskStreamUrl, kioskReset, KioskSessionError } from '../../api'
import type { Device } from '../../types' import type { Device } from '../../types'
import CheckinFlow from './CheckinFlow' import CheckinFlow from './CheckinFlow'
@ -48,12 +48,26 @@ export default function Kiosk() {
const storedId = localStorage.getItem(DEVICE_ID_KEY) const storedId = localStorage.getItem(DEVICE_ID_KEY)
const d = await kioskRegisterDevice(storedId) const d = await kioskRegisterDevice(storedId)
localStorage.setItem(DEVICE_ID_KEY, d.device_id) localStorage.setItem(DEVICE_ID_KEY, d.device_id)
// This effect only runs on a genuine fresh mount (empty-deps useEffect
// below) — a browser refresh, crash, or power cycle. If the device
// comes back 'busy' at exactly this moment, nothing on THIS browser
// is actually mid-checkin (React state was just initialized), so
// whatever 'busy' the server thinks is stale by definition — the
// in-flow Cancel button only fires its own reset on a clean unmount,
// which a raw page refresh never triggers at all. Self-correct here
// rather than leaving the device invisibly locked with no route back
// except a manual admin reset (hit for real 2026-09-01, see
// project_checkin memory — refreshing mid-checkin instead of using
// Cancel). Best-effort: even if the reset call itself fails, still
// show idle locally so the tablet is at least usable again once
// staff/an admin clears it some other way.
if (d.status === 'busy') {
await kioskReset(d.device_id).catch(() => {})
d.status = 'idle'
}
setDevice(d) setDevice(d)
// Always land on the idle screen regardless of the device's last known
// status — if it was left 'busy' from a session that never completed
// (tablet lost power mid-checkin), the idle screen's own SSE
// connection is what would receive a fresh 'navigate' push anyway, and
// a stuck busy device is fixed from the Devices admin page, not here.
setView('idle') setView('idle')
} catch (err) { } catch (err) {
if (err instanceof KioskSessionError) { if (err instanceof KioskSessionError) {