import React, { useState, useEffect } from 'react'; import { authFetch } from '../lib/auth'; import { User } from '../types'; import { Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff, Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw, Terminal, Network, Trash2, Plus, HardDrive, Download, Upload, AlertTriangle, Plug, Server, Pencil, X, } from 'lucide-react'; const SECRET_SENTINEL = '__SET__'; interface RawSettings { azure_enabled: string; azure_client_id: string; azure_tenant_id: string; azure_client_secret: string; azure_redirect_uri: string; azure_allowed_group: string; checkmk_enabled: string; checkmk_api_url: string; checkmk_api_user: string; checkmk_api_secret: string; checkmk_sync_interval_ms: string; semaphore_enabled: string; semaphore_api_url: string; semaphore_api_token: string; semaphore_project_id: string; caddy_enabled: string; caddy_admin_url: string; } interface CaddyRoute { id: number; hostname: string; upstream: string; tls: number; compress: number; } interface DbInfo { sizeBytes: number; lastModified: string; tables: Record; path: string; } interface SettingsProps { currentUser: User; } function Label({ children }: { children: React.ReactNode }) { return ; } function Hint({ children }: { children: React.ReactNode }) { return

{children}

; } function ConfiguredBadge() { return ( CONFIGURED ); } function FieldRow({ label, hint, badge, children }: { label: string; hint?: string; badge?: React.ReactNode; children: React.ReactNode; }) { return (
{badge}
{children} {hint && {hint}}
); } function Input({ value, onChange, placeholder, monospace, icon }: { value: string; onChange: (v: string) => void; placeholder?: string; monospace?: boolean; icon?: React.ReactNode; }) { return (
{icon && ( {icon} )} onChange(e.target.value)} placeholder={placeholder} className={`w-full bg-slate-900 border border-slate-700 rounded-lg py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all ${icon ? 'pl-9 pr-3' : 'px-3'} ${monospace ? 'font-mono text-xs' : ''}`} />
); } function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: { value: string; onChange: (v: string) => void; alreadySet: boolean; show: boolean; onToggleShow: () => void; }) { return (
onChange(e.target.value)} placeholder={alreadySet ? '•••••••• (leave blank to keep)' : 'Secret'} className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-xs font-mono text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all" />
); } function SectionCard({ accentColor, children }: { accentColor: string; children: React.ReactNode; }) { return (
{children}
); } export default function Settings({ currentUser: _currentUser }: SettingsProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [activeSection, setActiveSection] = useState<'integrations' | 'system'>('integrations'); const [error, setError] = useState(''); const [successMsg, setSuccessMsg] = useState(''); const [copied, setCopied] = useState(false); const [effectiveRedirectUri, setEffectiveRedirectUri] = useState(''); const [azureEnabled, setAzureEnabled] = useState(false); const [azureClientId, setAzureClientId] = useState(''); const [azureTenantId, setAzureTenantId] = useState(''); const [azureClientSecret, setAzureClientSecret] = useState(''); const [azureSecretSet, setAzureSecretSet] = useState(false); const [azureRedirectUri, setAzureRedirectUri] = useState(''); const [azureAllowedGroup, setAzureAllowedGroup] = useState(''); const [showAzureSecret, setShowAzureSecret] = useState(false); const [checkmkEnabled, setCheckmkEnabled] = useState(false); const [checkmkApiUrl, setCheckmkApiUrl] = useState(''); const [checkmkApiUser, setCheckmkApiUser] = useState('automation'); const [checkmkApiSecret, setCheckmkApiSecret] = useState(''); const [checkmkSecretSet, setCheckmkSecretSet] = useState(false); const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000'); const [showCheckmkSecret, setShowCheckmkSecret] = useState(false); const [syncing, setSyncing] = useState(false); const [semaphoreEnabled, setSemaphoreEnabled] = useState(false); const [semaphoreApiUrl, setSemaphoreApiUrl] = useState(''); const [semaphoreApiToken, setSemaphoreApiToken] = useState(''); const [semaphoreTokenSet, setSemaphoreTokenSet] = useState(false); const [semaphoreProjectId, setSemaphoreProjectId] = useState(''); const [showSemaphoreToken, setShowSemaphoreToken] = useState(false); const [semaphoreTesting, setSemaphoreTesting] = useState(false); const [semaphoreTestResult, setSemaphoreTestResult] = useState(null); const [caddyEnabled, setCaddyEnabled] = useState(false); const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019'); const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown'); const [caddyRoutes, setCaddyRoutes] = useState<{ system: { hostname: string; upstream: string }[]; custom: CaddyRoute[] } | null>(null); const [addingRoute, setAddingRoute] = useState(false); const [newHostname, setNewHostname] = useState(''); const [newUpstream, setNewUpstream] = useState(''); const [newTls, setNewTls] = useState(true); const [newCompress, setNewCompress] = useState(true); const [editingRouteId, setEditingRouteId] = useState(null); const [editHostname, setEditHostname] = useState(''); const [editUpstream, setEditUpstream] = useState(''); const [editTls, setEditTls] = useState(true); const [editCompress, setEditCompress] = useState(true); const [savingRoute, setSavingRoute] = useState(false); const [dbInfo, setDbInfo] = useState(null); const [backingUp, setBackingUp] = useState(false); const [importFile, setImportFile] = useState(null); const [importing, setImporting] = useState(false); const [importConfirmed, setImportConfirmed] = useState(false); const [importResult, setImportResult] = useState<{ ok: boolean; msg: string } | null>(null); useEffect(() => { loadSettings(); loadDbInfo(); fetch('/api/auth/config') .then(r => r.json()) .then(d => { if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); }) .catch(() => {}); }, []); useEffect(() => { if (caddyEnabled) loadCaddyRoutes(); }, [caddyEnabled]); async function loadSettings() { setLoading(true); setError(''); try { const res = await authFetch('/api/settings'); if (!res.ok) { let msg = `Failed to load settings (HTTP ${res.status}).`; try { const d = await res.json(); if (d.error) msg = `${d.error} (HTTP ${res.status})`; } catch {} setError(msg); return; } const data: RawSettings = await res.json(); setAzureEnabled(data.azure_enabled === 'true'); setAzureClientId(data.azure_client_id || ''); setAzureTenantId(data.azure_tenant_id || ''); setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL); setAzureClientSecret(''); setAzureRedirectUri(data.azure_redirect_uri || ''); setAzureAllowedGroup(data.azure_allowed_group || ''); setCheckmkEnabled(data.checkmk_enabled === 'true'); setCheckmkApiUrl(data.checkmk_api_url || ''); setCheckmkApiUser(data.checkmk_api_user || 'automation'); setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL); setCheckmkApiSecret(''); setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000'); setSemaphoreEnabled(data.semaphore_enabled === 'true'); setSemaphoreApiUrl(data.semaphore_api_url || ''); setSemaphoreTokenSet(data.semaphore_api_token === SECRET_SENTINEL); setSemaphoreApiToken(''); setSemaphoreProjectId(data.semaphore_project_id || ''); setCaddyEnabled(data.caddy_enabled === 'true'); setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019'); } catch { setError('Network error loading settings.'); } finally { setLoading(false); } } async function handleSave() { setSaving(true); setError(''); setSuccessMsg(''); const payload: Record = { azure_enabled: azureEnabled ? 'true' : 'false', azure_client_id: azureClientId, azure_tenant_id: azureTenantId, azure_redirect_uri: azureRedirectUri, azure_allowed_group: azureAllowedGroup, checkmk_enabled: checkmkEnabled ? 'true' : 'false', checkmk_api_url: checkmkApiUrl, checkmk_api_user: checkmkApiUser, checkmk_sync_interval_ms: checkmkSyncInterval, semaphore_enabled: semaphoreEnabled ? 'true' : 'false', semaphore_api_url: semaphoreApiUrl, semaphore_project_id: semaphoreProjectId, caddy_enabled: caddyEnabled ? 'true' : 'false', caddy_admin_url: caddyAdminUrl, }; if (azureClientSecret) payload.azure_client_secret = azureClientSecret; if (checkmkApiSecret) payload.checkmk_api_secret = checkmkApiSecret; if (semaphoreApiToken) payload.semaphore_api_token = semaphoreApiToken; try { const res = await authFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }); if (!res.ok) { const d = await res.json(); setError(d.error || 'Failed to save settings.'); return; } const data: RawSettings = await res.json(); setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL); setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL); setSemaphoreTokenSet(data.semaphore_api_token === SECRET_SENTINEL); setAzureClientSecret(''); setCheckmkApiSecret(''); setSemaphoreApiToken(''); await loadCaddyRoutes(); setSuccessMsg('Settings saved successfully.'); setTimeout(() => setSuccessMsg(''), 4000); } catch { setError('Network error saving settings.'); } finally { setSaving(false); } } async function runSync() { setSyncing(true); setError(''); try { const res = await authFetch('/api/checkmk/sync', { method: 'POST' }); if (!res.ok) { const d = await res.json(); setError(d.error || 'Sync failed.'); return; } setSuccessMsg('CheckMK sync triggered successfully.'); setTimeout(() => setSuccessMsg(''), 4000); } catch { setError('Network error during sync.'); } finally { setSyncing(false); } } async function testSemaphoreConnection() { setSemaphoreTesting(true); setSemaphoreTestResult(null); try { const res = await authFetch('/api/semaphore/templates'); if (!res.ok) { const d = await res.json(); setSemaphoreTestResult(`Error: ${d.error || `HTTP ${res.status}`}`); return; } const templates = await res.json() as any[]; setSemaphoreTestResult(`Connected — ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`); } catch { setSemaphoreTestResult('Error: Network error connecting to Semaphore.'); } finally { setSemaphoreTesting(false); } } function copyRedirectUri() { if (!effectiveRedirectUri) return; navigator.clipboard.writeText(effectiveRedirectUri).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }).catch(() => {}); } async function loadCaddyRoutes() { // Status check is background — never blocks route display authFetch('/api/caddy/status') .then(res => res.ok ? res.json() : null) .then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable')) .catch(() => setCaddyStatus('unavailable')); // Routes load immediately from DB try { const res = await authFetch('/api/caddy/routes'); if (res.ok) setCaddyRoutes(await res.json()); } catch {} } async function handleAddRoute() { if (!newHostname.trim() || !newUpstream.trim()) return; setAddingRoute(true); try { const res = await authFetch('/api/caddy/routes', { method: 'POST', body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress }), }); if (!res.ok) { const d = await res.json(); setError(d.error || 'Failed to add route.'); return; } setNewHostname(''); setNewUpstream(''); setNewTls(true); setNewCompress(true); await loadCaddyRoutes(); } catch { setError('Network error adding route.'); } finally { setAddingRoute(false); } } async function handleDeleteRoute(id: number) { try { const res = await authFetch(`/api/caddy/routes/${id}`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) { const d = await res.json().catch(() => ({})); setError((d as any).error || 'Failed to delete route.'); return; } await loadCaddyRoutes(); } catch { setError('Network error deleting route.'); } } function handleEditStart(r: CaddyRoute) { setEditingRouteId(r.id); setEditHostname(r.hostname); setEditUpstream(r.upstream); setEditTls(r.tls === 1); setEditCompress(r.compress === 1); } function handleEditCancel() { setEditingRouteId(null); } async function handleEditSave(id: number) { if (!editHostname.trim() || !editUpstream.trim()) return; setSavingRoute(true); try { const res = await authFetch(`/api/caddy/routes/${id}`, { method: 'PUT', body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress }), }); if (!res.ok) { const d = await res.json(); setError(d.error || 'Failed to update route.'); return; } setEditingRouteId(null); await loadCaddyRoutes(); } catch { setError('Network error updating route.'); } finally { setSavingRoute(false); } } async function loadDbInfo() { try { const res = await authFetch('/api/database/info'); if (res.ok) setDbInfo(await res.json()); } catch {} } async function handleBackup() { setBackingUp(true); setError(''); try { const res = await authFetch('/api/database/backup'); if (!res.ok) { const d = await res.json().catch(() => ({})); setError((d as any).error || 'Backup failed.'); return; } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ghostgrid-backup-${new Date().toISOString().slice(0, 10)}.db`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch { setError('Network error during backup.'); } finally { setBackingUp(false); } } async function handleImport() { if (!importFile || !importConfirmed) return; setImporting(true); setImportResult(null); try { const res = await authFetch('/api/database/import', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: importFile, }); if (!res.ok) { const d = await res.json().catch(() => ({})); setImportResult({ ok: false, msg: (d as any).error || 'Import failed.' }); return; } setImportResult({ ok: true, msg: 'Database imported successfully. Reload the page to reflect all changes.' }); setImportFile(null); setImportConfirmed(false); loadDbInfo(); loadSettings(); } catch { setImportResult({ ok: false, msg: 'Network error during import.' }); } finally { setImporting(false); } } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } if (loading) { return (
); } return (
{/* Page header */}
SYSTEM SETTINGS

Settings

Configure integrations and authentication providers.

{/* Feedback banners */} {error && (
{error}
)} {successMsg && (
{successMsg}
)} {/* Section tabs — switch between Integrations and System to keep the page light */}
{([ { id: 'integrations', label: 'Integrations', icon: }, { id: 'system', label: 'System', icon: }, ] as const).map(tab => ( ))}
{/* ── Integrations: Azure in column one, monitoring & automation stacked in column two ── */} {activeSection === 'integrations' && (
{/* ── Microsoft Entra ID ── */}

Microsoft Entra ID

{azureEnabled && azureSecretSet && ( ACTIVE )}

OAuth 2.0 SSO for organizational accounts

{azureEnabled ? 'ENABLED' : 'DISABLED'}
: undefined} > setShowAzureSecret(v => !v)} />
} />
{/* Redirect URI – read-only */} {effectiveRedirectUri && azureEnabled && (

Required Redirect URI

{effectiveRedirectUri}

Register this in Azure Portal > App registrations > Authentication > Redirect URIs

)} {/* Monitoring & automation, stacked together in the second column */}
{/* ── CheckMK ── */}

