Files
GhostGrid/src/components/Settings.tsx
Brückner e0332b05ad feat(caddy): single owner via CADDY_MANAGER env flag
One Caddy serves the whole container and POST /load replaces the entire
config, so two instances pushing would clobber each other. Now only the
instance with CADDY_MANAGER=true (production) pushes, seeds routes from the
Caddyfile, and accepts route mutations (others get 403). /api/auth/config
exposes caddyManaged so the non-owner Settings UI shows the Caddy section
read-only. The installer sets the flag on the production .env only.
2026-06-09 12:47:20 +02:00

1221 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, number>;
path: string;
}
interface SettingsProps {
currentUser: User;
}
function Label({ children }: { children: React.ReactNode }) {
return <label className="block text-[11px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5">{children}</label>;
}
function Hint({ children }: { children: React.ReactNode }) {
return <p className="mt-1 text-[10px] text-slate-500 font-mono leading-relaxed">{children}</p>;
}
function ConfiguredBadge() {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
<span className="w-1 h-1 rounded-full bg-emerald-400 inline-block" />
CONFIGURED
</span>
);
}
function FieldRow({ label, hint, badge, children }: {
label: string;
hint?: string;
badge?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>{label}</Label>
{badge}
</div>
{children}
{hint && <Hint>{hint}</Hint>}
</div>
);
}
function Input({ value, onChange, placeholder, monospace, icon }: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
monospace?: boolean;
icon?: React.ReactNode;
}) {
return (
<div className="relative">
{icon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
{icon}
</span>
)}
<input
type="text"
value={value}
onChange={e => 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' : ''}`}
/>
</div>
);
}
function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
value: string;
onChange: (v: string) => void;
alreadySet: boolean;
show: boolean;
onToggleShow: () => void;
}) {
return (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
<KeyRound className="w-3.5 h-3.5" />
</span>
<input
type={show ? 'text' : 'password'}
value={value}
onChange={e => 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"
/>
<button
type="button"
onClick={onToggleShow}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
tabIndex={-1}
>
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
</div>
);
}
function SectionCard({ accentColor, children }: {
accentColor: string;
children: React.ReactNode;
}) {
return (
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl overflow-hidden">
<div className={`h-0.5 w-full ${accentColor}`} />
<div className="p-6 space-y-5">
{children}
</div>
</div>
);
}
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<string | null>(null);
const [caddyEnabled, setCaddyEnabled] = useState(false);
const [caddyManaged, setCaddyManaged] = useState(true);
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
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<number | null>(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<DbInfo | null>(null);
const [backingUp, setBackingUp] = useState(false);
const [importFile, setImportFile] = useState<File | null>(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);
setCaddyManaged(d.caddyManaged !== false);
})
.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<string, string> = {
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() {
try {
const res = await authFetch('/api/caddy/routes');
if (res.ok) setCaddyRoutes(await res.json());
} catch {}
// Status check runs separately — purely informational, never blocks the list
authFetch('/api/caddy/status')
.then(res => res.ok ? res.json() : null)
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
.catch(() => setCaddyStatus('unavailable'));
}
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 (
<div className="flex items-center justify-center h-64">
<span className="w-5 h-5 border-2 border-slate-700 border-t-cyan-400 rounded-full animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
<Settings2 className="w-3 h-3" />
<span>SYSTEM</span>
<ChevronRight className="w-3 h-3" />
<span className="text-slate-400">SETTINGS</span>
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Settings</h1>
<p className="text-xs text-slate-500 mt-0.5">Configure integrations and authentication providers.</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="shrink-0 flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50"
>
{saving ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? 'Saving…' : 'Save Settings'}
</button>
</div>
{/* Feedback banners */}
{error && (
<div className="flex items-start gap-2.5 bg-red-950/40 border border-red-900/50 rounded-xl px-4 py-3 text-xs text-red-300">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400 mt-0.5" />
<span>{error}</span>
</div>
)}
{successMsg && (
<div className="flex items-center gap-2.5 bg-emerald-950/40 border border-emerald-900/50 rounded-xl px-4 py-3 text-xs text-emerald-300">
<CheckCircle className="w-4 h-4 shrink-0 text-emerald-400" />
{successMsg}
</div>
)}
{/* Section tabs — switch between Integrations and System to keep the page light */}
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
{([
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
{ id: 'system', label: 'System', icon: <Server className="w-3.5 h-3.5" /> },
] as const).map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setActiveSection(tab.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
activeSection === tab.id
? 'bg-cyan-950/40 border border-cyan-900/50 text-cyan-400'
: 'border border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-800'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* ── Integrations: Azure in column one, monitoring & automation stacked in column two ── */}
{activeSection === 'integrations' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* ── Microsoft Entra ID ── */}
<SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-950/60 border border-blue-900/40 rounded-xl">
<Shield className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-white">Microsoft Entra ID</h2>
{azureEnabled && azureSecretSet && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-blue-950/60 border border-blue-900/50 text-blue-400">
<span className="w-1 h-1 rounded-full bg-blue-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue-400' : 'text-slate-600'}`}>
{azureEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setAzureEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${azureEnabled ? 'bg-blue-600 shadow-[0_0_10px_rgba(37,99,235,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${azureEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-slate-800/60" />
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Tenant ID" monospace />
</FieldRow>
<FieldRow label="Client ID (Application ID)" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<Input value={azureClientId} onChange={setAzureClientId} placeholder="Client ID" monospace />
</FieldRow>
<FieldRow
label="Client Secret"
badge={azureSecretSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
value={azureClientSecret}
onChange={setAzureClientSecret}
alreadySet={azureSecretSet}
show={showAzureSecret}
onToggleShow={() => setShowAzureSecret(v => !v)}
/>
</FieldRow>
<div className="sm:col-span-2">
<FieldRow
label="Redirect URI"
hint="Must match exactly what is registered in Azure Portal > App registrations > Authentication. Leave blank to auto-derive from APP_URL."
>
<Input
value={azureRedirectUri}
onChange={setAzureRedirectUri}
placeholder={effectiveRedirectUri || 'https://…/api/auth/azure/callback'}
monospace
/>
</FieldRow>
</div>
<FieldRow
label="Allowed Group"
hint="Object ID of an Azure AD group. If set, only members of this group can sign in."
>
<Input
value={azureAllowedGroup}
onChange={setAzureAllowedGroup}
placeholder="Leave blank to allow all tenant users"
monospace
icon={<Users className="w-3.5 h-3.5" />}
/>
</FieldRow>
</div>
{/* Redirect URI read-only */}
{effectiveRedirectUri && azureEnabled && (
<div className="flex items-start gap-3 bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-3">
<div className="flex-1 min-w-0">
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">Required Redirect URI</p>
<p className="text-[11px] font-mono text-slate-200 break-all">{effectiveRedirectUri}</p>
<p className="text-[10px] text-slate-500 mt-1">Register this in Azure Portal &gt; App registrations &gt; Authentication &gt; Redirect URIs</p>
</div>
<button
type="button"
onClick={copyRedirectUri}
className="shrink-0 p-1.5 rounded-lg text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-all"
title="Copy to clipboard"
>
{copied ? <CheckCircle className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
</SectionCard>
{/* Monitoring & automation, stacked together in the second column */}
<div className="space-y-6">
{/* ── CheckMK ── */}
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-950/60 border border-emerald-900/40 rounded-xl">
<Activity className="w-4 h-4 text-emerald-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-white">CheckMK</h2>
{checkmkEnabled && checkmkApiUrl && checkmkSecretSet && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
<span className="w-1 h-1 rounded-full bg-emerald-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">Device status sync via CheckMK REST API</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${checkmkEnabled ? 'text-emerald-400' : 'text-slate-600'}`}>
{checkmkEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setCheckmkEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${checkmkEnabled ? 'bg-emerald-600 shadow-[0_0_10px_rgba(5,150,105,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${checkmkEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-slate-800/60" />
<div className={`space-y-5 transition-opacity duration-200 ${!checkmkEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="API URL">
<Input
value={checkmkApiUrl}
onChange={setCheckmkApiUrl}
placeholder="https://checkmk/<site>/check_mk/api/1.0"
monospace
icon={<Globe className="w-3.5 h-3.5" />}
/>
</FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow label="Automation User" hint="Setup > Users > Automation user (e.g. automation)">
<Input
value={checkmkApiUser}
onChange={setCheckmkApiUser}
placeholder="automation"
monospace
icon={<KeyRound className="w-3.5 h-3.5" />}
/>
</FieldRow>
<FieldRow
label="Automation Secret"
hint="Setup > Users > Automation user > Automation secret"
badge={checkmkSecretSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
value={checkmkApiSecret}
onChange={setCheckmkApiSecret}
alreadySet={checkmkSecretSet}
show={showCheckmkSecret}
onToggleShow={() => setShowCheckmkSecret(v => !v)}
/>
</FieldRow>
<FieldRow label="Sync Interval" hint="Milliseconds between status polls (default: 60000)">
<Input
value={checkmkSyncInterval}
onChange={setCheckmkSyncInterval}
placeholder="60000"
monospace
icon={<Clock className="w-3.5 h-3.5" />}
/>
</FieldRow>
</div>
{checkmkApiUrl && checkmkSecretSet && (
<button
type="button"
onClick={runSync}
disabled={syncing}
className="flex items-center gap-2 bg-emerald-950/60 hover:bg-emerald-900/40 border border-emerald-900/50 text-emerald-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Run sync now'}
</button>
)}
</div>
</SectionCard>
{/* ── Ansible Semaphore ── */}
<SectionCard accentColor="bg-gradient-to-r from-orange-600 to-amber-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-950/60 border border-orange-900/40 rounded-xl">
<Terminal className="w-4 h-4 text-orange-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-white">Ansible Semaphore</h2>
{semaphoreEnabled && semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-orange-950/60 border border-orange-900/50 text-orange-400">
<span className="w-1 h-1 rounded-full bg-orange-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">Trigger playbooks automatically at booking start and end</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${semaphoreEnabled ? 'text-orange-400' : 'text-slate-600'}`}>
{semaphoreEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setSemaphoreEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${semaphoreEnabled ? 'bg-orange-600 shadow-[0_0_10px_rgba(234,88,12,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${semaphoreEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-slate-800/60" />
<div className={`space-y-5 transition-opacity duration-200 ${!semaphoreEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="API URL">
<Input
value={semaphoreApiUrl}
onChange={setSemaphoreApiUrl}
placeholder="https://semaphore/api/v1alpha"
monospace
icon={<Globe className="w-3.5 h-3.5" />}
/>
</FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow
label="API Token"
hint="Profile > API Tokens in Semaphore"
badge={semaphoreTokenSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
value={semaphoreApiToken}
onChange={setSemaphoreApiToken}
alreadySet={semaphoreTokenSet}
show={showSemaphoreToken}
onToggleShow={() => setShowSemaphoreToken(v => !v)}
/>
</FieldRow>
<FieldRow label="Project ID" hint="Numeric ID from the Semaphore project URL">
<Input
value={semaphoreProjectId}
onChange={setSemaphoreProjectId}
placeholder="1"
monospace
icon={<KeyRound className="w-3.5 h-3.5" />}
/>
</FieldRow>
</div>
{semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
<div className="space-y-2">
<button
type="button"
onClick={testSemaphoreConnection}
disabled={semaphoreTesting}
className="flex items-center gap-2 bg-orange-950/60 hover:bg-orange-900/40 border border-orange-900/50 text-orange-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${semaphoreTesting ? 'animate-spin' : ''}`} />
{semaphoreTesting ? 'Testing…' : 'Test connection'}
</button>
{semaphoreTestResult && (
<p className={`text-[11px] font-mono ${semaphoreTestResult.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
{semaphoreTestResult}
</p>
)}
</div>
)}
</div>
</SectionCard>
</div>{/* end second column */}
</div>
)}
{/* ── System ── */}
{activeSection === 'system' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* ── Caddy Reverse Proxy ── */}
<SectionCard accentColor="bg-gradient-to-r from-sky-600 to-cyan-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-sky-950/60 border border-sky-900/40 rounded-xl">
<Network className="w-4 h-4 text-sky-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-white">Caddy Reverse Proxy</h2>
{caddyEnabled && caddyStatus === 'available' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-sky-950/60 border border-sky-900/50 text-sky-400">
<span className="w-1 h-1 rounded-full bg-sky-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">Manage reverse proxy routes for internal services</p>
</div>
</div>
<div className="flex items-center gap-2.5">
{caddyManaged ? (
<>
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setCaddyEnabled((v: boolean) => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${caddyEnabled ? 'bg-sky-600 shadow-[0_0_10px_rgba(2,132,199,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${caddyEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</>
) : (
<span className="text-[10px] font-semibold font-mono text-slate-500">MANAGED BY PRODUCTION</span>
)}
</div>
</div>
<div className="h-px bg-slate-800/60" />
{!caddyManaged && (
<p className="text-[11px] font-mono text-slate-500 leading-relaxed">
Caddy is centrally managed by the production instance (ghostgrid). Manage proxy routes there.
</p>
)}
{caddyManaged && (
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Caddy Admin URL" hint="Default: http://localhost:2019">
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
</FieldRow>
{/* Route list */}
{caddyEnabled && (
<div className="space-y-2">
<Label>Proxy Routes</Label>
<Hint>Prefix the upstream with https:// for TLS backends (e.g. Semaphore) — the certificate is not verified.</Hint>
{caddyStatus === 'unavailable' && (
<p className="text-[11px] font-mono text-amber-400 mb-2">
Caddy Admin API not reachable routes will be applied when Caddy starts.
</p>
)}
{caddyRoutes.length === 0 && (
<p className="text-[11px] font-mono text-slate-500 mb-2">
No proxy routes configured yet.
</p>
)}
{/* Custom routes */}
{caddyRoutes.map((r: CaddyRoute) => (
<div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
{editingRouteId === r.id ? (
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Label>Hostname</Label>
<Input value={editHostname} onChange={setEditHostname} placeholder="hostname" monospace />
</div>
<div className="flex-1 min-w-0">
<Label>Upstream</Label>
<Input value={editUpstream} onChange={setEditUpstream} placeholder="127.0.0.1:3000" monospace />
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
<button type="button" onClick={() => setEditTls(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editTls ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${editTls ? 'left-4' : 'left-0.5'}`} />
</button>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
<button type="button" onClick={() => setEditCompress(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editCompress ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${editCompress ? 'left-4' : 'left-0.5'}`} />
</button>
</div>
<button type="button" onClick={() => handleEditSave(r.id)} disabled={savingRoute || !editHostname.trim() || !editUpstream.trim()}
className="flex items-center gap-1 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-2.5 py-2 rounded-lg transition-all disabled:opacity-50 shrink-0">
<CheckCircle className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={handleEditCancel}
className="p-2 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800 transition-all shrink-0">
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
<span className="text-slate-600 text-[11px]">&gt;</span>
<span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span>
{r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null}
{r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
</div>
<div className="flex items-center gap-1 ml-3 shrink-0">
<button type="button" onClick={() => handleEditStart(r)}
className="p-1.5 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-950/30 transition-all" title="Edit route">
<Pencil className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={() => handleDeleteRoute(r.id)}
className="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-950/30 transition-all" title="Remove route">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
</div>
))}
{/* Add route form */}
<div className="flex items-end gap-2 pt-1">
<div className="flex-1 min-w-0">
<Label>Hostname</Label>
<Input value={newHostname} onChange={setNewHostname} placeholder="semaphore" monospace />
</div>
<div className="flex-1 min-w-0">
<Label>Upstream</Label>
<Input value={newUpstream} onChange={setNewUpstream} placeholder="127.0.0.1:3000" monospace />
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
<button
type="button"
onClick={() => setNewTls(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newTls ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${newTls ? 'left-4' : 'left-0.5'}`} />
</button>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
<button
type="button"
onClick={() => setNewCompress(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newCompress ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${newCompress ? 'left-4' : 'left-0.5'}`} />
</button>
</div>
<button
type="button"
onClick={handleAddRoute}
disabled={addingRoute || !newHostname.trim() || !newUpstream.trim()}
className="flex items-center gap-1.5 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
<Plus className="w-3.5 h-3.5" />
{addingRoute ? 'Adding…' : 'Add'}
</button>
</div>
</div>
)}
</div>
)}
</SectionCard>
{/* ── Database ── */}
<SectionCard accentColor="bg-gradient-to-r from-violet-600 to-purple-600">
{/* Header: icon + title + file size */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-violet-950/60 border border-violet-900/40 rounded-xl">
<HardDrive className="w-4 h-4 text-violet-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Database</h2>
<p className="text-[11px] text-slate-500 mt-0.5">SQLite · {dbInfo?.path ?? 'ghostgrid.db'}</p>
</div>
</div>
<div className="text-right">
<p className="text-xl font-bold text-white font-mono leading-none">
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '—'}
</p>
<p className="text-[10px] text-slate-500 font-mono mt-1">
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
</p>
</div>
</div>
<div className="h-px bg-slate-800/60" />
{/* 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<string, string> = {
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 (
<div className="space-y-3">
<div className="flex h-1.5 rounded-full overflow-hidden bg-slate-800 gap-px">
{total === 0
? <div className="flex-1 bg-slate-700" />
: tableEntries.filter(([, n]) => n > 0).map(([t, n]) => (
<div
key={t}
title={`${t}: ${n}`}
className={`${palette[t] ?? 'bg-slate-500'} transition-all`}
style={{ width: `${(n / total) * 100}%` }}
/>
))
}
</div>
<div className="grid grid-cols-4 gap-1.5">
{tableEntries.map(([t, n]) => (
<div key={t} className="bg-slate-900/50 border border-slate-700/60 rounded-lg px-2 py-1.5">
<div className={`w-1.5 h-1.5 rounded-full mb-1 ${palette[t] ?? 'bg-slate-500'}`} />
<p className="text-[8px] font-semibold text-slate-600 uppercase tracking-wide truncate">{t}</p>
<p className="text-sm font-bold text-white font-mono">{n}</p>
</div>
))}
</div>
</div>
);
})() : (
<div className="h-16 flex items-center justify-center">
<span className="w-4 h-4 border-2 border-slate-700 border-t-violet-400 rounded-full animate-spin" />
</div>
)}
<div className="h-px bg-slate-800/60" />
{/* Actions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Backup */}
<div className="space-y-2">
<Label>Backup</Label>
<button
type="button"
onClick={handleBackup}
disabled={backingUp}
className="w-full flex items-center justify-center gap-2 bg-violet-950/60 hover:bg-violet-900/40 border border-violet-900/50 text-violet-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className={`w-3.5 h-3.5 ${backingUp ? 'animate-pulse' : ''}`} />
{backingUp ? 'Creating backup…' : 'Download Backup'}
</button>
<Hint>Downloads a consistent snapshot of the SQLite database.</Hint>
</div>
{/* Import */}
<div className="space-y-2">
<Label>Import</Label>
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-amber-300 leading-relaxed">
<strong>Import overwrites the entire database</strong> this cannot be undone.
</p>
</div>
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">
<Upload className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{importFile ? importFile.name : 'Select .db file…'}</span>
<input
type="file"
accept=".db"
className="sr-only"
onChange={e => {
setImportFile(e.target.files?.[0] ?? null);
setImportConfirmed(false);
setImportResult(null);
}}
/>
</label>
{importFile && (
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={importConfirmed}
onChange={e => setImportConfirmed(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-violet-500"
/>
<span className="text-[11px] text-slate-400">I confirm this will overwrite all existing data</span>
</label>
<button
type="button"
onClick={handleImport}
disabled={importing || !importConfirmed}
className="w-full flex items-center justify-center gap-2 bg-red-950/60 hover:bg-red-900/40 border border-red-900/50 text-red-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{importing
? <span className="w-3.5 h-3.5 border-2 border-red-400/30 border-t-red-400 rounded-full animate-spin" />
: <Upload className="w-3.5 h-3.5" />
}
{importing ? 'Importing…' : 'Import Database'}
</button>
</div>
)}
{importResult && (
<p className={`text-[11px] font-mono ${importResult.ok ? 'text-emerald-400' : 'text-red-400'}`}>
{importResult.msg}
</p>
)}
</div>
</div>
</SectionCard>
</div>
)}
</div>
);
}