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 secrets: Record 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 = { 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'>('integrations') const [integrations, setIntegrations] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { fetch('/settings/api/integrations', { credentials: 'include' }) .then(r => r.json()).then(setIntegrations).finally(() => setLoading(false)) }, []) return (

Settings

Third-party integrations and global configuration

{(['integrations', 'rooms', 'apps', 'device', 'mqtt-clients'] as const).map(t => ( ))}
{tab === 'integrations' && ( loading ?

Loading…

:
{integrations.map(intg => ( setIntegrations(prev => prev.map(i => i.slug === updated.slug ? updated : i)) } /> ))}
)} {tab === 'rooms' && } {tab === 'apps' && } {tab === 'device' && } {tab === 'mqtt-clients' && }
) } function IntegrationCard({ integration, onSaved }: { integration: Integration; onSaved: (i: Integration) => void }) { const [open, setOpen] = useState(false) const [form, setForm] = useState>({}) const [changing, setChanging] = useState>(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(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(null) // Hosted Tables — auth secret reveal const [secretPanelOpen, setSecretPanelOpen] = useState(false) const [secretPassword, setSecretPassword] = useState('') const [revealedSecret, setRevealedSecret] = useState(null) const [revealing, setRevealing] = useState(false) const [revealError, setRevealError] = useState(null) const [copied, setCopied] = useState(false) const secretClearTimer = useRef | null>(null) function openForm() { const initial: Record = {} 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 = { ...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 (
{integration.name}
{isConfigured ? 'Configured' : 'Not configured'}
{integration.slug === 'hosted_tables' && ( )}
{secretPanelOpen && (

Re-enter your admin password to reveal the CENTRAL_AUTH_SECRET. Set this as the CENTRAL_AUTH_SECRET environment variable in Hosted Tables to enable shared session SSO.

{!revealedSecret ? (
{revealError && (
{revealError}
)}
) : (
{revealedSecret}

Secret will be hidden automatically after 60 seconds.

)}
)} {open && (
{/* Config fields (plaintext) */} {Object.entries(integration.config).map(([k]) => { if (integration.slug === 'nextcloud' && k === 'backup_path') { const pathSegments = ncBrowsePath === '/' ? [] : ncBrowsePath.split('/').filter(Boolean) return (
Backup Directory
setField(k, e.target.value)} placeholder="e.g. HNF-Backups" style={{ ...inputStyle, flex: 1 }} />
{ncBrowseOpen && (
{/* Breadcrumb */}
{pathSegments.map((seg, i) => { const segPath = '/' + pathSegments.slice(0, i + 1).join('/') const isLast = i === pathSegments.length - 1 return ( ) })}
{/* Directory list */}
{ncBrowseLoading && (
Loading…
)} {ncBrowseError && (
{ncBrowseError}
)} {!ncBrowseLoading && !ncBrowseError && ncBrowseDirs.length === 0 && (
No subdirectories here
)} {!ncBrowseLoading && ncBrowseDirs.map(dir => ( ))}
{/* Footer: select current or cancel */}
{ncBrowsePath}
)}
) } if (integration.slug === 'workforce' && k === 'location_id') { return (
Default Location
{locError && (
{locError}
)}
) } return ( ) })} {/* Secret fields (encrypted) */} {Object.entries(integration.secrets).map(([k, v]) => ( ))} {/* Enable toggle */}
{isConfigured && ( )} {testResult && ( {testResult.ok ? <> {testResult.message ?? 'Connected'} : <> {testResult.error} } )}
)}
) } function RoomsTab() { const [data, setData] = useState(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 (
{data?.synced_at && ( Last synced {new Date(data.synced_at).toLocaleString()} )}
{loading &&

Loading…

} {!loading && !data && (

No room data yet. Configure Newbook credentials then sync.

)} {data && ( <>

Room categories ({data.categories.length})

{data.categories.map((cat, i) => (
setColour(i, e.target.value)} style={{ width: '28px', height: '28px', border: 'none', padding: 0, borderRadius: '4px', cursor: 'pointer', background: 'none' }} /> {cat.name} {data.sites.filter(s => s.category_id === cat.id).length} rooms
))}
)}
) } function AppsTab() { const [apps, setApps] = useState([]) const [loading, setLoading] = useState(true) const [values, setValues] = useState>({}) const [saving, setSaving] = useState>({}) const [saved, setSaved] = useState>({}) 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 = {} 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>((acc, a) => { const cat = a.category ?? 'Other' ;(acc[cat] ??= []).push(a) return acc }, {}) if (loading) return

Loading…

return (

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).

{Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b)).map(([cat, catApps]) => (

{cat}

{catApps.map(app => (
{app.name}
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' }} /> hours
))}
))}
) } 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 (

{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.'}

Inactivity timeout

{TIMEOUT_OPTIONS.map(opt => ( ))}
{saved && (

✓ Saved — takes effect immediately

)}
) } 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([]) 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 (

MQTT Broker Clients

Broker logins with access to all topics. Reuse one login across a batch of similar devices — passwords can be revealed any time.

{broker && (

Point devices at this broker

Host {broker.host} Port {broker.port}

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.

)} {showForm && (
{error && (
{error}
)}
)} {revealed && (

Login for {revealed.username}

Username: {revealed.username}
{revealed.password}
)} {loading ? (

Loading…

) : clients.length === 0 ? (

No MQTT clients yet.

) : (
{clients.map(c => (
{c.name} {c.username} {c.is_service && ( service )}
{c.rolename === 'open' ? 'all topics' : c.topic_scope.split('\n').join(', ')} {[c.can_publish && 'publish', c.can_subscribe && 'subscribe'].filter(Boolean).join(' + ')}
{c.active && ( <> {c.rolename !== 'open' && ( <> )} )} {c.active ? ( ) : ( Revoked )}
))}
)}
) } 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', } 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', }