diff --git a/backend/src/auth.js b/backend/src/auth.js index 56243e5..ffed52b 100644 --- a/backend/src/auth.js +++ b/backend/src/auth.js @@ -5,7 +5,9 @@ const APP_SLUG = process.env.APP_SLUG || 'lights' const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '') 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' }) let payload diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3dd9b47..4046b8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import Timeline from './pages/Timeline' import Devices from './pages/Devices' import Log from './pages/Log' import Settings from './pages/Settings' +import Kiosk from './pages/Kiosk' import { can } from './types' function Home() { @@ -18,24 +19,36 @@ function Home() { return

You don't have access to any Lighting pages yet.

} +function StaffApp() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + export default function App() { return ( <> - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + {/* Shared back-of-house kiosk screen: own session check (a kiosk session has + no user, and a failed check must send the tablet back to the kiosk home, + not the staff login) — deliberately outside AuthGate/Layout. */} + } /> + } /> + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3014fee..d60a3f6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -11,8 +11,10 @@ async function request(path: string, opts: RequestInit = {}): Promise { ...opts, }) if (res.status === 401) { - ;(window.top ?? window).location.href = '/login' - throw new Error('Unauthenticated') + // The kiosk page handles its own session loss (back to the kiosk home) — a + // 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) { const err = await res.json().catch(() => ({ error: res.statusText })) diff --git a/frontend/src/index.css b/frontend/src/index.css index daf000d..6943083 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 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; } } + +/* 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; } diff --git a/frontend/src/pages/Kiosk.tsx b/frontend/src/pages/Kiosk.tsx new file mode 100644 index 0000000..1aff8c7 --- /dev/null +++ b/frontend/src/pages/Kiosk.tsx @@ -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(null) + const [error, setError] = useState('') + const [pausing, setPausing] = useState(null) + const pending = useRef(new Set()) + + 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) { + 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

Loading…

+ if (session === 'ended') { + return ( +
+

Kiosk session ended — back to the kiosk home

+
+ ) + } + if (!data) return
{error ?
{error}
:

Loading…

}
+ + 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 ( +
+
+ Lighting + + {fmtTime(data.sun?.sunrise, data.timezone)} + {fmtTime(data.sun?.sunset, data.timezone)} + +
+ + {error &&
{error}
} + {!data.mqtt_connected &&
Can't reach the lighting controller — switches may be out of date and won't respond.
} + {!data.schedules_enabled &&
Schedules are switched off — lights only change when switched by hand.
} + +
+ {data.groups.length > 0 && ( + <> +
Groups
+
+ {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 ( +
+
{g.name}
+
{onCount} of {members.length} on
+
+ + +
+
+ ) + })} +
+ + )} + + {active.length === 0 &&

No lights set up yet.

} + {locations.map(loc => ( +
+
{loc}
+
+ {active.filter(d => (d.location || 'Other') === loc).map(d => { + const on = d.state === 'on' + const offline = d.online === false + return ( +
+ + {canControl && data.schedules_enabled && ( + + )} +
+ ) + })} +
+
+ ))} +
+ + {pausing && ( + 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) + }} + /> + )} +
+ ) +} + +// 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() + 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 ( +
+
e.stopPropagation()}> +
+ {device.name} + +
+

+ {device.schedules_paused ? 'Its schedule is paused.' : 'Pause this light\'s schedule so it stays as you set it.'} +

+ {device.schedules_paused ? ( + + ) : ( +
+ + + + +
+ )} +
+
+ ) +}