From 82f926d7369b191be0ee5b58d12725aa9ca8bcb5 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 1 Sep 2026 19:20:10 +0000 Subject: [PATCH] Kiosk: self-heal a busy device on fresh page load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/pages/kiosk/Kiosk.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/kiosk/Kiosk.tsx b/frontend/src/pages/kiosk/Kiosk.tsx index 7137a61..d7ecae2 100644 --- a/frontend/src/pages/kiosk/Kiosk.tsx +++ b/frontend/src/pages/kiosk/Kiosk.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from '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 CheckinFlow from './CheckinFlow' @@ -48,12 +48,26 @@ export default function Kiosk() { const storedId = localStorage.getItem(DEVICE_ID_KEY) const d = await kioskRegisterDevice(storedId) 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) - // 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') } catch (err) { if (err instanceof KioskSessionError) {