import { useState, useEffect, useCallback } from 'react'; import { useConfig } from '../contexts/ConfigContext'; import { Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, Save, ChevronDown, ChevronRight, CheckCircle, XCircle, AlertCircle, RefreshCw, Lock } from 'lucide-react'; import { cellAPI } from '../services/api'; // ── 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; } 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; } 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)'; 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`; } } 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 (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 }) { return ( onChange && onChange(e.target.value)} placeholder={placeholder} readOnly={readOnly} 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(); // identity const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' }); const [identityDirty, setIdentityDirty] = useState(false); const [identitySaving, setIdentitySaving] = useState(false); // service configs const [serviceConfigs, setServiceConfigs] = useState({}); const [serviceDirty, setServiceDirty] = useState({}); const [serviceSaving, setServiceSaving] = useState({}); // backups const [backups, setBackups] = useState([]); const [backupsLoading, setBackupsLoading] = useState(false); const [backupCreating, setBackupCreating] = 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 || '', }); setServiceConfigs(cfg.service_configs || {}); setBackups(bkRes.data || []); } catch (err) { toast('Failed to load configuration', 'error'); } finally { setIsLoading(false); } }, []); useEffect(() => { loadAll(); }, [loadAll]); const _applyResult = (res, label) => { const { restarted = [], warnings = [] } = res.data || {}; if (restarted.length > 0) { toast(`${label} saved — restarted: ${restarted.join(', ')}`); } else { toast(`${label} saved`); } warnings.forEach((w) => toast(w, 'warning')); }; // 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 saveIdentity = async () => { if (ipRangeError) return; setIdentitySaving(true); try { const res = await cellAPI.updateConfig(identity); setIdentityDirty(false); _applyResult(res, 'Cell identity'); refreshConfig(); } catch { toast('Failed to save identity', 'error'); } finally { setIdentitySaving(false); } }; // service config save const saveService = async (key) => { const { defaults } = SERVICE_DEFS.find((d) => d.key === key) || {}; const data = { ...(defaults || {}), ...(serviceConfigs[key] || {}) }; if (Object.keys(validateServiceConfig(key, data)).length > 0) return; setServiceSaving((s) => ({ ...s, [key]: true })); try { const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); setServiceDirty((d) => ({ ...d, [key]: false })); _applyResult(res, key); refreshConfig(); } catch { toast(`Failed to save ${key} config`, 'error'); } finally { setServiceSaving((s) => ({ ...s, [key]: false })); } }; const updateServiceConfig = (key, data) => { setServiceConfigs((prev) => ({ ...prev, [key]: data })); setServiceDirty((d) => ({ ...d, [key]: true })); }; // 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 restoreBackup = async (id) => { if (!confirm(`Restore backup ${id}? Current config will be overwritten.`)) return; try { await cellAPI.restoreBackup(id); toast('Configuration restored — reloading…'); setTimeout(() => loadAll(), 500); } catch { toast('Failed to restore backup', 'error'); } }; 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); }} placeholder="mycell" /> { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); }} placeholder="cell.local" /> { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); }} placeholder="172.20.0.0/16" />

IP Range and port changes update the .env file and mark affected containers for restart. Use the banner above to apply when ready.

{/* Service Configurations */}

Service Configuration

{SERVICE_DEFS.map(({ key, label, icon: Icon, Form, defaults }) => { const data = { ...defaults, ...(serviceConfigs[key] || {}) }; const errors = validateServiceConfig(key, data); const hasErrors = Object.keys(errors).length > 0; const dirty = serviceDirty[key]; const saving = serviceSaving[key]; return (
updateServiceConfig(key, d)} errors={errors} />
Port/directory changes take effect after container restart.
); })} {/* 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
)}
{/* Export / Import */}

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

); } export default Settings;