import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useConfig } from '../contexts/ConfigContext'; import { useDraftConfig } from '../contexts/DraftConfigContext'; import { Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, ChevronDown, ChevronRight, CheckCircle, XCircle, RefreshCw, Lock, FolderDown, X, Globe, Loader } from 'lucide-react'; import { cellAPI, ddnsAPI } from '../services/api'; // ── constants ──────────────────────────────────────────────────────────────── const RESTORE_SERVICES = [ { key: 'identity', label: 'Identity (cell name, domain, IP range)' }, { key: 'network', label: 'Network (DNS, DHCP, NTP)' }, { key: 'wireguard', label: 'WireGuard VPN' }, { key: 'email', label: 'Email' }, { key: 'calendar', label: 'Calendar & Contacts' }, { key: 'files', label: 'File Storage' }, { key: 'routing', label: 'Routing' }, { key: 'vault', label: 'Vault & Certificates' }, ]; // ── helpers ────────────────────────────────────────────────────────────────── function toast(msg, type = 'success') { // simple inline notification via a thrown CustomEvent consumed below window.dispatchEvent(new CustomEvent('settings-toast', { detail: { msg, type } })); } function Toast({ toasts }) { return (
{toasts.map((t) => (
{t.type === 'success' ? : } {t.msg}
))}
); } function useToasts() { const [toasts, setToasts] = useState([]); useEffect(() => { const handler = (e) => { const id = Date.now(); setToasts((prev) => [...prev, { ...e.detail, id }]); setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); }; window.addEventListener('settings-toast', handler); return () => window.removeEventListener('settings-toast', handler); }, []); return toasts; } // ── Section wrapper ─────────────────────────────────────────────────────────── function Section({ icon: Icon, title, children, collapsible = false, defaultOpen = true }) { const [open, setOpen] = useState(defaultOpen); return (
{(!collapsible || open) &&
{children}
}
); } // ── Validation utilities ────────────────────────────────────────────────────── function isValidPort(v) { const n = Number(v); return Number.isInteger(n) && n >= 1 && n <= 65535; } // Mirror of api/port_registry.py PORT_FIELDS — must stay in sync const PORT_CONFLICT_FIELDS = { network: ['dns_port'], wireguard: ['port'], email: ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'], calendar: ['port'], files: ['port', 'manager_port'], }; function detectPortConflicts(configs) { const portMap = {}; for (const [section, fields] of Object.entries(PORT_CONFLICT_FIELDS)) { const sec = configs[section] || {}; for (const field of fields) { const raw = sec[field]; if (raw === undefined || raw === null || raw === '') continue; const n = parseInt(raw, 10); if (isNaN(n)) continue; (portMap[n] = portMap[n] || []).push([section, field]); } } const result = {}; for (const [port, slots] of Object.entries(portMap)) { if (slots.length < 2) continue; const others = slots.map(([s, f]) => `${s}.${f}`).join(', '); for (const [section, field] of slots) { result[`${section}|${field}`] = `Port ${port} conflicts with ${others}`; } } return result; } function isValidIp(v) { if (!v || !v.trim()) return false; const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); if (!m) return false; return m.slice(1, 5).map(Number).every(n => n >= 0 && n <= 255); } function isValidIpCidr(v) { if (!v || !v.trim()) return false; const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/); if (!m) return false; const [, a, b, c, d, p] = m.map(Number); return [a, b, c, d].every(n => n >= 0 && n <= 255) && p >= 0 && p <= 32; } function isValidDomain(v) { if (!v || !v.trim()) return false; const s = v.trim(); if (s.length > 253) return false; return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(s); } function isValidDomainOrIp(v) { const s = (v || '').trim(); return isValidIp(s) || isValidDomain(s); } const E_PORT = 'Must be 1–65535'; const E_IP = 'Must be a valid IP address'; const E_CIDR = 'Must be a valid IP/CIDR (e.g. 10.0.0.1/24)'; const E_DOMAIN = 'Must be a valid domain (e.g. mail.example.com)'; function validateServiceConfig(key, data) { const errors = {}; const port = (field) => { if (data[field] !== undefined && data[field] !== '' && !isValidPort(data[field])) errors[field] = E_PORT; }; if (key === 'network') { port('dns_port'); if (data.dhcp_range) { const parts = data.dhcp_range.split(','); if (parts[0]?.trim() && !isValidIp(parts[0].trim())) errors.dhcp_range = `Start IP is invalid`; else if (parts[1]?.trim() && !isValidIp(parts[1].trim())) errors.dhcp_range = `End IP is invalid`; } const badNtp = (data.ntp_servers || []).find(s => !isValidDomainOrIp(s)); if (badNtp !== undefined) errors.ntp_servers = `"${badNtp}" is not a valid hostname or IP`; } if (key === 'wireguard') { port('port'); if (data.address && !isValidIpCidr(data.address)) errors.address = E_CIDR; } if (key === 'email') { port('smtp_port'); port('submission_port'); port('imap_port'); port('webmail_port'); if (data.domain && !isValidDomain(data.domain)) errors.domain = E_DOMAIN; } if (key === 'calendar') port('port'); if (key === 'files') { port('port'); port('manager_port'); } return errors; } // ── RFC-1918 validation ─────────────────────────────────────────────────────── function isRFC1918Cidr(cidr) { if (!cidr || !cidr.trim()) return false; const m = cidr.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/); if (!m) return false; const [, a, b, c, d, p] = m.map(Number); if ([a, b, c, d].some(n => n < 0 || n > 255) || p < 0 || p > 32) return false; const ip = ((a << 24) | (b << 16) | (c << 8) | d) >>> 0; const ranges = [ { net: 0x0a000000, prefix: 8 }, // 10.0.0.0/8 { net: 0xac100000, prefix: 12 }, // 172.16.0.0/12 { net: 0xc0a80000, prefix: 16 }, // 192.168.0.0/16 ]; for (const { net, prefix } of ranges) { if (p < prefix) continue; const mask = (0xffffffff << (32 - prefix)) >>> 0; if ((ip & mask) >>> 0 === (net & mask) >>> 0) return true; } return false; } // ── Field components ────────────────────────────────────────────────────────── function Field({ label, children, hint, error }) { return (
{children} {error &&

{error}

}
{hint && !error && {hint}}
); } function TextInput({ value, onChange, placeholder, type = 'text', readOnly, maxLength }) { return ( onChange && onChange(e.target.value)} placeholder={placeholder} readOnly={readOnly} maxLength={maxLength} className={`w-full text-sm border rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-400 ${ readOnly ? 'bg-gray-50 text-gray-500 cursor-default' : 'bg-white' }`} /> ); } function NumberInput({ value, onChange, min, max }) { return ( onChange && onChange(Number(e.target.value))} className="w-full text-sm border rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-400 bg-white" /> ); } function BoolToggle({ value, onChange, label }) { return ( ); } function TagList({ value = [], onChange, placeholder }) { const [input, setInput] = useState(''); const add = () => { const v = input.trim(); if (v && !value.includes(v)) { onChange([...value, v]); setInput(''); } }; return (
{value.map((item) => ( {item} ))}
setInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())} placeholder={placeholder} />
); } // ── Service config forms ────────────────────────────────────────────────────── function NetworkForm({ data, onChange, errors = {} }) { return (
onChange({ ...data, dns_port: v })} min={1} max={65535} /> onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" /> onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" />
); } function WireguardForm({ data, onChange, errors = {} }) { return (
onChange({ ...data, port: v })} min={1} max={65535} /> onChange({ ...data, address: v })} placeholder="10.0.0.1/24" /> onChange({ ...data, private_key: v })} placeholder="base64 private key" type="password" />
); } function EmailForm({ data, onChange, errors = {} }) { return (
onChange({ ...data, domain: v })} placeholder="mail.example.com" /> onChange({ ...data, smtp_port: v })} min={1} max={65535} /> onChange({ ...data, submission_port: v })} min={1} max={65535} /> onChange({ ...data, imap_port: v })} min={1} max={65535} /> onChange({ ...data, webmail_port: v })} min={1} max={65535} />
); } function CalendarForm({ data, onChange, errors = {} }) { return (
onChange({ ...data, port: v })} min={1} max={65535} /> onChange({ ...data, data_dir: v })} placeholder="/app/data/radicale" />
); } function FilesForm({ data, onChange, errors = {} }) { return (
onChange({ ...data, port: v })} min={1} max={65535} /> onChange({ ...data, manager_port: v })} min={1} max={65535} /> onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" /> onChange({ ...data, quota: v })} min={0} />
); } function RoutingForm({ data, onChange }) { return (
onChange({ ...data, nat_enabled: v })} label="NAT Enabled" /> onChange({ ...data, firewall_enabled: v })} label="Firewall Enabled" />
); } function VaultForm({ data, onChange }) { return (
onChange({ ...data, ca_configured: v })} label="CA Configured" /> onChange({ ...data, fernet_configured: v })} label="Fernet Encryption Configured" />
); } // service config meta const SERVICE_DEFS = [ { key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } }, { key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } }, { key: 'email', label: 'Email (SMTP/IMAP)', icon: Mail, Form: EmailForm, defaults: { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 } }, { key: 'calendar', label: 'Calendar (CalDAV)', icon: Calendar, Form: CalendarForm, defaults: { port: 5232, data_dir: '' } }, { key: 'files', label: 'Files (WebDAV)', icon: HardDrive, Form: FilesForm, defaults: { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 } }, { key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } }, { key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } }, ]; // ── Main component ──────────────────────────────────────────────────────────── function Settings() { const toasts = useToasts(); const { refresh: refreshConfig } = useConfig(); const draftConfig = useDraftConfig(); // identity const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' }); const [identityDirty, setIdentityDirty] = useState(false); const [loadedCellName, setLoadedCellName] = useState(''); // DDNS const [domainMode, setDomainMode] = useState('lan'); const [domainName, setDomainName] = useState(''); const [ddnsHasToken, setDdnsHasToken] = useState(false); const [picAvail, setPicAvail] = useState(null); // null|'checking'|'available'|'taken'|'unreachable' const [ddnsCfToken, setDdnsCfToken] = useState(''); const [ddnsDuckToken, setDdnsDuckToken] = useState(''); const [ddnsCfStatus, setDdnsCfStatus] = useState(null); // null|'valid'|'invalid' const [ddnsDuckStatus, setDdnsDuckStatus] = useState(null); const [ddnsDirty, setDdnsDirty] = useState(false); const [ddnsSaving, setDdnsSaving] = useState(false); const [ddnsRegistering, setDdnsRegistering] = useState(false); // service configs const [serviceConfigs, setServiceConfigs] = useState({}); const [serviceDirty, setServiceDirty] = useState({}); const portConflicts = useMemo(() => detectPortConflicts(serviceConfigs), [serviceConfigs]); // backups const [backups, setBackups] = useState([]); const [backupsLoading, setBackupsLoading] = useState(false); const [backupCreating, setBackupCreating] = useState(false); const [restoreModal, setRestoreModal] = useState(null); // backup object or null const [restoreServices, setRestoreServices] = useState(new Set(RESTORE_SERVICES.map(s => s.key))); const [backupUploading, setBackupUploading] = useState(false); const [isLoading, setIsLoading] = useState(true); const loadAll = useCallback(async () => { setIsLoading(true); try { const [cfgRes, bkRes] = await Promise.all([ cellAPI.getConfig(), cellAPI.listBackups(), ]); const cfg = cfgRes.data; setIdentity({ cell_name: cfg.cell_name || '', domain: cfg.domain || '', ip_range: cfg.ip_range || '', }); setLoadedCellName(cfg.cell_name || ''); setIdentityDirty(false); setDomainMode(cfg.domain_mode || 'lan'); setDomainName(cfg.domain_name || ''); setDdnsHasToken(cfg.ddns?.has_token || false); setPicAvail(null); setDdnsCfToken(''); setDdnsDuckToken(''); setDdnsCfStatus(null); setDdnsDuckStatus(null); setDdnsDirty(false); setServiceConfigs(cfg.service_configs || {}); setServiceDirty({}); setBackups(bkRes.data || []); } catch (err) { toast('Failed to load configuration', 'error'); } finally { setIsLoading(false); } }, []); useEffect(() => { loadAll(); }, [loadAll]); useEffect(() => { const handler = () => loadAll(); window.addEventListener('pic-config-discarded', handler); return () => window.removeEventListener('pic-config-discarded', handler); }, [loadAll]); // identity save const ipRangeError = identity.ip_range && !isRFC1918Cidr(identity.ip_range) ? 'Must be within an RFC-1918 range: 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16' : null; const cellNameError = identity.cell_name && identity.cell_name.length > 255 ? 'Cell name must be 255 characters or fewer' : (!identity.cell_name ? 'Cell name is required' : null); const domainError = identity.domain && identity.domain.length > 255 ? 'Domain must be 255 characters or fewer' : (!identity.domain ? 'Domain is required' : null); // pic_ngo availability check — fires 900ms after cell_name changes const picAvailTimerRef = useRef(null); useEffect(() => { if (domainMode !== 'pic_ngo') { setPicAvail(null); return; } const name = identity.cell_name; if (!name) { setPicAvail(null); return; } clearTimeout(picAvailTimerRef.current); setPicAvail(null); picAvailTimerRef.current = setTimeout(async () => { setPicAvail('checking'); try { const res = await ddnsAPI.checkName(name); setPicAvail(res.data.available ? 'available' : 'taken'); } catch { setPicAvail('unreachable'); } }, 900); return () => clearTimeout(picAvailTimerRef.current); }, [identity.cell_name, domainMode]); // eslint-disable-line react-hooks/exhaustive-deps const saveIdentity = useCallback(async () => { if (ipRangeError || cellNameError || domainError) return; if (domainMode === 'pic_ngo' && picAvail === 'taken') { toast('This subdomain is already taken on pic.ngo — choose a different cell name', 'error'); return; } try { const res = await cellAPI.updateConfig(identity); setIdentityDirty(false); setLoadedCellName(identity.cell_name); draftConfig?.setDirty('identity', false); if (res.data.warnings?.length) res.data.warnings.forEach((w) => toast(w, 'warning')); // Refresh to get updated domain_name after DDNS registration const cfgRes = await cellAPI.getConfig(); setDomainName(cfgRes.data.domain_name || ''); setDdnsHasToken(cfgRes.data.ddns?.has_token || false); refreshConfig(); } catch (err) { toast(err.response?.data?.error || 'Failed to save identity', 'error'); } }, [identity, ipRangeError, cellNameError, domainError, domainMode, picAvail, draftConfig, refreshConfig]); const saveDdns = useCallback(async () => { setDdnsSaving(true); try { const payload = { domain_mode: domainMode, domain_name: domainName }; if (domainMode === 'cloudflare' && ddnsCfToken) payload.cloudflare_api_token = ddnsCfToken; if (domainMode === 'duckdns' && ddnsDuckToken) payload.duckdns_token = ddnsDuckToken; await ddnsAPI.updateConfig(payload); setDdnsDirty(false); setDdnsCfToken(''); setDdnsDuckToken(''); setDdnsCfStatus(null); setDdnsDuckStatus(null); const cfgRes = await cellAPI.getConfig(); setDomainName(cfgRes.data.domain_name || ''); setDdnsHasToken(cfgRes.data.ddns?.has_token || false); toast('DDNS configuration saved'); } catch (err) { toast(err.response?.data?.error || 'Failed to save DDNS config', 'error'); } finally { setDdnsSaving(false); } }, [domainMode, domainName, ddnsCfToken, ddnsDuckToken]); const verifyCf = useCallback(async () => { if (!ddnsCfToken.trim()) return; setDdnsCfStatus('checking'); try { await ddnsAPI.updateConfig({ domain_mode: 'cloudflare', domain_name: domainName, cloudflare_api_token: ddnsCfToken }); setDdnsCfStatus('valid'); setDdnsDirty(false); const cfgRes = await cellAPI.getConfig(); setDdnsHasToken(cfgRes.data.ddns?.has_token || false); toast('Cloudflare token saved'); } catch (err) { setDdnsCfStatus('invalid'); toast(err.response?.data?.error || 'Invalid Cloudflare token', 'error'); } }, [ddnsCfToken, domainName]); const reRegister = useCallback(async () => { setDdnsRegistering(true); try { const res = await ddnsAPI.register(); setDomainName(res.data.subdomain || ''); setDdnsHasToken(true); setPicAvail(null); toast(`Registered as ${res.data.subdomain}`); } catch (err) { toast(err.response?.data?.error || 'Registration failed', 'error'); } finally { setDdnsRegistering(false); } }, []); const verifyDuck = useCallback(async () => { if (!ddnsDuckToken.trim()) return; setDdnsDuckStatus('checking'); try { await ddnsAPI.updateConfig({ domain_mode: 'duckdns', domain_name: domainName, duckdns_token: ddnsDuckToken }); setDdnsDuckStatus('valid'); setDdnsDirty(false); const cfgRes = await cellAPI.getConfig(); setDdnsHasToken(cfgRes.data.ddns?.has_token || false); toast('DuckDNS token saved'); } catch (err) { setDdnsDuckStatus('invalid'); toast(err.response?.data?.error || 'Invalid DuckDNS token', 'error'); } }, [ddnsDuckToken, domainName]); // service config save const saveService = useCallback(async (key) => { const { defaults } = SERVICE_DEFS.find((d) => d.key === key) || {}; const data = { ...(defaults || {}), ...(serviceConfigs[key] || {}) }; const hasFieldErrors = Object.keys(validateServiceConfig(key, data)).length > 0; const hasConflicts = (PORT_CONFLICT_FIELDS[key] || []).some(f => portConflicts[`${key}|${f}`]); if (hasFieldErrors || hasConflicts) return; try { await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); setServiceDirty((d) => ({ ...d, [key]: false })); draftConfig?.setDirty(key, false); refreshConfig(); } catch (err) { toast(err.response?.data?.error || `Failed to save ${key} config`, 'error'); } }, [serviceConfigs, portConflicts, draftConfig, refreshConfig]); const updateServiceConfig = (key, data) => { setServiceConfigs((prev) => ({ ...prev, [key]: data })); setServiceDirty((d) => ({ ...d, [key]: true })); draftConfig?.setDirty(key, true); }; // ── Flusher registration (autosave on Apply) ────────────────────────────── // Use refs so flush functions always see current dirty/save state without stale closures. const identityDirtyRef = useRef(identityDirty); useEffect(() => { identityDirtyRef.current = identityDirty; }, [identityDirty]); const serviceDirtyRef = useRef(serviceDirty); useEffect(() => { serviceDirtyRef.current = serviceDirty; }, [serviceDirty]); const saveIdentityRef = useRef(saveIdentity); useEffect(() => { saveIdentityRef.current = saveIdentity; }, [saveIdentity]); const saveServiceRef = useRef(saveService); useEffect(() => { saveServiceRef.current = saveService; }, [saveService]); useEffect(() => { if (!draftConfig) return; const unregister = draftConfig.registerFlusher('identity', async () => { if (identityDirtyRef.current) await saveIdentityRef.current(); }); return unregister; }, [draftConfig]); useEffect(() => { if (!draftConfig) return; const unregisters = SERVICE_DEFS.map(({ key }) => draftConfig.registerFlusher(key, async () => { if (serviceDirtyRef.current[key]) await saveServiceRef.current(key); }) ); return () => unregisters.forEach((fn) => fn()); }, [draftConfig]); // ───────────────────────────────────────────────────────────────────────── // ── Debounced auto-save ─────────────────────────────────────────────────── useEffect(() => { if (!identityDirty) return; if (ipRangeError || cellNameError || domainError) return; // In pic_ngo mode, if the cell name differs from what was last saved/loaded, // wait for the availability check to reach a terminal state before saving. // 'available' and 'unreachable' are terminal; null/'checking'/'taken' are not. if (domainMode === 'pic_ngo' && identity.cell_name !== loadedCellName) { if (picAvail !== 'available' && picAvail !== 'unreachable') return; } const timer = setTimeout(() => saveIdentityRef.current(), 800); return () => clearTimeout(timer); }, [identity, identityDirty, ipRangeError, cellNameError, domainError, domainMode, picAvail, loadedCellName]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const timers = SERVICE_DEFS .filter(({ key }) => serviceDirty[key]) .filter(({ key, defaults }) => { const data = { ...defaults, ...(serviceConfigs[key] || {}) }; const hasFieldErrors = Object.keys(validateServiceConfig(key, data)).length > 0; const hasConflicts = (PORT_CONFLICT_FIELDS[key] || []).some(f => portConflicts[`${key}|${f}`]); return !hasFieldErrors && !hasConflicts; }) .map(({ key }) => setTimeout(() => saveServiceRef.current(key), 800)); return () => timers.forEach(clearTimeout); }, [serviceConfigs, serviceDirty, portConflicts]); // eslint-disable-line react-hooks/exhaustive-deps // ───────────────────────────────────────────────────────────────────────── // backups const createBackup = async () => { setBackupCreating(true); try { await cellAPI.createBackup(); toast('Backup created'); const res = await cellAPI.listBackups(); setBackups(res.data || []); } catch { toast('Failed to create backup', 'error'); } finally { setBackupCreating(false); } }; const openRestoreModal = (backup) => { setRestoreServices(new Set(RESTORE_SERVICES.map(s => s.key))); setRestoreModal(backup); }; const doRestore = async () => { if (!restoreModal) return; const allSelected = restoreServices.size === RESTORE_SERVICES.length; const services = allSelected ? null : Array.from(restoreServices); try { await cellAPI.restoreBackup(restoreModal.backup_id, services); toast('Configuration restored — reloading…'); setRestoreModal(null); setTimeout(() => loadAll(), 500); } catch { toast('Failed to restore backup', 'error'); } }; const downloadBackup = async (id) => { try { const res = await cellAPI.downloadBackup(id); const url = URL.createObjectURL(res.data); const a = document.createElement('a'); a.href = url; a.download = `${id}.zip`; a.click(); URL.revokeObjectURL(url); } catch { toast('Download failed', 'error'); } }; const uploadBackup = async (e) => { const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; setBackupUploading(true); try { await cellAPI.uploadBackup(file); toast('Backup uploaded'); const res = await cellAPI.listBackups(); setBackups(res.data || []); } catch { toast('Upload failed — ensure it is a valid backup zip', 'error'); } finally { setBackupUploading(false); } }; const deleteBackup = async (id) => { if (!confirm(`Delete backup ${id}?`)) return; try { await cellAPI.deleteBackup(id); setBackups((prev) => prev.filter((b) => b.backup_id !== id)); toast('Backup deleted'); } catch { toast('Failed to delete backup', 'error'); } }; // export const exportConfig = async () => { try { const res = await cellAPI.exportConfig('json'); const blob = new Blob([res.data.config], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `pic-config-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); } catch { toast('Export failed', 'error'); } }; // import const importConfig = async (e) => { const file = e.target.files?.[0]; if (!file) return; const text = await file.text(); if (!confirm('Import this config? Current settings will be replaced.')) { e.target.value = ''; return; } try { await cellAPI.importConfig(text, 'json'); toast('Config imported — reloading…'); setTimeout(() => loadAll(), 500); } catch { toast('Import failed', 'error'); } finally { e.target.value = ''; } }; if (isLoading) { return (
); } return (

Settings

Configure your Personal Internet Cell

{/* Cell Identity */}
{ setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} placeholder="mycell" maxLength={255} /> {domainMode === 'pic_ngo' && picAvail === 'checking' && ( checking… )} {domainMode === 'pic_ngo' && picAvail === 'available' && ( available )} {domainMode === 'pic_ngo' && picAvail === 'taken' && ( taken )} {domainMode === 'pic_ngo' && picAvail === 'unreachable' && ( DDNS unreachable )}
{domainMode === 'pic_ngo' && (

External: {identity.cell_name || '…'}.pic.ngo

)}
{ setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} placeholder="cell" maxLength={255} /> { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} placeholder="172.20.0.0/16" />
{/* DDNS / External Domain */}
{domainMode === 'pic_ngo' && (
Your cell is registered as {domainName || `${identity.cell_name}.pic.ngo`} on pic.ngo. Change the Cell Name above to update this subdomain.
Use if DDNS lost your record — sends current public IP
)} {domainMode === 'cloudflare' && (
Provider: Cloudflare {domainName && <> — {domainName}}
{ setDomainName(v); setDdnsDirty(true); }} placeholder="home.example.com" />
{ setDdnsCfToken(v); setDdnsDirty(true); setDdnsCfStatus(null); }} placeholder={ddnsHasToken ? '••••••••' : 'Cloudflare API token'} type="password" />
{ddnsCfStatus === 'valid' &&

Token valid and saved

} {ddnsCfStatus === 'invalid' &&

Token invalid

}
{ddnsDirty && domainName && ( )}
)} {domainMode === 'duckdns' && (
Provider: DuckDNS {domainName && <> — {domainName}}
{ setDomainName(v); setDdnsDirty(true); }} placeholder="myname.duckdns.org" />
{ setDdnsDuckToken(v); setDdnsDirty(true); setDdnsDuckStatus(null); }} placeholder={ddnsHasToken ? '••••••••' : 'DuckDNS token'} type="password" />
{ddnsDuckStatus === 'valid' &&

Token valid and saved

} {ddnsDuckStatus === 'invalid' &&

Token invalid

}
{ddnsDirty && domainName && ( )}
)} {(domainMode === 'http01' || domainMode === 'lan') && (
{domainMode === 'http01' ? <>Domain: {domainName || '—'} : 'Local-only install — no external domain configured.'}
)}
{/* Service Configurations */}

