diff --git a/api/routes/config.py b/api/routes/config.py index 4b65f7a..6a05a72 100644 --- a/api/routes/config.py +++ b/api/routes/config.py @@ -118,6 +118,7 @@ def get_config(): 'vip_webdav': _ips['vip_webdav'], } config['service_configs'] = service_configs + config['installed_services'] = config_manager.get_installed_services() config['domain_mode'] = identity.get('domain_mode', 'lan') config['domain_name'] = identity.get('domain_name', '') config['effective_domain'] = config_manager.get_effective_domain() diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py index 6e7ee71..e39723b 100644 --- a/tests/test_api_endpoints.py +++ b/tests/test_api_endpoints.py @@ -76,13 +76,44 @@ class TestAPIEndpoints(unittest.TestCase): """Test get config endpoint""" response = self.client.get('/api/config') self.assertEqual(response.status_code, 200) - + data = json.loads(response.data) self.assertIn('cell_name', data) self.assertIn('domain', data) self.assertIn('ip_range', data) self.assertIn('wireguard_port', data) + self.assertIn('installed_services', data) + def test_get_config_installed_services_is_dict(self): + """installed_services must be a dict, never a list or primitive""" + response = self.client.get('/api/config') + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertIsInstance(data['installed_services'], dict) + + def test_get_config_installed_services_empty_when_none_installed(self): + """installed_services defaults to empty dict when no services are installed""" + response = self.client.get('/api/config') + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + # Fresh test environment has no installed services + self.assertEqual(data['installed_services'], {}) + + def test_get_config_installed_services_reflects_stored_value(self): + """installed_services in GET /api/config reflects what config_manager returns""" + from app import config_manager + config_manager.configs.setdefault('_identity', {})['installed_services'] = { + 'mailserver': {'status': 'running', 'installed_at': '2026-01-01T00:00:00'} + } + try: + response = self.client.get('/api/config') + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertIn('mailserver', data['installed_services']) + self.assertEqual(data['installed_services']['mailserver']['status'], 'running') + finally: + config_manager.configs.get('_identity', {}).pop('installed_services', None) + def test_update_config_endpoint(self): """Test update config endpoint""" update_data = {'cell_name': 'newcell'} diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 4569f93..41d39de 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,4 +1,4 @@ -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { useState, useEffect, useCallback } from 'react'; import { Home, @@ -30,9 +30,6 @@ import Dashboard from './pages/Dashboard'; import Peers from './pages/Peers'; import NetworkServices from './pages/NetworkServices'; import WireGuard from './pages/WireGuard'; -import Email from './pages/Email'; -import Calendar from './pages/Calendar'; -import Files from './pages/Files'; import Routing from './pages/Routing'; import Logs from './pages/Logs'; import Settings from './pages/Settings'; @@ -43,7 +40,10 @@ import Login from './pages/Login'; import AccountSettings from './pages/AccountSettings'; import PeerDashboard from './pages/PeerDashboard'; import MyServices from './pages/MyServices'; -import Store from './pages/Store'; +import ServicesIndex from './pages/ServicesIndex'; +import EmailPage from './pages/services/EmailPage'; +import CalendarPage from './pages/services/CalendarPage'; +import FilesPage from './pages/services/FilesPage'; import Connectivity from './pages/Connectivity'; import Setup from './pages/Setup'; import SetupGuard from './components/SetupGuard'; @@ -230,29 +230,33 @@ function AppCore() { window.dispatchEvent(new CustomEvent('pic-config-discarded')); }, []); + const coreServiceChildren = [ + { name: 'Email', href: '/services/email', icon: Mail }, + { name: 'Calendar', href: '/services/calendar', icon: CalendarIcon }, + { name: 'Files', href: '/services/files', icon: FolderOpen }, + ]; + const adminNavigation = [ - { name: 'Dashboard', href: '/', icon: Home }, - { name: 'Peers', href: '/peers', icon: Users }, - { name: 'Network Services', href: '/network', icon: Network }, - { name: 'WireGuard', href: '/wireguard', icon: Shield }, - { name: 'Email', href: '/email', icon: Mail }, - { name: 'Calendar', href: '/calendar', icon: CalendarIcon }, - { name: 'Files', href: '/files', icon: FolderOpen }, - { name: 'Routing', href: '/routing', icon: Wifi }, - { name: 'Vault', href: '/vault', icon: Key }, - { name: 'Containers', href: '/containers', icon: Package2 }, - { name: 'Store', href: '/store', icon: Package }, - { name: 'Cell Network', href: '/cell-network', icon: Link2 }, - { name: 'Connectivity', href: '/connectivity', icon: Network }, - { name: 'Logs', href: '/logs', icon: Activity }, - { name: 'Settings', href: '/settings', icon: SettingsIcon }, - { name: 'Account', href: '/account', icon: User }, + { name: 'Dashboard', href: '/', icon: Home }, + { name: 'Peers', href: '/peers', icon: Users }, + { name: 'Network Services', href: '/network', icon: Network }, + { name: 'WireGuard', href: '/wireguard', icon: Shield }, + { name: 'Services', href: '/services', icon: Package, children: coreServiceChildren }, + { name: 'Routing', href: '/routing', icon: Wifi }, + { name: 'Vault', href: '/vault', icon: Key }, + { name: 'Containers', href: '/containers', icon: Package2 }, + { name: 'Cell Network', href: '/cell-network', icon: Link2 }, + { name: 'Connectivity', href: '/connectivity', icon: Network }, + { name: 'Logs', href: '/logs', icon: Activity }, + { name: 'Settings', href: '/settings', icon: SettingsIcon }, + { name: 'Account', href: '/account', icon: User }, ]; const peerNavigation = [ - { name: 'Dashboard', href: '/', icon: Home }, - { name: 'My Services', href: '/my-services', icon: FolderOpen }, - { name: 'Account', href: '/account', icon: User }, + { name: 'Dashboard', href: '/', icon: Home }, + { name: 'My Services', href: '/my-services', icon: Wifi }, + { name: 'Services', href: '/services', icon: Package, children: coreServiceChildren }, + { name: 'Account', href: '/account', icon: User }, ]; const { user } = useAuth(); @@ -339,16 +343,22 @@ function AppCore() { } /> } /> } /> + {/* Service pages — accessible to both admin and peer (role-conditional content inside) */} + } /> + } /> + } /> + } /> + {/* Legacy URL redirects */} + } /> + } /> + } /> + } /> } /> } /> } /> - } /> - } /> - } /> } /> } /> } /> - } /> } /> } /> } /> diff --git a/webui/src/components/FormFields.jsx b/webui/src/components/FormFields.jsx new file mode 100644 index 0000000..5bf2fa6 --- /dev/null +++ b/webui/src/components/FormFields.jsx @@ -0,0 +1,40 @@ +export function Field({ label, children, hint, error }) { + return ( +
+ +
+ {children} + {error &&

{error}

} +
+ {hint && !error && {hint}} +
+ ); +} + +export function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) { + return ( + onChange && onChange(e.target.value)} + placeholder={placeholder} + readOnly={readOnly} + 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' + }`} + /> + ); +} + +export function NumberInput({ value, onChange, min, max }) { + return ( + onChange && onChange(Number(e.target.value))} + className="w-full text-sm border rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-400 bg-white" + /> + ); +} diff --git a/webui/src/components/Sidebar.jsx b/webui/src/components/Sidebar.jsx index 84fab38..4198d9b 100644 --- a/webui/src/components/Sidebar.jsx +++ b/webui/src/components/Sidebar.jsx @@ -1,178 +1,203 @@ -import { useState } from 'react'; -import { Link, useLocation } from 'react-router-dom'; -import { X, LogOut } from 'lucide-react'; -import { clsx } from 'clsx'; -import { useAuth } from '../contexts/AuthContext'; - -function Sidebar({ navigation, isOnline }) { - const [sidebarOpen, setSidebarOpen] = useState(false); - const location = useLocation(); - const auth = useAuth(); - const { logout, user } = auth || {}; - - return ( - <> - {/* Mobile sidebar */} -
-
setSidebarOpen(false)} /> - -
-
-
-

