Add kiosk wall-panel control view

Full-screen /kiosk route, separate from the staff SPA: dedicated long-lived
kiosk login (own password, hashed, mints its own hnf_session cookie locally
via CENTRAL_AUTH_SECRET -- no central auth changes needed), scrollable grid
of small mode/setpoint blocks per live-controllable device (shelly_trv,
mhi_mqtt, daikin), tap-to-edit modal reusing the existing per-type control
panels, and a type-aware bulk multiselect (separate TRV/AC sections, only
applied to matching selected devices, TRV commands staggered).

- backend/src/routes/kiosk.js: public POST /api/kiosk/login
- backend/src/routes/settings.js: admin set/check kiosk password (bcrypt)
- backend/src/routes/config.js: exclude/protect kiosk_password_hash
- frontend/src/pages/Kiosk.tsx + components/Kiosk*: the kiosk UI
- reuses GET /api/status for the combined device list -- no new status
  endpoint needed since the kiosk token now also carries the 'view' cap

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 20:44:50 +00:00
parent f0b4ac3c41
commit 2db7d5f4d0
16 changed files with 757 additions and 14 deletions

View file

@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Thermometer, CheckSquare, X } from 'lucide-react'
import { kioskLogin, kioskFetchStatus, KioskSessionError } from '../api'
import type { DeviceSummary, ZoneStatus } from '../types'
import { KIOSK_DEVICE_TYPES } from '../types'
import KioskDeviceBlock from '../components/KioskDeviceBlock'
import KioskModal from '../components/KioskModal'
import KioskBulkPanel from '../components/KioskBulkPanel'
import TrvControlPanel from '../components/TrvControlPanel'
import MhiControlPanel from '../components/MhiControlPanel'
import DaikinControlPanel from '../components/DaikinControlPanel'
// Control surface, not a passive display — far shorter than weather's 5-minute
// precedent, but not so aggressive it hammers the backend given the underlying
// data is already push/poll-cached (MQTT for TRV/MHI, a 60s poller for Daikin).
const POLL_MS = 12_000
interface FlatDevice { device: DeviceSummary; zoneName: string }
function flattenDevices(zones: ZoneStatus[]): FlatDevice[] {
const out: FlatDevice[] = []
for (const z of zones) {
for (const d of z.devices) {
if (KIOSK_DEVICE_TYPES.includes(d.device_type)) out.push({ device: d, zoneName: z.name })
}
}
return out.sort((a, b) => a.zoneName.localeCompare(b.zoneName) || (a.device.location || '').localeCompare(b.device.location || ''))
}
// The wall-panel kiosk view — a dedicated route/login, deliberately outside the
// normal staff AuthGate/Layout (see App.tsx). One master panel: every live-
// controllable device (shelly_trv / mhi_mqtt / daikin) in one scrollable grid of
// small blocks; tap a block for the full control modal, or toggle Select for
// type-aware bulk editing. See project_hvac_kiosk memory for the design decisions
// behind this (access model, layout, bulk-edit mechanism).
export default function Kiosk() {
const [authState, setAuthState] = useState<'checking' | 'login' | 'authed'>('checking')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [loginLoading, setLoginLoading] = useState(false)
const [flatDevices, setFlatDevices] = useState<FlatDevice[]>([])
const [loadError, setLoadError] = useState('')
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [editingDevice, setEditingDevice] = useState<DeviceSummary | null>(null)
const [bulkOpen, setBulkOpen] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(() => {
kioskFetchStatus()
.then(s => {
setFlatDevices(flattenDevices(s.zones))
setLoadError('')
setAuthState('authed')
})
.catch(e => {
if (e instanceof KioskSessionError) { setAuthState('login'); return }
setLoadError(e instanceof Error ? e.message : 'Failed to load device status')
})
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
if (authState !== 'authed') {
if (pollRef.current) clearInterval(pollRef.current)
return
}
pollRef.current = setInterval(load, POLL_MS)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [authState, load])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoginLoading(true); setLoginError('')
try {
await kioskLogin(password)
setPassword('')
load()
} catch (e) {
setLoginError(e instanceof Error ? e.message : 'Login failed')
} finally {
setLoginLoading(false)
}
}
function toggleSelectionMode() {
setSelectionMode(m => !m)
setSelectedIds(new Set())
}
function tapDevice(d: DeviceSummary) {
if (selectionMode) {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(d.id)) next.delete(d.id); else next.add(d.id)
return next
})
} else {
setEditingDevice(d)
}
}
function closeBulk() {
setBulkOpen(false)
setSelectionMode(false)
setSelectedIds(new Set())
load()
}
const selectedDevices = flatDevices.filter(fd => selectedIds.has(fd.device.id)).map(fd => fd.device)
const editingZoneName = editingDevice ? flatDevices.find(fd => fd.device.id === editingDevice.id)?.zoneName : ''
if (authState === 'checking') {
return <div className="kiosk-shell kiosk-center"><p className="muted">Loading…</p></div>
}
if (authState === 'login') {
return (
<div className="kiosk-shell kiosk-center">
<div className="kiosk-login-card">
<Thermometer size={28} strokeWidth={1.5} style={{ color: 'var(--gold)', marginBottom: 8 }} />
<h1 style={{ fontSize: '1.3rem', margin: '0 0 1.25rem' }}>HVAC Kiosk</h1>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Kiosk password" autoFocus required autoComplete="off"
/>
{loginError && <div className="error-banner">{loginError}</div>}
<button className="btn btn-primary" type="submit" disabled={loginLoading}>
{loginLoading ? 'Signing in…' : 'Unlock'}
</button>
</form>
</div>
</div>
)
}
return (
<div className="kiosk-shell">
<header className="kiosk-header">
<span className="kiosk-header-title">
<Thermometer size={18} strokeWidth={1.75} /> HVAC Kiosk
</span>
<button className={`btn btn-sm${selectionMode ? ' btn-primary' : ''}`} onClick={toggleSelectionMode}>
<CheckSquare size={14} strokeWidth={1.75} /> {selectionMode ? 'Cancel select' : 'Select'}
</button>
</header>
{loadError && <div className="error-banner kiosk-error">{loadError}</div>}
<div className="kiosk-grid-wrap">
<div className="kiosk-grid">
{flatDevices.map(({ device, zoneName }) => (
<KioskDeviceBlock
key={device.id}
device={device}
zoneName={zoneName}
selectionMode={selectionMode}
selected={selectedIds.has(device.id)}
onTap={() => tapDevice(device)}
/>
))}
{flatDevices.length === 0 && !loadError && (
<p className="muted">No live-controllable devices assigned to a zone yet.</p>
)}
</div>
</div>
{selectionMode && selectedIds.size > 0 && (
<div className="kiosk-bulk-bar">
<span>{selectedIds.size} selected</span>
<button className="btn btn-primary" onClick={() => setBulkOpen(true)}>Edit selected</button>
<button className="btn btn-sm" onClick={() => setSelectedIds(new Set())} aria-label="Clear selection">
<X size={13} strokeWidth={1.75} />
</button>
</div>
)}
{editingDevice && (
<KioskModal
title={editingDevice.location ? `${editingZoneName} — ${editingDevice.location}` : editingZoneName || 'Device'}
onClose={() => { setEditingDevice(null); load() }}
>
{editingDevice.device_type === 'shelly_trv' && <TrvControlPanel deviceId={editingDevice.id} canControl={true} />}
{editingDevice.device_type === 'mhi_mqtt' && <MhiControlPanel deviceId={editingDevice.id} canControl={true} />}
{editingDevice.device_type === 'daikin' && <DaikinControlPanel deviceId={editingDevice.id} canControl={true} />}
</KioskModal>
)}
{bulkOpen && (
<KioskModal title={`Edit ${selectedDevices.length} selected`} wide onClose={closeBulk}>
<KioskBulkPanel devices={selectedDevices} onDone={closeBulk} />
</KioskModal>
)}
</div>
)
}

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { Wifi, WifiOff } from 'lucide-react'
import { Wifi, WifiOff, MonitorCog } from 'lucide-react'
import {
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone,
fetchKioskPasswordStatus, setKioskPassword,
} from '../api'
import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate, HvacWeatherTier } from '../types'
import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } from '../types'
@ -41,6 +42,12 @@ export default function Settings() {
const [publicAreaName, setPublicAreaName] = useState('')
const [creatingZone, setCreatingZone] = useState(false)
const [kioskConfigured, setKioskConfigured] = useState<boolean | null>(null)
const [kioskPassword, setKioskPasswordInput] = useState('')
const [kioskSaving, setKioskSaving] = useState(false)
const [kioskMsg, setKioskMsg] = useState('')
const [kioskError, setKioskError] = useState('')
function loadSites() {
fetchNewbookSites()
.then(res => setSites(res.sites))
@ -64,6 +71,7 @@ export default function Settings() {
setDefaults({ ...DEFAULT_HVAC_TEMPLATE, ...cfg.hvac_defaults, ac_occupied_tiers: cfg.hvac_defaults?.ac_occupied_tiers ?? DEFAULT_HVAC_TEMPLATE.ac_occupied_tiers })
}).catch(() => {})
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {})
fetchStatus()
.then(s => { setWeatherPeakC(s.weather_peak_c); setWeatherTier(s.weather_tier) })
.catch(() => {})
@ -165,6 +173,20 @@ export default function Settings() {
}
}
async function handleSaveKioskPassword() {
setKioskSaving(true); setKioskError(''); setKioskMsg('')
try {
await setKioskPassword(kioskPassword)
setKioskPasswordInput('')
setKioskConfigured(true)
setKioskMsg('Kiosk password saved — enter it once on each kiosk tablet at /hvac/kiosk.')
} catch (e) {
setKioskError(e instanceof Error ? e.message : 'Save failed')
} finally {
setKioskSaving(false)
}
}
async function handleCreatePublicArea(e: React.FormEvent) {
e.preventDefault()
if (!publicAreaName.trim()) return
@ -442,6 +464,36 @@ export default function Settings() {
</div>
</div>
<div className="section-title">Kiosk Wall Panel</div>
<p className="field-hint" style={{ marginBottom: 10 }}>
A dedicated login for the full-screen wall-panel control view at <code>/hvac/kiosk</code> — separate
from staff logins, meant to be entered once per tablet and left signed in indefinitely (see{' '}
<MonitorCog size={12} strokeWidth={1.75} style={{ verticalAlign: '-1px' }} /> the kiosk itself). Scoped to
view + control only — it can never reach this Settings page or device assignment.
</p>
{kioskError && <div className="error-banner">{kioskError}</div>}
{kioskMsg && <div className="ok-banner">{kioskMsg}</div>}
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<MonitorCog size={16} strokeWidth={1.75} color={kioskConfigured ? 'var(--health-healthy)' : 'var(--text-mid)'} />
<span>
{kioskConfigured == null ? 'Checking…' : kioskConfigured ? 'A kiosk password is configured.' : 'No kiosk password set yet — the kiosk view cannot be logged into until one is set below.'}
</span>
</div>
<div className="field-row" style={{ marginBottom: 12 }}>
<div className="field">
<label>{kioskConfigured ? 'New kiosk password' : 'Kiosk password'}</label>
<input
type="password" value={kioskPassword} onChange={e => setKioskPasswordInput(e.target.value)}
placeholder="At least 6 characters" autoComplete="new-password"
/>
</div>
</div>
<div style={{ marginBottom: 24 }}>
<button className="btn btn-primary" disabled={kioskSaving || kioskPassword.length < 6} onClick={handleSaveKioskPassword}>
{kioskSaving ? 'Saving…' : kioskConfigured ? 'Change kiosk password' : 'Set kiosk password'}
</button>
</div>
<div className="section-title">Maintenance Integration</div>
<p className="field-hint" style={{ marginBottom: 10 }}>
Not yet available — maintenance needs its own Settings → API Keys page first.