Files
pic/webui/src/pages/Settings.jsx
T
roof 7a273ad43e fix: consolidate WireGuard port config and propagate port changes to UI
- docker-compose: fix WireGuard port mapping to ${WG_PORT}:${WG_PORT} so
  the daemon ListenPort matches the Docker host-to-container binding
- app.py: sync wireguard.port ↔ identity.wireguard_port in both directions
  so changing either keeps them consistent; identity path now also updates
  wg0.conf via wireguard_manager.update_config
- Settings.jsx: remove duplicate wireguard_port from Cell Identity section
  (port is configurable under WireGuard VPN service config); add
  refreshConfig() after saveService so other pages see new values immediately
- WireGuard.jsx: import useConfig() and use service_configs.wireguard.port
  as the reactive port source for endpoint display and port-open warnings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:27:35 -04:00

606 lines
23 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 } 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 (
<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>
);
}
// ── Field components ──────────────────────────────────────────────────────────
function Field({ label, children, hint }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
<label className="text-sm text-gray-600 sm:w-48 shrink-0">{label}</label>
<div className="flex-1">{children}</div>
{hint && <span className="text-xs text-gray-400">{hint}</span>}
</div>
);
}
function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) {
return (
<input
type={type}
value={value ?? ''}
onChange={(e) => 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 (
<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 }) {
return (
<div className="space-y-3">
<Field label="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">
<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">
<TagList value={data.ntp_servers || []} onChange={(v) => onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" />
</Field>
</div>
);
}
function WireguardForm({ data, onChange }) {
return (
<div className="space-y-3">
<Field label="Listen 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">
<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 }) {
return (
<div className="space-y-3">
<Field label="Mail 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)">
<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)">
<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)">
<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)">
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
</Field>
</div>
);
}
function CalendarForm({ data, onChange }) {
return (
<div className="space-y-3">
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy">
<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 }) {
return (
<div className="space-y-3">
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)">
<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)">
<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();
// identity
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [identityDirty, setIdentityDirty] = useState(false);
const [identitySaving, setIdentitySaving] = useState(false);
// service configs
const [serviceConfigs, setServiceConfigs] = useState({});
const [serviceDirty, setServiceDirty] = useState({});
const [serviceSaving, setServiceSaving] = useState({});
// backups
const [backups, setBackups] = useState([]);
const [backupsLoading, setBackupsLoading] = useState(false);
const [backupCreating, setBackupCreating] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const loadAll = useCallback(async () => {
setIsLoading(true);
try {
const [cfgRes, bkRes] = await Promise.all([
cellAPI.getConfig(),
cellAPI.listBackups(),
]);
const cfg = cfgRes.data;
setIdentity({
cell_name: cfg.cell_name || '',
domain: cfg.domain || '',
ip_range: cfg.ip_range || '',
});
setServiceConfigs(cfg.service_configs || {});
setBackups(bkRes.data || []);
} catch (err) {
toast('Failed to load configuration', 'error');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => { loadAll(); }, [loadAll]);
const _applyResult = (res, label) => {
const { restarted = [], warnings = [] } = res.data || {};
if (restarted.length > 0) {
toast(`${label} saved — restarted: ${restarted.join(', ')}`);
} else {
toast(`${label} saved`);
}
warnings.forEach((w) => toast(w, 'warning'));
};
// identity save
const 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);
refreshConfig();
} catch {
toast(`Failed to save ${key} config`, 'error');
} finally {
setServiceSaving((s) => ({ ...s, [key]: false }));
}
};
const updateServiceConfig = (key, data) => {
setServiceConfigs((prev) => ({ ...prev, [key]: data }));
setServiceDirty((d) => ({ ...d, [key]: true }));
};
// backups
const createBackup = async () => {
setBackupCreating(true);
try {
await cellAPI.createBackup();
toast('Backup created');
const res = await cellAPI.listBackups();
setBackups(res.data || []);
} catch {
toast('Failed to create backup', 'error');
} finally {
setBackupCreating(false);
}
};
const restoreBackup = async (id) => {
if (!confirm(`Restore backup ${id}? Current config will be overwritten.`)) return;
try {
await cellAPI.restoreBackup(id);
toast('Configuration restored — reloading…');
setTimeout(() => loadAll(), 500);
} catch {
toast('Failed to restore backup', 'error');
}
};
const deleteBackup = async (id) => {
if (!confirm(`Delete backup ${id}?`)) return;
try {
await cellAPI.deleteBackup(id);
setBackups((prev) => prev.filter((b) => b.backup_id !== id));
toast('Backup deleted');
} catch {
toast('Failed to delete backup', 'error');
}
};
// export
const exportConfig = async () => {
try {
const res = await cellAPI.exportConfig('json');
const blob = new Blob([res.data.config], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pic-config-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast('Export failed', 'error');
}
};
// import
const importConfig = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
if (!confirm('Import this config? Current settings will be replaced.')) { e.target.value = ''; return; }
try {
await cellAPI.importConfig(text, 'json');
toast('Config imported — reloading…');
setTimeout(() => loadAll(), 500);
} catch {
toast('Import failed', 'error');
} finally {
e.target.value = '';
}
};
if (isLoading) {
return (
<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">
<TextInput
value={identity.cell_name}
onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); }}
placeholder="mycell"
/>
</Field>
<Field label="Domain">
<TextInput
value={identity.domain}
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); }}
placeholder="cell.local"
/>
</Field>
<Field label="IP Range" hint="Docker bridge subnet">
<TextInput
value={identity.ip_range}
onChange={(v) => { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); }}
placeholder="172.20.0.0/16"
/>
</Field>
</div>
<div className="flex justify-end mt-4">
<button
onClick={saveIdentity}
disabled={!identityDirty || identitySaving}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{identitySaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Identity
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
IP Range and port changes update the .env file and mark affected containers for restart.
Use the banner above to apply when ready.
</p>
</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 dirty = serviceDirty[key];
const saving = serviceSaving[key];
return (
<Section key={key} icon={Icon} title={label} collapsible defaultOpen={false}>
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} />
<div className="flex items-center justify-between mt-4">
<span className="text-xs text-gray-400">Port/directory changes take effect after container restart.</span>
<button
onClick={() => saveService(key)}
disabled={!dirty || saving}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</button>
</div>
</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>
<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>
{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={() => restoreBackup(b.backup_id)}
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>
{/* 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;