- Personal Internet Cell -

- -
- -
-
-
- - {/* Desktop sidebar */} -
-
-
-

- Personal Internet Cell -

-
- -
-
- - {/* Mobile menu button */} -
- -
- Personal Internet Cell -
-
-
- - {isOnline ? 'Connected' : 'Disconnected'} - -
-
- - ); -} - -export default Sidebar; \ No newline at end of file +import { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { X, LogOut, ChevronDown } from 'lucide-react'; +import { clsx } from 'clsx'; +import { useAuth } from '../contexts/AuthContext'; + +function NavItem({ item, location, onNavigate }) { + const hasChildren = !!(item.children && item.children.length); + const childActive = hasChildren && item.children.some( + (c) => location.pathname === c.href || location.pathname.startsWith(c.href + '/') + ); + const selfActive = location.pathname === item.href; + const groupActive = selfActive || childActive; + + const [open, setOpen] = useState(() => childActive); + + if (hasChildren) { + return ( +
  • +
    + { setOpen(true); onNavigate?.(); }} + className={clsx( + groupActive ? 'bg-primary-50 text-primary-600' : 'text-gray-700 hover:text-primary-600 hover:bg-primary-50', + 'flex-1 flex gap-x-3 rounded-l-md p-2 text-sm leading-6 font-semibold' + )} + > +
    + {open && ( +
      + {item.children.map((child) => { + const active = location.pathname === child.href || location.pathname.startsWith(child.href + '/'); + return ( +
    • + +
    • + ); + })} +
    + )} +
  • + ); + } + + return ( +
  • + +
  • + ); +} + +function NavList({ navigation, location, onNavigate }) { + return ( +
      + {navigation.map((item) => ( + + ))} +
    + ); +} + +function Sidebar({ navigation, isOnline }) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const location = useLocation(); + const auth = useAuth(); + const { logout, user } = auth || {}; + + return ( + <> + {/* Mobile sidebar */} +
    +
    setSidebarOpen(false)} /> +
    +
    +
    +

    Personal Internet Cell

    + +
    + +
    +
    +
    + + {/* Desktop sidebar */} +
    +
    +
    +

    Personal Internet Cell

    +
    + +
    +
    + + {/* Mobile menu button */} +
    + +
    Personal Internet Cell
    +
    +
    + {isOnline ? 'Connected' : 'Disconnected'} +
    +
    + + ); +} + +export default Sidebar; diff --git a/webui/src/pages/ServicesIndex.jsx b/webui/src/pages/ServicesIndex.jsx new file mode 100644 index 0000000..365d473 --- /dev/null +++ b/webui/src/pages/ServicesIndex.jsx @@ -0,0 +1,380 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { + Package, + Download, + Trash2, + RefreshCw, + CheckCircle, + AlertCircle, + Mail, + Calendar as CalendarIcon, + FolderOpen, + ArrowRight, +} from 'lucide-react'; +import { storeAPI } from '../services/api'; + +function toastEvent(msg, type = 'success') { + window.dispatchEvent(new CustomEvent('store-toast', { detail: { msg, type } })); +} + +function Toast({ toasts }) { + return ( +
    + {toasts.map((t) => ( +
    + {t.type === 'success' ? : } + {t.msg} +
    + ))} +
    + ); +} + +function useToasts() { + const [toasts, setToasts] = useState([]); + useEffect(() => { + const handler = (e) => { + const id = Date.now(); + setToasts((prev) => [...prev, { ...e.detail, id }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); + }; + window.addEventListener('store-toast', handler); + return () => window.removeEventListener('store-toast', handler); + }, []); + return toasts; +} + +function SkeletonCard() { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +} + +function ConfirmRemoveDialog({ service, onConfirm, onCancel }) { + const [purge, setPurge] = useState(false); + return ( +
    +
    +
    + +
    +

    Remove {service.name}?

    +

    + The service will be stopped and uninstalled. By default, data is kept on disk. +

    +
    +
    + +
    + + +
    +
    +
    + ); +} + +function StoreServiceCard({ service, isInstalled, installedInfo, onInstall, onRemove, installing, removing }) { + return ( +
    +
    +
    + + {service.name} +
    + {isInstalled && ( + + + Installed + + )} +
    +

    {service.description || 'No description available.'}

    +
    + {service.version && v{service.version}} + {service.author && by {service.author}} + {isInstalled && installedInfo?.installed_at && ( + Installed {new Date(installedInfo.installed_at).toLocaleDateString()} + )} +
    +
    + {isInstalled ? ( + + ) : ( + + )} +
    +
    + ); +} + +// Cards for core services (always present, not removable) +const CORE_SERVICES = [ + { name: 'Email', href: '/services/email', icon: Mail, desc: 'Postfix (SMTP) + Dovecot (IMAP) email server' }, + { name: 'Calendar & Contacts', href: '/services/calendar', icon: CalendarIcon, desc: 'Radicale CalDAV / CardDAV server' }, + { name: 'File Storage', href: '/services/files', icon: FolderOpen, desc: 'FileGator browser UI + WebDAV network drive' }, +]; + +function CoreServiceCard({ svc }) { + return ( +
    +
    +
    + + {svc.name} +
    + + Core + +
    +

    {svc.desc}

    +
    + + Manage + + +
    +
    + ); +} + +export default function ServicesIndex() { + const toasts = useToasts(); + + const [services, setServices] = useState([]); + const [installed, setInstalled] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [opState, setOpState] = useState({}); + const [removeTarget, setRemoveTarget] = useState(null); + + const loadStore = useCallback(async () => { + setLoadError(null); + try { + const res = await storeAPI.listServices(); + const data = res.data || {}; + setServices(Array.isArray(data.available) ? data.available : []); + setInstalled(data.installed && typeof data.installed === 'object' ? data.installed : {}); + } catch (err) { + setLoadError( + err.response?.data?.error || + err.response?.data?.message || + 'Could not load the service store. Check that the API is reachable.' + ); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { loadStore(); }, [loadStore]); + + const handleRefresh = async () => { + setRefreshing(true); + try { + await storeAPI.refreshIndex(); + toastEvent('Store index refreshed'); + await loadStore(); + } catch (err) { + toastEvent(err.response?.data?.error || 'Failed to refresh store index', 'error'); + } finally { + setRefreshing(false); + } + }; + + const handleInstall = async (service) => { + setOpState((s) => ({ ...s, [service.id]: 'installing' })); + try { + await storeAPI.installService(service.id); + toastEvent(`${service.name} installed successfully`); + await loadStore(); + } catch (err) { + toastEvent(err.response?.data?.error || `Failed to install ${service.name}`, 'error'); + } finally { + setOpState((s) => ({ ...s, [service.id]: null })); + } + }; + + const handleRemoveConfirm = async (purge) => { + const service = removeTarget; + setRemoveTarget(null); + setOpState((s) => ({ ...s, [service.id]: 'removing' })); + try { + await storeAPI.removeService(service.id, purge); + toastEvent(`${service.name} removed`); + await loadStore(); + } catch (err) { + toastEvent(err.response?.data?.error || `Failed to remove ${service.name}`, 'error'); + } finally { + setOpState((s) => ({ ...s, [service.id]: null })); + } + }; + + const installedServices = services.filter((s) => installed[s.id]); + const availableServices = services.filter((s) => !installed[s.id]); + + return ( +
    + + +
    +
    +

    Services

    +

    + Manage built-in services and browse optional add-ons +

    +
    + +
    + + {/* Core services — always shown */} +
    +

    + Built-in ({CORE_SERVICES.length}) +

    +
    + {CORE_SERVICES.map((svc) => )} +
    +
    + + {/* Add-ons from store */} + {isLoading && ( +
    +
    +
    + {[1, 2, 3].map((n) => )} +
    +
    + )} + + {!isLoading && loadError && ( +
    +
    + +
    +

    Failed to load add-ons

    +

    {loadError}

    +
    + +
    +
    + )} + + {!isLoading && !loadError && ( + <> + {installedServices.length > 0 && ( +
    +

    + Installed Add-ons ({installedServices.length}) +

    +
    + {installedServices.map((svc) => ( + setRemoveTarget(s)} + installing={opState[svc.id] === 'installing'} + removing={opState[svc.id] === 'removing'} + /> + ))} +
    +
    + )} + +
    +

    + {installedServices.length > 0 ? 'Available Add-ons' : 'Available Add-ons'} + {availableServices.length > 0 && ` (${availableServices.length})`} +

    + {availableServices.length === 0 && installedServices.length === 0 && ( +
    + +

    No add-ons in the store yet

    +

    Click "Refresh" to check for available add-ons.

    +
    + )} + {availableServices.length === 0 && installedServices.length > 0 && ( +
    + +

    All available add-ons are installed.

    +
    + )} + {availableServices.length > 0 && ( +
    + {availableServices.map((svc) => ( + setRemoveTarget(s)} + installing={opState[svc.id] === 'installing'} + removing={opState[svc.id] === 'removing'} + /> + ))} +
    + )} +
    + + )} + + {removeTarget && ( + setRemoveTarget(null)} + /> + )} +
    + ); +} diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index cd26b72..c90717f 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -2,12 +2,13 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useConfig } from '../contexts/ConfigContext'; import { useDraftConfig } from '../contexts/DraftConfigContext'; import { - Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, - HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, + Settings as SettingsIcon, Server, Shield, Network, + GitBranch, Archive, Upload, Download, Trash2, RotateCcw, ChevronDown, ChevronRight, CheckCircle, XCircle, RefreshCw, Lock, FolderDown, X, Globe, Loader } from 'lucide-react'; import { cellAPI, ddnsAPI } from '../services/api'; +import { PORT_CONFLICT_FIELDS, detectPortConflicts } from '../utils/serviceConfig'; // ── constants ──────────────────────────────────────────────────────────────── @@ -90,38 +91,6 @@ function isValidPort(v) { return Number.isInteger(n) && n >= 1 && n <= 65535; } -// Mirror of api/port_registry.py PORT_FIELDS — must stay in sync -const PORT_CONFLICT_FIELDS = { - network: ['dns_port'], - wireguard: ['port'], - email: ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'], - calendar: ['port'], - files: ['port', 'manager_port'], -}; - -function detectPortConflicts(configs) { - const portMap = {}; - for (const [section, fields] of Object.entries(PORT_CONFLICT_FIELDS)) { - const sec = configs[section] || {}; - for (const field of fields) { - const raw = sec[field]; - if (raw === undefined || raw === null || raw === '') continue; - const n = parseInt(raw, 10); - if (isNaN(n)) continue; - (portMap[n] = portMap[n] || []).push([section, field]); - } - } - const result = {}; - for (const [port, slots] of Object.entries(portMap)) { - if (slots.length < 2) continue; - const others = slots.map(([s, f]) => `${s}.${f}`).join(', '); - for (const [section, field] of slots) { - result[`${section}|${field}`] = `Port ${port} conflicts with ${others}`; - } - } - return result; -} - function isValidIp(v) { if (!v || !v.trim()) return false; const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); @@ -176,12 +145,6 @@ function validateServiceConfig(key, data) { port('port'); if (data.address && !isValidIpCidr(data.address)) errors.address = E_CIDR; } - if (key === 'email') { - port('smtp_port'); port('submission_port'); port('imap_port'); port('webmail_port'); - if (data.domain && !isValidDomain(data.domain)) errors.domain = E_DOMAIN; - } - if (key === 'calendar') port('port'); - if (key === 'files') { port('port'); port('manager_port'); } return errors; } @@ -331,60 +294,6 @@ function WireguardForm({ data, onChange, errors = {} }) { ); } -function EmailForm({ data, onChange, errors = {} }) { - return ( -
    - - onChange({ ...data, domain: v })} placeholder="mail.example.com" /> - - - onChange({ ...data, smtp_port: v })} min={1} max={65535} /> - - - onChange({ ...data, submission_port: v })} min={1} max={65535} /> - - - onChange({ ...data, imap_port: v })} min={1} max={65535} /> - - - onChange({ ...data, webmail_port: v })} min={1} max={65535} /> - -
    - ); -} - -function CalendarForm({ data, onChange, errors = {} }) { - return ( -
    - - onChange({ ...data, port: v })} min={1} max={65535} /> - - - onChange({ ...data, data_dir: v })} placeholder="/app/data/radicale" /> - -
    - ); -} - -function FilesForm({ data, onChange, errors = {} }) { - return ( -
    - - onChange({ ...data, port: v })} min={1} max={65535} /> - - - onChange({ ...data, manager_port: v })} min={1} max={65535} /> - - - onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" /> - - - onChange({ ...data, quota: v })} min={0} /> - -
    - ); -} - function RoutingForm({ data, onChange }) { return (
    @@ -411,15 +320,12 @@ function VaultForm({ data, onChange }) { ); } -// service config meta +// Service configs shown in Settings — email/calendar/files moved to their own pages const SERVICE_DEFS = [ - { key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } }, - { key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } }, - { key: 'email', label: 'Email (SMTP/IMAP)', icon: Mail, Form: EmailForm, defaults: { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 } }, - { key: 'calendar', label: 'Calendar (CalDAV)', icon: Calendar, Form: CalendarForm, defaults: { port: 5232, data_dir: '' } }, - { key: 'files', label: 'Files (WebDAV)', icon: HardDrive, Form: FilesForm, defaults: { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 } }, - { key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } }, - { key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } }, + { key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } }, + { key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } }, + { key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } }, + { key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } }, ]; // ── Main component ──────────────────────────────────────────────────────────── diff --git a/webui/src/pages/services/CalendarPage.jsx b/webui/src/pages/services/CalendarPage.jsx new file mode 100644 index 0000000..eb0cab7 --- /dev/null +++ b/webui/src/pages/services/CalendarPage.jsx @@ -0,0 +1,301 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { Calendar as CalendarIcon, Users, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react'; +import { calendarAPI, cellAPI, peerAPI } from '../../services/api'; +import { useConfig } from '../../contexts/ConfigContext'; +import { useDraftConfig } from '../../contexts/DraftConfigContext'; +import { useAuth } from '../../contexts/AuthContext'; +import { Field, TextInput, NumberInput } from '../../components/FormFields'; +import { detectPortConflicts, validateCalendarConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; + +const CAL_DEFAULTS = { port: 5232, data_dir: '' }; + +function CopyButton({ text }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function InfoRow({ label, value }) { + return ( +
    + {label} +
    + {value} + +
    +
    + ); +} + +function AdminConfigSection({ calCfg, onChange, errors, portConflicts, saving }) { + return ( +
    +
    + +

    Service Configuration

    + {saving && Saving…} +
    +
    + + onChange({ ...calCfg, port: v })} min={1} max={65535} /> + + + onChange({ ...calCfg, data_dir: v })} placeholder="/app/data/radicale" /> + +
    +
    + ); +} + +function Toast({ msg, type }) { + if (!msg) return null; + return ( +
    + {type === 'error' ? : } + {msg} +
    + ); +} + +export default function CalendarPage() { + const { user } = useAuth(); + const isAdmin = user?.role === 'admin'; + const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); + const draftConfig = useDraftConfig(); + + const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain; + const proto = domain_mode === 'lan' ? 'http' : 'https'; + const cellHost = `calendar.${svcDomain}`; + const calendarIp = service_ips.vip_calendar || '172.20.0.21'; + const dnsIp = service_ips.dns || '172.20.0.3'; + const calendarPort = service_configs.calendar?.port ?? 5232; + + const [calCfg, setCalCfg] = useState({ ...CAL_DEFAULTS }); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [users, setUsers] = useState([]); + const [status, setStatus] = useState(null); + const [toast, setToast] = useState(null); + const [peerData, setPeerData] = useState(null); + + useEffect(() => { + if (service_configs.calendar) setCalCfg({ ...CAL_DEFAULTS, ...service_configs.calendar }); + }, [service_configs.calendar]); + + useEffect(() => { + if (!isAdmin) { + peerAPI.services().then(r => setPeerData(r.data)).catch(() => {}); + return; + } + calendarAPI.getUsers().then(r => setUsers(r.data)).catch(() => {}); + calendarAPI.getStatus().then(r => setStatus(r.data)).catch(() => {}); + }, [isAdmin]); + + const showToast = (msg, type = 'success') => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); + }; + + const errors = useMemo(() => validateCalendarConfig(calCfg), [calCfg]); + const portConflicts = useMemo( + () => detectPortConflicts({ ...service_configs, calendar: calCfg }), + [calCfg, service_configs] + ); + const hasErrors = useMemo( + () => Object.keys(errors).length > 0 || !!portConflicts['calendar|port'], + [errors, portConflicts] + ); + + const calCfgRef = useRef(calCfg); + useEffect(() => { calCfgRef.current = calCfg; }, [calCfg]); + const dirtyRef = useRef(dirty); + useEffect(() => { dirtyRef.current = dirty; }, [dirty]); + const hasErrorsRef = useRef(hasErrors); + useEffect(() => { hasErrorsRef.current = hasErrors; }, [hasErrors]); + + const save = useCallback(async () => { + if (!dirtyRef.current || hasErrorsRef.current) return; + setSaving(true); + try { + await cellAPI.updateConfig({ calendar: calCfgRef.current }); + setDirty(false); + draftConfig?.setDirty('calendar', false); + refreshConfig(); + } catch (err) { + showToast(err?.response?.data?.error || 'Failed to save calendar config', 'error'); + } finally { + setSaving(false); + } + }, [draftConfig, refreshConfig]); + + const saveRef = useRef(save); + useEffect(() => { saveRef.current = save; }, [save]); + + useEffect(() => { + if (!draftConfig) return; + draftConfig.registerFlusher('calendar', () => saveRef.current()); + }, [draftConfig]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!dirty || hasErrors) return; + const t = setTimeout(() => saveRef.current(), 800); + return () => clearTimeout(t); + }, [calCfg, dirty, hasErrors]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleChange = (cfg) => { + setCalCfg(cfg); + setDirty(true); + draftConfig?.setDirty('calendar', true); + }; + + return ( +
    + + +
    +

    Calendar & Contacts

    +

    Radicale CalDAV / CardDAV server

    +
    + +
    + {/* Connection info */} +
    +
    + +

    Connect your device

    +
    +

    + Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.) +

    +
    + + + + + + +
    +

    + Requires VPN. DNS must be set to {dnsIp}. +

    +
    + + {/* Quick setup guide */} +
    +
    + +

    Quick setup guide

    +
    +
    +
    +

    iOS (Settings → Calendar → Accounts)

    +
      +
    1. Add Account → Other → Add CalDAV Account
    2. +
    3. Server: {cellHost}
    4. +
    5. Enter username & password
    6. +
    7. For contacts: Add CardDAV Account, same server
    8. +
    +
    +
    +

    Android (DAVx⁵ app)

    +
      +
    1. Install DAVx⁵ from Play Store / F-Droid
    2. +
    3. Login with URL: {proto}://{cellHost}/
    4. +
    5. Select calendars & address books to sync
    6. +
    +
    +
    +

    Thunderbird

    +
      +
    1. Calendar → New Calendar → On the Network
    2. +
    3. Format: CalDAV, Location: {proto}://{cellHost}/
    4. +
    +
    +
    +
    + + {/* Status — admin only */} + {isAdmin && ( +
    +
    + +

    Service Status

    +
    + {status ? ( +
    +
    + Radicale: + Running +
    +
    + CalDAV: + Active +
    +
    + CardDAV: + Active +
    +
    + ) : ( +

    Status unavailable

    + )} +
    + )} + + {/* Peer credentials */} + {!isAdmin && peerData?.caldav && ( +
    +
    + +

    Your Account

    +
    +
    + +
    +

    Authenticate with your dashboard username and password.

    +
    + )} + + {/* Admin users list */} + {isAdmin && ( +
    +
    + +

    Calendar Users

    +
    + {users.length > 0 ? ( +
    + {users.map((u, i) => ( +
    + {u.username} + {u.calendars || 0} calendars +
    + ))} +
    + ) : ( +

    No calendar users configured

    + )} +
    + )} +
    + + {isAdmin && ( + + )} +
    + ); +} diff --git a/webui/src/pages/services/EmailPage.jsx b/webui/src/pages/services/EmailPage.jsx new file mode 100644 index 0000000..f29c75f --- /dev/null +++ b/webui/src/pages/services/EmailPage.jsx @@ -0,0 +1,306 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { Mail, Users, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react'; +import { emailAPI, cellAPI, peerAPI } from '../../services/api'; +import { useConfig } from '../../contexts/ConfigContext'; +import { useDraftConfig } from '../../contexts/DraftConfigContext'; +import { useAuth } from '../../contexts/AuthContext'; +import { Field, TextInput, NumberInput } from '../../components/FormFields'; +import { detectPortConflicts, validateEmailConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; + +const EMAIL_DEFAULTS = { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 }; + +function CopyButton({ text }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function InfoRow({ label, value }) { + return ( +
    + {label} +
    + {value} + +
    +
    + ); +} + +function AdminConfigSection({ emailCfg, onChange, errors, portConflicts, saving }) { + const conflictFor = (f) => portConflicts[`email|${f}`]; + return ( +
    +
    + +

    Service Configuration

    + {saving && Saving…} +
    +
    + + onChange({ ...emailCfg, domain: v })} placeholder="mail.example.com" /> + + + onChange({ ...emailCfg, smtp_port: v })} min={1} max={65535} /> + + + onChange({ ...emailCfg, submission_port: v })} min={1} max={65535} /> + + + onChange({ ...emailCfg, imap_port: v })} min={1} max={65535} /> + + + onChange({ ...emailCfg, webmail_port: v })} min={1} max={65535} /> + +
    +
    + ); +} + +function Toast({ msg, type }) { + if (!msg) return null; + return ( +
    + {type === 'error' ? : } + {msg} +
    + ); +} + +export default function EmailPage() { + const { user } = useAuth(); + const isAdmin = user?.role === 'admin'; + const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); + const draftConfig = useDraftConfig(); + + const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain; + const proto = domain_mode === 'lan' ? 'http' : 'https'; + const cellHost = `mail.${svcDomain}`; + const mailIp = service_ips.vip_mail || '172.20.0.23'; + const dnsIp = service_ips.dns || '172.20.0.3'; + const emailCfgServer = service_configs.email || {}; + const imapPort = emailCfgServer.imap_port ?? 993; + const smtpPort = emailCfgServer.smtp_port ?? 25; + const webmailPort = emailCfgServer.webmail_port ?? 8888; + + // Admin state + const [emailCfg, setEmailCfg] = useState({ ...EMAIL_DEFAULTS }); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [users, setUsers] = useState([]); + const [status, setStatus] = useState(null); + const [toast, setToast] = useState(null); + + // Peer state + const [peerData, setPeerData] = useState(null); + + useEffect(() => { + if (service_configs.email) setEmailCfg({ ...EMAIL_DEFAULTS, ...service_configs.email }); + }, [service_configs.email]); + + useEffect(() => { + if (!isAdmin) { + peerAPI.services().then(r => setPeerData(r.data)).catch(() => {}); + return; + } + emailAPI.getUsers().then(r => setUsers(r.data)).catch(() => {}); + emailAPI.getStatus().then(r => setStatus(r.data)).catch(() => {}); + }, [isAdmin]); + + const showToast = (msg, type = 'success') => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); + }; + + const errors = useMemo(() => validateEmailConfig(emailCfg), [emailCfg]); + + const portConflicts = useMemo( + () => detectPortConflicts({ ...service_configs, email: emailCfg }), + [emailCfg, service_configs] + ); + + const hasErrors = useMemo( + () => Object.keys(errors).length > 0 || + PORT_CONFLICT_FIELDS.email.some(f => portConflicts[`email|${f}`]), + [errors, portConflicts] + ); + + // Refs so flusher closure always sees current values after navigation + const emailCfgRef = useRef(emailCfg); + useEffect(() => { emailCfgRef.current = emailCfg; }, [emailCfg]); + const dirtyRef = useRef(dirty); + useEffect(() => { dirtyRef.current = dirty; }, [dirty]); + const hasErrorsRef = useRef(hasErrors); + useEffect(() => { hasErrorsRef.current = hasErrors; }, [hasErrors]); + + const save = useCallback(async () => { + if (!dirtyRef.current || hasErrorsRef.current) return; + setSaving(true); + try { + await cellAPI.updateConfig({ email: emailCfgRef.current }); + setDirty(false); + draftConfig?.setDirty('email', false); + refreshConfig(); + } catch (err) { + showToast(err?.response?.data?.error || 'Failed to save email config', 'error'); + } finally { + setSaving(false); + } + }, [draftConfig, refreshConfig]); + + const saveRef = useRef(save); + useEffect(() => { saveRef.current = save; }, [save]); + + // Register flusher without cleanup so it persists when user navigates away mid-edit + useEffect(() => { + if (!draftConfig) return; + draftConfig.registerFlusher('email', () => saveRef.current()); + }, [draftConfig]); // eslint-disable-line react-hooks/exhaustive-deps + + // Debounced auto-save + useEffect(() => { + if (!dirty || hasErrors) return; + const t = setTimeout(() => saveRef.current(), 800); + return () => clearTimeout(t); + }, [emailCfg, dirty, hasErrors]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleChange = (cfg) => { + setEmailCfg(cfg); + setDirty(true); + draftConfig?.setDirty('email', true); + }; + + return ( +
    + + +
    +

    Email Services

    +

    Postfix (SMTP) + Dovecot (IMAP)

    +
    + +
    + {/* IMAP */} +
    +
    + +

    Incoming mail (IMAP)

    +
    +
    + + + + +
    +
    + + {/* SMTP */} +
    +
    + +

    Outgoing mail (SMTP)

    +
    +
    + + + + +
    +
    + + {/* Webmail */} +
    +
    + +

    Webmail

    +
    +
    + + + + +
    +

    + Requires VPN + DNS set to {dnsIp}. +

    +
    + + {/* Status — admin only */} + {isAdmin && ( +
    +
    + +

    Service Status

    +
    + {status ? ( +
    +
    + Postfix (SMTP): + Running +
    +
    + Dovecot (IMAP): + Running +
    +
    + ) : ( +

    Status unavailable

    + )} +
    + )} + + {/* Peer credentials */} + {!isAdmin && peerData?.email && ( +
    +
    + +

    Your Account

    +
    +
    + + +
    +

    Authenticate with your dashboard username and password.

    +
    + )} + + {/* Admin users list */} + {isAdmin && users.length > 0 && ( +
    +
    + +

    Email Accounts

    +
    +
    + {users.map((u, i) => ( +
    + {u.username} + {u.domain} +
    + ))} +
    +
    + )} +
    + + {/* Admin config form */} + {isAdmin && ( + + )} +
    + ); +} diff --git a/webui/src/pages/services/FilesPage.jsx b/webui/src/pages/services/FilesPage.jsx new file mode 100644 index 0000000..ab28181 --- /dev/null +++ b/webui/src/pages/services/FilesPage.jsx @@ -0,0 +1,305 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { FolderOpen, Users, HardDrive, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react'; +import { fileAPI, cellAPI, peerAPI } from '../../services/api'; +import { useConfig } from '../../contexts/ConfigContext'; +import { useDraftConfig } from '../../contexts/DraftConfigContext'; +import { useAuth } from '../../contexts/AuthContext'; +import { Field, TextInput, NumberInput } from '../../components/FormFields'; +import { detectPortConflicts, validateFilesConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; + +const FILES_DEFAULTS = { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 }; + +function CopyButton({ text }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function InfoRow({ label, value }) { + return ( +
    + {label} +
    + {value} + +
    +
    + ); +} + +function AdminConfigSection({ filesCfg, onChange, errors, portConflicts, saving }) { + const cf = (f) => portConflicts[`files|${f}`]; + return ( +
    +
    + +

    Service Configuration

    + {saving && Saving…} +
    +
    + + onChange({ ...filesCfg, port: v })} min={1} max={65535} /> + + + onChange({ ...filesCfg, manager_port: v })} min={1} max={65535} /> + + + onChange({ ...filesCfg, data_dir: v })} placeholder="/app/data/webdav" /> + + + onChange({ ...filesCfg, quota: v })} min={0} /> + +
    +
    + ); +} + +function Toast({ msg, type }) { + if (!msg) return null; + return ( +
    + {type === 'error' ? : } + {msg} +
    + ); +} + +export default function FilesPage() { + const { user } = useAuth(); + const isAdmin = user?.role === 'admin'; + const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); + const draftConfig = useDraftConfig(); + + const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain; + const proto = domain_mode === 'lan' ? 'http' : 'https'; + const filesHost = `files.${svcDomain}`; + const webdavHost = `webdav.${svcDomain}`; + const filesIp = service_ips.vip_files || '172.20.0.22'; + const webdavIp = service_ips.vip_webdav || '172.20.0.24'; + const filesCfgServer = service_configs.files || {}; + const webdavPort = filesCfgServer.port ?? 8080; + const filegatorPort = filesCfgServer.manager_port ?? 8082; + + const [filesCfg, setFilesCfg] = useState({ ...FILES_DEFAULTS }); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [users, setUsers] = useState([]); + const [status, setStatus] = useState(null); + const [toast, setToast] = useState(null); + const [peerData, setPeerData] = useState(null); + + useEffect(() => { + if (service_configs.files) setFilesCfg({ ...FILES_DEFAULTS, ...service_configs.files }); + }, [service_configs.files]); + + useEffect(() => { + if (!isAdmin) { + peerAPI.services().then(r => setPeerData(r.data)).catch(() => {}); + return; + } + fileAPI.getUsers().then(r => setUsers(r.data)).catch(() => {}); + fileAPI.getStatus().then(r => setStatus(r.data)).catch(() => {}); + }, [isAdmin]); + + const showToast = (msg, type = 'success') => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); + }; + + const errors = useMemo(() => validateFilesConfig(filesCfg), [filesCfg]); + const portConflicts = useMemo( + () => detectPortConflicts({ ...service_configs, files: filesCfg }), + [filesCfg, service_configs] + ); + const hasErrors = useMemo( + () => Object.keys(errors).length > 0 || + PORT_CONFLICT_FIELDS.files.some(f => portConflicts[`files|${f}`]), + [errors, portConflicts] + ); + + const filesCfgRef = useRef(filesCfg); + useEffect(() => { filesCfgRef.current = filesCfg; }, [filesCfg]); + const dirtyRef = useRef(dirty); + useEffect(() => { dirtyRef.current = dirty; }, [dirty]); + const hasErrorsRef = useRef(hasErrors); + useEffect(() => { hasErrorsRef.current = hasErrors; }, [hasErrors]); + + const save = useCallback(async () => { + if (!dirtyRef.current || hasErrorsRef.current) return; + setSaving(true); + try { + await cellAPI.updateConfig({ files: filesCfgRef.current }); + setDirty(false); + draftConfig?.setDirty('files', false); + refreshConfig(); + } catch (err) { + showToast(err?.response?.data?.error || 'Failed to save files config', 'error'); + } finally { + setSaving(false); + } + }, [draftConfig, refreshConfig]); + + const saveRef = useRef(save); + useEffect(() => { saveRef.current = save; }, [save]); + + useEffect(() => { + if (!draftConfig) return; + draftConfig.registerFlusher('files', () => saveRef.current()); + }, [draftConfig]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!dirty || hasErrors) return; + const t = setTimeout(() => saveRef.current(), 800); + return () => clearTimeout(t); + }, [filesCfg, dirty, hasErrors]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleChange = (cfg) => { + setFilesCfg(cfg); + setDirty(true); + draftConfig?.setDirty('files', true); + }; + + return ( +
    + + +
    +

    File Storage

    +

    FileGator (browser) + WebDAV (native clients)

    +
    + +
    + {/* File manager */} +
    +
    + +

    Web file manager

    +
    +
    + + + +
    +

    Browser-based file manager. Requires VPN.

    +
    + + {/* WebDAV */} +
    +
    + +

    WebDAV (mount as drive)

    +
    +
    + + + + +
    +

    + Mount in macOS Finder, Windows Explorer, or any WebDAV client. +

    +
    + + {/* Mount guide */} +
    +
    + +

    Mount as network drive

    +
    +
    +
    +

    macOS (Finder)

    +

    Go → Connect to Server → {proto}://{webdavHost}

    +
    +
    +

    Windows

    +

    Map Network Drive → {proto}://{webdavHost}

    +
    +
    +

    iOS (Files app)

    +

    Files → ... → Connect to Server → {proto}://{webdavHost}

    +
    +
    +

    Android

    +

    Use Solid Explorer or FX File Explorer → WebDAV → {proto}://{webdavHost}

    +
    +
    +
    + + {/* Status — admin only */} + {isAdmin && ( +
    +
    + +

    Service Status

    +
    + {status ? ( +
    +
    + FileGator: + Running +
    +
    + WebDAV: + Running +
    +
    + ) : ( +

    Status unavailable

    + )} +
    + )} + + {/* Peer credentials */} + {!isAdmin && peerData?.files && ( +
    +
    + +

    Your Account

    +
    +
    + +
    +

    Authenticate with your dashboard username and password.

    +
    + )} + + {/* Admin users list */} + {isAdmin && users.length > 0 && ( +
    +
    + +

    Storage Users

    +
    +
    + {users.map((u, i) => ( +
    + {u.username} + {u.storage_used || '0'} MB +
    + ))} +
    +
    + )} +
    + + {isAdmin && ( + + )} +
    + ); +} diff --git a/webui/src/utils/serviceConfig.js b/webui/src/utils/serviceConfig.js new file mode 100644 index 0000000..eee5afa --- /dev/null +++ b/webui/src/utils/serviceConfig.js @@ -0,0 +1,70 @@ +// Port-conflict detection — mirror of api/port_registry.py PORT_FIELDS. +// Kept in sync manually; both must agree on which fields carry port numbers. +export const PORT_CONFLICT_FIELDS = { + network: ['dns_port'], + wireguard: ['port'], + email: ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'], + calendar: ['port'], + files: ['port', 'manager_port'], +}; + +export function detectPortConflicts(configs) { + const portMap = {}; + for (const [section, fields] of Object.entries(PORT_CONFLICT_FIELDS)) { + const sec = configs[section] || {}; + for (const field of fields) { + const raw = sec[field]; + if (raw === undefined || raw === null || raw === '') continue; + const n = parseInt(raw, 10); + if (isNaN(n)) continue; + (portMap[n] = portMap[n] || []).push([section, field]); + } + } + const result = {}; + for (const [port, slots] of Object.entries(portMap)) { + if (slots.length < 2) continue; + const others = slots.map(([s, f]) => `${s}.${f}`).join(', '); + for (const [section, field] of slots) { + result[`${section}|${field}`] = `Port ${port} conflicts with ${others}`; + } + } + return result; +} + +export function isValidPort(v) { + const n = Number(v); + return Number.isInteger(n) && n >= 1 && n <= 65535; +} + +export function isValidDomain(v) { + if (!v || !v.trim()) return false; + const s = v.trim(); + if (s.length > 253) return false; + return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(s); +} + +const E_PORT = 'Must be 1–65535'; +const E_DOMAIN = 'Must be a valid domain (e.g. mail.example.com)'; + +export function validateEmailConfig(data) { + const errors = {}; + ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'].forEach(f => { + if (data[f] !== undefined && data[f] !== '' && !isValidPort(data[f])) errors[f] = E_PORT; + }); + if (data.domain && !isValidDomain(data.domain)) errors.domain = E_DOMAIN; + return errors; +} + +export function validateCalendarConfig(data) { + const errors = {}; + if (data.port !== undefined && data.port !== '' && !isValidPort(data.port)) errors.port = E_PORT; + return errors; +} + +export function validateFilesConfig(data) { + const errors = {}; + ['port', 'manager_port'].forEach(f => { + if (data[f] !== undefined && data[f] !== '' && !isValidPort(data[f])) errors[f] = E_PORT; + }); + return errors; +}