import { useState, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X, ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap, LogIn, Activity, } from 'lucide-react'; import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api'; import type { MaintenanceWindow, ConfigTemplate } from '../services/api'; import type { CertInfo, AlertRule, AlertChannel } from '../services/api'; import PollerHealthCard from '../components/system/PollerHealthCard'; import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import type { User, UserRole } from '../types'; import { parseBackupCron, scheduleToCron, describeBackupSchedule, WEEKDAY_NAMES, ordinal, type BackupFrequency, } from '../utils/backupSchedule'; import clsx from 'clsx'; import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings'; import AutomationSettings from '../components/settings/AutomationSettings'; import OidcSettings from '../components/settings/OidcSettings'; const ROLE_META: Record = { admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' }, operator: { label: 'Operator', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', desc: 'Manage devices and configs, no user admin' }, viewer: { label: 'Viewer', color: 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300', desc: 'Read-only access' }, }; function RoleBadge({ role }: { role: string }) { const meta = ROLE_META[role as UserRole] ?? ROLE_META.viewer; return ( {meta.label} ); } interface EditUserState { id: number; username: string; currentRole: UserRole; role: UserRole; password: string; confirmPassword: string; } export default function SettingsPage() { const queryClient = useQueryClient(); const { user } = useAuthStore(); const { theme, setTheme } = useThemeStore(); const isAdmin = user?.role === 'admin'; const canWrite = user?.role !== 'viewer'; const [activeTab, setActiveTab] = useState<'general' | 'users' | 'sso' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation' | 'poller'>('general'); const [auditSearch, setAuditSearch] = useState(''); const [auditPage, setAuditPage] = useState(1); const [newTagName, setNewTagName] = useState(''); const [newTagColor, setNewTagColor] = useState('#6366f1'); const [mwForm, setMwForm] = useState({ name: '', start_at: '', end_at: '' }); // ─── App settings ───────────────────────────────────────────────────────── const { data: settings = {} } = useQuery({ queryKey: ['settings'], queryFn: () => settingsApi.get().then((r) => r.data), }); const updateSettingsMutation = useMutation({ mutationFn: (data: Record) => settingsApi.update(data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['settings'] }), }); // ─── Users ──────────────────────────────────────────────────────────────── const { data: users = [] } = useQuery({ queryKey: ['settings-users'], queryFn: () => settingsApi.getUsers().then((r) => r.data as User[]), }); const [newUser, setNewUser] = useState({ username: '', password: '', role: 'viewer' as UserRole }); const [userError, setUserError] = useState(''); const [userSuccess, setUserSuccess] = useState(''); const [editUser, setEditUser] = useState(null); const [editError, setEditError] = useState(''); const createUserMutation = useMutation({ mutationFn: () => settingsApi.createUser(newUser), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings-users'] }); setNewUser({ username: '', password: '', role: 'viewer' }); setUserSuccess('User created successfully'); setUserError(''); setTimeout(() => setUserSuccess(''), 3000); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setUserError(msg || 'Failed to create user'); }, }); const updateUserMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: { role?: string; password?: string } }) => settingsApi.updateUser(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings-users'] }); setEditUser(null); setEditError(''); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setEditError(msg || 'Failed to update user'); }, }); const deleteUserMutation = useMutation({ mutationFn: (id: number) => settingsApi.deleteUser(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['settings-users'] }), }); const handleUpdateUser = () => { if (!editUser) return; if (editUser.password && editUser.password !== editUser.confirmPassword) { setEditError('Passwords do not match'); return; } const data: { role?: string; password?: string } = {}; if (editUser.role !== editUser.currentRole) data.role = editUser.role; if (editUser.password) data.password = editUser.password; if (!data.role && !data.password) { setEditUser(null); return; } updateUserMutation.mutate({ id: editUser.id, data }); }; // ─── Password change ─────────────────────────────────────────────────────── const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' }); const [pwError, setPwError] = useState(''); const [pwSuccess, setPwSuccess] = useState(''); // ─── TOTP / 2FA ──────────────────────────────────────────────────────────── const [totpSetupData, setTotpSetupData] = useState<{ secret: string; uri: string; qr: string } | null>(null); const [totpConfirmCode, setTotpConfirmCode] = useState(''); const [totpSetupPassword, setTotpSetupPassword] = useState(''); const [totpDisablePassword, setTotpDisablePassword] = useState(''); const [totpMsg, setTotpMsg] = useState(''); const [totpError, setTotpError] = useState(''); const { data: totpStatus, refetch: refetchTotpStatus } = useQuery({ queryKey: ['totp-status'], queryFn: () => authApi.totpStatus().then((r) => r.data), enabled: activeTab === 'security', }); const startTotpSetupMutation = useMutation({ mutationFn: () => authApi.totpSetup(totpSetupPassword), onSuccess: (res) => { setTotpSetupData(res.data); setTotpConfirmCode(''); setTotpSetupPassword(''); setTotpError(''); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setTotpError(msg || 'Failed to start TOTP setup'); }, }); const confirmTotpMutation = useMutation({ mutationFn: () => authApi.totpConfirm(totpConfirmCode), onSuccess: () => { setTotpSetupData(null); setTotpConfirmCode(''); setTotpMsg('Two-factor authentication enabled.'); setTotpError(''); refetchTotpStatus(); setTimeout(() => setTotpMsg(''), 4000); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setTotpError(msg || 'Invalid code'); }, }); const disableTotpMutation = useMutation({ mutationFn: () => authApi.totpDisable(totpDisablePassword), onSuccess: () => { setTotpDisablePassword(''); setTotpMsg('Two-factor authentication disabled.'); setTotpError(''); refetchTotpStatus(); setTimeout(() => setTotpMsg(''), 4000); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setTotpError(msg || 'Failed to disable TOTP'); }, }); const changePasswordMutation = useMutation({ mutationFn: () => authApi.changePassword(pwForm.current, pwForm.next), onSuccess: () => { setPwForm({ current: '', next: '', confirm: '' }); setPwSuccess('Password changed successfully'); setPwError(''); setTimeout(() => setPwSuccess(''), 3000); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setPwError(msg || 'Failed to change password'); }, }); // ─── Certificate ────────────────────────────────────────────────────────── const { data: certInfo, isLoading: certLoading } = useQuery({ queryKey: ['cert'], queryFn: () => certApi.get().then((r) => r.data as CertInfo), enabled: activeTab === 'certificate', }); const [certUpload, setCertUpload] = useState({ certificate: '', private_key: '' }); const [certError, setCertError] = useState(''); const [certSuccess, setCertSuccess] = useState(''); const certFileRef = useRef(null); const keyFileRef = useRef(null); const regenerateMutation = useMutation({ mutationFn: () => certApi.regenerate(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cert'] }); setCertSuccess('Self-signed certificate regenerated. Nginx will reload in a few seconds.'); setCertError(''); setTimeout(() => setCertSuccess(''), 6000); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setCertError(msg || 'Failed to regenerate certificate'); }, }); const uploadMutation = useMutation({ mutationFn: () => certApi.upload(certUpload.certificate, certUpload.private_key), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cert'] }); setCertUpload({ certificate: '', private_key: '' }); setCertSuccess('Certificate installed. Nginx will reload in a few seconds.'); setCertError(''); setTimeout(() => setCertSuccess(''), 6000); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setCertError(msg || 'Failed to install certificate'); }, }); const readFile = (file: File): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target?.result as string); reader.onerror = reject; reader.readAsText(file); }); // ─── Config Templates ────────────────────────────────────────────────────── const [tplFormOpen, setTplFormOpen] = useState(false); const [tplForm, setTplForm] = useState<{ name: string; description: string; applies_to_type: string; dns_servers: string; ntp_servers: string; syslog_host: string; }>({ name: '', description: '', applies_to_type: '', dns_servers: '', ntp_servers: '', syslog_host: '' }); const [tplApplyId, setTplApplyId] = useState(null); const [tplApplyDeviceIds, setTplApplyDeviceIds] = useState([]); const [tplApplyResults, setTplApplyResults] = useState<{ device_id: number; device_name: string; ok: boolean; error?: string }[] | null>(null); const [tplError, setTplError] = useState(''); const { data: templates = [] } = useQuery({ queryKey: ['config-templates'], queryFn: () => configTemplatesApi.list().then((r) => r.data), enabled: activeTab === 'templates', }); const { data: allDevicesList = [] } = useQuery({ queryKey: ['devices-list-for-templates'], queryFn: () => import('../services/api').then(m => m.default.get<{ id: number; name: string }[]>('/devices').then(r => r.data)), enabled: tplApplyId != null, }); const createTemplateMutation = useMutation({ mutationFn: () => { const tj: ConfigTemplate['template_json'] = {}; if (tplForm.dns_servers.trim()) tj.dns_servers = tplForm.dns_servers.split(',').map((s) => s.trim()).filter(Boolean); if (tplForm.ntp_servers.trim()) tj.ntp_servers = tplForm.ntp_servers.split(',').map((s) => s.trim()).filter(Boolean); if (tplForm.syslog_host.trim()) tj.syslog_host = tplForm.syslog_host.trim(); return configTemplatesApi.create({ name: tplForm.name.trim(), description: tplForm.description.trim() || null, applies_to_type: tplForm.applies_to_type || null, template_json: tj, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['config-templates'] }); setTplFormOpen(false); setTplForm({ name: '', description: '', applies_to_type: '', dns_servers: '', ntp_servers: '', syslog_host: '' }); setTplError(''); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setTplError(msg || 'Failed to create template'); }, }); const deleteTemplateMutation = useMutation({ mutationFn: (id: number) => configTemplatesApi.delete(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-templates'] }), }); const applyTemplateMutation = useMutation({ mutationFn: () => configTemplatesApi.apply(tplApplyId!, tplApplyDeviceIds), onSuccess: (res) => { setTplApplyResults(res.data.results); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setTplError(msg || 'Failed to apply template'); }, }); // ─── Tags ───────────────────────────────────────────────────────────────── const { data: tags = [] } = useQuery({ queryKey: ['tags'], queryFn: () => tagsApi.list().then((r) => r.data), enabled: activeTab === 'tags', }); const createTagMutation = useMutation({ mutationFn: () => tagsApi.create(newTagName.trim(), newTagColor), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tags'] }); setNewTagName(''); }, }); const deleteTagMutation = useMutation({ mutationFn: (id: number) => tagsApi.delete(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tags'] }), }); // ─── Maintenance Windows ─────────────────────────────────────────────────── const { data: maintenanceWindows = [] } = useQuery({ queryKey: ['maintenance-windows'], queryFn: () => maintenanceApi.list().then((r) => r.data), enabled: activeTab === 'maintenance', }); const createMwMutation = useMutation({ mutationFn: () => maintenanceApi.create({ name: mwForm.name.trim(), device_ids: [], start_at: mwForm.start_at, end_at: mwForm.end_at, recurring_cron: null, active: true, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['maintenance-windows'] }); setMwForm({ name: '', start_at: '', end_at: '' }); }, }); const deleteMwMutation = useMutation({ mutationFn: (id: number) => maintenanceApi.delete(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['maintenance-windows'] }), }); const toggleMwMutation = useMutation({ mutationFn: ({ id, active }: { id: number; active: boolean }) => maintenanceApi.update(id, { active }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['maintenance-windows'] }), }); // ─── Audit Log ──────────────────────────────────────────────────────────── const { data: auditData } = useQuery({ queryKey: ['audit-log', auditPage, auditSearch], queryFn: () => auditLogApi.list({ page: auditPage, limit: 50, search: auditSearch || undefined }).then((r) => r.data), enabled: activeTab === 'audit', }); // ─── Alerting ────────────────────────────────────────────────────────────── const { data: alertRules = [] } = useQuery({ queryKey: ['alert-rules'], queryFn: () => alertsApi.getRules().then((r) => r.data), enabled: activeTab === 'alerting', }); const { data: alertChannels = [] } = useQuery({ queryKey: ['alert-channels'], queryFn: () => alertsApi.getChannels().then((r) => r.data), enabled: activeTab === 'alerting', }); const { data: alertHistory = [] } = useQuery({ queryKey: ['alert-history'], queryFn: () => alertsApi.getHistory(20).then((r) => r.data), enabled: activeTab === 'alerting', }); const updateRuleMutation = useMutation({ mutationFn: ({ type, data }: { type: string; data: Partial }) => alertsApi.updateRule(type, data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alert-rules'] }), }); type ChannelFormState = { name: string; type: AlertChannel['type']; enabled: boolean; config: Record; }; const EMPTY_FORM: ChannelFormState = { name: '', type: 'slack', enabled: true, config: {} }; const [chModal, setChModal] = useState<{ mode: 'add' | 'edit'; id?: number } | null>(null); const [chForm, setChForm] = useState(EMPTY_FORM); const [chError, setChError] = useState(''); const [chTestStatus, setChTestStatus] = useState>({}); const [chTestMsg, setChTestMsg] = useState>({}); const saveChannelMutation = useMutation({ mutationFn: () => chModal?.mode === 'edit' && chModal.id != null ? alertsApi.updateChannel(chModal.id, chForm) : alertsApi.createChannel(chForm), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['alert-channels'] }); setChModal(null); setChForm(EMPTY_FORM); setChError(''); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setChError(msg || 'Failed to save channel'); }, }); const deleteChannelMutation = useMutation({ mutationFn: (id: number) => alertsApi.deleteChannel(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alert-channels'] }), }); const testChannel = async (id: number) => { setChTestStatus((s) => ({ ...s, [id]: 'testing' })); setChTestMsg((s) => ({ ...s, [id]: '' })); try { await alertsApi.testChannel(id); setChTestStatus((s) => ({ ...s, [id]: 'ok' })); setChTestMsg((s) => ({ ...s, [id]: 'Test message sent!' })); } catch (err) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Test failed'; setChTestStatus((s) => ({ ...s, [id]: 'err' })); setChTestMsg((s) => ({ ...s, [id]: msg })); } setTimeout(() => setChTestStatus((s) => ({ ...s, [id]: 'idle' })), 6000); }; const RULE_LABELS: Record = { device_offline: 'Device goes offline', device_online: 'Device comes back online', log_error: 'Log error detected', log_warning: 'Log warning detected', high_cpu: 'High CPU usage', high_memory: 'High memory usage', cert_expiry: 'Certificate expiring soon', device_discovered: 'New device discovered', }; const cfgStr = (key: string) => (chForm.config[key] as string) ?? ''; const setCfg = (key: string, val: unknown) => setChForm((f) => ({ ...f, config: { ...f.config, [key]: val } })); // Tabs are grouped by audience: platform administration, the signed-in // user's own account, and fleet-facing operational configuration. const tabGroups = [ { label: 'Platform', tabs: [ { key: 'general' as const, label: 'General', icon: Settings }, { key: 'users' as const, label: 'Users & Roles', icon: Users }, ...(isAdmin ? [{ key: 'sso' as const, label: 'SSO / OIDC', icon: LogIn }] : []), { key: 'certificate' as const, label: 'Certificate', icon: Lock }, ...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []), ...(isAdmin ? [{ key: 'poller' as const, label: 'Polling', icon: Activity }] : []), ], }, { label: 'My Account', tabs: [ { key: 'security' as const, label: 'My Password', icon: Key }, ], }, { label: 'Fleet', tabs: [ { key: 'credentials' as const, label: 'Device Credentials', icon: KeyRound }, { key: 'alerting' as const, label: 'Alerting', icon: Bell }, { key: 'automation' as const, label: 'Automation', icon: Zap }, ...(isAdmin ? [ { key: 'templates' as const, label: 'Config Templates', icon: FileText }, { key: 'tags' as const, label: 'Tags', icon: ShieldCheck }, { key: 'maintenance' as const, label: 'Maintenance', icon: ShieldAlert }, ] : []), ], }, ]; return (

Settings

{tabGroups.map((group, gi) => (
0 && 'sm:border-l sm:border-gray-200 sm:dark:border-slate-700 sm:pl-6')}>
{group.label}
{group.tabs.map((tab) => ( ))}
))}
{/* ── General ── */} {activeTab === 'general' && (

Appearance

Choose your preferred color scheme

{(['light', 'dark'] as const).map((t) => ( ))}

Polling Intervals

{[ { key: 'polling_fast_interval', label: 'Fast poll (interface stats, clients)', unit: 'sec' }, { key: 'polling_slow_interval', label: 'Slow poll (config, VLANs)', unit: 'sec' }, { key: 'polling_logs_interval', label: 'Log poll (events)', unit: 'sec' }, ].map(({ key, label, unit }) => (
{label}
{unit}
updateSettingsMutation.mutate({ [key]: parseInt(e.target.value) }) } min="10" step="10" disabled={!isAdmin} />
))}
{/* Login Rate Limit */}

Login Rate Limiting

Limits login attempts per IP address to protect against brute-force attacks.

{[ { key: 'login_rate_limit_max', label: 'Max attempts', unit: 'attempts' }, { key: 'login_rate_limit_window_sec', label: 'Time window', unit: 'sec' }, ].map(({ key, label, unit }) => (
{label}
{unit}
updateSettingsMutation.mutate({ [key]: parseInt(e.target.value) }) } min="1" step="1" disabled={!isAdmin} />
))}
{/* MAC Scan */}

