01027c171e
Unit Tests / test (push) Successful in 15m24s
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>
1173 lines
50 KiB
React
1173 lines
50 KiB
React
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 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 (
|
||
<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;
|