Files
pic/webui/src/pages/Settings.jsx
T
roof 01027c171e
Unit Tests / test (push) Successful in 15m24s
fix: clarify Re-register button purpose with inline hint
Add a short label explaining the button is for DDNS recovery (when the
DDNS server lost your record), not routine IP updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:08:49 -04:00

1173 lines
50 KiB
React
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 { 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 (
<div className="fixed bottom-4 right-4 z-50 space-y-2">
{toasts.map((t) => (
<div
key={t.id}
className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
t.type === 'success' ? 'bg-green-600' : t.type === 'error' ? 'bg-red-600' : 'bg-yellow-600'
}`}
>
{t.type === 'success' ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{t.msg}
</div>
))}
</div>
);
}
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 (
<div className="card mb-4">
<button
className="w-full flex items-center justify-between"
onClick={() => collapsible && setOpen((v) => !v)}
disabled={!collapsible}
>
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-primary-500" />
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
</div>
{collapsible && (open ? <ChevronDown className="h-4 w-4 text-gray-400" /> : <ChevronRight className="h-4 w-4 text-gray-400" />)}
</button>
{(!collapsible || open) && <div className="mt-4">{children}</div>}
</div>
);
}
// ── 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 165535';
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 (
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<label className="text-sm text-gray-600 sm:w-48 shrink-0 sm:pt-1.5">{label}</label>
<div className="flex-1">
{children}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
{hint && !error && <span className="text-xs text-gray-400 sm:pt-1.5">{hint}</span>}
</div>
);
}
function TextInput({ value, onChange, placeholder, type = 'text', readOnly, maxLength }) {
return (
<input
type={type}
value={value ?? ''}
onChange={(e) => 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 (
<input
type="number"
value={value ?? ''}
min={min}
max={max}
onChange={(e) => 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 (
<label className="flex items-center gap-2 cursor-pointer select-none">
<div
onClick={() => onChange && onChange(!value)}
className={`relative w-10 h-5 rounded-full transition-colors ${value ? 'bg-primary-500' : 'bg-gray-300'}`}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${value ? 'translate-x-5' : ''}`}
/>
</div>
<span className="text-sm text-gray-700">{label}</span>
</label>
);
}
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 (
<div className="space-y-1">
<div className="flex gap-2 flex-wrap">
{value.map((item) => (
<span key={item} className="flex items-center gap-1 bg-primary-100 text-primary-700 text-xs rounded-full px-2 py-0.5">
{item}
<button onClick={() => onChange(value.filter((v) => v !== item))} className="hover:text-red-500">×</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
className="flex-1 text-sm border rounded px-3 py-1 focus:outline-none focus:ring-2 focus:ring-primary-400"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
placeholder={placeholder}
/>
<button onClick={add} className="btn-secondary text-xs px-3 py-1">Add</button>
</div>
</div>
);
}
// ── Service config forms ──────────────────────────────────────────────────────
function NetworkForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="DNS Port" error={errors.dns_port}>
<NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} />
</Field>
<Field label="DHCP Range" hint="e.g. 10.0.0.100,10.0.0.200,12h" error={errors.dhcp_range}>
<TextInput value={data.dhcp_range} onChange={(v) => onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" />
</Field>
<Field label="NTP Servers" hint="Hostnames or IPs" error={errors.ntp_servers}>
<TagList value={data.ntp_servers || []} onChange={(v) => onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" />
</Field>
</div>
);
}
function WireguardForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="Listen Port" error={errors.port}>
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="Server Address" hint="CIDR, e.g. 10.0.0.1/24" error={errors.address}>
<TextInput value={data.address} onChange={(v) => onChange({ ...data, address: v })} placeholder="10.0.0.1/24" />
</Field>
<Field label="Private Key">
<TextInput value={data.private_key} onChange={(v) => onChange({ ...data, private_key: v })} placeholder="base64 private key" type="password" />
</Field>
</div>
);
}
function EmailForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="Mail Domain" error={errors.domain}>
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
</Field>
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)" error={errors.smtp_port}>
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
</Field>
<Field label="Submission Port" hint="Client mail send (default 587)" error={errors.submission_port}>
<NumberInput value={data.submission_port ?? 587} onChange={(v) => onChange({ ...data, submission_port: v })} min={1} max={65535} />
</Field>
<Field label="IMAP Port" hint="Client mail fetch (default 993)" error={errors.imap_port}>
<NumberInput value={data.imap_port ?? 993} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} />
</Field>
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)" error={errors.webmail_port}>
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
</Field>
</div>
);
}
function CalendarForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy" error={errors.port}>
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/radicale" />
</Field>
</div>
);
}
function FilesForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)" error={errors.port}>
<NumberInput value={data.port ?? 8080} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="File Manager Port" hint="Filegator host port (default 8082)" error={errors.manager_port}>
<NumberInput value={data.manager_port ?? 8082} onChange={(v) => onChange({ ...data, manager_port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" />
</Field>
<Field label="Default Quota (MB)">
<NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} />
</Field>
</div>
);
}
function RoutingForm({ data, onChange }) {
return (
<div className="space-y-3">
<Field label="">
<BoolToggle value={data.nat_enabled} onChange={(v) => onChange({ ...data, nat_enabled: v })} label="NAT Enabled" />
</Field>
<Field label="">
<BoolToggle value={data.firewall_enabled} onChange={(v) => onChange({ ...data, firewall_enabled: v })} label="Firewall Enabled" />
</Field>
</div>
);
}
function VaultForm({ data, onChange }) {
return (
<div className="space-y-3">
<Field label="">
<BoolToggle value={data.ca_configured} onChange={(v) => onChange({ ...data, ca_configured: v })} label="CA Configured" />
</Field>
<Field label="">
<BoolToggle value={data.fernet_configured} onChange={(v) => onChange({ ...data, fernet_configured: v })} label="Fernet Encryption Configured" />
</Field>
</div>
);
}
// 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 (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
);
}
return (
<div>
<Toast toasts={toasts} />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="mt-1 text-gray-500 text-sm">Configure your Personal Internet Cell</p>
</div>
{/* Cell Identity */}
<Section icon={Server} title="Cell Identity">
<div className="space-y-3">
<Field label="Cell Name" error={cellNameError}>
<div className="flex items-center gap-2">
<TextInput
value={identity.cell_name}
onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
placeholder="mycell"
maxLength={255}
/>
{domainMode === 'pic_ngo' && picAvail === 'checking' && (
<span className="text-xs text-gray-400 whitespace-nowrap flex items-center gap-1"><Loader className="h-3 w-3 animate-spin" /> checking</span>
)}
{domainMode === 'pic_ngo' && picAvail === 'available' && (
<span className="text-xs text-green-600 whitespace-nowrap flex items-center gap-1"><CheckCircle className="h-3 w-3" /> available</span>
)}
{domainMode === 'pic_ngo' && picAvail === 'taken' && (
<span className="text-xs text-red-600 whitespace-nowrap flex items-center gap-1"><XCircle className="h-3 w-3" /> taken</span>
)}
{domainMode === 'pic_ngo' && picAvail === 'unreachable' && (
<span className="text-xs text-yellow-600 whitespace-nowrap">DDNS unreachable</span>
)}
</div>
{domainMode === 'pic_ngo' && (
<p className="mt-1 text-xs text-gray-400">External: <span className="font-mono">{identity.cell_name || '…'}.pic.ngo</span></p>
)}
</Field>
<Field label="Local Domain" error={domainError}>
<TextInput
value={identity.domain}
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
placeholder="cell"
maxLength={255}
/>
</Field>
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
<TextInput
value={identity.ip_range}
onChange={(v) => { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
placeholder="172.20.0.0/16"
/>
</Field>
</div>
</Section>
{/* DDNS / External Domain */}
<Section icon={Globe} title="External Domain & DDNS" collapsible defaultOpen>
<div className="space-y-3">
{domainMode === 'pic_ngo' && (
<div className="space-y-2">
<div className="rounded-lg bg-blue-50 border border-blue-100 px-4 py-3 text-sm text-blue-700">
Your cell is registered as <span className="font-mono font-semibold">{domainName || `${identity.cell_name}.pic.ngo`}</span> on pic.ngo.
Change the Cell Name above to update this subdomain.
</div>
<div className="flex items-center gap-3">
<button
onClick={reRegister}
disabled={ddnsRegistering}
className="px-3 py-1.5 text-xs font-medium rounded border border-blue-300 text-blue-700 hover:bg-blue-50 disabled:opacity-50"
>
{ddnsRegistering ? 'Registering…' : 'Re-register with pic.ngo'}
</button>
<span className="text-xs text-gray-400">Use if DDNS lost your record sends current public IP</span>
</div>
</div>
)}
{domainMode === 'cloudflare' && (
<div className="space-y-3">
<div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2 text-xs text-gray-500">
Provider: <span className="font-semibold text-gray-700">Cloudflare</span>
{domainName && <> <span className="font-mono">{domainName}</span></>}
</div>
<Field label="Domain">
<TextInput value={domainName} onChange={(v) => { setDomainName(v); setDdnsDirty(true); }} placeholder="home.example.com" />
</Field>
<Field label="API Token" hint={ddnsHasToken ? 'Token is set — enter a new one to replace it' : undefined}>
<div className="flex gap-2">
<TextInput
value={ddnsCfToken}
onChange={(v) => { setDdnsCfToken(v); setDdnsDirty(true); setDdnsCfStatus(null); }}
placeholder={ddnsHasToken ? '••••••••' : 'Cloudflare API token'}
type="password"
/>
<button
onClick={verifyCf}
disabled={!ddnsCfToken.trim() || ddnsCfStatus === 'checking'}
className="px-3 py-1.5 text-xs font-medium rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap"
>
{ddnsCfStatus === 'checking' ? 'Verifying…' : 'Verify & Save'}
</button>
</div>
{ddnsCfStatus === 'valid' && <p className="mt-1 text-xs text-green-600 flex items-center gap-1"><CheckCircle className="h-3 w-3" /> Token valid and saved</p>}
{ddnsCfStatus === 'invalid' && <p className="mt-1 text-xs text-red-600 flex items-center gap-1"><XCircle className="h-3 w-3" /> Token invalid</p>}
</Field>
{ddnsDirty && domainName && (
<button
onClick={saveDdns}
disabled={ddnsSaving}
className="btn-primary text-sm"
>
{ddnsSaving ? 'Saving…' : 'Save Domain'}
</button>
)}
</div>
)}
{domainMode === 'duckdns' && (
<div className="space-y-3">
<div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2 text-xs text-gray-500">
Provider: <span className="font-semibold text-gray-700">DuckDNS</span>
{domainName && <> <span className="font-mono">{domainName}</span></>}
</div>
<Field label="Subdomain" hint="e.g. myname.duckdns.org">
<TextInput value={domainName} onChange={(v) => { setDomainName(v); setDdnsDirty(true); }} placeholder="myname.duckdns.org" />
</Field>
<Field label="Token" hint={ddnsHasToken ? 'Token is set — enter a new one to replace it' : undefined}>
<div className="flex gap-2">
<TextInput
value={ddnsDuckToken}
onChange={(v) => { setDdnsDuckToken(v); setDdnsDirty(true); setDdnsDuckStatus(null); }}
placeholder={ddnsHasToken ? '••••••••' : 'DuckDNS token'}
type="password"
/>
<button
onClick={verifyDuck}
disabled={!ddnsDuckToken.trim() || ddnsDuckStatus === 'checking'}
className="px-3 py-1.5 text-xs font-medium rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap"
>
{ddnsDuckStatus === 'checking' ? 'Verifying…' : 'Verify & Save'}
</button>
</div>
{ddnsDuckStatus === 'valid' && <p className="mt-1 text-xs text-green-600 flex items-center gap-1"><CheckCircle className="h-3 w-3" /> Token valid and saved</p>}
{ddnsDuckStatus === 'invalid' && <p className="mt-1 text-xs text-red-600 flex items-center gap-1"><XCircle className="h-3 w-3" /> Token invalid</p>}
</Field>
{ddnsDirty && domainName && (
<button
onClick={saveDdns}
disabled={ddnsSaving}
className="btn-primary text-sm"
>
{ddnsSaving ? 'Saving…' : 'Save Domain'}
</button>
)}
</div>
)}
{(domainMode === 'http01' || domainMode === 'lan') && (
<div className="text-sm text-gray-500">
{domainMode === 'http01'
? <>Domain: <span className="font-mono text-gray-700">{domainName || '—'}</span></>
: 'Local-only install — no external domain configured.'}
</div>
)}
</div>
</Section>
{/* Service Configurations */}
<div className="mb-2">
<h2 className="text-lg font-semibold text-gray-800">Service Configuration</h2>
</div>
{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 (
<Section key={key} icon={Icon} title={label} collapsible defaultOpen={false}>
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} errors={errors} />
</Section>
);
})}
{/* Backup & Restore */}
<Section icon={Archive} title="Backup & Restore" collapsible defaultOpen>
<div className="flex justify-between items-center mb-3">
<span className="text-sm text-gray-600">{backups.length} backup{backups.length !== 1 ? 's' : ''} stored</span>
<div className="flex gap-2">
<label className="btn-secondary flex items-center gap-2 text-sm cursor-pointer" title="Upload backup zip">
{backupUploading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Upload
<input type="file" accept=".zip" className="hidden" onChange={uploadBackup} />
</label>
<button
onClick={createBackup}
disabled={backupCreating}
className="btn-secondary flex items-center gap-2 text-sm"
>
{backupCreating ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Archive className="h-4 w-4" />}
Create Backup
</button>
</div>
</div>
{backups.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No backups yet</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 border-b">
<th className="pb-2 font-medium">Backup ID</th>
<th className="pb-2 font-medium">Timestamp</th>
<th className="pb-2 font-medium">Services</th>
<th className="pb-2" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{backups.map((b) => (
<tr key={b.backup_id} className="hover:bg-gray-50">
<td className="py-2 font-mono text-xs text-gray-700">{b.backup_id}</td>
<td className="py-2 text-gray-600">{new Date(b.timestamp).toLocaleString()}</td>
<td className="py-2 text-gray-500">{(b.services || []).length} services</td>
<td className="py-2">
<div className="flex gap-2 justify-end">
<button
onClick={() => downloadBackup(b.backup_id)}
className="text-gray-500 hover:text-gray-700 flex items-center gap-1 text-xs"
title="Download"
>
<FolderDown className="h-3.5 w-3.5" /> Download
</button>
<button
onClick={() => openRestoreModal(b)}
className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-xs"
title="Restore"
>
<RotateCcw className="h-3.5 w-3.5" /> Restore
</button>
<button
onClick={() => deleteBackup(b.backup_id)}
className="text-red-500 hover:text-red-700 flex items-center gap-1 text-xs"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" /> Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
{/* Restore modal */}
{restoreModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-96 max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold text-gray-900">Restore Backup</h3>
<p className="text-xs text-gray-500 mt-0.5 font-mono">{restoreModal.backup_id}</p>
</div>
<button onClick={() => setRestoreModal(null)} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-gray-600 mb-3">Select which services to restore:</p>
<div className="space-y-2 mb-4">
{RESTORE_SERVICES.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={restoreServices.has(key)}
onChange={(e) => {
setRestoreServices((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(key);
else next.delete(key);
return next;
});
}}
className="h-4 w-4 rounded"
/>
<span className="text-sm text-gray-700">{label}</span>
</label>
))}
</div>
<div className="flex justify-between items-center mb-4">
<button
onClick={() => setRestoreServices(new Set(RESTORE_SERVICES.map(s => s.key)))}
className="text-xs text-blue-600 hover:text-blue-800"
>
Select all
</button>
<button
onClick={() => setRestoreServices(new Set())}
className="text-xs text-gray-500 hover:text-gray-700"
>
Deselect all
</button>
</div>
{restoreServices.size === RESTORE_SERVICES.length && (
<p className="text-xs text-gray-400 mb-4">All services selected full restore including system files.</p>
)}
<div className="flex gap-2 justify-end">
<button onClick={() => setRestoreModal(null)} className="btn-secondary text-sm">Cancel</button>
<button
onClick={doRestore}
disabled={restoreServices.size === 0}
className="btn-primary text-sm disabled:opacity-50"
>
Restore Selected
</button>
</div>
</div>
</div>
)}
{/* Export / Import */}
<Section icon={Download} title="Export & Import">
<div className="flex flex-wrap gap-3">
<button onClick={exportConfig} className="btn-secondary flex items-center gap-2 text-sm">
<Download className="h-4 w-4" /> Export JSON
</button>
<label className="btn-secondary flex items-center gap-2 text-sm cursor-pointer">
<Upload className="h-4 w-4" /> Import JSON
<input type="file" accept=".json" className="hidden" onChange={importConfig} />
</label>
</div>
<p className="text-xs text-gray-400 mt-2">
Export downloads all service configs as JSON. Import replaces current service configs.
</p>
</Section>
</div>
);
}
export default Settings;