MAC Scan

Runs /tool/mac-scan on each switch to map MAC addresses to IP addresses, enriching the Clients section.

Enable MAC scan
Scan interval
seconds
updateSettingsMutation.mutate({ mac_scan_interval: parseInt(e.target.value) }) } min="60" step="30" disabled={!isAdmin || !settings['mac_scan_enabled']} />
{/* Configuration Snapshots */}

Configuration Snapshots

Periodically captures each device's /export for the Config History / drift detection feature. This runs over SSH, so it only executes on devices that have SSH credentials configured — devices without them are skipped (no connection is attempted).

Enable config snapshots
Snapshot interval
minutes
updateSettingsMutation.mutate({ config_snapshot_interval_min: parseInt(e.target.value) })} min="15" step="15" disabled={!isAdmin || settings['config_snapshot_enabled'] === false} />
Retention
snapshots kept per device
updateSettingsMutation.mutate({ config_snapshot_retention: parseInt(e.target.value) })} min="1" step="1" disabled={!isAdmin || settings['config_snapshot_enabled'] === false} />
{/* Reverse DNS */}

Reverse DNS Lookup

Performs PTR record lookups on client IP addresses to resolve hostnames. Runs every 5 minutes, filling in clients that have an IP but no hostname.

Enable reverse DNS
{/* Maps & geocoding (issue #106) */}

