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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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