1346 lines
62 KiB
TypeScript
1346 lines
62 KiB
TypeScript
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',
|
||
}
|