feat: DDNS settings integration — check availability, update credentials

- GET /api/config now returns domain_mode, domain_name, ddns.{provider,subdomain,has_token}
- GET /api/ddns/check/<name> proxies availability check to DDNS service
- PUT /api/ddns validates and saves cloudflare/duckdns credentials post-setup
- When cell_name changes for pic_ngo provider, auto-registers the new subdomain
- Settings: Cell Name shows availability badge for pic_ngo; auto-save blocks on taken
- Settings: new External Domain & DDNS section — pic_ngo info, cloudflare/duckdns edit
- 11 new tests for the two new endpoints (all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 14:35:37 -04:00
parent 81dcced0ca
commit 61e8631c7d
4 changed files with 500 additions and 14 deletions
+235 -14
View File
@@ -5,9 +5,9 @@ import {
Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar,
HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw,
ChevronDown, ChevronRight, CheckCircle, XCircle,
RefreshCw, Lock, FolderDown, X
RefreshCw, Lock, FolderDown, X, Globe, Loader
} from 'lucide-react';
import { cellAPI } from '../services/api';
import { cellAPI, ddnsAPI } from '../services/api';
// ── constants ────────────────────────────────────────────────────────────────
@@ -222,7 +222,7 @@ function Field({ label, children, hint, error }) {
);
}
function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) {
function TextInput({ value, onChange, placeholder, type = 'text', readOnly, maxLength }) {
return (
<input
type={type}
@@ -230,6 +230,7 @@ function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) {
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'
}`}
@@ -432,6 +433,18 @@ function Settings() {
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [identityDirty, setIdentityDirty] = useState(false);
// 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);
// service configs
const [serviceConfigs, setServiceConfigs] = useState({});
const [serviceDirty, setServiceDirty] = useState({});
@@ -462,6 +475,15 @@ function Settings() {
ip_range: cfg.ip_range || '',
});
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 || []);
@@ -493,17 +515,101 @@ function Settings() {
? '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 {
await cellAPI.updateConfig(identity);
const res = await cellAPI.updateConfig(identity);
setIdentityDirty(false);
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, draftConfig, refreshConfig]);
}, [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 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) => {
@@ -565,9 +671,10 @@ function Settings() {
useEffect(() => {
if (!identityDirty) return;
if (ipRangeError || cellNameError || domainError) return;
if (domainMode === 'pic_ngo' && (picAvail === 'taken' || picAvail === 'checking')) return;
const timer = setTimeout(() => saveIdentityRef.current(), 800);
return () => clearTimeout(timer);
}, [identity, identityDirty, ipRangeError, cellNameError, domainError]); // eslint-disable-line react-hooks/exhaustive-deps
}, [identity, identityDirty, ipRangeError, cellNameError, domainError, domainMode, picAvail]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const timers = SERVICE_DEFS
@@ -712,18 +819,35 @@ function Settings() {
<Section icon={Server} title="Cell Identity">
<div className="space-y-3">
<Field label="Cell Name" error={cellNameError}>
<TextInput
value={identity.cell_name}
onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
placeholder="mycell"
maxLength={255}
/>
<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="Domain" error={domainError}>
<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.local"
placeholder="cell"
maxLength={255}
/>
</Field>
@@ -737,6 +861,103 @@ function Settings() {
</div>
</Section>
{/* DDNS / External Domain */}
<Section icon={Globe} title="External Domain & DDNS" collapsible defaultOpen>
<div className="space-y-3">
{domainMode === 'pic_ngo' && (
<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>
)}
{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>