From 41a56ce1280625206ca91f22ae30e51097bc4ea4 Mon Sep 17 00:00:00 2001
From: jtricerolph
Date: Thu, 15 Oct 2026 11:15:46 +0000
Subject: [PATCH] Add /kiosk shell (login, app home, iframe launcher) and Kiosk
admin tab
Co-Authored-By: Claude Sonnet 5.5
---
src/App.tsx | 2 +
src/pages/AdminSettings.tsx | 97 ++++++++++++++++++++++++-
src/pages/Kiosk.tsx | 141 ++++++++++++++++++++++++++++++++++++
3 files changed, 237 insertions(+), 3 deletions(-)
create mode 100644 src/pages/Kiosk.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 70663d7..7429158 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-
import { AuthGate } from './components/AuthGate'
import { Login } from './pages/Login'
import { Register } from './pages/Register'
+import { Kiosk } from './pages/Kiosk'
import { Dashboard } from './pages/Dashboard'
import { AppFrame } from './pages/AppFrame'
import { AdminUsers } from './pages/AdminUsers'
@@ -48,6 +49,7 @@ export default function App() {
} />
} />
+ } />
{user => (
diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx
index cde9f8c..3fa4fc7 100644
--- a/src/pages/AdminSettings.tsx
+++ b/src/pages/AdminSettings.tsx
@@ -57,7 +57,7 @@ interface AppRow {
}
export function AdminSettings({ user }: { user: User }) {
- const [tab, setTab] = useState<'integrations' | 'rooms' | 'apps' | 'device' | 'mqtt-clients'>('integrations')
+ const [tab, setTab] = useState<'integrations' | 'rooms' | 'apps' | 'device' | 'mqtt-clients' | 'kiosk'>('integrations')
const [integrations, setIntegrations] = useState([])
const [loading, setLoading] = useState(true)
@@ -76,7 +76,7 @@ export function AdminSettings({ user }: { user: User }) {
- {(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients'] as const).map(t => (
+ {(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients', 'kiosk'] as const).map(t => (
setTab(t)} style={{
background: 'none', border: 'none', padding: '0.5rem 1rem',
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
@@ -84,7 +84,7 @@ export function AdminSettings({ user }: { user: User }) {
borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
marginBottom: '-1px',
}}>
- {t === 'integrations' ? 'Integrations' : t === 'rooms' ? 'Rooms & Sites' : t === 'apps' ? 'App Settings' : t === 'device' ? 'This Device' : 'MQTT Clients'}
+ {t === 'integrations' ? 'Integrations' : t === 'rooms' ? 'Rooms & Sites' : t === 'apps' ? 'App Settings' : t === 'device' ? 'This Device' : t === 'kiosk' ? 'Kiosk' : 'MQTT Clients'}
))}
@@ -105,6 +105,7 @@ export function AdminSettings({ user }: { user: User }) {
{tab === 'apps' && }
{tab === 'device' && }
{tab === 'mqtt-clients' && }
+ {tab === 'kiosk' && }
)
}
@@ -1242,6 +1243,96 @@ const smallBtn: React.CSSProperties = {
padding: '0.35rem 0.85rem', fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer',
}
+interface KioskInfo {
+ configured: boolean
+ apps: { slug: string; name: string; kiosk_path: string; kiosk_caps: string[] }[]
+}
+
+function KioskTab() {
+ const [info, setInfo] = useState(null)
+ const [password, setPassword] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const [msg, setMsg] = useState('')
+
+ function load() {
+ fetch('/api/auth/admin/kiosk', { credentials: 'include' })
+ .then(r => r.json()).then(setInfo).catch(() => setError('Failed to load kiosk settings'))
+ }
+ useEffect(load, [])
+
+ async function save(e: React.FormEvent) {
+ e.preventDefault()
+ if (info?.configured && !confirm('Change the kiosk password? Every tablet signed in with the old one will be signed out.')) return
+ setSaving(true); setError(''); setMsg('')
+ try {
+ const res = await fetch('/api/auth/admin/kiosk-password', {
+ method: 'POST', credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password }),
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) { setError(data.error || 'Failed to save'); return }
+ setPassword(''); setMsg('Kiosk password saved'); load()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
Kiosk (back-of-house wall tablets)
+
+ One shared password unlocks the kiosk home on a tablet. Point the tablet's browser (e.g. Fully Kiosk) at{' '}
+ {window.location.origin}/kiosk. It only offers the apps below, and only the limited controls each app's
+ kiosk screen allows — never settings or admin.
+
+
+
+
+
Apps on the kiosk
+ {!info ?
Loading…
: info.apps.length === 0 ? (
+
No apps have registered a kiosk screen.
+ ) : (
+
+ {info.apps.map(a => (
+
+
{a.name}
+
+ {a.kiosk_path} · kiosk can: {a.kiosk_caps.join(', ') || 'nothing'}
+
+
+ ))}
+
+ )}
+
+ )
+}
+
const inputStyle: React.CSSProperties = {
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
diff --git a/src/pages/Kiosk.tsx b/src/pages/Kiosk.tsx
new file mode 100644
index 0000000..20c8f2a
--- /dev/null
+++ b/src/pages/Kiosk.tsx
@@ -0,0 +1,141 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Hotel, Home, LayoutGrid } from 'lucide-react'
+import { AppIcon } from '../components/AppIcon'
+
+interface KioskApp {
+ slug: string
+ name: string
+ icon: string
+ theme_color: string
+ kiosk_path: string
+}
+
+// Re-check the session (and pick up newly registered kiosk apps) this often.
+const REFRESH_MS = 5 * 60_000
+
+// Shared back-of-house wall-tablet shell. Lives outside the staff AuthGate: it
+// has its own login (one shared kiosk password, see auth/src/kiosk.js) and only
+// ever offers apps that registered a kiosk screen — each app's /kiosk page is
+// built around what a shared device should be able to do. Fully Kiosk Browser
+// (or similar) should be locked to this page's URL.
+export function Kiosk() {
+ const [state, setState] = useState<'checking' | 'login' | 'ready'>('checking')
+ const [apps, setApps] = useState([])
+ const [open, setOpen] = useState(null)
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const refresh = useCallback(async () => {
+ try {
+ const res = await fetch('/api/auth/kiosk/apps', { credentials: 'include' })
+ if (res.status === 401) { setState('login'); setOpen(null); return }
+ if (!res.ok) return // transient — keep what's on screen
+ setApps((await res.json()).apps)
+ setState('ready')
+ } catch {
+ // network blip — keep what's on screen
+ }
+ }, [])
+
+ useEffect(() => {
+ refresh()
+ const t = window.setInterval(refresh, REFRESH_MS)
+ return () => window.clearInterval(t)
+ }, [refresh])
+
+ async function login(e: React.FormEvent) {
+ e.preventDefault()
+ setBusy(true); setError('')
+ try {
+ const res = await fetch('/api/auth/kiosk/login', {
+ method: 'POST', credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password }),
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok) { setError(data.error || 'Login failed'); return }
+ setPassword('')
+ setApps(data.apps)
+ setState('ready')
+ } catch {
+ setError('Connection error — please try again')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const shell: React.CSSProperties = { height: '100dvh', display: 'flex', flexDirection: 'column', background: 'var(--navy-dark)', color: 'var(--text)' }
+
+ if (state === 'checking') {
+ return Loading…
+ }
+
+ if (state === 'login') {
+ return (
+
+ )
+ }
+
+ if (open) {
+ return (
+
+
+ { setOpen(null); refresh() }} aria-label="Back to home" style={{
+ display: 'flex', alignItems: 'center', gap: 6, background: 'var(--surface-2)', border: 'none',
+ color: 'var(--text)', borderRadius: 8, padding: '0.4rem 0.8rem', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
+ }}>
+ Home
+
+ {open.name}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
Control Panel
+
+ {apps.length === 0 ? (
+
No kiosk screens are set up yet.
+ ) : (
+
+ {apps.map(a => (
+
setOpen(a)} style={{
+ background: 'var(--navy)', border: '1px solid var(--surface-2)', borderTop: `4px solid ${a.theme_color}`,
+ borderRadius: 'var(--radius)', padding: '1.5rem 1rem', color: 'var(--text)', cursor: 'pointer',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', fontSize: '1.05rem', fontWeight: 600,
+ }}>
+
+ {a.name}
+
+ ))}
+
+ )}
+
+ )
+}