Maps & Geocoding

Device location maps use OpenStreetMap for tiles and Nominatim to turn an address into coordinates. Both are outbound requests to a third party, and the address you enter is sent to them. Turn this off on isolated networks, or wherever device locations should not leave your infrastructure — everything else keeps working, and addresses are still recorded as text.

Enable maps and address lookup

Data Retention

{[ { key: 'retention_events_days', label: 'Events retention', desc: 'Auto-delete event log entries older than this many days' }, { key: 'retention_clients_days', label: 'Client retention', desc: 'Auto-delete inactive client records not seen within this many days' }, { key: 'netflow_retention_days', label: 'Traffic detail retention', desc: 'Auto-delete per-client NetFlow time-series data older than this many days' }, { key: 'netflow_daily_retention_days', label: 'Traffic daily rollup retention', desc: 'Auto-delete per-client daily traffic totals older than this many days' }, ].map(({ key, label, desc }) => (
{label}
{desc}
updateSettingsMutation.mutate({ [key]: parseInt(e.target.value) }) } min="1" max="365" disabled={!isAdmin} />
))}
{/* Scheduled Backups */}

Scheduled Backups

Automatically backs up all online devices on a schedule via SSH export. Backups appear in the Backups section with type scheduled.

{(() => { const enabled = !!settings['backup_schedule_enabled']; const sched = parseBackupCron(settings['backup_schedule_cron'] as string); const controlsDisabled = !isAdmin || !enabled; const apply = (next: typeof sched) => updateSettingsMutation.mutate({ backup_schedule_cron: scheduleToCron(next) }); return (
Enable scheduled backups
{/* Frequency */}
{/* Day selector — weekly or monthly */} {sched.frequency === 'weekly' && (
)} {sched.frequency === 'monthly' && (
)} {/* Time of day */}
{ const [h, m] = e.target.value.split(':').map((n) => parseInt(n, 10)); if (Number.isNaN(h) || Number.isNaN(m)) return; apply({ ...sched, hour: h, minute: m }); }} />

{enabled ? <>Runs {describeBackupSchedule(sched).toLowerCase()}, server time. : 'Enable to choose a backup schedule.'}

{sched.frequency === 'monthly' && (

Days are limited to 1–28 so the backup runs every month, including February.

)}
); })()}
)} {/* ── Users ── */} {activeTab === 'poller' && (
)} {activeTab === 'users' && (
{/* Role legend */}

Role Permissions

{(Object.entries(ROLE_META) as [UserRole, typeof ROLE_META[UserRole]][]).map(([role, meta]) => (
{meta.desc}
))}
{/* Users table */}

System Users ({(users as User[]).length})

{(users as User[]).map((u) => ( ))}
Username Role Created
{u.username} {u.id === user?.id && ( (you) )} {u.auth_provider === 'oidc' && ( SSO )}
{u.email && (
{u.email}
)}
{u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'} {isAdmin && (
{u.id !== user?.id && ( )}
)}
{/* Add user form */} {isAdmin &&

Add User

setNewUser((f) => ({ ...f, username: e.target.value }))} placeholder="username" />
setNewUser((f) => ({ ...f, password: e.target.value }))} autoComplete="new-password" />
{userError && (
{userError}
)} {userSuccess && (
{userSuccess}
)}
}
)} {/* ── Device Credential Presets ── */} {activeTab === 'credentials' && ( )} {/* ── My Password ── */} {activeTab === 'security' && (

Change My Password

setPwForm((f) => ({ ...f, current: e.target.value }))} autoComplete="current-password" />
setPwForm((f) => ({ ...f, next: e.target.value }))} autoComplete="new-password" />
setPwForm((f) => ({ ...f, confirm: e.target.value }))} autoComplete="new-password" />
{pwError && (
{pwError}
)} {pwSuccess && (
{pwSuccess}
)}
{/* ── TOTP / 2FA ── */}

