import { useState, useEffect } from 'react' import { CheckCircle, XCircle, RefreshCw, ChevronDown, ChevronUp, Clock, Folder, FolderOpen, ChevronRight } from 'lucide-react' import { Sidebar } from '../components/Sidebar' 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', 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', } 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'>('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'] 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' && }
) } 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 } | 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) 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) } 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) return (
{integration.name}
{isConfigured ? 'Configured' : 'Not configured'}
{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 ? <> 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 } function setInactivityMins(mins: number) { document.cookie = mins > 0 ? `hnf_inactivity_mins=${mins}; max-age=31536000; path=/; SameSite=Strict` : 'hnf_inactivity_mins=; max-age=0; 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

)}
) } 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', }