feat: Services UI — nested nav, per-service pages, settings migration

Rename Store → Services: ServicesIndex.jsx shows built-in core services
(Email, Calendar, Files) with Manage links, plus the existing add-on
store below.

New service sub-pages at /services/email|calendar|files serve both
admin and peer roles. Admins see connection info, service status, users
list, and an inline config form (port/data-dir). Peers see connection
info and their personal credentials fetched from peerAPI.

Navigation restructured: a Services parent item expands to show the
three sub-pages via a collapsible sidebar group (ChevronDown toggle).
Both admin and peer navigation include the Services group. Sidebar
extracted NavItem/NavList components to eliminate the duplicate mobile/
desktop rendering.

Settings.jsx drops EmailForm, CalendarForm, FilesForm and their
SERVICE_DEFS entries. Port conflict detection and per-service validation
logic extracted to utils/serviceConfig.js, shared by Settings and the
new service pages. Service form flushers are registered without cleanup
so the Apply banner saves dirty config even when the user navigates away
from a service page before clicking Apply.

Legacy routes /email, /calendar, /files, /store redirect to their new
canonical paths.

GET /api/config now includes installed_services so the nav can derive
which add-ons are installed without a separate store fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 06:46:17 -04:00
parent b16189d00f
commit 0afdee32da
11 changed files with 1684 additions and 309 deletions
+1
View File
@@ -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()
+32 -1
View File
@@ -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'}
+38 -28
View File
@@ -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() {
<Route path="/" element={<PrivateRoute><RoleHome isOnline={isOnline} /></PrivateRoute>} />
<Route path="/account" element={<PrivateRoute><AccountSettings /></PrivateRoute>} />
<Route path="/my-services" element={<PrivateRoute requireRole="peer"><MyServices /></PrivateRoute>} />
{/* Service pages — accessible to both admin and peer (role-conditional content inside) */}
<Route path="/services" element={<PrivateRoute requireRole="admin"><ServicesIndex /></PrivateRoute>} />
<Route path="/services/email" element={<PrivateRoute><EmailPage /></PrivateRoute>} />
<Route path="/services/calendar" element={<PrivateRoute><CalendarPage /></PrivateRoute>} />
<Route path="/services/files" element={<PrivateRoute><FilesPage /></PrivateRoute>} />
{/* Legacy URL redirects */}
<Route path="/email" element={<Navigate to="/services/email" replace />} />
<Route path="/calendar" element={<Navigate to="/services/calendar" replace />} />
<Route path="/files" element={<Navigate to="/services/files" replace />} />
<Route path="/store" element={<Navigate to="/services" replace />} />
<Route path="/peers" element={<PrivateRoute requireRole="admin"><Peers /></PrivateRoute>} />
<Route path="/network" element={<PrivateRoute requireRole="admin"><NetworkServices /></PrivateRoute>} />
<Route path="/wireguard" element={<PrivateRoute requireRole="admin"><WireGuard /></PrivateRoute>} />
<Route path="/email" element={<PrivateRoute requireRole="admin"><Email /></PrivateRoute>} />
<Route path="/calendar" element={<PrivateRoute requireRole="admin"><Calendar /></PrivateRoute>} />
<Route path="/files" element={<PrivateRoute requireRole="admin"><Files /></PrivateRoute>} />
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
<Route path="/store" element={<PrivateRoute requireRole="admin"><Store /></PrivateRoute>} />
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
+40
View File
@@ -0,0 +1,40 @@
export function Field({ label, children, hint, error }) {
return (
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<label className="text-sm text-gray-600 sm:w-48 shrink-0 sm:pt-1.5">{label}</label>
<div className="flex-1">
{children}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
{hint && !error && <span className="text-xs text-gray-400 sm:pt-1.5 whitespace-nowrap">{hint}</span>}
</div>
);
}
export function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) {
return (
<input
type={type}
value={value ?? ''}
onChange={(e) => 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 (
<input
type="number"
value={value ?? ''}
min={min}
max={max}
onChange={(e) => 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"
/>
);
}
+203 -178
View File
@@ -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 */}
<div className={clsx(
'fixed inset-0 z-50 lg:hidden',
sidebarOpen ? 'block' : 'hidden'
)}>
<div className="fixed inset-0 bg-gray-900/80" onClick={() => setSidebarOpen(false)} />
<div className="fixed inset-y-0 left-0 z-50 w-72 bg-white">
<div className="flex h-full flex-col gap-y-5 overflow-y-auto px-6 py-4">
<div className="flex h-16 shrink-0 items-center">
<h1 className="text-xl font-semibold text-gray-900">
Personal Internet Cell
</h1>
<button
type="button"
className="ml-auto"
onClick={() => setSidebarOpen(false)}
>
<X className="h-6 w-6" />
</button>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<Link
to={item.href}
className={clsx(
location.pathname === item.href
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:text-primary-600 hover:bg-primary-50',
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
)}
onClick={() => setSidebarOpen(false)}
>
<item.icon
className={clsx(
location.pathname === item.href ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-600',
'h-6 w-6 shrink-0'
)}
aria-hidden="true"
/>
{item.name}
</Link>
</li>
))}
</ul>
</li>
<li className="mt-auto">
{logout && (
<button
onClick={logout}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors w-full"
>
<LogOut className="h-4 w-4" />
Sign out{user ? ` (${user.username})` : ''}
</button>
)}
</li>
</ul>
</nav>
</div>
</div>
</div>
{/* Desktop sidebar */}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4">
<div className="flex h-16 shrink-0 items-center">
<h1 className="text-xl font-semibold text-gray-900">
Personal Internet Cell
</h1>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<Link
to={item.href}
className={clsx(
location.pathname === item.href
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:text-primary-600 hover:bg-primary-50',
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
)}
>
<item.icon
className={clsx(
location.pathname === item.href ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-600',
'h-6 w-6 shrink-0'
)}
aria-hidden="true"
/>
{item.name}
</Link>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<div className="flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2">
<div className={clsx(
'h-2 w-2 rounded-full',
isOnline ? 'bg-success-500' : 'bg-danger-500'
)} />
<span className="text-xs text-gray-500">
{isOnline ? 'Connected' : 'Disconnected'}
</span>
</div>
{logout && (
<button
onClick={logout}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
title="Sign out"
>
<LogOut className="h-3.5 w-3.5" />
Sign out
</button>
)}
</div>
{user && (
<p className="text-xs text-gray-400 mt-1 truncate">{user.username}</p>
)}
</li>
</ul>
</nav>
</div>
</div>
{/* Mobile menu button */}
<div className="sticky top-0 z-40 flex items-center gap-x-6 bg-white px-4 py-4 shadow-sm sm:px-6 lg:hidden">
<button
type="button"
className="-m-2.5 p-2.5 text-gray-700 lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<span className="sr-only">Open sidebar</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<div className="flex-1 text-sm font-semibold leading-6 text-gray-900">
Personal Internet Cell
</div>
<div className="flex items-center gap-x-2">
<div className={clsx(
'h-2 w-2 rounded-full',
isOnline ? 'bg-success-500' : 'bg-danger-500'
)} />
<span className="text-xs text-gray-500">
{isOnline ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</>
);
}
export default Sidebar;
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 (
<li>
<div className="flex items-center">
<Link
to={item.href}
onClick={() => { 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'
)}
>
<item.icon
className={clsx(groupActive ? 'text-primary-600' : 'text-gray-400', 'h-6 w-6 shrink-0')}
aria-hidden="true"
/>
{item.name}
</Link>
<button
onClick={() => setOpen((v) => !v)}
aria-label={open ? 'Collapse' : 'Expand'}
className={clsx(
groupActive ? 'bg-primary-50 text-primary-600' : 'text-gray-400 hover:text-primary-600 hover:bg-primary-50',
'rounded-r-md p-2'
)}
>
<ChevronDown className={clsx('h-4 w-4 transition-transform', open && 'rotate-180')} />
</button>
</div>
{open && (
<ul className="mt-1 ml-8 space-y-1">
{item.children.map((child) => {
const active = location.pathname === child.href || location.pathname.startsWith(child.href + '/');
return (
<li key={child.name}>
<Link
to={child.href}
onClick={onNavigate}
className={clsx(
active ? 'bg-primary-50 text-primary-600' : 'text-gray-600 hover:text-primary-600 hover:bg-primary-50',
'flex gap-x-2 rounded-md px-2 py-1.5 text-sm leading-6 font-medium'
)}
>
<child.icon
className={clsx(active ? 'text-primary-600' : 'text-gray-400', 'h-5 w-5 shrink-0')}
aria-hidden="true"
/>
{child.name}
</Link>
</li>
);
})}
</ul>
)}
</li>
);
}
return (
<li>
<Link
to={item.href}
onClick={onNavigate}
className={clsx(
selfActive ? 'bg-primary-50 text-primary-600' : 'text-gray-700 hover:text-primary-600 hover:bg-primary-50',
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
)}
>
<item.icon
className={clsx(selfActive ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-600', 'h-6 w-6 shrink-0')}
aria-hidden="true"
/>
{item.name}
</Link>
</li>
);
}
function NavList({ navigation, location, onNavigate }) {
return (
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<NavItem key={item.name} item={item} location={location} onNavigate={onNavigate} />
))}
</ul>
);
}
function Sidebar({ navigation, isOnline }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
const auth = useAuth();
const { logout, user } = auth || {};
return (
<>
{/* Mobile sidebar */}
<div className={clsx('fixed inset-0 z-50 lg:hidden', sidebarOpen ? 'block' : 'hidden')}>
<div className="fixed inset-0 bg-gray-900/80" onClick={() => setSidebarOpen(false)} />
<div className="fixed inset-y-0 left-0 z-50 w-72 bg-white">
<div className="flex h-full flex-col gap-y-5 overflow-y-auto px-6 py-4">
<div className="flex h-16 shrink-0 items-center">
<h1 className="text-xl font-semibold text-gray-900">Personal Internet Cell</h1>
<button type="button" className="ml-auto" onClick={() => setSidebarOpen(false)}>
<X className="h-6 w-6" />
</button>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<NavList navigation={navigation} location={location} onNavigate={() => setSidebarOpen(false)} />
</li>
<li className="mt-auto">
{logout && (
<button
onClick={logout}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors w-full"
>
<LogOut className="h-4 w-4" />
Sign out{user ? ` (${user.username})` : ''}
</button>
)}
</li>
</ul>
</nav>
</div>
</div>
</div>
{/* Desktop sidebar */}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4">
<div className="flex h-16 shrink-0 items-center">
<h1 className="text-xl font-semibold text-gray-900">Personal Internet Cell</h1>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<NavList navigation={navigation} location={location} />
</li>
<li className="mt-auto">
<div className="flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2">
<div className={clsx('h-2 w-2 rounded-full', isOnline ? 'bg-success-500' : 'bg-danger-500')} />
<span className="text-xs text-gray-500">{isOnline ? 'Connected' : 'Disconnected'}</span>
</div>
{logout && (
<button
onClick={logout}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
title="Sign out"
>
<LogOut className="h-3.5 w-3.5" />
Sign out
</button>
)}
</div>
{user && <p className="text-xs text-gray-400 mt-1 truncate">{user.username}</p>}
</li>
</ul>
</nav>
</div>
</div>
{/* Mobile menu button */}
<div className="sticky top-0 z-40 flex items-center gap-x-6 bg-white px-4 py-4 shadow-sm sm:px-6 lg:hidden">
<button
type="button"
className="-m-2.5 p-2.5 text-gray-700 lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<span className="sr-only">Open sidebar</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<div className="flex-1 text-sm font-semibold leading-6 text-gray-900">Personal Internet Cell</div>
<div className="flex items-center gap-x-2">
<div className={clsx('h-2 w-2 rounded-full', isOnline ? 'bg-success-500' : 'bg-danger-500')} />
<span className="text-xs text-gray-500">{isOnline ? 'Connected' : 'Disconnected'}</span>
</div>
</div>
</>
);
}
export default Sidebar;
+380
View File
@@ -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 (
<div className="fixed bottom-4 right-4 z-50 space-y-2 pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 pointer-events-auto ${
t.type === 'success' ? 'bg-green-600' : t.type === 'error' ? 'bg-red-600' : 'bg-yellow-600'
}`}
>
{t.type === 'success' ? <CheckCircle className="h-4 w-4 shrink-0" /> : <AlertCircle className="h-4 w-4 shrink-0" />}
{t.msg}
</div>
))}
</div>
);
}
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 (
<div className="card animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-gray-100 rounded w-3/4 mb-1" />
<div className="h-3 bg-gray-100 rounded w-1/2 mb-4" />
<div className="flex justify-between items-center mt-auto">
<div className="h-3 bg-gray-100 rounded w-1/4" />
<div className="h-8 bg-gray-200 rounded w-20" />
</div>
</div>
);
}
function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
const [purge, setPurge] = useState(false);
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-96 mx-4">
<div className="flex items-start gap-3 mb-4">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold text-gray-900">Remove {service.name}?</h3>
<p className="text-sm text-gray-500 mt-1">
The service will be stopped and uninstalled. By default, data is kept on disk.
</p>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none mb-5">
<input
type="checkbox"
checked={purge}
onChange={(e) => setPurge(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-400"
/>
<span className="text-sm text-gray-700">Also delete service data (cannot be undone)</span>
</label>
<div className="flex gap-2 justify-end">
<button onClick={onCancel} className="btn-secondary text-sm">Cancel</button>
<button
onClick={() => onConfirm(purge)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
>
{purge ? 'Remove and Delete Data' : 'Remove Service'}
</button>
</div>
</div>
</div>
);
}
function StoreServiceCard({ service, isInstalled, installedInfo, onInstall, onRemove, installing, removing }) {
return (
<div className="card flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Package className="h-5 w-5 text-primary-500 shrink-0" />
<span className="font-semibold text-gray-900 truncate">{service.name}</span>
</div>
{isInstalled && (
<span className="flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5 shrink-0">
<CheckCircle className="h-3 w-3" />
Installed
</span>
)}
</div>
<p className="text-sm text-gray-500 flex-1">{service.description || 'No description available.'}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400">
{service.version && <span>v{service.version}</span>}
{service.author && <span>by {service.author}</span>}
{isInstalled && installedInfo?.installed_at && (
<span>Installed {new Date(installedInfo.installed_at).toLocaleDateString()}</span>
)}
</div>
<div className="flex justify-end pt-1 border-t border-gray-100">
{isInstalled ? (
<button
onClick={() => onRemove(service)}
disabled={removing}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
>
{removing ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
{removing ? 'Removing…' : 'Remove'}
</button>
) : (
<button
onClick={() => onInstall(service)}
disabled={installing}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
>
{installing ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
{installing ? 'Installing…' : 'Install'}
</button>
)}
</div>
</div>
);
}
// 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 (
<div className="card flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<svc.icon className="h-5 w-5 text-primary-500 shrink-0" />
<span className="font-semibold text-gray-900 truncate">{svc.name}</span>
</div>
<span className="flex items-center gap-1 text-xs font-medium text-primary-700 bg-primary-50 border border-primary-200 rounded-full px-2 py-0.5 shrink-0">
Core
</span>
</div>
<p className="text-sm text-gray-500 flex-1">{svc.desc}</p>
<div className="flex justify-end pt-1 border-t border-gray-100">
<Link
to={svc.href}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-primary-700 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors"
>
Manage
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
);
}
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 (
<div>
<Toast toasts={toasts} />
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
<p className="mt-1 text-sm text-gray-500">
Manage built-in services and browse optional add-ons
</p>
</div>
<button
onClick={handleRefresh}
disabled={refreshing || isLoading}
className="btn-secondary flex items-center gap-2 text-sm shrink-0"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{/* Core services — always shown */}
<section className="mb-8">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Built-in ({CORE_SERVICES.length})
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{CORE_SERVICES.map((svc) => <CoreServiceCard key={svc.name} svc={svc} />)}
</div>
</section>
{/* Add-ons from store */}
{isLoading && (
<div>
<div className="h-4 bg-gray-200 rounded w-40 mb-4 animate-pulse" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((n) => <SkeletonCard key={n} />)}
</div>
</div>
)}
{!isLoading && loadError && (
<div className="card border border-red-200 bg-red-50">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">Failed to load add-ons</p>
<p className="text-sm text-red-600 mt-1">{loadError}</p>
</div>
<button onClick={() => { setIsLoading(true); loadStore(); }} className="btn-secondary text-sm shrink-0">
Retry
</button>
</div>
</div>
)}
{!isLoading && !loadError && (
<>
{installedServices.length > 0 && (
<section className="mb-8">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Installed Add-ons ({installedServices.length})
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{installedServices.map((svc) => (
<StoreServiceCard
key={svc.id} service={svc} isInstalled={true}
installedInfo={installed[svc.id]}
onInstall={handleInstall} onRemove={(s) => setRemoveTarget(s)}
installing={opState[svc.id] === 'installing'}
removing={opState[svc.id] === 'removing'}
/>
))}
</div>
</section>
)}
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
{installedServices.length > 0 ? 'Available Add-ons' : 'Available Add-ons'}
{availableServices.length > 0 && ` (${availableServices.length})`}
</h2>
{availableServices.length === 0 && installedServices.length === 0 && (
<div className="card border border-gray-100 text-center py-12">
<Package className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-medium text-gray-500">No add-ons in the store yet</p>
<p className="text-xs text-gray-400 mt-1">Click "Refresh" to check for available add-ons.</p>
</div>
)}
{availableServices.length === 0 && installedServices.length > 0 && (
<div className="card border border-gray-100 text-center py-8">
<CheckCircle className="h-8 w-8 text-green-400 mx-auto mb-2" />
<p className="text-sm text-gray-500">All available add-ons are installed.</p>
</div>
)}
{availableServices.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availableServices.map((svc) => (
<StoreServiceCard
key={svc.id} service={svc} isInstalled={false} installedInfo={null}
onInstall={handleInstall} onRemove={(s) => setRemoveTarget(s)}
installing={opState[svc.id] === 'installing'}
removing={opState[svc.id] === 'removing'}
/>
))}
</div>
)}
</section>
</>
)}
{removeTarget && (
<ConfirmRemoveDialog
service={removeTarget}
onConfirm={handleRemoveConfirm}
onCancel={() => setRemoveTarget(null)}
/>
)}
</div>
);
}
+8 -102
View File
@@ -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 (
<div className="space-y-3">
<Field label="Mail Domain" error={errors.domain}>
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
</Field>
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)" error={errors.smtp_port}>
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
</Field>
<Field label="Submission Port" hint="Client mail send (default 587)" error={errors.submission_port}>
<NumberInput value={data.submission_port ?? 587} onChange={(v) => onChange({ ...data, submission_port: v })} min={1} max={65535} />
</Field>
<Field label="IMAP Port" hint="Client mail fetch (default 993)" error={errors.imap_port}>
<NumberInput value={data.imap_port ?? 993} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} />
</Field>
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)" error={errors.webmail_port}>
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
</Field>
</div>
);
}
function CalendarForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy" error={errors.port}>
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/radicale" />
</Field>
</div>
);
}
function FilesForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)" error={errors.port}>
<NumberInput value={data.port ?? 8080} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="File Manager Port" hint="Filegator host port (default 8082)" error={errors.manager_port}>
<NumberInput value={data.manager_port ?? 8082} onChange={(v) => onChange({ ...data, manager_port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" />
</Field>
<Field label="Default Quota (MB)">
<NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} />
</Field>
</div>
);
}
function RoutingForm({ data, onChange }) {
return (
<div className="space-y-3">
@@ -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 ────────────────────────────────────────────────────────────
+301
View File
@@ -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 (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="ml-2 text-gray-400 hover:text-gray-600" title="Copy"
>
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-32 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={String(value)} />
</div>
</div>
);
}
function AdminConfigSection({ calCfg, onChange, errors, portConflicts, saving }) {
return (
<div className="card mt-6">
<div className="flex items-center gap-2 mb-4">
<SettingsIcon className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Service Configuration</h3>
{saving && <span className="ml-auto text-xs text-gray-400">Saving</span>}
</div>
<div className="space-y-3">
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy" error={errors.port || portConflicts['calendar|port']}>
<NumberInput value={calCfg.port} onChange={(v) => onChange({ ...calCfg, port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={calCfg.data_dir} onChange={(v) => onChange({ ...calCfg, data_dir: v })} placeholder="/app/data/radicale" />
</Field>
</div>
</div>
);
}
function Toast({ msg, type }) {
if (!msg) return null;
return (
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
{msg}
</div>
);
}
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 (
<div>
<Toast {...(toast || {})} />
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1>
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Connection info */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Connect your device</h3>
</div>
<p className="text-xs text-gray-500 mb-3">
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
</p>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server URL" value={`${proto}://${cellHost}`} />
<InfoRow label="CalDAV path" value={`${proto}://${cellHost}/`} />
<InfoRow label="CardDAV path" value={`${proto}://${cellHost}/`} />
<InfoRow label="Port" value={domain_mode === 'lan' ? '80' : '443'} />
<InfoRow label="Direct IP" value={calendarIp} />
<InfoRow label="Direct port" value={String(calendarPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN. DNS must be set to <span className="font-mono">{dnsIp}</span>.
</p>
</div>
{/* Quick setup guide */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Quick setup guide</h3>
</div>
<div className="space-y-3 text-sm text-gray-700">
<div>
<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">
<li>Add Account Other Add CalDAV Account</li>
<li>Server: <span className="font-mono">{cellHost}</span></li>
<li>Enter username &amp; password</li>
<li>For contacts: Add CardDAV Account, same server</li>
</ol>
</div>
<div>
<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">
<li>Install DAVx⁵ from Play Store / F-Droid</li>
<li>Login with URL: <span className="font-mono">{proto}://{cellHost}/</span></li>
<li>Select calendars &amp; address books to sync</li>
</ol>
</div>
<div>
<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">
<li>Calendar New Calendar On the Network</li>
<li>Format: CalDAV, Location: <span className="font-mono">{proto}://{cellHost}/</span></li>
</ol>
</div>
</div>
</div>
{/* Status — admin only */}
{isAdmin && (
<div className="card">
<div className="flex items-center mb-4">
<Server className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Radicale:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CalDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CardDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
)}
{/* Peer credentials */}
{!isAdmin && peerData?.caldav && (
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Your Account</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Username" value={peerData.caldav.username || peerData.username || '—'} />
</div>
<p className="text-xs text-gray-400 mt-3">Authenticate with your dashboard username and password.</p>
</div>
)}
{/* Admin users list */}
{isAdmin && (
<div className="card">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
</div>
{users.length > 0 ? (
<div className="space-y-2">
{users.map((u, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{u.username}</span>
<span className="text-sm text-gray-500">{u.calendars || 0} calendars</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">No calendar users configured</p>
)}
</div>
)}
</div>
{isAdmin && (
<AdminConfigSection
calCfg={calCfg}
onChange={handleChange}
errors={errors}
portConflicts={portConflicts}
saving={saving}
/>
)}
</div>
);
}
+306
View File
@@ -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 (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="ml-2 text-gray-400 hover:text-gray-600" title="Copy"
>
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={String(value)} />
</div>
</div>
);
}
function AdminConfigSection({ emailCfg, onChange, errors, portConflicts, saving }) {
const conflictFor = (f) => portConflicts[`email|${f}`];
return (
<div className="card mt-6">
<div className="flex items-center gap-2 mb-4">
<SettingsIcon className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Service Configuration</h3>
{saving && <span className="ml-auto text-xs text-gray-400">Saving</span>}
</div>
<div className="space-y-3">
<Field label="Mail Domain" error={errors.domain}>
<TextInput value={emailCfg.domain} onChange={(v) => onChange({ ...emailCfg, domain: v })} placeholder="mail.example.com" />
</Field>
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)" error={errors.smtp_port || conflictFor('smtp_port')}>
<NumberInput value={emailCfg.smtp_port ?? 25} onChange={(v) => onChange({ ...emailCfg, smtp_port: v })} min={1} max={65535} />
</Field>
<Field label="Submission Port" hint="Client mail send (default 587)" error={errors.submission_port || conflictFor('submission_port')}>
<NumberInput value={emailCfg.submission_port ?? 587} onChange={(v) => onChange({ ...emailCfg, submission_port: v })} min={1} max={65535} />
</Field>
<Field label="IMAP Port" hint="Client mail fetch (default 993)" error={errors.imap_port || conflictFor('imap_port')}>
<NumberInput value={emailCfg.imap_port ?? 993} onChange={(v) => onChange({ ...emailCfg, imap_port: v })} min={1} max={65535} />
</Field>
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)" error={errors.webmail_port || conflictFor('webmail_port')}>
<NumberInput value={emailCfg.webmail_port ?? 8888} onChange={(v) => onChange({ ...emailCfg, webmail_port: v })} min={1} max={65535} />
</Field>
</div>
</div>
);
}
function Toast({ msg, type }) {
if (!msg) return null;
return (
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
{msg}
</div>
);
}
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 (
<div>
<Toast {...(toast || {})} />
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IMAP */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(imapPort)} />
<InfoRow label="Security" value="SSL/TLS" />
<InfoRow label="Direct IP" value={mailIp} />
</div>
</div>
{/* SMTP */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Outgoing mail (SMTP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(smtpPort)} />
<InfoRow label="Security" value="STARTTLS" />
<InfoRow label="Auth" value="Username + Password" />
</div>
</div>
{/* Webmail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Webmail</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://mail.${svcDomain}`} />
<InfoRow label="Alt URL" value={`${proto}://webmail.${svcDomain}`} />
<InfoRow label="Direct IP" value={`http://${mailIp}`} />
<InfoRow label="Direct port" value={String(webmailPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN + DNS set to <span className="font-mono">{dnsIp}</span>.
</p>
</div>
{/* Status — admin only */}
{isAdmin && (
<div className="card">
<div className="flex items-center mb-4">
<Server className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Postfix (SMTP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Dovecot (IMAP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
)}
{/* Peer credentials */}
{!isAdmin && peerData?.email && (
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Your Account</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Email address" value={peerData.email.address || '—'} />
<InfoRow label="Username" value={peerData.username || '—'} />
</div>
<p className="text-xs text-gray-400 mt-3">Authenticate with your dashboard username and password.</p>
</div>
)}
{/* Admin users list */}
{isAdmin && users.length > 0 && (
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Email Accounts</h3>
</div>
<div className="space-y-2">
{users.map((u, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{u.username}</span>
<span className="text-sm text-gray-500">{u.domain}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Admin config form */}
{isAdmin && (
<AdminConfigSection
emailCfg={emailCfg}
onChange={handleChange}
errors={errors}
portConflicts={portConflicts}
saving={saving}
/>
)}
</div>
);
}
+305
View File
@@ -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 (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="ml-2 text-gray-400 hover:text-gray-600" title="Copy"
>
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={String(value)} />
</div>
</div>
);
}
function AdminConfigSection({ filesCfg, onChange, errors, portConflicts, saving }) {
const cf = (f) => portConflicts[`files|${f}`];
return (
<div className="card mt-6">
<div className="flex items-center gap-2 mb-4">
<SettingsIcon className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Service Configuration</h3>
{saving && <span className="ml-auto text-xs text-gray-400">Saving</span>}
</div>
<div className="space-y-3">
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)" error={errors.port || cf('port')}>
<NumberInput value={filesCfg.port ?? 8080} onChange={(v) => onChange({ ...filesCfg, port: v })} min={1} max={65535} />
</Field>
<Field label="File Manager Port" hint="Filegator host port (default 8082)" error={errors.manager_port || cf('manager_port')}>
<NumberInput value={filesCfg.manager_port ?? 8082} onChange={(v) => onChange({ ...filesCfg, manager_port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={filesCfg.data_dir} onChange={(v) => onChange({ ...filesCfg, data_dir: v })} placeholder="/app/data/webdav" />
</Field>
<Field label="Default Quota (MB)">
<NumberInput value={filesCfg.quota} onChange={(v) => onChange({ ...filesCfg, quota: v })} min={0} />
</Field>
</div>
</div>
);
}
function Toast({ msg, type }) {
if (!msg) return null;
return (
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
{msg}
</div>
);
}
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 (
<div>
<Toast {...(toast || {})} />
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File manager */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Web file manager</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://${filesHost}`} />
<InfoRow label="Direct IP" value={`http://${filesIp}`} />
<InfoRow label="Direct port" value={String(filegatorPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">Browser-based file manager. Requires VPN.</p>
</div>
{/* WebDAV */}
<div className="card">
<div className="flex items-center mb-4">
<FolderOpen className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">WebDAV (mount as drive)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://${webdavHost}`} />
<InfoRow label="Direct IP" value={`http://${webdavIp}`} />
<InfoRow label="Direct port" value={String(webdavPort)} />
<InfoRow label="Auth" value="Basic (user / password)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Mount in macOS Finder, Windows Explorer, or any WebDAV client.
</p>
</div>
{/* Mount guide */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Mount as network drive</h3>
</div>
<div className="space-y-3 text-sm">
<div>
<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">{proto}://{webdavHost}</span></p>
</div>
<div>
<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">{proto}://{webdavHost}</span></p>
</div>
<div>
<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">{proto}://{webdavHost}</span></p>
</div>
<div>
<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> WebDAV <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
</div>
</div>
{/* Status — admin only */}
{isAdmin && (
<div className="card">
<div className="flex items-center mb-4">
<Server className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">FileGator:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">WebDAV:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
)}
{/* Peer credentials */}
{!isAdmin && peerData?.files && (
<div className="card">
<div className="flex items-center mb-4">
<FolderOpen className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Your Account</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Username" value={peerData.files.username || peerData.username || '—'} />
</div>
<p className="text-xs text-gray-400 mt-3">Authenticate with your dashboard username and password.</p>
</div>
)}
{/* Admin users list */}
{isAdmin && users.length > 0 && (
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
</div>
<div className="space-y-2">
{users.map((u, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{u.username}</span>
<span className="text-sm text-gray-500">{u.storage_used || '0'} MB</span>
</div>
))}
</div>
</div>
)}
</div>
{isAdmin && (
<AdminConfigSection
filesCfg={filesCfg}
onChange={handleChange}
errors={errors}
portConflicts={portConflicts}
saving={saving}
/>
)}
</div>
);
}
+70
View File
@@ -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 165535';
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;
}