The Backup Directory field in the Nextcloud integration card gains a Browse button that opens an inline panel with: breadcrumb navigation, scrollable directory list, and a Select button to pick the current path. Falls back to manual text entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
795 lines
35 KiB
TypeScript
795 lines
35 KiB
TypeScript
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<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',
|
|
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<Integration[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
fetch('/settings/api/integrations', { credentials: 'include' })
|
|
.then(r => r.json()).then(setIntegrations).finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
return (
|
|
<div style={{ display: 'flex', height: '100dvh' }}>
|
|
<Sidebar user={user} />
|
|
<main style={{ flex: 1, overflowY: 'auto', 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'] 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' : 'This Device'}
|
|
</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 />}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 } | 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)
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
|
|
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>
|
|
<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>
|
|
|
|
{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} /> 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
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|
|
|
|
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',
|
|
}
|