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:
jtricerolph 2026-10-15 11:15:46 +00:00
parent fe78bcfc49
commit 41a56ce128
3 changed files with 237 additions and 3 deletions

View file

@ -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<Integration[]>([])
const [loading, setLoading] = useState(true)
@ -76,7 +76,7 @@ export function AdminSettings({ user }: { user: User }) {
</p>
<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={{
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'}
</button>
))}
</div>
@ -105,6 +105,7 @@ export function AdminSettings({ user }: { user: User }) {
{tab === 'apps' && <AppsTab />}
{tab === 'device' && <DeviceTab />}
{tab === 'mqtt-clients' && <MqttClientsTab />}
{tab === 'kiosk' && <KioskTab />}
</PageShell>
)
}
@ -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<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 = {
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',