portal/src/pages/AdminSettings.tsx
jtricerolph 8a8c4073e5 Add Nextcloud directory browser to backup_path field in Settings
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>
2026-07-14 11:09:47 +00:00

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