Add /kiosk shell (login, app home, iframe launcher) and Kiosk admin tab
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fe78bcfc49
commit
41a56ce128
3 changed files with 237 additions and 3 deletions
|
|
@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-
|
||||||
import { AuthGate } from './components/AuthGate'
|
import { AuthGate } from './components/AuthGate'
|
||||||
import { Login } from './pages/Login'
|
import { Login } from './pages/Login'
|
||||||
import { Register } from './pages/Register'
|
import { Register } from './pages/Register'
|
||||||
|
import { Kiosk } from './pages/Kiosk'
|
||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
import { AppFrame } from './pages/AppFrame'
|
import { AppFrame } from './pages/AppFrame'
|
||||||
import { AdminUsers } from './pages/AdminUsers'
|
import { AdminUsers } from './pages/AdminUsers'
|
||||||
|
|
@ -48,6 +49,7 @@ export default function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/kiosk" element={<Kiosk />} />
|
||||||
<Route path="/*" element={
|
<Route path="/*" element={
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
{user => (
|
{user => (
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ interface AppRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSettings({ user }: { user: User }) {
|
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<Integration[]>([])
|
const [integrations, setIntegrations] = useState<Integration[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ export function AdminSettings({ user }: { user: User }) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.75rem', borderBottom: '1px solid var(--card-border)', paddingBottom: '0' }}>
|
||||||
{(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients'] as const).map(t => (
|
{(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients', 'kiosk'] as const).map(t => (
|
||||||
<button key={t} onClick={() => setTab(t)} style={{
|
<button key={t} onClick={() => setTab(t)} style={{
|
||||||
background: 'none', border: 'none', padding: '0.5rem 1rem',
|
background: 'none', border: 'none', padding: '0.5rem 1rem',
|
||||||
fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
|
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',
|
borderBottom: tab === t ? '2px solid var(--navy)' : '2px solid transparent',
|
||||||
marginBottom: '-1px',
|
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'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,6 +105,7 @@ export function AdminSettings({ user }: { user: User }) {
|
||||||
{tab === 'apps' && <AppsTab />}
|
{tab === 'apps' && <AppsTab />}
|
||||||
{tab === 'device' && <DeviceTab />}
|
{tab === 'device' && <DeviceTab />}
|
||||||
{tab === 'mqtt-clients' && <MqttClientsTab />}
|
{tab === 'mqtt-clients' && <MqttClientsTab />}
|
||||||
|
{tab === 'kiosk' && <KioskTab />}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1242,6 +1243,96 @@ const smallBtn: React.CSSProperties = {
|
||||||
padding: '0.35rem 0.85rem', fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer',
|
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<KioskInfo | null>(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 (
|
||||||
|
<div style={{ maxWidth: '640px' }}>
|
||||||
|
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.15rem' }}>Kiosk (back-of-house wall tablets)</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '1.25rem' }}>
|
||||||
|
One shared password unlocks the kiosk home on a tablet. Point the tablet's browser (e.g. Fully Kiosk) at{' '}
|
||||||
|
<code>{window.location.origin}/kiosk</code>. It only offers the apps below, and only the limited controls each app's
|
||||||
|
kiosk screen allows — never settings or admin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={save} style={{
|
||||||
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 'var(--radius)',
|
||||||
|
padding: '1.25rem', marginBottom: '1.25rem', boxShadow: 'var(--shadow-sm)', display: 'flex', flexDirection: 'column', gap: '0.75rem',
|
||||||
|
}}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
|
||||||
|
{info?.configured ? 'Change kiosk password' : 'Set kiosk password (not set yet — kiosks cannot sign in)'}
|
||||||
|
</span>
|
||||||
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} minLength={6} required
|
||||||
|
autoComplete="new-password" placeholder="At least 6 characters" style={inputStyle} />
|
||||||
|
</label>
|
||||||
|
{error && <div style={{ fontSize: '0.82rem', color: '#dc2626' }}>{error}</div>}
|
||||||
|
{msg && <div style={{ fontSize: '0.82rem', color: '#16a34a' }}>{msg}</div>}
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={saving} style={{
|
||||||
|
background: 'var(--gold)', color: 'var(--navy)', border: 'none', borderRadius: '6px',
|
||||||
|
padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 700, cursor: saving ? 'wait' : 'pointer', opacity: saving ? 0.6 : 1,
|
||||||
|
}}>
|
||||||
|
{saving ? 'Saving…' : 'Save password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.5rem' }}>Apps on the kiosk</h3>
|
||||||
|
{!info ? <p style={{ color: 'var(--text-mid)' }}>Loading…</p> : info.apps.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-mid)', fontSize: '0.85rem' }}>No apps have registered a kiosk screen.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{info.apps.map(a => (
|
||||||
|
<div key={a.slug} style={{
|
||||||
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 'var(--radius)',
|
||||||
|
padding: '0.7rem 1rem', boxShadow: 'var(--shadow-sm)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)' }}>{a.name}</div>
|
||||||
|
<div style={{ fontSize: '0.74rem', color: 'var(--text-mid)' }}>
|
||||||
|
<code>{a.kiosk_path}</code> · kiosk can: {a.kiosk_caps.join(', ') || 'nothing'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
||||||
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
|
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
|
||||||
|
|
|
||||||
141
src/pages/Kiosk.tsx
Normal file
141
src/pages/Kiosk.tsx
Normal file
|
|
@ -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<KioskApp[]>([])
|
||||||
|
const [open, setOpen] = useState<KioskApp | null>(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 <div style={{ ...shell, alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'login') {
|
||||||
|
return (
|
||||||
|
<div style={{ ...shell, alignItems: 'center', justifyContent: 'center', padding: '1.5rem' }}>
|
||||||
|
<form onSubmit={login} style={{
|
||||||
|
background: 'var(--navy)', borderRadius: 'var(--radius)', padding: '2rem', width: '100%', maxWidth: 340,
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '0.85rem', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<Hotel size={30} strokeWidth={1.5} style={{ color: 'var(--gold)', alignSelf: 'center' }} />
|
||||||
|
<h1 style={{ fontSize: '1.2rem', margin: 0 }}>Control Panel</h1>
|
||||||
|
<input
|
||||||
|
type="password" value={password} onChange={e => 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 && <div style={{ color: 'var(--danger)', fontSize: '0.85rem' }}>{error}</div>}
|
||||||
|
<button type="submit" disabled={busy} style={{
|
||||||
|
background: 'var(--gold)', color: 'var(--navy)', border: 'none', borderRadius: 8,
|
||||||
|
padding: '0.7rem', fontSize: '1rem', fontWeight: 700, cursor: busy ? 'wait' : 'pointer',
|
||||||
|
}}>
|
||||||
|
{busy ? 'Signing in…' : 'Unlock'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
return (
|
||||||
|
<div style={shell}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.35rem 0.75rem', background: 'var(--navy)', flexShrink: 0 }}>
|
||||||
|
<button onClick={() => { 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 size={15} strokeWidth={1.75} /> Home
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>{open.name}</span>
|
||||||
|
</div>
|
||||||
|
<iframe title={open.name} src={open.kiosk_path} style={{ flex: 1, border: 'none', width: '100%', background: 'var(--body-bg)' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...shell, padding: '1.5rem', overflowY: 'auto' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.5rem', color: 'var(--text)' }}>
|
||||||
|
<LayoutGrid size={22} strokeWidth={1.75} style={{ color: 'var(--gold)' }} />
|
||||||
|
<h1 style={{ fontSize: '1.3rem', margin: 0 }}>Control Panel</h1>
|
||||||
|
</div>
|
||||||
|
{apps.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No kiosk screens are set up yet.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: '1rem' }}>
|
||||||
|
{apps.map(a => (
|
||||||
|
<button key={a.slug} onClick={() => 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,
|
||||||
|
}}>
|
||||||
|
<AppIcon name={a.icon} size={40} color="var(--gold)" />
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue