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:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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>} />
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user