Service Configuration

{SERVICE_DEFS.map(({ key, label, icon: Icon, Form, defaults }) => { const data = { ...defaults, ...(serviceConfigs[key] || {}) }; const conflictErrors = {}; for (const field of (PORT_CONFLICT_FIELDS[key] || [])) { const msg = portConflicts[`${key}|${field}`]; if (msg) conflictErrors[field] = msg; } const errors = { ...validateServiceConfig(key, data), ...conflictErrors }; return (
updateServiceConfig(key, d)} errors={errors} />
); })} {/* Backup & Restore */}
{backups.length} backup{backups.length !== 1 ? 's' : ''} stored
{backups.length === 0 ? (

No backups yet

) : (
{backups.map((b) => ( ))}
Backup ID Timestamp Services
{b.backup_id} {new Date(b.timestamp).toLocaleString()} {(b.services || []).length} services
)}
{/* Restore modal */} {restoreModal && (

Restore Backup

{restoreModal.backup_id}

Select which services to restore:

{RESTORE_SERVICES.map(({ key, label }) => ( ))}
{restoreServices.size === RESTORE_SERVICES.length && (

All services selected — full restore including system files.

)}
)} {/* Export / Import */}

Export downloads all service configs as JSON. Import replaces current service configs.

); } export default Settings;