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
+119
View File
@@ -118,6 +118,14 @@ def get_config():
'vip_webdav': _ips['vip_webdav'],
}
config['service_configs'] = service_configs
config['domain_mode'] = identity.get('domain_mode', 'lan')
config['domain_name'] = identity.get('domain_name', '')
ddns_section = config_manager.configs.get('ddns', {})
config['ddns'] = {
'provider': ddns_section.get('provider', ''),
'subdomain': ddns_section.get('subdomain', ''),
'has_token': bool(ddns_section.get('token') or ddns_section.get('api_token')),
}
return jsonify(config)
except Exception as e:
logger.error(f"Error getting config: {e}")
@@ -336,6 +344,18 @@ def update_config():
['dns'],
pre_change_snapshot=_pre_change_snapshot,
)
_ddns_cfg = config_manager.configs.get('ddns', {})
if _ddns_cfg.get('provider') == 'pic_ngo':
try:
from ddns_manager import DDNSManager as _DDNSManager
_ddns_mgr = _DDNSManager(config_manager)
_result = _ddns_mgr.register(new_name, '')
_new_sub = _result.get('subdomain', f'{new_name}.pic.ngo')
config_manager.set_identity_field('domain_name', _new_sub)
logger.info('DDNS re-registered: cell_name=%r subdomain=%r', new_name, _new_sub)
except Exception as _exc:
logger.warning('DDNS re-registration failed for %r: %s', new_name, _exc)
all_warnings.append(f'DDNS name update failed — {_exc}')
if identity_updates.get('ip_range') and identity_updates['ip_range'] != old_identity.get('ip_range', ''):
new_range = identity_updates['ip_range']
@@ -442,6 +462,105 @@ def update_config():
return jsonify({"error": str(e)}), 500
@bp.route('/api/ddns/check/<name>', methods=['GET'])
def ddns_check_name(name):
import urllib.request as _ureq
import urllib.error as _uerr
import json as _json_
from setup_manager import DDNS_API_BASE as _DDNS_BASE
try:
url = f'{_DDNS_BASE}/api/v1/check/{name}'
with _ureq.urlopen(url, timeout=8) as resp:
body = _json_.loads(resp.read())
return jsonify({'available': bool(body.get('available'))})
except Exception as exc:
logger.warning('DDNS check failed for %r: %s', name, exc)
return jsonify({'available': None, 'error': 'DDNS service unreachable'}), 503
@bp.route('/api/ddns', methods=['PUT'])
def update_ddns_config():
import urllib.request as _ureq
import urllib.error as _uerr
import json as _json_
try:
from app import config_manager
from setup_manager import _build_ddns_config, DDNS_API_BASE as _DDNS_BASE
data = request.get_json(silent=True) or {}
domain_mode = data.get('domain_mode', '').strip()
domain_name = data.get('domain_name', '').strip()
cf_token = data.get('cloudflare_api_token', '').strip()
duck_token = data.get('duckdns_token', '').strip()
from setup_manager import VALID_DOMAIN_MODES
if domain_mode not in VALID_DOMAIN_MODES:
return jsonify({'error': f'domain_mode must be one of: {", ".join(sorted(VALID_DOMAIN_MODES))}'}), 400
if domain_mode == 'cloudflare':
if not domain_name:
return jsonify({'error': 'domain_name is required for cloudflare'}), 400
if not cf_token:
existing = config_manager.configs.get('ddns', {}).get('api_token', '')
if not existing:
return jsonify({'error': 'cloudflare_api_token is required'}), 400
cf_token = existing
try:
req = _ureq.Request(
'https://api.cloudflare.com/client/v4/user/tokens/verify',
headers={'Authorization': f'Bearer {cf_token}'},
)
with _ureq.urlopen(req, timeout=8) as resp:
body = _json_.loads(resp.read())
if not body.get('success'):
return jsonify({'error': 'Cloudflare token is invalid'}), 422
except _uerr.HTTPError:
return jsonify({'error': 'Cloudflare token is invalid'}), 422
except Exception as exc:
return jsonify({'error': f'Could not reach Cloudflare: {exc}'}), 503
if domain_mode == 'duckdns':
if not domain_name:
return jsonify({'error': 'domain_name is required for duckdns'}), 400
if not duck_token:
existing = config_manager.configs.get('ddns', {}).get('token', '')
if not existing:
return jsonify({'error': 'duckdns_token is required'}), 400
duck_token = existing
subdomain = domain_name.replace('.duckdns.org', '')
try:
url = f'https://www.duckdns.org/update?domains={subdomain}&token={duck_token}&ip='
with _ureq.urlopen(url, timeout=8) as resp:
if resp.read().strip() != b'OK':
return jsonify({'error': 'DuckDNS token or subdomain is invalid'}), 422
except Exception as exc:
return jsonify({'error': f'Could not reach DuckDNS: {exc}'}), 503
duck_sub = domain_name.replace('.duckdns.org', '') if domain_mode == 'duckdns' else ''
ddns_cfg = _build_ddns_config(
domain_mode,
cloudflare_api_token=cf_token,
duckdns_token=duck_token,
duckdns_subdomain=duck_sub,
)
config_manager.set_ddns_config(ddns_cfg)
config_manager.set_identity_field('domain_mode', domain_mode)
if domain_name:
config_manager.set_identity_field('domain_name', domain_name)
if domain_mode == 'cloudflare' and cf_token:
config_manager.set_identity_field('cloudflare_api_token', cf_token)
if domain_mode == 'duckdns':
if duck_token:
config_manager.set_identity_field('duckdns_token', duck_token)
config_manager.set_identity_field('duckdns_subdomain', duck_sub)
logger.info('DDNS config updated: domain_mode=%r domain_name=%r', domain_mode, domain_name)
return jsonify({'updated': True})
except Exception as e:
logger.error(f'Error updating DDNS config: {e}')
return jsonify({'error': str(e)}), 500
@bp.route('/api/config/pending', methods=['GET'])
def get_pending_config():
from app import config_manager
+140
View File
@@ -0,0 +1,140 @@
"""Tests for GET /api/ddns/check/<name> and PUT /api/ddns."""
import json
import sys
import os
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
def _make_client():
from app import app
app.config['TESTING'] = True
return app.test_client()
class TestDdnsCheckName(unittest.TestCase):
def setUp(self):
self.client = _make_client()
def _get(self, name):
return self.client.get(f'/api/ddns/check/{name}')
@patch('routes.config.DDNS_API_BASE', 'http://ddns.test', create=True)
def test_available_name_returns_true(self):
with patch('routes.config._ureq', create=True):
import io
resp_mock = MagicMock()
resp_mock.read.return_value = b'{"available": true}'
resp_mock.__enter__ = lambda s: resp_mock
resp_mock.__exit__ = MagicMock(return_value=False)
with patch('urllib.request.urlopen', return_value=resp_mock):
r = self._get('testname')
self.assertEqual(r.status_code, 200)
body = json.loads(r.data)
self.assertTrue(body['available'])
@patch('routes.config.DDNS_API_BASE', 'http://ddns.test', create=True)
def test_taken_name_returns_false(self):
resp_mock = MagicMock()
resp_mock.read.return_value = b'{"available": false}'
resp_mock.__enter__ = lambda s: resp_mock
resp_mock.__exit__ = MagicMock(return_value=False)
with patch('urllib.request.urlopen', return_value=resp_mock):
r = self._get('taken')
self.assertEqual(r.status_code, 200)
body = json.loads(r.data)
self.assertFalse(body['available'])
def test_unreachable_returns_503(self):
import urllib.error
with patch('urllib.request.urlopen', side_effect=OSError('conn refused')):
r = self._get('anything')
self.assertEqual(r.status_code, 503)
body = json.loads(r.data)
self.assertIsNone(body['available'])
class TestUpdateDdnsConfig(unittest.TestCase):
def setUp(self):
self.client = _make_client()
def _put(self, payload):
return self.client.put(
'/api/ddns',
data=json.dumps(payload),
content_type='application/json',
)
def test_invalid_domain_mode_returns_400(self):
r = self._put({'domain_mode': 'invalid_mode'})
self.assertEqual(r.status_code, 400)
self.assertIn('domain_mode', json.loads(r.data)['error'])
def test_cloudflare_requires_domain_name(self):
r = self._put({'domain_mode': 'cloudflare', 'cloudflare_api_token': 'tok'})
self.assertEqual(r.status_code, 400)
self.assertIn('domain_name', json.loads(r.data)['error'])
def test_cloudflare_invalid_token_returns_422(self):
import urllib.error
with patch('urllib.request.urlopen', side_effect=urllib.error.HTTPError(
None, 403, 'Forbidden', {}, None
)):
r = self._put({'domain_mode': 'cloudflare', 'domain_name': 'home.example.com',
'cloudflare_api_token': 'bad-token'})
self.assertEqual(r.status_code, 422)
def test_cloudflare_valid_token_saves_config(self):
from app import config_manager
resp_mock = MagicMock()
resp_mock.read.return_value = b'{"success": true}'
resp_mock.__enter__ = lambda s: resp_mock
resp_mock.__exit__ = MagicMock(return_value=False)
with patch('urllib.request.urlopen', return_value=resp_mock):
with patch.object(config_manager, 'set_ddns_config') as mock_set_ddns, \
patch.object(config_manager, 'set_identity_field') as mock_set_id:
r = self._put({'domain_mode': 'cloudflare', 'domain_name': 'home.example.com',
'cloudflare_api_token': 'valid-token'})
self.assertEqual(r.status_code, 200)
self.assertTrue(json.loads(r.data)['updated'])
mock_set_ddns.assert_called_once()
mock_set_id.assert_any_call('domain_mode', 'cloudflare')
def test_duckdns_requires_domain_name(self):
r = self._put({'domain_mode': 'duckdns', 'duckdns_token': 'tok'})
self.assertEqual(r.status_code, 400)
def test_duckdns_invalid_token_returns_422(self):
resp_mock = MagicMock()
resp_mock.read.return_value = b'KO'
resp_mock.__enter__ = lambda s: resp_mock
resp_mock.__exit__ = MagicMock(return_value=False)
with patch('urllib.request.urlopen', return_value=resp_mock):
r = self._put({'domain_mode': 'duckdns', 'domain_name': 'myname.duckdns.org',
'duckdns_token': 'bad'})
self.assertEqual(r.status_code, 422)
def test_lan_mode_saves_without_validation(self):
from app import config_manager
with patch.object(config_manager, 'set_ddns_config') as mock_ddns, \
patch.object(config_manager, 'set_identity_field') as mock_id:
r = self._put({'domain_mode': 'lan'})
self.assertEqual(r.status_code, 200)
mock_ddns.assert_called_once()
mock_id.assert_any_call('domain_mode', 'lan')
def test_http01_mode_saves_with_domain(self):
from app import config_manager
with patch.object(config_manager, 'set_ddns_config') as mock_ddns, \
patch.object(config_manager, 'set_identity_field') as mock_id:
r = self._put({'domain_mode': 'http01', 'domain_name': 'home.example.com'})
self.assertEqual(r.status_code, 200)
mock_id.assert_any_call('domain_name', 'home.example.com')
if __name__ == '__main__':
unittest.main()
+229 -8
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}>
<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>
+6
View File
@@ -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'),