Add /lights/kiosk screen for the shared back-of-house kiosk
Big-touch tiles + group buttons + per-light schedule pause; own session check (never bounces a tablet to the staff login); backend accepts hnf_kiosk_session. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d33b2fe11d
commit
831b562ee2
5 changed files with 286 additions and 17 deletions
|
|
@ -5,7 +5,9 @@ const APP_SLUG = process.env.APP_SLUG || 'lights'
|
||||||
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
||||||
|
|
||||||
export async function requireAuth(request, reply) {
|
export async function requireAuth(request, reply) {
|
||||||
const token = request.cookies?.hnf_session
|
// hnf_kiosk_session is the shared back-of-house kiosk session minted by central
|
||||||
|
// auth (portal /kiosk); its token carries only lights' registered kiosk caps.
|
||||||
|
const token = request.cookies?.hnf_session || request.cookies?.hnf_kiosk_session
|
||||||
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||||
|
|
||||||
let payload
|
let payload
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import Timeline from './pages/Timeline'
|
||||||
import Devices from './pages/Devices'
|
import Devices from './pages/Devices'
|
||||||
import Log from './pages/Log'
|
import Log from './pages/Log'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
|
import Kiosk from './pages/Kiosk'
|
||||||
import { can } from './types'
|
import { can } from './types'
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
|
|
@ -18,24 +19,36 @@ function Home() {
|
||||||
return <div className="page"><p className="muted">You don't have access to any Lighting pages yet.</p></div>
|
return <div className="page"><p className="muted">You don't have access to any Lighting pages yet.</p></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StaffApp() {
|
||||||
|
return (
|
||||||
|
<AuthGate>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
|
<Route path="/timeline" element={<Timeline />} />
|
||||||
|
<Route path="/devices" element={<Devices />} />
|
||||||
|
<Route path="/log" element={<Log />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</AuthGate>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BrowserRouter basename="/lights">
|
<BrowserRouter basename="/lights">
|
||||||
<AuthGate>
|
<Routes>
|
||||||
<Layout>
|
{/* Shared back-of-house kiosk screen: own session check (a kiosk session has
|
||||||
<Routes>
|
no user, and a failed check must send the tablet back to the kiosk home,
|
||||||
<Route path="/" element={<Home />} />
|
not the staff login) — deliberately outside AuthGate/Layout. */}
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/kiosk" element={<Kiosk />} />
|
||||||
<Route path="/schedules" element={<Schedules />} />
|
<Route path="/*" element={<StaffApp />} />
|
||||||
<Route path="/timeline" element={<Timeline />} />
|
</Routes>
|
||||||
<Route path="/devices" element={<Devices />} />
|
|
||||||
<Route path="/log" element={<Log />} />
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</Layout>
|
|
||||||
</AuthGate>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
<IosInstallHint />
|
<IosInstallHint />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
...opts,
|
...opts,
|
||||||
})
|
})
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
;(window.top ?? window).location.href = '/login'
|
// The kiosk page handles its own session loss (back to the kiosk home) — a
|
||||||
throw new Error('Unauthenticated')
|
// wall tablet must never be dumped on the staff login.
|
||||||
|
if (!window.location.pathname.endsWith('/kiosk')) (window.top ?? window).location.href = '/login'
|
||||||
|
throw new Error('Not authenticated')
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
|
|
||||||
|
|
@ -395,3 +395,47 @@ table.data tr.clickable { cursor: pointer; }
|
||||||
.tl-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--text-mid); margin-top: 10px; align-items: center; }
|
.tl-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--text-mid); margin-top: 10px; align-items: center; }
|
||||||
.tl-legend i { display: inline-block; width: 14px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
|
.tl-legend i { display: inline-block; width: 14px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
|
||||||
@media (max-width: 768px) { .tl-row { grid-template-columns: 96px 1fr 52px; gap: 6px; } }
|
@media (max-width: 768px) { .tl-row { grid-template-columns: 96px 1fr 52px; gap: 6px; } }
|
||||||
|
|
||||||
|
/* Kiosk (shared back-of-house wall tablet) — big touch targets, scrolling grid */
|
||||||
|
.kiosk-screen { min-height: 100dvh; background: var(--body-bg); display: flex; flex-direction: column; }
|
||||||
|
.kiosk-center { align-items: center; justify-content: center; }
|
||||||
|
.kiosk-header { display: flex; align-items: center; gap: 16px; padding: 12px 20px; background: var(--navy); color: var(--text); position: sticky; top: 0; z-index: 5; }
|
||||||
|
.kiosk-title { display: inline-flex; align-items: center; gap: 8px; font-size: 18px; font-weight: 700; flex: 1; }
|
||||||
|
.kiosk-sun { display: inline-flex; gap: 18px; font-size: 15px; color: var(--text-muted); }
|
||||||
|
.kiosk-sun span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.kiosk-note { margin: 12px 20px 0; }
|
||||||
|
.kiosk-body { padding: 8px 20px 28px; }
|
||||||
|
.kiosk-section { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-mid); margin: 20px 0 10px; }
|
||||||
|
.kiosk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; }
|
||||||
|
.kiosk-tile { background: var(--card-bg); border: 2px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.kiosk-tile.on { border-color: var(--gold); }
|
||||||
|
.kiosk-tile.offline { opacity: .55; }
|
||||||
|
.kiosk-tile-main {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 18px 10px 12px; min-height: 110px;
|
||||||
|
background: none; border: none; cursor: pointer; font-family: inherit; color: var(--text-dark);
|
||||||
|
}
|
||||||
|
.kiosk-tile.on .kiosk-tile-main { color: var(--navy); }
|
||||||
|
.kiosk-tile.on .kiosk-tile-main svg { color: var(--gold); }
|
||||||
|
.kiosk-tile-main:disabled { cursor: default; }
|
||||||
|
.kiosk-tile-name { font-size: 15px; font-weight: 600; text-align: center; overflow-wrap: anywhere; }
|
||||||
|
.kiosk-tile-meta { font-size: 13px; color: var(--text-mid); display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.kiosk-pause {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px; font-size: 12px; font-family: inherit;
|
||||||
|
border: none; border-top: 1px solid var(--card-border); background: var(--body-bg); color: var(--text-mid); cursor: pointer;
|
||||||
|
}
|
||||||
|
.kiosk-pause.active { color: var(--navy); font-weight: 600; }
|
||||||
|
.kiosk-group { padding: 14px; gap: 6px; align-items: stretch; }
|
||||||
|
.kiosk-group .kiosk-tile-name, .kiosk-group .kiosk-tile-meta { text-align: left; justify-content: flex-start; }
|
||||||
|
.kiosk-group-btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 6px; }
|
||||||
|
.kiosk-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 48px; padding: 0 14px;
|
||||||
|
border: 1px solid var(--card-border); border-radius: 10px; background: var(--card-bg); color: var(--text-dark);
|
||||||
|
font-size: 15px; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.kiosk-btn:disabled { opacity: .5; cursor: default; }
|
||||||
|
.kiosk-btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); }
|
||||||
|
.kiosk-modal-overlay { position: fixed; inset: 0; background: rgba(15,15,32,.6); display: flex; align-items: center; justify-content: center; padding: 16px; z-index: 50; }
|
||||||
|
.kiosk-modal { background: var(--card-bg); border-radius: var(--radius); box-shadow: var(--shadow-md); width: 100%; max-width: 380px; padding: 18px; }
|
||||||
|
.kiosk-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 16px; }
|
||||||
|
.kiosk-close { background: none; border: none; color: var(--text-mid); cursor: pointer; display: flex; padding: 4px; }
|
||||||
|
.kiosk-modal-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
|
|
||||||
208
frontend/src/pages/Kiosk.tsx
Normal file
208
frontend/src/pages/Kiosk.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Sunrise, Sunset, PauseCircle, WifiOff, X, Lightbulb, LightbulbOff } from 'lucide-react'
|
||||||
|
import { fetchState, setDevicePower, setGroupPower, holdSchedules } from '../api'
|
||||||
|
import type { StateResponse, LightDevice } from '../types'
|
||||||
|
|
||||||
|
const POLL_MS = 5000
|
||||||
|
const MODAL_IDLE_MS = 20_000
|
||||||
|
|
||||||
|
function fmtTime(iso: string | null | undefined, tz: string) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to the shared kiosk home (or, if opened standalone, just say so) when the
|
||||||
|
// kiosk session is gone — never the staff login.
|
||||||
|
function leaveToKioskHome() {
|
||||||
|
if (window.self !== window.top) window.top!.location.href = '/kiosk'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared back-of-house wall-tablet screen (launched from the portal's /kiosk
|
||||||
|
// shell). Operational controls only: switch lights and groups, pause a light's
|
||||||
|
// schedule. No editing of schedules, devices or settings — a kiosk session only
|
||||||
|
// holds lights:view + lights:control (see auth's kiosk registry).
|
||||||
|
export default function Kiosk() {
|
||||||
|
const [session, setSession] = useState<'checking' | 'ok' | 'ended'>('checking')
|
||||||
|
const [canControl, setCanControl] = useState(false)
|
||||||
|
const [data, setData] = useState<StateResponse | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [pausing, setPausing] = useState<LightDevice | null>(null)
|
||||||
|
const pending = useRef(new Set<string>())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/verify?app=lights', { credentials: 'include' })
|
||||||
|
.then(async r => {
|
||||||
|
if (!r.ok) { setSession('ended'); leaveToKioskHome(); return }
|
||||||
|
const u = await r.json()
|
||||||
|
setCanControl(!!u.is_admin || (u.caps ?? []).includes('control'))
|
||||||
|
setSession('ok')
|
||||||
|
})
|
||||||
|
.catch(() => setSession('ended'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setData(await fetchState()); setError('') }
|
||||||
|
catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Failed to load'
|
||||||
|
if (/not authenticated|invalid session|permission/i.test(msg)) { setSession('ended'); leaveToKioskHome() }
|
||||||
|
else setError(msg)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session !== 'ok') return
|
||||||
|
load()
|
||||||
|
const t = window.setInterval(load, POLL_MS)
|
||||||
|
return () => window.clearInterval(t)
|
||||||
|
}, [session, load])
|
||||||
|
|
||||||
|
async function act(key: string, fn: () => Promise<unknown>) {
|
||||||
|
if (pending.current.has(key)) return
|
||||||
|
pending.current.add(key); setError('')
|
||||||
|
try { await fn() } catch (e) { setError(e instanceof Error ? e.message : 'Command failed') }
|
||||||
|
// The device confirms over MQTT a moment after the command.
|
||||||
|
setTimeout(() => { pending.current.delete(key); load() }, 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session === 'checking') return <div className="kiosk-screen kiosk-center"><p className="muted">Loading…</p></div>
|
||||||
|
if (session === 'ended') {
|
||||||
|
return (
|
||||||
|
<div className="kiosk-screen kiosk-center">
|
||||||
|
<p className="muted">Kiosk session ended — <a href="/kiosk">back to the kiosk home</a></p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!data) return <div className="kiosk-screen kiosk-center">{error ? <div className="error-banner">{error}</div> : <p className="muted">Loading…</p>}</div>
|
||||||
|
|
||||||
|
const byId = new Map(data.devices.map(d => [d.id, d]))
|
||||||
|
const active = data.devices.filter(d => d.active)
|
||||||
|
const locations = [...new Set(active.map(d => d.location || 'Other'))]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kiosk-screen">
|
||||||
|
<header className="kiosk-header">
|
||||||
|
<span className="kiosk-title"><Lightbulb size={20} strokeWidth={1.75} /> Lighting</span>
|
||||||
|
<span className="kiosk-sun">
|
||||||
|
<span><Sunrise size={16} strokeWidth={1.75} /> {fmtTime(data.sun?.sunrise, data.timezone)}</span>
|
||||||
|
<span><Sunset size={16} strokeWidth={1.75} /> {fmtTime(data.sun?.sunset, data.timezone)}</span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="error-banner kiosk-note">{error}</div>}
|
||||||
|
{!data.mqtt_connected && <div className="warn-banner kiosk-note">Can't reach the lighting controller — switches may be out of date and won't respond.</div>}
|
||||||
|
{!data.schedules_enabled && <div className="warn-banner kiosk-note">Schedules are switched off — lights only change when switched by hand.</div>}
|
||||||
|
|
||||||
|
<div className="kiosk-body">
|
||||||
|
{data.groups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="kiosk-section">Groups</div>
|
||||||
|
<div className="kiosk-grid">
|
||||||
|
{data.groups.map(g => {
|
||||||
|
const members = g.device_ids.map(id => byId.get(id)).filter((d): d is LightDevice => !!d)
|
||||||
|
const onCount = members.filter(d => d.state === 'on').length
|
||||||
|
return (
|
||||||
|
<div key={g.id} className={`kiosk-tile kiosk-group${onCount ? ' on' : ''}`}>
|
||||||
|
<div className="kiosk-tile-name">{g.name}</div>
|
||||||
|
<div className="kiosk-tile-meta">{onCount} of {members.length} on</div>
|
||||||
|
<div className="kiosk-group-btns">
|
||||||
|
<button className="kiosk-btn" disabled={!canControl} onClick={() => act(`g${g.id}`, () => setGroupPower(g.id, 'on'))}>
|
||||||
|
<Lightbulb size={18} strokeWidth={1.75} /> All on
|
||||||
|
</button>
|
||||||
|
<button className="kiosk-btn" disabled={!canControl} onClick={() => act(`g${g.id}`, () => setGroupPower(g.id, 'off'))}>
|
||||||
|
<LightbulbOff size={18} strokeWidth={1.75} /> All off
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active.length === 0 && <p className="muted">No lights set up yet.</p>}
|
||||||
|
{locations.map(loc => (
|
||||||
|
<div key={loc}>
|
||||||
|
<div className="kiosk-section">{loc}</div>
|
||||||
|
<div className="kiosk-grid">
|
||||||
|
{active.filter(d => (d.location || 'Other') === loc).map(d => {
|
||||||
|
const on = d.state === 'on'
|
||||||
|
const offline = d.online === false
|
||||||
|
return (
|
||||||
|
<div key={d.id} className={`kiosk-tile${on ? ' on' : ''}${offline ? ' offline' : ''}`}>
|
||||||
|
<button className="kiosk-tile-main" disabled={!canControl}
|
||||||
|
onClick={() => act(`d${d.id}`, () => setDevicePower(d.id, on ? 'off' : 'on'))}>
|
||||||
|
{on ? <Lightbulb size={30} strokeWidth={1.75} /> : <LightbulbOff size={30} strokeWidth={1.75} />}
|
||||||
|
<span className="kiosk-tile-name">{d.name}</span>
|
||||||
|
<span className="kiosk-tile-meta">
|
||||||
|
{offline ? <><WifiOff size={13} strokeWidth={1.75} /> Offline</> : d.state === null ? 'Unknown' : on ? 'On' : 'Off'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{canControl && data.schedules_enabled && (
|
||||||
|
<button className={`kiosk-pause${d.schedules_paused ? ' active' : ''}`} onClick={() => setPausing(d)}
|
||||||
|
aria-label={`Schedule for ${d.name}`}>
|
||||||
|
<PauseCircle size={16} strokeWidth={1.75} />
|
||||||
|
{d.schedules_paused && <span>Paused{d.paused_until ? ` until ${fmtTime(d.paused_until, data.timezone)}` : ''}</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pausing && (
|
||||||
|
<PauseModal
|
||||||
|
device={data.devices.find(d => d.id === pausing.id) ?? pausing}
|
||||||
|
onClose={() => setPausing(null)}
|
||||||
|
onChoose={(hours, resume) => {
|
||||||
|
act(`h${pausing.id}`, () => resume ? holdSchedules(pausing.id, false) : holdSchedules(pausing.id, true, hours))
|
||||||
|
setPausing(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause/resume a light's schedule. Auto-closes after a short idle so a tablet
|
||||||
|
// left on this dialog doesn't sit there.
|
||||||
|
function PauseModal({ device, onClose, onChoose }: {
|
||||||
|
device: LightDevice
|
||||||
|
onClose: () => void
|
||||||
|
onChoose: (hours: number | undefined, resume: boolean) => void
|
||||||
|
}) {
|
||||||
|
const timer = useRef<number>()
|
||||||
|
useEffect(() => {
|
||||||
|
const reset = () => { window.clearTimeout(timer.current); timer.current = window.setTimeout(onClose, MODAL_IDLE_MS) }
|
||||||
|
const events = ['pointerdown', 'keydown'] as const
|
||||||
|
events.forEach(e => window.addEventListener(e, reset))
|
||||||
|
reset()
|
||||||
|
return () => { window.clearTimeout(timer.current); events.forEach(e => window.removeEventListener(e, reset)) }
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kiosk-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="kiosk-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="kiosk-modal-head">
|
||||||
|
<strong>{device.name}</strong>
|
||||||
|
<button className="kiosk-close" onClick={onClose} aria-label="Close"><X size={20} strokeWidth={1.75} /></button>
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ margin: '0 0 12px' }}>
|
||||||
|
{device.schedules_paused ? 'Its schedule is paused.' : 'Pause this light\'s schedule so it stays as you set it.'}
|
||||||
|
</p>
|
||||||
|
{device.schedules_paused ? (
|
||||||
|
<button className="kiosk-btn kiosk-btn-primary" onClick={() => onChoose(undefined, true)}>Resume schedule</button>
|
||||||
|
) : (
|
||||||
|
<div className="kiosk-modal-options">
|
||||||
|
<button className="kiosk-btn" onClick={() => onChoose(1, false)}>Pause 1 hour</button>
|
||||||
|
<button className="kiosk-btn" onClick={() => onChoose(4, false)}>Pause 4 hours</button>
|
||||||
|
<button className="kiosk-btn" onClick={() => onChoose(24, false)}>Pause 24 hours</button>
|
||||||
|
<button className="kiosk-btn" onClick={() => onChoose(undefined, false)}>Until resumed</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue