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}
}
);
}
// ── Field components ──────────────────────────────────────────────────────────
function Field({ label, children, hint }) {
return (
{children}
{hint &&
{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 (
);
}
// ── Service config forms ──────────────────────────────────────────────────────
function NetworkForm({ data, onChange }) {
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 }) {
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 }) {
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 }) {
return (
onChange({ ...data, port: v })} min={1} max={65535} />
onChange({ ...data, data_dir: v })} placeholder="/app/data/radicale" />
);
}
function FilesForm({ data, onChange }) {
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: '', wireguard_port: 51820 });
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 || '',
wireguard_port: cfg.wireguard_port || 51820,
});
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 saveIdentity = async () => {
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) => {
setServiceSaving((s) => ({ ...s, [key]: true }));
try {
const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
setServiceDirty((d) => ({ ...d, [key]: false }));
_applyResult(res, key);
} 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"
/>
{ setIdentity((i) => ({ ...i, wireguard_port: v })); setIdentityDirty(true); }}
min={1} max={65535}
/>
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 dirty = serviceDirty[key];
const saving = serviceSaving[key];
return (
);
})}
{/* Backup & Restore */}
{backups.length} backup{backups.length !== 1 ? 's' : ''} stored
{backups.length === 0 ? (
No backups yet
) : (
| Backup ID |
Timestamp |
Services |
|
{backups.map((b) => (
| {b.backup_id} |
{new Date(b.timestamp).toLocaleString()} |
{(b.services || []).length} services |
|
))}
)}
{/* Export / Import */}
);
}
export default Settings;