portal/src/pages/AdminSettings.tsx
jtricerolph 41a56ce128 Add /kiosk shell (login, app home, iframe launcher) and Kiosk admin tab
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:15:46 +00:00

1346 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react'
import { CheckCircle, XCircle, RefreshCw, ChevronDown, ChevronUp, Clock, Folder, FolderOpen, ChevronRight, KeyRound, Copy, Check } from 'lucide-react'
import { PageShell } from '../components/PageShell'
import type { User } from '../types'
const MASKED = '••••••••'
interface Integration {
slug: string
name: string
config: Record<string, string>
secrets: Record<string, string | null>
enabled: boolean
updated_at: string
}
interface RoomCategory {
id: string
name: string
sort_order: number
colour: string | null
}
interface RoomsData {
categories: RoomCategory[]
sites: { id: string; name: string; category_id: string }[]
synced_at: string
}
const FIELD_LABELS: Record<string, string> = {
region: 'Region', username: 'Username', api_key: 'API Key', password: 'Password',
base_url: 'Base URL', client_secret: 'Client Secret', tenant_id: 'Tenant ID',
client_id: 'Client ID', host: 'Host', port: 'Port', database: 'Database',
graphql_endpoint: 'GraphQL Endpoint', bearer_token: 'Bearer Token',
sql_host: 'SQL Host', sql_port: 'SQL Port', sql_database: 'SQL Database',
sql_username: 'SQL Username', sql_password: 'SQL Password',
graphql_username: 'GraphQL Username', graphql_password: 'GraphQL Password',
graphql_client_id: 'GraphQL Client ID',
email: 'Email', sync_hours: 'Sync Interval (hours)', user: 'Username', pass: 'Password',
location_id: 'Default Location', from: 'From Address', reply_to: 'Reply-To Address',
backup_path: 'Backup Directory', site_url: 'Site URL',
latitude: 'Latitude', longitude: 'Longitude',
// Met Office DataHub sells "Global Spot" and "Forecast" as separate
// subscriptions with separate keys — api_key above is Spot; this is the
// (optional) second one.
forecast_api_key: 'Forecast API Key',
}
interface AppRow {
id: number
slug: string
name: string
theme_color: string
category: string | null
active: boolean
max_session_hours: number | null
}
export function AdminSettings({ user }: { user: User }) {
const [tab, setTab] = useState<'integrations' | 'rooms' | 'apps' | 'device' | 'mqtt-clients' | 'kiosk'>('integrations')
const [integrations, setIntegrations] = useState<Integration[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/settings/api/integrations', { credentials: 'include' })
.then(r => r.json()).then(setIntegrations).finally(() => setLoading(false))
}, [])
return (
<PageShell user={user} padding="2rem">
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
Settings
</h1>
<p style={{ fontSize: '0.85rem', color: 'var(--text-mid)', marginBottom: '1.75rem' }}>
Third-party integrations and global configuration
</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', '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',
color: tab === t ? 'var(--navy)' : 'var(--text-mid)',
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' : t === 'kiosk' ? 'Kiosk' : 'MQTT Clients'}
</button>
))}
</div>
{tab === 'integrations' && (
loading
? <p style={{ color: 'var(--text-mid)' }}>Loading…</p>
: <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: '640px' }}>
{integrations.map(intg => (
<IntegrationCard key={intg.slug} integration={intg} onSaved={updated =>
setIntegrations(prev => prev.map(i => i.slug === updated.slug ? updated : i))
} />
))}
</div>
)}
{tab === 'rooms' && <RoomsTab />}
{tab === 'apps' && <AppsTab />}
{tab === 'device' && <DeviceTab />}
{tab === 'mqtt-clients' && <MqttClientsTab />}
{tab === 'kiosk' && <KioskTab />}
</PageShell>
)
}
function IntegrationCard({ integration, onSaved }: { integration: Integration; onSaved: (i: Integration) => void }) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Record<string, string>>({})
const [changing, setChanging] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string; message?: string } | null>(null)
const [enabled, setEnabled] = useState(integration.enabled)
const [wfLocations, setWfLocations] = useState<{ id: string; name: string; short_name: string | null }[]>([])
const [fetchingLocs, setFetchingLocs] = useState(false)
const [locError, setLocError] = useState<string | null>(null)
const [ncBrowseOpen, setNcBrowseOpen] = useState(false)
const [ncBrowsePath, setNcBrowsePath] = useState('/')
const [ncBrowseDirs, setNcBrowseDirs] = useState<{ name: string; path: string }[]>([])
const [ncBrowseLoading, setNcBrowseLoading] = useState(false)
const [ncBrowseError, setNcBrowseError] = useState<string | null>(null)
// Hosted Tables — auth secret reveal
const [secretPanelOpen, setSecretPanelOpen] = useState(false)
const [secretPassword, setSecretPassword] = useState('')
const [revealedSecret, setRevealedSecret] = useState<string | null>(null)
const [revealing, setRevealing] = useState(false)
const [revealError, setRevealError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const secretClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function openForm() {
const initial: Record<string, string> = {}
for (const [k, v] of Object.entries(integration.config)) initial[k] = v ?? ''
setForm(initial)
setChanging(new Set())
setTestResult(null)
setNcBrowseOpen(false)
setOpen(true)
}
function openSecretPanel() {
setSecretPassword('')
setRevealedSecret(null)
setRevealError(null)
setCopied(false)
if (secretClearTimer.current) clearTimeout(secretClearTimer.current)
setSecretPanelOpen(true)
}
function closeSecretPanel() {
setSecretPanelOpen(false)
setRevealedSecret(null)
setSecretPassword('')
if (secretClearTimer.current) clearTimeout(secretClearTimer.current)
}
async function revealSecret() {
if (!secretPassword) return
setRevealing(true)
setRevealError(null)
const res = await fetch('/api/auth/admin/reveal-secret', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: secretPassword }),
})
const data = await res.json()
setRevealing(false)
if (!res.ok) { setRevealError(data.error || 'Failed'); return }
setRevealedSecret(data.secret)
setSecretPassword('')
// Auto-clear after 60 seconds
if (secretClearTimer.current) clearTimeout(secretClearTimer.current)
secretClearTimer.current = setTimeout(() => {
setRevealedSecret(null)
setCopied(false)
}, 60_000)
}
function copySecret() {
if (!revealedSecret) return
navigator.clipboard.writeText(revealedSecret)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
async function ncBrowse(path: string) {
setNcBrowseLoading(true)
setNcBrowseError(null)
setNcBrowsePath(path)
try {
const res = await fetch(`/settings/api/integrations/nextcloud/browse?path=${encodeURIComponent(path)}`, {
credentials: 'include',
})
const data = await res.json()
if (!res.ok) { setNcBrowseError(data.error || `Error ${res.status}`); return }
setNcBrowseDirs(data.dirs)
} catch {
setNcBrowseError('Network error')
} finally {
setNcBrowseLoading(false)
}
}
function setField(key: string, value: string) {
setForm(prev => ({ ...prev, [key]: value }))
}
function startChangingSecret(key: string) {
setChanging(prev => new Set([...prev, key]))
setForm(prev => ({ ...prev, [key]: '' }))
}
async function save() {
setSaving(true)
const body: Record<string, unknown> = { ...form, enabled }
// Include secret fields only if user is actively changing them
for (const [k, v] of Object.entries(integration.secrets)) {
if (changing.has(k)) body[k] = form[k] ?? ''
// else omit — backend keeps existing encrypted value
}
const res = await fetch(`/settings/api/integrations/${integration.slug}`, {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
setSaving(false)
if (res.ok) { onSaved(await res.json()); setOpen(false) }
}
async function test() {
setTesting(true)
setTestResult(null)
const res = await fetch(`/settings/api/integrations/${integration.slug}/test`, {
method: 'POST', credentials: 'include',
})
setTestResult(await res.json())
setTesting(false)
}
const isConfigured = Object.values(integration.secrets).some(v => v === MASKED)
|| (integration.slug === 'hosted_tables' && !!integration.config.site_url)
|| (integration.slug === 'hotel_location' && !!integration.config.latitude && !!integration.config.longitude)
return (
<div style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-sm)', overflow: 'hidden',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-dark)' }}>{integration.name}</div>
<div style={{ fontSize: '0.75rem', color: isConfigured ? '#16a34a' : 'var(--text-mid)', marginTop: '0.1rem' }}>
{isConfigured ? 'Configured' : 'Not configured'}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{integration.slug === 'hosted_tables' && (
<button onClick={secretPanelOpen ? closeSecretPanel : openSecretPanel} style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.35rem 0.85rem', fontSize: '0.8rem',
fontWeight: 600, color: 'var(--text-dark)', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<KeyRound size={13} strokeWidth={1.75} />
{secretPanelOpen ? 'Cancel' : 'Auth secret'}
</button>
)}
<button onClick={open ? () => setOpen(false) : openForm} style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.35rem 0.85rem', fontSize: '0.8rem',
fontWeight: 600, color: 'var(--text-dark)', cursor: 'pointer',
}}>
{open ? 'Cancel' : 'Configure'}
</button>
</div>
</div>
{secretPanelOpen && (
<div style={{ borderTop: '1px solid var(--card-border)', padding: '1.25rem', background: 'var(--body-bg)' }}>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Re-enter your admin password to reveal the <code style={{ fontSize: '0.78rem', background: 'var(--surface-2)', padding: '0.1em 0.35em', borderRadius: 3 }}>CENTRAL_AUTH_SECRET</code>.
Set this as the <code style={{ fontSize: '0.78rem', background: 'var(--surface-2)', padding: '0.1em 0.35em', borderRadius: 3 }}>CENTRAL_AUTH_SECRET</code> environment variable in Hosted Tables to enable shared session SSO.
</p>
{!revealedSecret ? (
<div style={{ 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)' }}>Admin password</span>
<input
type="password"
value={secretPassword}
onChange={e => setSecretPassword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && revealSecret()}
placeholder="Your password"
autoFocus
style={inputStyle}
/>
</label>
{revealError && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.82rem', color: '#dc2626' }}>
<XCircle size={14} strokeWidth={1.75} /> {revealError}
</div>
)}
<div>
<button onClick={revealSecret} disabled={revealing || !secretPassword} style={{
background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: (revealing || !secretPassword) ? 'not-allowed' : 'pointer', opacity: !secretPassword ? 0.5 : 1,
}}>
{revealing ? 'Verifying…' : 'Reveal secret'}
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.55rem 0.75rem',
}}>
<code style={{
flex: 1, fontSize: '0.8rem', color: 'var(--text-dark)',
wordBreak: 'break-all', fontFamily: 'monospace', letterSpacing: '0.02em',
}}>
{revealedSecret}
</code>
<button onClick={copySecret} style={{
background: copied ? '#16a34a' : 'var(--body-bg)',
border: '1px solid var(--card-border)',
borderRadius: '5px', padding: '0.3rem 0.6rem', fontSize: '0.75rem', fontWeight: 600,
cursor: 'pointer', color: copied ? '#fff' : 'var(--text-dark)',
display: 'flex', alignItems: 'center', gap: '0.3rem', flexShrink: 0,
transition: 'all 0.15s',
}}>
{copied ? <><Check size={12} strokeWidth={2} /> Copied</> : <><Copy size={12} strokeWidth={1.75} /> Copy</>}
</button>
</div>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)' }}>
Secret will be hidden automatically after 60 seconds.
</p>
</div>
)}
</div>
)}
{open && (
<div style={{ borderTop: '1px solid var(--card-border)', padding: '1.25rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '1rem' }}>
{/* Config fields (plaintext) */}
{Object.entries(integration.config).map(([k]) => {
if (integration.slug === 'nextcloud' && k === 'backup_path') {
const pathSegments = ncBrowsePath === '/' ? [] : ncBrowsePath.split('/').filter(Boolean)
return (
<div key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
Backup Directory
</span>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input value={form[k] ?? ''} onChange={e => setField(k, e.target.value)}
placeholder="e.g. HNF-Backups" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => {
if (ncBrowseOpen) { setNcBrowseOpen(false); return }
setNcBrowseOpen(true)
setNcBrowsePath('/')
ncBrowse('/')
}}
style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.78rem',
cursor: 'pointer', color: 'var(--text-dark)',
display: 'flex', alignItems: 'center', gap: '0.35rem', whiteSpace: 'nowrap',
}}
>
<Folder size={13} strokeWidth={1.75} />
Browse
</button>
</div>
{ncBrowseOpen && (
<div style={{
border: '1px solid var(--card-border)', borderRadius: '6px',
background: 'var(--body-bg)', overflow: 'hidden',
}}>
{/* Breadcrumb */}
<div style={{
display: 'flex', alignItems: 'center', gap: '0.2rem', flexWrap: 'wrap',
padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--card-border)',
fontSize: '0.78rem',
}}>
<button onClick={() => ncBrowse('/')}
style={{ background: 'none', border: 'none', cursor: 'pointer',
color: ncBrowsePath === '/' ? 'var(--text-dark)' : 'var(--gold)',
fontSize: '0.78rem', fontWeight: 600, padding: 0 }}>
/
</button>
{pathSegments.map((seg, i) => {
const segPath = '/' + pathSegments.slice(0, i + 1).join('/')
const isLast = i === pathSegments.length - 1
return (
<span key={segPath} style={{ display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
<ChevronRight size={11} strokeWidth={2} style={{ color: 'var(--text-mid)' }} />
<button onClick={() => !isLast && ncBrowse(segPath)}
style={{ background: 'none', border: 'none', cursor: isLast ? 'default' : 'pointer',
color: isLast ? 'var(--text-dark)' : 'var(--gold)',
fontSize: '0.78rem', fontWeight: isLast ? 600 : 400, padding: 0 }}>
{seg}
</button>
</span>
)
})}
</div>
{/* Directory list */}
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
{ncBrowseLoading && (
<div style={{ padding: '0.75rem', fontSize: '0.78rem', color: 'var(--text-mid)' }}>
Loading…
</div>
)}
{ncBrowseError && (
<div style={{ padding: '0.75rem', fontSize: '0.78rem', color: 'var(--danger)' }}>
{ncBrowseError}
</div>
)}
{!ncBrowseLoading && !ncBrowseError && ncBrowseDirs.length === 0 && (
<div style={{ padding: '0.75rem', fontSize: '0.78rem', color: 'var(--text-mid)' }}>
No subdirectories here
</div>
)}
{!ncBrowseLoading && ncBrowseDirs.map(dir => (
<button
key={dir.path}
onClick={() => ncBrowse(dir.path)}
style={{
width: '100%', background: 'none', border: 'none',
borderBottom: '1px solid var(--card-border)',
padding: '0.45rem 0.75rem', fontSize: '0.82rem',
color: 'var(--text-dark)', cursor: 'pointer', textAlign: 'left',
display: 'flex', alignItems: 'center', gap: '0.5rem',
}}
>
<FolderOpen size={13} strokeWidth={1.75} style={{ color: 'var(--gold)', flexShrink: 0 }} />
{dir.name}
</button>
))}
</div>
{/* Footer: select current or cancel */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.5rem 0.75rem', borderTop: '1px solid var(--card-border)',
background: 'var(--card-bg)',
}}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)', fontFamily: 'monospace' }}>
{ncBrowsePath}
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={() => setNcBrowseOpen(false)}
style={{ background: 'none', border: '1px solid var(--card-border)',
borderRadius: '5px', padding: '0.25rem 0.65rem', fontSize: '0.75rem',
cursor: 'pointer', color: 'var(--text-mid)' }}>
Cancel
</button>
<button
onClick={() => {
setField(k, ncBrowsePath.replace(/^\//, ''))
setNcBrowseOpen(false)
}}
style={{ background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '5px', padding: '0.25rem 0.65rem', fontSize: '0.75rem',
fontWeight: 600, cursor: 'pointer' }}>
Select
</button>
</div>
</div>
</div>
)}
</div>
)
}
if (integration.slug === 'workforce' && k === 'location_id') {
return (
<div key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
Default Location
</span>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<select value={form[k] ?? ''} onChange={e => setField(k, e.target.value)}
style={{ ...inputStyle, flex: 1 }}>
<option value="">— Select location —</option>
{wfLocations.map(l => (
<option key={l.id} value={l.id}>
{l.name}{l.short_name ? ` (${l.short_name})` : ''}
</option>
))}
{wfLocations.length === 0 && form[k] && (
<option value={form[k]}>Location ID: {form[k]}</option>
)}
</select>
<button
onClick={async () => {
setFetchingLocs(true)
setLocError(null)
try {
const res = await fetch('/settings/api/integrations/workforce/locations', { credentials: 'include' })
if (res.ok) {
setWfLocations(await res.json())
} else {
const data = await res.json().catch(() => ({}))
setLocError(data.error || `Error ${res.status}`)
}
} catch {
setLocError('Network error')
} finally { setFetchingLocs(false) }
}}
disabled={fetchingLocs}
style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.78rem',
cursor: fetchingLocs ? 'wait' : 'pointer', color: 'var(--text-dark)',
whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '0.35rem',
}}
>
<RefreshCw size={12} strokeWidth={1.75} style={{ animation: fetchingLocs ? 'spin 1s linear infinite' : 'none' }} />
{fetchingLocs ? 'Fetching…' : 'Fetch locations'}
</button>
</div>
{locError && (
<div style={{ fontSize: '0.78rem', color: '#dc2626', marginTop: '0.35rem' }}>{locError}</div>
)}
</div>
)
}
return (
<label key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
{FIELD_LABELS[k] ?? k}
</span>
<input value={form[k] ?? ''} onChange={e => setField(k, e.target.value)}
style={inputStyle} />
</label>
)
})}
{/* Secret fields (encrypted) */}
{Object.entries(integration.secrets).map(([k, v]) => (
<label key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
{FIELD_LABELS[k] ?? k}
</span>
{v === MASKED && !changing.has(k)
? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.85rem', color: '#16a34a', letterSpacing: '0.05em' }}>Configured</span>
<button onClick={() => startChangingSecret(k)} style={{
background: 'none', border: '1px solid var(--card-border)', borderRadius: '4px',
padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer', color: 'var(--text-mid)',
}}>Change</button>
</div>
)
: (
<input type="password" value={form[k] ?? ''} onChange={e => setField(k, e.target.value)}
placeholder={v === null ? 'Not set' : 'Enter new value'}
style={inputStyle} />
)
}
</label>
))}
{/* Enable toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
<span style={{ fontSize: '0.85rem', color: 'var(--text-dark)' }}>Enabled</span>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} style={{
background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: saving ? 'wait' : 'pointer',
}}>
{saving ? 'Saving…' : 'Save'}
</button>
{isConfigured && (
<button onClick={test} disabled={testing} style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: testing ? 'wait' : 'pointer', color: 'var(--text-dark)',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<RefreshCw size={13} strokeWidth={1.75} style={{ animation: testing ? 'spin 1s linear infinite' : 'none' }} />
Test connection
</button>
)}
{testResult && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.82rem',
color: testResult.ok ? '#16a34a' : '#dc2626' }}>
{testResult.ok
? <><CheckCircle size={14} strokeWidth={1.75} /> {testResult.message ?? 'Connected'}</>
: <><XCircle size={14} strokeWidth={1.75} /> {testResult.error}</>
}
</span>
)}
</div>
</div>
)}
</div>
)
}
function RoomsTab() {
const [data, setData] = useState<RoomsData | null>(null)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
const res = await fetch('/settings/api/config/newbook.rooms', { credentials: 'include' })
if (res.ok) setData((await res.json()).value)
setLoading(false)
}
async function sync() {
setSyncing(true)
const res = await fetch('/settings/api/integrations/newbook/sync-rooms', {
method: 'POST', credentials: 'include',
})
if (res.ok) setData(await res.json())
setSyncing(false)
}
async function saveOrder() {
if (!data) return
setSaving(true)
await fetch('/settings/api/integrations/newbook/rooms', {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categories: data.categories }),
})
setSaving(false)
}
function move(index: number, dir: -1 | 1) {
if (!data) return
const cats = [...data.categories]
const swap = index + dir
if (swap < 0 || swap >= cats.length) return
;[cats[index], cats[swap]] = [cats[swap], cats[index]]
setData({ ...data, categories: cats.map((c, i) => ({ ...c, sort_order: i })) })
}
function setColour(index: number, colour: string) {
if (!data) return
const cats = [...data.categories]
cats[index] = { ...cats[index], colour }
setData({ ...data, categories: cats })
}
return (
<div style={{ maxWidth: '560px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}>
<button onClick={sync} disabled={syncing} style={{
background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: syncing ? 'wait' : 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<RefreshCw size={13} strokeWidth={1.75} style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
{syncing ? 'Syncing…' : 'Sync from Newbook'}
</button>
{data?.synced_at && (
<span style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>
Last synced {new Date(data.synced_at).toLocaleString()}
</span>
)}
</div>
{loading && <p style={{ color: 'var(--text-mid)' }}>Loading…</p>}
{!loading && !data && (
<p style={{ color: 'var(--text-mid)', fontSize: '0.85rem' }}>
No room data yet. Configure Newbook credentials then sync.
</p>
)}
{data && (
<>
<p style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)',
textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.75rem' }}>
Room categories ({data.categories.length})
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.25rem' }}>
{data.categories.map((cat, i) => (
<div key={cat.id} style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderRadius: 'var(--radius)', padding: '0.75rem 1rem',
display: 'flex', alignItems: 'center', gap: '0.75rem',
boxShadow: 'var(--shadow-sm)',
}}>
<input type="color" value={cat.colour ?? '#1e3a5f'}
onChange={e => setColour(i, e.target.value)}
style={{ width: '28px', height: '28px', border: 'none', padding: 0,
borderRadius: '4px', cursor: 'pointer', background: 'none' }} />
<span style={{ flex: 1, fontSize: '0.88rem', fontWeight: 500, color: 'var(--text-dark)' }}>
{cat.name}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)' }}>
{data.sites.filter(s => s.category_id === cat.id).length} rooms
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1px' }}>
<button onClick={() => move(i, -1)} disabled={i === 0} style={arrowBtn}>
<ChevronUp size={12} strokeWidth={2} />
</button>
<button onClick={() => move(i, 1)} disabled={i === data.categories.length - 1} style={arrowBtn}>
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>
</div>
))}
</div>
<button onClick={saveOrder} disabled={saving} style={{
background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600,
cursor: saving ? 'wait' : 'pointer',
}}>
{saving ? 'Saving…' : 'Save order'}
</button>
</>
)}
</div>
)
}
function AppsTab() {
const [apps, setApps] = useState<AppRow[]>([])
const [loading, setLoading] = useState(true)
const [values, setValues] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
const [saved, setSaved] = useState<Record<string, boolean>>({})
useEffect(() => {
fetch('/api/auth/admin/apps', { credentials: 'include' })
.then(r => r.json())
.then((data: AppRow[]) => {
const active = data.filter(a => a.active)
setApps(active)
const init: Record<string, string> = {}
active.forEach(a => { init[a.slug] = a.max_session_hours != null ? String(a.max_session_hours) : '' })
setValues(init)
})
.finally(() => setLoading(false))
}, [])
async function save(slug: string) {
setSaving(prev => ({ ...prev, [slug]: true }))
setSaved(prev => ({ ...prev, [slug]: false }))
const raw = values[slug].trim()
const max_session_hours = raw === '' ? null : parseInt(raw)
await fetch(`/api/auth/admin/apps/${slug}`, {
method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_session_hours }),
})
setSaving(prev => ({ ...prev, [slug]: false }))
setSaved(prev => ({ ...prev, [slug]: true }))
setTimeout(() => setSaved(prev => ({ ...prev, [slug]: false })), 2000)
}
const byCategory = apps.reduce<Record<string, AppRow[]>>((acc, a) => {
const cat = a.category ?? 'Other'
;(acc[cat] ??= []).push(a)
return acc
}, {})
if (loading) return <p style={{ color: 'var(--text-mid)' }}>Loading…</p>
return (
<div style={{ maxWidth: '640px' }}>
<p style={{ fontSize: '0.83rem', color: 'var(--text-mid)', marginBottom: '1.5rem' }}>
Set a maximum session age per app. Users will be prompted to re-login when the limit is reached,
even if their global session is still valid. Leave blank to use the global default (30 days).
</p>
{Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b)).map(([cat, catApps]) => (
<div key={cat} style={{ marginBottom: '1.5rem' }}>
<p style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-mid)',
textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>
{cat}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{catApps.map(app => (
<div key={app.slug} style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderRadius: 'var(--radius)', padding: '0.85rem 1.1rem',
display: 'flex', alignItems: 'center', gap: '1rem',
boxShadow: 'var(--shadow-sm)',
}}>
<div style={{
width: '10px', height: '10px', borderRadius: '50%', flexShrink: 0,
background: app.theme_color,
}} />
<span style={{ flex: 1, fontSize: '0.88rem', fontWeight: 500, color: 'var(--text-dark)' }}>
{app.name}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Clock size={13} strokeWidth={1.75} style={{ color: 'var(--text-mid)', flexShrink: 0 }} />
<input
type="number" min="1"
value={values[app.slug] ?? ''}
onChange={e => setValues(prev => ({ ...prev, [app.slug]: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && save(app.slug)}
placeholder="Default (720h)"
style={{ ...inputStyle, width: '130px', textAlign: 'right' }}
/>
<span style={{ fontSize: '0.78rem', color: 'var(--text-mid)', flexShrink: 0 }}>hours</span>
</div>
<button
onClick={() => save(app.slug)}
disabled={saving[app.slug]}
style={{
background: saved[app.slug] ? '#16a34a' : 'var(--navy)',
color: '#fff', border: 'none', borderRadius: '6px',
padding: '0.35rem 0.85rem', fontSize: '0.78rem', fontWeight: 600,
cursor: saving[app.slug] ? 'wait' : 'pointer', flexShrink: 0,
transition: 'background 0.2s',
}}
>
{saving[app.slug] ? '…' : saved[app.slug] ? '✓' : 'Save'}
</button>
</div>
))}
</div>
</div>
))}
</div>
)
}
const TIMEOUT_OPTIONS = [
{ label: 'Off', value: 0 },
{ label: '5 min', value: 5 },
{ label: '10 min', value: 10 },
{ label: '15 min', value: 15 },
{ label: '30 min', value: 30 },
]
function getInactivityMins(): number {
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
return c ? parseInt(c.split('=')[1]) || 0 : 0
}
// Always writes the cookie, including for "Off" (mins=0) — this makes an
// explicit Off choice distinguishable from a device that was never visited,
// which the portal's own inactivity default relies on.
function setInactivityMins(mins: number) {
document.cookie = `hnf_inactivity_mins=${mins}; max-age=31536000; path=/; SameSite=Strict`
}
function DeviceTab() {
const [inactivityMins, setInactivityMinsState] = useState(getInactivityMins)
const [saved, setSaved] = useState(false)
const isPwa = window.matchMedia('(display-mode: standalone)').matches
return (
<div style={{ maxWidth: '480px' }}>
<p style={{ fontSize: '0.83rem', color: 'var(--text-mid)', marginBottom: '1.5rem' }}>
{isPwa
? 'This device is running as an installed PWA — inactivity timeout is automatically disabled. Sessions last until the 30-day sign-in limit.'
: 'Set an inactivity timeout for this device. Users will be signed out automatically after the chosen period of no activity. Use on shared or front-desk computers. Ignored when running as an installed PWA.'}
</p>
<p style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-mid)',
textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.75rem' }}>
Inactivity timeout
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1.25rem' }}>
{TIMEOUT_OPTIONS.map(opt => (
<button
key={opt.value}
disabled={isPwa}
onClick={() => {
setInactivityMins(opt.value)
setInactivityMinsState(opt.value)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}}
style={{
padding: '0.4rem 1rem', borderRadius: '6px', fontSize: '0.85rem', fontWeight: 600,
cursor: isPwa ? 'not-allowed' : 'pointer', opacity: isPwa ? 0.4 : 1,
border: `1px solid ${inactivityMins === opt.value ? 'var(--navy)' : 'var(--card-border)'}`,
background: inactivityMins === opt.value ? 'var(--navy)' : 'var(--body-bg)',
color: inactivityMins === opt.value ? '#fff' : 'var(--text-dark)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
{saved && (
<p style={{ fontSize: '0.82rem', color: '#16a34a' }}>
✓ Saved — takes effect immediately
</p>
)}
</div>
)
}
interface MqttClient {
id: number
name: string
username: string
rolename: string
topic_scope: string
can_publish: boolean
can_subscribe: boolean
is_service: boolean
active: boolean
created_at: string
revoked_at: string | null
}
function MqttClientsTab() {
const [clients, setClients] = useState<MqttClient[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [name, setName] = useState('')
const [isService, setIsService] = useState(false)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const [broker, setBroker] = useState<{ host: string; port: number } | null>(null)
const [revealed, setRevealed] = useState<{ id: number; username: string; password: string } | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
load()
fetch('/settings/api/mqtt-broker', { credentials: 'include' })
.then(r => r.ok ? r.json() : null).then(b => b && setBroker(b)).catch(() => {})
}, [])
async function load() {
setLoading(true)
const res = await fetch('/settings/api/mqtt-clients', { credentials: 'include' })
if (res.ok) setClients(await res.json())
setLoading(false)
}
async function create(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!name.trim()) { setError('Name is required'); return }
setCreating(true)
try {
const res = await fetch('/settings/api/mqtt-clients', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), is_service: isService }),
})
const data = await res.json()
if (!res.ok) { setError(data.error || 'Failed to create client'); return }
setRevealed({ id: data.id, username: data.username, password: data.password })
setShowForm(false)
setName(''); setIsService(false)
load()
} finally {
setCreating(false)
}
}
async function revoke(c: MqttClient) {
if (!confirm(`Revoke MQTT client "${c.name}" (${c.username})? It will lose broker access immediately.`)) return
const res = await fetch(`/settings/api/mqtt-clients/${c.id}/revoke`, { method: 'POST', credentials: 'include' })
if (res.ok) load()
}
async function removeClient(c: MqttClient) {
if (!confirm(`Permanently delete the record for "${c.name}"? This cannot be undone.`)) return
const res = await fetch(`/settings/api/mqtt-clients/${c.id}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) load()
}
async function addTopic(c: MqttClient) {
const topic = prompt(`Add a topic scope to "${c.name}" (e.g. hvac/mhi/#) — existing scopes are kept:`)
if (!topic || !topic.trim()) return
const res = await fetch(`/settings/api/mqtt-clients/${c.id}`, {
method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ add_topic_scope: topic.trim() }),
})
const data = await res.json()
if (!res.ok) { alert(data.error || 'Failed to add topic'); return }
load()
}
async function reveal(c: MqttClient) {
const res = await fetch(`/settings/api/mqtt-clients/${c.id}/password`, { credentials: 'include' })
const data = await res.json()
if (!res.ok) { alert(data.error || 'Failed to load password'); return }
setRevealed({ id: c.id, username: c.username, password: data.password })
}
async function resetPassword(c: MqttClient) {
if (!confirm(`Generate a new password for "${c.username}"? Anything still using the old one will be refused until updated.`)) return
const res = await fetch(`/settings/api/mqtt-clients/${c.id}/password`, { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { alert(data.error || 'Failed to reset password'); return }
setRevealed({ id: c.id, username: c.username, password: data.password })
}
async function openAccess(c: MqttClient) {
if (!confirm(`Give "${c.username}" access to all topics? Its current topic restrictions are removed.`)) return
const res = await fetch(`/settings/api/mqtt-clients/${c.id}/open-access`, { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { alert(data.error || 'Failed'); return }
load()
}
function copyPassword() {
if (!revealed) return
navigator.clipboard.writeText(revealed.password)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div style={{ maxWidth: '680px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<div>
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.15rem' }}>
MQTT Broker Clients
</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)' }}>
Broker logins with access to all topics. Reuse one login across a batch of similar devices — passwords can be revealed any time.
</p>
</div>
<button onClick={() => { setShowForm(!showForm); setError('') }} style={{
background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 600, cursor: 'pointer',
}}>
{showForm ? 'Cancel' : 'New Client'}
</button>
</div>
{broker && (
<div style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 'var(--radius)',
padding: '0.85rem 1rem', marginBottom: '1.25rem', boxShadow: 'var(--shadow-sm)',
}}>
<p style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)', marginBottom: '0.4rem' }}>
Point devices at this broker
</p>
<div style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', fontSize: '0.85rem', color: 'var(--text-dark)' }}>
<span>Host <code style={{ background: 'var(--surface-2)', padding: '0.1em 0.4em', borderRadius: 3 }}>{broker.host}</code></span>
<span>Port <code style={{ background: 'var(--surface-2)', padding: '0.1em 0.4em', borderRadius: 3 }}>{broker.port}</code></span>
</div>
<p style={{ fontSize: '0.74rem', color: 'var(--text-mid)', marginTop: '0.4rem' }}>
Tasmota: Configuration → Configure MQTT. Enter the host and port above, a client's username and password
(use the Password button below), and a unique Topic per switch. The switch must be able to reach this address from its network.
</p>
</div>
)}
{showForm && (
<form onSubmit={create} 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.85rem',
}}>
{error && (
<div style={{ fontSize: '0.82rem', color: '#dc2626', display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<XCircle size={14} strokeWidth={1.75} /> {error}
</div>
)}
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
Name
<span style={{ fontWeight: 400 }}> — becomes the username (lowercased, hyphenated)</span>
</span>
<input value={name} onChange={e => setName(e.target.value)}
placeholder="e.g. Tasmota Lights" style={inputStyle} />
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--text-dark)' }}>
<input type="checkbox" checked={isService} onChange={e => setIsService(e.target.checked)} style={{ marginTop: '0.2rem' }} />
<span>
Service consumer (stack app)
<span style={{ display: 'block', fontSize: '0.76rem', color: 'var(--text-mid)', fontWeight: 400 }}>
Stores the password encrypted so a stack app can fetch its own login at runtime — needed for apps like
<code style={{ margin: '0 0.25em' }}>hvac-backend</code> that reconnect on restart. Name it to match the app's
client id. Leave off for device logins (Shelly, Tasmota, gateways).
</span>
</span>
</label>
<div>
<button type="submit" disabled={creating} style={{
background: 'var(--gold)', color: 'var(--navy)', border: 'none',
borderRadius: '6px', padding: '0.45rem 1.1rem', fontSize: '0.82rem', fontWeight: 700,
cursor: creating ? 'wait' : 'pointer', opacity: creating ? 0.6 : 1,
}}>
{creating ? 'Creating…' : 'Create'}
</button>
</div>
</form>
)}
{revealed && (
<div style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 'var(--radius)',
padding: '1.25rem', marginBottom: '1.25rem', boxShadow: 'var(--shadow-sm)',
}}>
<p style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.5rem' }}>
Login for {revealed.username}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', color: 'var(--text-mid)' }}>
Username: <code style={{ background: 'var(--surface-2)', padding: '0.1em 0.4em', borderRadius: 3 }}>{revealed.username}</code>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<code style={{
flex: 1, fontSize: '0.8rem', color: 'var(--text-dark)', background: 'var(--body-bg)',
border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.55rem 0.75rem',
wordBreak: 'break-all', fontFamily: 'monospace',
}}>{revealed.password}</code>
<button onClick={copyPassword} style={{
background: copied ? '#16a34a' : 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '5px', padding: '0.4rem 0.7rem', fontSize: '0.78rem', fontWeight: 600,
cursor: 'pointer', color: copied ? '#fff' : 'var(--text-dark)',
display: 'flex', alignItems: 'center', gap: '0.3rem', flexShrink: 0,
}}>
{copied ? <><Check size={12} strokeWidth={2} /> Copied</> : <><Copy size={12} strokeWidth={1.75} /> Copy</>}
</button>
</div>
</div>
<button onClick={() => setRevealed(null)} style={{
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '0.78rem',
cursor: 'pointer', marginTop: '0.75rem', padding: 0, textDecoration: 'underline',
}}>
Dismiss
</button>
</div>
)}
{loading ? (
<p style={{ color: 'var(--text-mid)' }}>Loading…</p>
) : clients.length === 0 ? (
<p style={{ color: 'var(--text-mid)', fontSize: '0.88rem' }}>No MQTT clients yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{clients.map(c => (
<div key={c.id} style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderLeft: `3px solid ${c.active ? '#16a34a' : 'var(--card-border)'}`,
borderRadius: 'var(--radius)', padding: '0.85rem 1rem',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
boxShadow: 'var(--shadow-sm)',
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)', marginBottom: '0.2rem' }}>
{c.name}
<span style={{ marginLeft: '0.5rem', fontFamily: 'monospace', fontSize: '0.78rem', color: 'var(--text-mid)', fontWeight: 400 }}>
{c.username}
</span>
{c.is_service && (
<span style={{
marginLeft: '0.5rem', fontSize: '0.68rem', fontWeight: 600, color: 'var(--navy)',
background: 'var(--surface-2)', border: '1px solid var(--card-border)',
borderRadius: '4px', padding: '0.05rem 0.4rem', verticalAlign: 'middle',
}}>
service
</span>
)}
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', fontFamily: 'monospace' }}>
{c.rolename === 'open' ? 'all topics' : c.topic_scope.split('\n').join(', ')}
<span style={{ marginLeft: '0.6rem', fontFamily: 'sans-serif' }}>
{[c.can_publish && 'publish', c.can_subscribe && 'subscribe'].filter(Boolean).join(' + ')}
</span>
</div>
</div>
<div style={{ flexShrink: 0, display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{c.active && (
<>
<button onClick={() => reveal(c)} title="Show this client's password" style={smallBtn}>Password</button>
<button onClick={() => resetPassword(c)} title="Generate and apply a new password" style={smallBtn}>Reset</button>
{c.rolename !== 'open' && (
<>
<button onClick={() => openAccess(c)} title="Remove topic restrictions" style={smallBtn}>Open access</button>
<button onClick={() => addTopic(c)} title="Add another topic scope to this client" style={smallBtn}>+ Topic</button>
</>
)}
</>
)}
{c.active ? (
<button onClick={() => revoke(c)} style={{
background: 'transparent', color: 'var(--text-mid)',
border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.35rem 0.85rem', fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer',
}}>
Revoke
</button>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)' }}>Revoked</span>
)}
<button onClick={() => removeClient(c)} style={{
background: 'transparent', color: 'var(--danger)', border: 'none',
fontSize: '1.1rem', cursor: 'pointer', padding: '0 0.3rem', lineHeight: 1,
}} title="Delete record">
×
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
const smallBtn: React.CSSProperties = {
background: 'transparent', color: 'var(--text-mid)',
border: '1px solid var(--card-border)', borderRadius: '6px',
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',
color: 'var(--text-dark)', width: '100%', boxSizing: 'border-box',
}
const arrowBtn: React.CSSProperties = {
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '3px', padding: '1px 3px', cursor: 'pointer',
color: 'var(--text-mid)', lineHeight: 1, display: 'flex',
}