Two-Factor Authentication

Add an extra layer of security with a TOTP authenticator app (Google Authenticator, Authy, etc.)

{totpMsg && (
{totpMsg}
)} {totpError && (
{totpError}
)} {totpStatus?.totp_enabled ? ( /* Already enabled — show disable form */
2FA is currently enabled
setTotpDisablePassword(e.target.value)} autoComplete="current-password" />
) : totpSetupData ? ( /* Setup in progress — show QR + confirm step */

Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.

TOTP QR code
Can't scan? Enter secret manually {totpSetupData.secret}
setTotpConfirmCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="000000" autoComplete="one-time-code" />
) : ( /* Not enabled, not in setup */
setTotpSetupPassword(e.target.value)} autoComplete="current-password" />
)}
)} {/* ── Certificate ── */} {activeTab === 'certificate' && (
{/* Current cert info */}

Current Certificate

{certLoading ? (

Loading...

) : !certInfo?.exists ? (

No certificate found.

) : (
Subject
{certInfo.subject}
Issuer
{certInfo.issuer} {certInfo.is_self_signed && ( Self-Signed )} {!certInfo.is_self_signed && ( Trusted CA )}
Valid From
{certInfo.valid_from ? new Date(certInfo.valid_from).toLocaleDateString() : '—'}
Expires
{certInfo.valid_to ? new Date(certInfo.valid_to).toLocaleDateString() : '—'} {certInfo.days_remaining !== undefined && ( ({certInfo.days_remaining > 0 ? `${certInfo.days_remaining} days remaining` : 'EXPIRED'}) )}
{certInfo.san && ( <>
SANs
{certInfo.san}
)}
)}
{/* Regenerate self-signed */}

Regenerate Self-Signed Certificate

Generates a new 10-year self-signed certificate. Use this to replace an expired self-signed cert or to reset back to defaults. Nginx reloads automatically within a few seconds.

{/* Upload custom cert */}

Install Custom Certificate

Paste PEM-encoded certificate and private key, or use the file pickers to load from disk. The certificate and key must match. Nginx reloads automatically within a few seconds.

{ const f = e.target.files?.[0]; if (f) setCertUpload((s) => ({ ...s, certificate: '' })); if (f) { const text = await readFile(f); setCertUpload((s) => ({ ...s, certificate: text })); } e.target.value = ''; }} />