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
+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'}