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 => ( ))}
@@ -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. +

+ +
+ + {error &&
{error}
} + {msg &&
{msg}
} +
+ +
+
+ +

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 ( +
+
+ +

Control Panel

+ setPassword(e.target.value)} + placeholder="Kiosk password" autoFocus required autoComplete="off" + style={{ padding: '0.7rem 0.85rem', borderRadius: 8, border: '1px solid var(--surface-2)', background: 'var(--navy-dark)', color: 'var(--text)', fontSize: '1rem' }} + /> + {error &&
{error}
} + + +
+ ) + } + + if (open) { + return ( +
+
+ + {open.name} +
+