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:
+235
-14
@@ -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>
|
||||
|
||||
@@ -321,6 +321,12 @@ export const logsAPI = {
|
||||
setVerbosity: (levels) => api.put('/api/logs/verbosity', levels),
|
||||
};
|
||||
|
||||
// DDNS API
|
||||
export const ddnsAPI = {
|
||||
checkName: (name) => api.get(`/api/ddns/check/${name}`),
|
||||
updateConfig: (data) => api.put('/api/ddns', data),
|
||||
};
|
||||
|
||||
// Setup Wizard API
|
||||
export const setupAPI = {
|
||||
getStatus: () => api.get('/api/setup/status'),
|
||||
|
||||
Reference in New Issue
Block a user