CheckMK

{checkmkEnabled && checkmkApiUrl && checkmkSecretSet && ( ACTIVE )}

Device status sync via CheckMK REST API

{checkmkEnabled ? 'ENABLED' : 'DISABLED'}
} />
} /> : undefined} > setShowCheckmkSecret(v => !v)} /> } />
{checkmkApiUrl && checkmkSecretSet && ( )}
{/* ── Ansible Semaphore ── */}

Ansible Semaphore

{semaphoreEnabled && semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && ( ACTIVE )}

Trigger playbooks automatically at booking start and end

{semaphoreEnabled ? 'ENABLED' : 'DISABLED'}
} />
: undefined} > setShowSemaphoreToken(v => !v)} /> } />
{semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
{semaphoreTestResult && (

{semaphoreTestResult}

)}
)}
{/* end second column */}
)} {/* ── System ── */} {activeSection === 'system' && (
{/* ── Caddy Reverse Proxy ── */}

Caddy Reverse Proxy

{caddyEnabled && caddyStatus === 'available' && ( ACTIVE )}

Manage reverse proxy routes for internal services

{caddyEnabled ? 'ENABLED' : 'DISABLED'}
} /> {/* Route list */} {caddyEnabled && (
{caddyStatus === 'unavailable' && (

Caddy Admin API not reachable — routes will be applied when Caddy starts.

)} {/* Custom routes */} {caddyRoutes?.custom.map(r => (
{editingRouteId === r.id ? (
TLS
GZ
) : (
{r.hostname} > {r.upstream} {r.tls ? TLS : null} {r.compress ? GZ : null}
)}
))} {/* Add route form */}
TLS
GZ
)}
{/* ── Database ── */} {/* Header: icon + title + file size */}

Database

SQLite · {dbInfo?.path ?? 'ghostgrid.db'}

{dbInfo ? formatBytes(dbInfo.sizeBytes) : '—'}

{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}

{/* Proportional usage bar + table stats */} {dbInfo ? (() => { const tableEntries = Object.entries(dbInfo.tables) as [string, number][]; const total = tableEntries.reduce((sum, [, n]) => sum + n, 0); const palette: Record = { users: 'bg-blue-500', devices: 'bg-emerald-500', labs: 'bg-orange-500', bookings: 'bg-cyan-500', logs: 'bg-slate-400', links: 'bg-violet-500', settings: 'bg-slate-600', caddy: 'bg-sky-500', }; return (
{total === 0 ?
: tableEntries.filter(([, n]) => n > 0).map(([t, n]) => (
)) }
{tableEntries.map(([t, n]) => (

{t}

{n}

))}
); })() : (
)}
{/* Actions */}
{/* Backup */}
Downloads a consistent snapshot of the SQLite database.
{/* Import */}

Import overwrites the entire database — this cannot be undone.

{importFile && (
)} {importResult && (

{importResult.msg}

)}
)}
); }