fix: all service pages use live domain; cell_name/domain propagate to DNS; /api/status reads stored identity

Changes:
- ConfigContext.jsx: React context that loads /api/config once; exposes domain,
  cell_name, refresh() — wraps entire app in App.jsx
- Email/Calendar/Files pages: replace hardcoded 'mail.cell', 'calendar.cell',
  'files.cell', 'webdav.cell' with domain from ConfigContext; hostname updates
  immediately after Settings save (refreshConfig() called on save)
- /api/status: cell_name and domain now read from stored _identity in config_manager,
  not hardcoded 'personal-internet-cell' / 'cell.local'
- network_manager.apply_cell_name(old, new): updates hostname A-record in primary
  zone file and reloads CoreDNS; called from PUT /api/config when cell_name changes
- Old identity captured before save so apply_cell_name gets the correct old value
- Settings EmailForm: smtp/imap ports are read-only with note (docker-compose.yml level)
- Settings FilesForm: port is read-only with note (Caddy proxies on 80 externally)
- Settings CalendarForm: port labeled "Internal port; clients use 80 via Caddy"

Tests added:
- test_apply_cell_name_renames_host_record
- test_apply_cell_name_noop_when_same

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 05:05:51 -04:00
parent ac9b26303f
commit 1f3386d43b
9 changed files with 157 additions and 29 deletions
+14 -2
View File
@@ -356,9 +356,10 @@ def get_cell_status():
current_time = time.time() current_time = time.time()
uptime_seconds = int(current_time - API_START_TIME) uptime_seconds = int(current_time - API_START_TIME)
identity = config_manager.configs.get('_identity', {})
return jsonify({ return jsonify({
"cell_name": "personal-internet-cell", "cell_name": identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
"domain": "cell.local", "domain": identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
"uptime": uptime_seconds, "uptime": uptime_seconds,
"peers_count": len(peers), "peers_count": len(peers),
"services": services_status, "services": services_status,
@@ -397,6 +398,8 @@ def update_config():
# Handle identity fields (cell_name, domain, ip_range, wireguard_port) # Handle identity fields (cell_name, domain, ip_range, wireguard_port)
identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'} identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'}
identity_updates = {k: v for k, v in data.items() if k in identity_keys} identity_updates = {k: v for k, v in data.items() if k in identity_keys}
# Capture old identity BEFORE saving, for apply_cell_name comparison
old_identity = dict(config_manager.configs.get('_identity', {}))
if identity_updates: if identity_updates:
stored = config_manager.configs.get('_identity', {}) stored = config_manager.configs.get('_identity', {})
stored.update(identity_updates) stored.update(identity_updates)
@@ -439,6 +442,15 @@ def update_config():
all_restarted.extend(net_result.get('restarted', [])) all_restarted.extend(net_result.get('restarted', []))
all_warnings.extend(net_result.get('warnings', [])) all_warnings.extend(net_result.get('warnings', []))
# Apply cell name change to DNS hostname record
if identity_updates.get('cell_name'):
old_name = old_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
new_name = identity_updates['cell_name']
if old_name != new_name:
cn_result = network_manager.apply_cell_name(old_name, new_name)
all_restarted.extend(cn_result.get('restarted', []))
all_warnings.extend(cn_result.get('warnings', []))
logger.info(f"Updated config, restarted: {all_restarted}") logger.info(f"Updated config, restarted: {all_restarted}")
return jsonify({ return jsonify({
"message": "Configuration updated and applied", "message": "Configuration updated and applied",
+30
View File
@@ -423,6 +423,36 @@ class NetworkManager(BaseServiceManager):
return {'restarted': restarted, 'warnings': warnings} return {'restarted': restarted, 'warnings': warnings}
def apply_cell_name(self, old_name: str, new_name: str) -> Dict[str, Any]:
"""Update the cell hostname record in the primary DNS zone file."""
restarted = []
warnings = []
if not old_name or not new_name or old_name == new_name:
return {'restarted': restarted, 'warnings': warnings}
try:
dns_data = os.path.join(self.data_dir, 'dns')
if os.path.isdir(dns_data):
for fname in os.listdir(dns_data):
if fname.endswith('.zone') and 'local' not in fname:
zone_file = os.path.join(dns_data, fname)
with open(zone_file) as f:
content = f.read()
# Replace hostname record: old_name IN A ...
import re
content = re.sub(
rf'^{re.escape(old_name)}(\s+IN\s+A\s+)',
f'{new_name}\\1',
content, flags=re.MULTILINE
)
with open(zone_file, 'w') as f:
f.write(content)
break
self._reload_dns_service()
restarted.append('cell-dns (reloaded)')
except Exception as e:
warnings.append(f"cell_name DNS update failed: {e}")
return {'restarted': restarted, 'warnings': warnings}
def test_dns_resolution(self, domain: str) -> Dict: def test_dns_resolution(self, domain: str) -> Dict:
"""Test DNS resolution for a domain using Python socket.""" """Test DNS resolution for a domain using Python socket."""
import socket import socket
+45
View File
@@ -335,6 +335,51 @@ class TestNetworkManagerApply(unittest.TestCase):
"Corefile must not keep old 'cell' zone block") "Corefile must not keep old 'cell' zone block")
class TestNetworkManagerApplyCellName(unittest.TestCase):
"""apply_cell_name updates the DNS zone hostname record."""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.data_dir = os.path.join(self.test_dir, 'data')
self.config_dir = os.path.join(self.test_dir, 'config')
os.makedirs(os.path.join(self.data_dir, 'dns'), exist_ok=True)
os.makedirs(os.path.join(self.config_dir, 'dhcp'), exist_ok=True)
os.makedirs(os.path.join(self.config_dir, 'ntp'), exist_ok=True)
with open(os.path.join(self.config_dir, 'dhcp', 'dnsmasq.conf'), 'w') as f:
f.write('domain=cell\n')
with open(os.path.join(self.config_dir, 'ntp', 'chrony.conf'), 'w') as f:
f.write('server pool.ntp.org iburst\n')
# Create a zone file with old cell name
with open(os.path.join(self.data_dir, 'dns', 'cell.zone'), 'w') as f:
f.write('$ORIGIN cell.\n$TTL 300\n'
'@ IN SOA ns1.cell. admin.cell. 2024010101 3600 900 604800 300\n'
'ns1 IN A 172.20.0.3\n'
'mycell IN A 172.20.0.2\n'
'@ IN A 172.20.0.2\n')
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from network_manager import NetworkManager
self.nm = NetworkManager(self.data_dir, self.config_dir)
def tearDown(self):
shutil.rmtree(self.test_dir)
@patch('subprocess.run')
def test_apply_cell_name_renames_host_record(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
result = self.nm.apply_cell_name('mycell', 'newcell')
zone = open(os.path.join(self.data_dir, 'dns', 'cell.zone')).read()
self.assertIn('newcell IN A 172.20.0.2', zone)
self.assertNotIn('mycell IN A', zone)
self.assertIn('cell-dns', ' '.join(result['restarted']))
@patch('subprocess.run')
def test_apply_cell_name_noop_when_same(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
result = self.nm.apply_cell_name('mycell', 'mycell')
self.assertEqual(result['restarted'], [])
mock_run.assert_not_called()
class TestEmailManagerApply(unittest.TestCase): class TestEmailManagerApply(unittest.TestCase):
"""Test email_manager.apply_config writes mailserver.env correctly.""" """Test email_manager.apply_config writes mailserver.env correctly."""
+3
View File
@@ -16,6 +16,7 @@ import {
Settings as SettingsIcon Settings as SettingsIcon
} from 'lucide-react'; } from 'lucide-react';
import { healthAPI } from './services/api'; import { healthAPI } from './services/api';
import { ConfigProvider } from './contexts/ConfigContext';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Peers from './pages/Peers'; import Peers from './pages/Peers';
@@ -81,6 +82,7 @@ function App() {
return ( return (
<Router> <Router>
<ConfigProvider>
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Sidebar navigation={navigation} isOnline={isOnline} /> <Sidebar navigation={navigation} isOnline={isOnline} />
@@ -126,6 +128,7 @@ function App() {
</main> </main>
</div> </div>
</div> </div>
</ConfigProvider>
</Router> </Router>
); );
} }
+22
View File
@@ -0,0 +1,22 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { cellAPI } from '../services/api';
const ConfigContext = createContext({ domain: 'cell', cell_name: 'mycell' });
export function ConfigProvider({ children }) {
const [config, setConfig] = useState({ domain: 'cell', cell_name: 'mycell' });
const refresh = useCallback(() => {
cellAPI.getConfig().then(r => setConfig(r.data)).catch(() => {});
}, []);
useEffect(() => { refresh(); }, [refresh]);
return (
<ConfigContext.Provider value={{ ...config, refresh }}>
{children}
</ConfigContext.Provider>
);
}
export const useConfig = () => useContext(ConfigContext);
+9 -7
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Calendar as CalendarIcon, Users, Wifi, Copy, CheckCheck } from 'lucide-react'; import { Calendar as CalendarIcon, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { calendarAPI } from '../services/api'; import { calendarAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
const CELL_HOST = 'calendar.cell';
const CELL_IP = '172.20.0.21'; const CELL_IP = '172.20.0.21';
function CopyButton({ text }) { function CopyButton({ text }) {
@@ -32,6 +32,8 @@ function InfoRow({ label, value }) {
} }
function Calendar() { function Calendar() {
const { domain = 'cell' } = useConfig();
const cellHost = `calendar.${domain}`;
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -81,9 +83,9 @@ function Calendar() {
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.) Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
</p> </p>
<div className="bg-gray-50 rounded-lg px-4 py-2"> <div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server URL" value={`http://${CELL_HOST}`} /> <InfoRow label="Server URL" value={`http://${cellHost}`} />
<InfoRow label="CalDAV path" value={`http://${CELL_HOST}/`} /> <InfoRow label="CalDAV path" value={`http://${cellHost}/`} />
<InfoRow label="CardDAV path" value={`http://${CELL_HOST}/`} /> <InfoRow label="CardDAV path" value={`http://${cellHost}/`} />
<InfoRow label="Port" value="80" /> <InfoRow label="Port" value="80" />
<InfoRow label="Direct IP" value={CELL_IP} /> <InfoRow label="Direct IP" value={CELL_IP} />
<InfoRow label="Protocol" value="HTTP (CalDAV/CardDAV)" /> <InfoRow label="Protocol" value="HTTP (CalDAV/CardDAV)" />
@@ -104,7 +106,7 @@ function Calendar() {
<p className="font-medium text-gray-900 mb-1">iOS (Settings Calendar Accounts)</p> <p className="font-medium text-gray-900 mb-1">iOS (Settings Calendar Accounts)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600"> <ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Add Account Other Add CalDAV Account</li> <li>Add Account Other Add CalDAV Account</li>
<li>Server: <span className="font-mono">calendar.cell</span></li> <li>Server: <span className="font-mono">{cellHost}</span></li>
<li>Enter username &amp; password</li> <li>Enter username &amp; password</li>
<li>For contacts: Add CardDAV Account, same server</li> <li>For contacts: Add CardDAV Account, same server</li>
</ol> </ol>
@@ -113,7 +115,7 @@ function Calendar() {
<p className="font-medium text-gray-900 mb-1">Android (DAVx⁵ app)</p> <p className="font-medium text-gray-900 mb-1">Android (DAVx⁵ app)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600"> <ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Install DAVx⁵ from Play Store / F-Droid</li> <li>Install DAVx⁵ from Play Store / F-Droid</li>
<li>Login with URL: <span className="font-mono">http://calendar.cell/</span></li> <li>Login with URL: <span className="font-mono">http://{cellHost}/</span></li>
<li>Select calendars &amp; address books to sync</li> <li>Select calendars &amp; address books to sync</li>
</ol> </ol>
</div> </div>
@@ -121,7 +123,7 @@ function Calendar() {
<p className="font-medium text-gray-900 mb-1">Thunderbird</p> <p className="font-medium text-gray-900 mb-1">Thunderbird</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600"> <ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Calendar New Calendar On the Network</li> <li>Calendar New Calendar On the Network</li>
<li>Format: CalDAV, Location: <span className="font-mono">http://calendar.cell/</span></li> <li>Format: CalDAV, Location: <span className="font-mono">http://{cellHost}/</span></li>
</ol> </ol>
</div> </div>
</div> </div>
+7 -5
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Mail, Users, Wifi, Copy, CheckCheck } from 'lucide-react'; import { Mail, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { emailAPI } from '../services/api'; import { emailAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
const CELL_HOST = 'mail.cell';
const CELL_IP = '172.20.0.23'; const CELL_IP = '172.20.0.23';
function CopyButton({ text }) { function CopyButton({ text }) {
@@ -32,6 +32,8 @@ function InfoRow({ label, value }) {
} }
function Email() { function Email() {
const { domain = 'cell' } = useConfig();
const cellHost = `mail.${domain}`;
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -78,7 +80,7 @@ function Email() {
<h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3> <h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3>
</div> </div>
<div className="bg-gray-50 rounded-lg px-4 py-2"> <div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={CELL_HOST} /> <InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(status?.imap_port ?? 993)} /> <InfoRow label="Port" value={String(status?.imap_port ?? 993)} />
<InfoRow label="Security" value="SSL/TLS" /> <InfoRow label="Security" value="SSL/TLS" />
<InfoRow label="Direct IP" value={CELL_IP} /> <InfoRow label="Direct IP" value={CELL_IP} />
@@ -92,7 +94,7 @@ function Email() {
<h3 className="text-lg font-medium text-gray-900">Outgoing mail (SMTP)</h3> <h3 className="text-lg font-medium text-gray-900">Outgoing mail (SMTP)</h3>
</div> </div>
<div className="bg-gray-50 rounded-lg px-4 py-2"> <div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={CELL_HOST} /> <InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(status?.smtp_port ?? 587)} /> <InfoRow label="Port" value={String(status?.smtp_port ?? 587)} />
<InfoRow label="Security" value="STARTTLS" /> <InfoRow label="Security" value="STARTTLS" />
<InfoRow label="Auth" value="Username + Password" /> <InfoRow label="Auth" value="Username + Password" />
@@ -106,8 +108,8 @@ function Email() {
<h3 className="text-lg font-medium text-gray-900">Webmail</h3> <h3 className="text-lg font-medium text-gray-900">Webmail</h3>
</div> </div>
<div className="bg-gray-50 rounded-lg px-4 py-2"> <div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value="http://mail.cell" /> <InfoRow label="URL" value={`http://mail.${domain}`} />
<InfoRow label="Alt URL" value="http://webmail.cell" /> <InfoRow label="Alt URL" value={`http://webmail.${domain}`} />
<InfoRow label="Direct IP" value={`http://${CELL_IP}`} /> <InfoRow label="Direct IP" value={`http://${CELL_IP}`} />
</div> </div>
<p className="text-xs text-gray-400 mt-3"> <p className="text-xs text-gray-400 mt-3">
+10 -8
View File
@@ -1,10 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { FolderOpen, Users, HardDrive, Wifi, Copy, CheckCheck } from 'lucide-react'; import { FolderOpen, Users, HardDrive, Wifi, Copy, CheckCheck } from 'lucide-react';
import { fileAPI } from '../services/api'; import { fileAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
const FILES_HOST = 'files.cell';
const FILES_IP = '172.20.0.22'; const FILES_IP = '172.20.0.22';
const WEBDAV_HOST = 'webdav.cell';
const WEBDAV_IP = '172.20.0.24'; const WEBDAV_IP = '172.20.0.24';
function CopyButton({ text }) { function CopyButton({ text }) {
@@ -34,6 +33,9 @@ function InfoRow({ label, value }) {
} }
function Files() { function Files() {
const { domain = 'cell' } = useConfig();
const filesHost = `files.${domain}`;
const webdavHost = `webdav.${domain}`;
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -80,7 +82,7 @@ function Files() {
<h3 className="text-lg font-medium text-gray-900">Web file manager</h3> <h3 className="text-lg font-medium text-gray-900">Web file manager</h3>
</div> </div>
<div className="bg-gray-50 rounded-lg px-4 py-2"> <div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`http://${FILES_HOST}`} /> <InfoRow label="URL" value={`http://${filesHost}`} />
<InfoRow label="Direct IP" value={`http://${FILES_IP}`} /> <InfoRow label="Direct IP" value={`http://${FILES_IP}`} />
<InfoRow label="Port" value="80" /> <InfoRow label="Port" value="80" />
</div> </div>
@@ -96,7 +98,7 @@ function Files() {
<h3 className="text-lg font-medium text-gray-900">WebDAV (mount as drive)</h3> <h3 className="text-lg font-medium text-gray-900">WebDAV (mount as drive)</h3>
</div> </div>
<div className="bg-gray-50 rounded-lg px-4 py-2"> <div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`http://${WEBDAV_HOST}`} /> <InfoRow label="URL" value={`http://${webdavHost}`} />
<InfoRow label="Direct IP" value={`http://${WEBDAV_IP}`} /> <InfoRow label="Direct IP" value={`http://${WEBDAV_IP}`} />
<InfoRow label="Port" value="80" /> <InfoRow label="Port" value="80" />
<InfoRow label="Auth" value="Basic (user / password)" /> <InfoRow label="Auth" value="Basic (user / password)" />
@@ -115,19 +117,19 @@ function Files() {
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div> <div>
<p className="font-medium text-gray-900 mb-1">macOS (Finder)</p> <p className="font-medium text-gray-900 mb-1">macOS (Finder)</p>
<p className="text-xs text-gray-600">Go Connect to Server <span className="font-mono">http://webdav.cell</span></p> <p className="text-xs text-gray-600">Go Connect to Server <span className="font-mono">http://{webdavHost}</span></p>
</div> </div>
<div> <div>
<p className="font-medium text-gray-900 mb-1">Windows</p> <p className="font-medium text-gray-900 mb-1">Windows</p>
<p className="text-xs text-gray-600">Map Network Drive <span className="font-mono">\\webdav.cell\DavWWWRoot</span> or use <span className="font-mono">http://webdav.cell</span> in "Connect to a Web Site"</p> <p className="text-xs text-gray-600">Map Network Drive <span className="font-mono">\\{webdavHost}\DavWWWRoot</span> or use <span className="font-mono">http://{webdavHost}</span> in "Connect to a Web Site"</p>
</div> </div>
<div> <div>
<p className="font-medium text-gray-900 mb-1">iOS (Files app)</p> <p className="font-medium text-gray-900 mb-1">iOS (Files app)</p>
<p className="text-xs text-gray-600">Files ... Connect to Server <span className="font-mono">http://webdav.cell</span></p> <p className="text-xs text-gray-600">Files ... Connect to Server <span className="font-mono">http://{webdavHost}</span></p>
</div> </div>
<div> <div>
<p className="font-medium text-gray-900 mb-1">Android</p> <p className="font-medium text-gray-900 mb-1">Android</p>
<p className="text-xs text-gray-600">Use <strong>Solid Explorer</strong> or <strong>FX File Explorer</strong> Add cloud WebDAV</p> <p className="text-xs text-gray-600">Use <strong>Solid Explorer</strong> or <strong>FX File Explorer</strong> Add cloud WebDAV <span className="font-mono">http://{webdavHost}</span></p>
</div> </div>
</div> </div>
</div> </div>
+17 -7
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useConfig } from '../contexts/ConfigContext';
import { import {
Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar,
HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw,
@@ -194,12 +195,16 @@ function EmailForm({ data, onChange }) {
<Field label="Mail Domain"> <Field label="Mail Domain">
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" /> <TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
</Field> </Field>
<Field label="SMTP Port"> <Field label="SMTP Port" hint="Fixed by docker-compose.yml">
<NumberInput value={data.smtp_port} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} /> <TextInput value={data.smtp_port ?? 587} readOnly />
</Field> </Field>
<Field label="IMAP Port"> <Field label="IMAP Port" hint="Fixed by docker-compose.yml">
<NumberInput value={data.imap_port} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} /> <TextInput value={data.imap_port ?? 993} readOnly />
</Field> </Field>
<p className="text-xs text-gray-400">
Ports 587 (SMTP) and 993 (IMAP) are set by docker-compose port bindings and cannot be changed at runtime.
Only <strong>domain</strong> is applied on Save.
</p>
</div> </div>
); );
} }
@@ -207,7 +212,7 @@ function EmailForm({ data, onChange }) {
function CalendarForm({ data, onChange }) { function CalendarForm({ data, onChange }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Field label="CalDAV Port"> <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} /> <NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field> </Field>
<Field label="Data Directory"> <Field label="Data Directory">
@@ -220,8 +225,8 @@ function CalendarForm({ data, onChange }) {
function FilesForm({ data, onChange }) { function FilesForm({ data, onChange }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Field label="WebDAV Port"> <Field label="Internal Port" hint="Fixed by docker-compose.yml">
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} /> <TextInput value={data.port ?? 80} readOnly />
</Field> </Field>
<Field label="Data Directory"> <Field label="Data Directory">
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" /> <TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" />
@@ -229,6 +234,9 @@ function FilesForm({ data, onChange }) {
<Field label="Default Quota (MB)"> <Field label="Default Quota (MB)">
<NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} /> <NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} />
</Field> </Field>
<p className="text-xs text-gray-400">
Clients always connect on port 80 via Caddy reverse proxy, regardless of internal port.
</p>
</div> </div>
); );
} }
@@ -274,6 +282,7 @@ const SERVICE_DEFS = [
function Settings() { function Settings() {
const toasts = useToasts(); const toasts = useToasts();
const { refresh: refreshConfig } = useConfig();
// identity // identity
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '', wireguard_port: 51820 }); const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '', wireguard_port: 51820 });
@@ -334,6 +343,7 @@ function Settings() {
const res = await cellAPI.updateConfig(identity); const res = await cellAPI.updateConfig(identity);
setIdentityDirty(false); setIdentityDirty(false);
_applyResult(res, 'Cell identity'); _applyResult(res, 'Cell identity');
refreshConfig();
} catch { } catch {
toast('Failed to save identity', 'error'); toast('Failed to save identity', 'error');
} finally { } finally {