From a10fe11136d3b9b4c77c0a67ec6ba11f55afa92d Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 29 May 2026 12:15:02 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20dynamic=20nav?= =?UTF-8?q?=20+=20service=20visibility=20based=20on=20installed=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email, calendar, and files no longer appear in the nav or as usable pages unless they are installed. The nav refreshes whenever a service is installed or removed via the new pic-services-changed CustomEvent. Changes: - routes/services.py: add GET /api/services/active endpoint - api.js: add servicesAPI.listActive() - App.jsx: replace hardcoded coreServiceChildren with dynamic state fetched from /api/services/active; SERVICE_META maps ids to nav entry shapes - ServiceNotInstalledBanner.jsx: new component — admin gets catalog link, peer gets "contact admin" message - EmailPage/CalendarPage/FilesPage: show banner when service not installed - ServicesIndex.jsx: remove CoreServiceCard + CORE_SERVICES "Built-in" section; rename Remove → Uninstall; dispatch pic-services-changed on install/uninstall success - MyServices.jsx: conditionally render service cards based on active list; placeholder card when absent; page-level notice when nothing is installed - tests/test_services_active_endpoint.py: 4 new endpoint tests Co-Authored-By: Claude Sonnet 4.6 --- api/routes/services.py | 20 +++ tests/test_services_active_endpoint.py | 80 ++++++++++++ webui/src/App.jsx | 36 ++++-- .../components/ServiceNotInstalledBanner.jsx | 30 +++++ webui/src/pages/MyServices.jsx | 122 ++++++++++++------ webui/src/pages/ServicesIndex.jsx | 65 ++-------- webui/src/pages/services/CalendarPage.jsx | 21 ++- webui/src/pages/services/EmailPage.jsx | 21 ++- webui/src/pages/services/FilesPage.jsx | 21 ++- webui/src/services/api.js | 1 + 10 files changed, 308 insertions(+), 109 deletions(-) create mode 100644 tests/test_services_active_endpoint.py create mode 100644 webui/src/components/ServiceNotInstalledBanner.jsx diff --git a/api/routes/services.py b/api/routes/services.py index 267fa95..b3e56e3 100644 --- a/api/routes/services.py +++ b/api/routes/services.py @@ -20,6 +20,26 @@ def get_services_catalog(): return jsonify({'error': str(e)}), 500 +@bp.route('/api/services/active', methods=['GET']) +def get_active_services(): + """Return minimal info for all installed services. Used by webui to build nav.""" + try: + from app import service_registry + active = service_registry.list_active() + return jsonify([ + { + 'id': svc['id'], + 'name': svc.get('name', svc['id']), + 'subdomain': svc.get('subdomain'), + 'capabilities': svc.get('capabilities', {}), + } + for svc in active + ]) + except Exception as e: + logger.error('get_active_services: %s', e) + return jsonify({'error': str(e)}), 500 + + @bp.route('/api/services/catalog/', methods=['GET']) def get_service_catalog_entry(service_id: str): """Return a single service manifest+config, or 404 if unknown.""" diff --git a/tests/test_services_active_endpoint.py b/tests/test_services_active_endpoint.py new file mode 100644 index 0000000..d7fdb0f --- /dev/null +++ b/tests/test_services_active_endpoint.py @@ -0,0 +1,80 @@ +""" +Tests for GET /api/services/active endpoint. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) + +from app import app + + +def _make_registry(active_services): + reg = MagicMock() + reg.list_active = MagicMock(return_value=active_services) + return reg + + +@pytest.fixture() +def client(): + app.config['TESTING'] = True + with app.test_client() as c: + yield c + + +def test_active_returns_200(client): + import app as app_module + with patch.object(app_module, 'service_registry', _make_registry([])): + resp = client.get('/api/services/active') + assert resp.status_code == 200 + + +def test_active_returns_empty_list_when_nothing_installed(client): + import app as app_module + with patch.object(app_module, 'service_registry', _make_registry([])): + resp = client.get('/api/services/active') + data = json.loads(resp.data) + assert data == [] + + +def test_active_returns_installed_services(client): + email_svc = { + 'id': 'email', + 'name': 'Email', + 'subdomain': 'mail', + 'capabilities': {'has_accounts': True}, + 'config': {}, + } + import app as app_module + with patch.object(app_module, 'service_registry', _make_registry([email_svc])): + resp = client.get('/api/services/active') + data = json.loads(resp.data) + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]['id'] == 'email' + assert data[0]['name'] == 'Email' + + +def test_active_response_shape(client): + """Each entry must have id, name, subdomain, and capabilities keys.""" + email_svc = { + 'id': 'email', + 'name': 'Email', + 'subdomain': 'mail', + 'capabilities': {'has_accounts': True}, + 'config': {}, + } + import app as app_module + with patch.object(app_module, 'service_registry', _make_registry([email_svc])): + resp = client.get('/api/services/active') + data = json.loads(resp.data) + entry = data[0] + assert 'id' in entry + assert 'name' in entry + assert 'subdomain' in entry + assert 'capabilities' in entry diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 41d39de..beba622 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -20,7 +20,7 @@ import { AlertTriangle, User, } from 'lucide-react'; -import { healthAPI, cellAPI } from './services/api'; +import { healthAPI, cellAPI, servicesAPI } from './services/api'; import { ConfigProvider } from './contexts/ConfigContext'; import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext'; import { AuthProvider, useAuth } from './contexts/AuthContext'; @@ -48,6 +48,12 @@ import Connectivity from './pages/Connectivity'; import Setup from './pages/Setup'; import SetupGuard from './components/SetupGuard'; +const SERVICE_META = { + email: { name: 'Email', href: '/services/email', icon: Mail }, + calendar: { name: 'Calendar', href: '/services/calendar', icon: CalendarIcon }, + files: { name: 'File Storage', href: '/services/files', icon: FolderOpen }, +}; + function PendingRestartBanner({ pending, onApply, onCancel }) { const [confirming, setConfirming] = useState(false); const [applying, setApplying] = useState(false); @@ -230,18 +236,32 @@ 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 [activeServiceChildren, setActiveServiceChildren] = useState([]); + + const fetchActiveServices = useCallback(async () => { + try { + const resp = await servicesAPI.listActive(); + const children = (resp.data || []) + .filter(svc => SERVICE_META[svc.id]) + .map(svc => SERVICE_META[svc.id]); + setActiveServiceChildren(children); + } catch { + // silent — empty nav children is safe + } + }, []); + + useEffect(() => { + fetchActiveServices(); + window.addEventListener('pic-services-changed', fetchActiveServices); + return () => window.removeEventListener('pic-services-changed', fetchActiveServices); + }, [fetchActiveServices]); 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: 'Services', href: '/services', icon: Package, children: coreServiceChildren }, + { name: 'Services', href: '/services', icon: Package, children: activeServiceChildren }, { name: 'Routing', href: '/routing', icon: Wifi }, { name: 'Vault', href: '/vault', icon: Key }, { name: 'Containers', href: '/containers', icon: Package2 }, @@ -255,7 +275,7 @@ function AppCore() { const peerNavigation = [ { name: 'Dashboard', href: '/', icon: Home }, { name: 'My Services', href: '/my-services', icon: Wifi }, - { name: 'Services', href: '/services', icon: Package, children: coreServiceChildren }, + { name: 'Services', href: '/services', icon: Package, children: activeServiceChildren }, { name: 'Account', href: '/account', icon: User }, ]; diff --git a/webui/src/components/ServiceNotInstalledBanner.jsx b/webui/src/components/ServiceNotInstalledBanner.jsx new file mode 100644 index 0000000..8352822 --- /dev/null +++ b/webui/src/components/ServiceNotInstalledBanner.jsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router-dom'; +import { Package } from 'lucide-react'; + +export default function ServiceNotInstalledBanner({ isAdmin = false }) { + return ( +
+
+ +
+

Service not installed

+ {isAdmin ? ( +

+ This service isn't installed yet. Visit the Services catalog to install it. +

+ ) : ( +

+ This service hasn't been set up yet. Contact your cell administrator. +

+ )} + {isAdmin && ( + + Go to Services catalog + + )} +
+ ); +} diff --git a/webui/src/pages/MyServices.jsx b/webui/src/pages/MyServices.jsx index 548f5dd..768c9bf 100644 --- a/webui/src/pages/MyServices.jsx +++ b/webui/src/pages/MyServices.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { Copy, Download, Wifi, Mail, Calendar, FolderOpen } from 'lucide-react'; -import { peerAPI } from '../services/api'; +import { Copy, Download, Wifi, Mail, Calendar, FolderOpen, Package } from 'lucide-react'; +import { peerAPI, servicesAPI } from '../services/api'; function CopyButton({ text }) { const [copied, setCopied] = useState(false); @@ -39,6 +39,7 @@ export default function MyServices() { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); + const [activeServiceIds, setActiveServiceIds] = useState([]); useEffect(() => { peerAPI.services() @@ -47,6 +48,12 @@ export default function MyServices() { .finally(() => setIsLoading(false)); }, []); + useEffect(() => { + servicesAPI.listActive() + .then(resp => setActiveServiceIds((resp.data || []).map(s => s.id))) + .catch(() => {}); + }, []); + const downloadConfig = (filename, content) => { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); @@ -114,52 +121,81 @@ export default function MyServices() { )} -
-
- -

Email

+ {activeServiceIds.length === 0 && ( +
+ No optional services are installed yet. Contact your administrator.
- - - - {(email.smtp || email.imap) && ( -

- When setting up your mail client, use your dashboard username and password for authentication. -

- )} -
+ )} -
-
- -

Calendar & Contacts

+ {activeServiceIds.includes('email') ? ( +
+
+ +

Email

+
+ + + + {(email.smtp || email.imap) && ( +

+ When setting up your mail client, use your dashboard username and password for authentication. +

+ )}
- - - {caldav.url && ( -

- Use this URL in your calendar client. Authenticate with your username and dashboard password. -

- )} -
- -
-
- -

Files

+ ) : ( +
+ +

Email — not installed

- - - {files.url && ( -

- Mount this URL as a network drive or use a WebDAV client. Authenticate with your username and dashboard password. -

- )} -
+ )} -

- Note: Changing your dashboard password does not update email, calendar, or files passwords. -

+ {activeServiceIds.includes('calendar') ? ( +
+
+ +

Calendar & Contacts

+
+ + + {caldav.url && ( +

+ Use this URL in your calendar client. Authenticate with your username and dashboard password. +

+ )} +
+ ) : ( +
+ +

Calendar & Contacts — not installed

+
+ )} + + {activeServiceIds.includes('files') ? ( +
+
+ +

Files

+
+ + + {files.url && ( +

+ Mount this URL as a network drive or use a WebDAV client. Authenticate with your username and dashboard password. +

+ )} +
+ ) : ( +
+ +

Files — not installed

+
+ )} + + {(activeServiceIds.includes('email') || activeServiceIds.includes('calendar') || activeServiceIds.includes('files')) && ( +

+ Note: Changing your dashboard password does not update email, calendar, or files passwords. +

+ )}
); } diff --git a/webui/src/pages/ServicesIndex.jsx b/webui/src/pages/ServicesIndex.jsx index 365d473..5ffa7d0 100644 --- a/webui/src/pages/ServicesIndex.jsx +++ b/webui/src/pages/ServicesIndex.jsx @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback } from 'react'; -import { Link } from 'react-router-dom'; import { Package, Download, @@ -7,10 +6,6 @@ import { RefreshCw, CheckCircle, AlertCircle, - Mail, - Calendar as CalendarIcon, - FolderOpen, - ArrowRight, } from 'lucide-react'; import { storeAPI } from '../services/api'; @@ -64,7 +59,7 @@ function SkeletonCard() { ); } -function ConfirmRemoveDialog({ service, onConfirm, onCancel }) { +function ConfirmUninstallDialog({ service, onConfirm, onCancel }) { const [purge, setPurge] = useState(false); return (
@@ -72,7 +67,7 @@ function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
-

Remove {service.name}?

+

Uninstall {service.name}?

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

@@ -93,7 +88,7 @@ function ConfirmRemoveDialog({ service, onConfirm, onCancel }) { 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'} + {purge ? 'Uninstall and Delete Data' : 'Uninstall Service'}
@@ -132,7 +127,7 @@ function StoreServiceCard({ service, isInstalled, installedInfo, onInstall, onRe 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 ? : } - {removing ? 'Removing…' : 'Remove'} + {removing ? 'Uninstalling…' : 'Uninstall'} ) : (
- ); -} export default function ServicesIndex() { const toasts = useToasts(); @@ -231,6 +194,7 @@ export default function ServicesIndex() { try { await storeAPI.installService(service.id); toastEvent(`${service.name} installed successfully`); + window.dispatchEvent(new CustomEvent('pic-services-changed')); await loadStore(); } catch (err) { toastEvent(err.response?.data?.error || `Failed to install ${service.name}`, 'error'); @@ -245,10 +209,11 @@ export default function ServicesIndex() { setOpState((s) => ({ ...s, [service.id]: 'removing' })); try { await storeAPI.removeService(service.id, purge); - toastEvent(`${service.name} removed`); + toastEvent(`${service.name} uninstalled`); + window.dispatchEvent(new CustomEvent('pic-services-changed')); await loadStore(); } catch (err) { - toastEvent(err.response?.data?.error || `Failed to remove ${service.name}`, 'error'); + toastEvent(err.response?.data?.error || `Failed to uninstall ${service.name}`, 'error'); } finally { setOpState((s) => ({ ...s, [service.id]: null })); } @@ -265,7 +230,7 @@ export default function ServicesIndex() {

Services

- Manage built-in services and browse optional add-ons + Install and manage optional services for your cell

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

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

-
- {CORE_SERVICES.map((svc) => )} -
-
- {/* Add-ons from store */} {isLoading && (
@@ -369,7 +324,7 @@ export default function ServicesIndex() { )} {removeTarget && ( - setRemoveTarget(null)} diff --git a/webui/src/pages/services/CalendarPage.jsx b/webui/src/pages/services/CalendarPage.jsx index b59e7a7..e948b54 100644 --- a/webui/src/pages/services/CalendarPage.jsx +++ b/webui/src/pages/services/CalendarPage.jsx @@ -1,6 +1,7 @@ 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 { calendarAPI, cellAPI, peerAPI, servicesAPI } from '../../services/api'; +import ServiceNotInstalledBanner from '../../components/ServiceNotInstalledBanner'; import { useConfig } from '../../contexts/ConfigContext'; import { useDraftConfig } from '../../contexts/DraftConfigContext'; import { useAuth } from '../../contexts/AuthContext'; @@ -69,6 +70,7 @@ function Toast({ msg, type }) { export default function CalendarPage() { const { user } = useAuth(); const isAdmin = user?.role === 'admin'; + const [notInstalled, setNotInstalled] = useState(null); const [activeTab, setActiveTab] = useState('overview'); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const draftConfig = useDraftConfig(); @@ -88,6 +90,15 @@ export default function CalendarPage() { const [toast, setToast] = useState(null); const [peerData, setPeerData] = useState(null); + useEffect(() => { + servicesAPI.listActive() + .then(resp => { + const ids = (resp.data || []).map(s => s.id); + setNotInstalled(!ids.includes('calendar')); + }) + .catch(() => setNotInstalled(false)); + }, []); + useEffect(() => { if (service_configs.calendar) setCalCfg({ ...CAL_DEFAULTS, ...service_configs.calendar }); }, [service_configs.calendar]); @@ -158,6 +169,14 @@ export default function CalendarPage() { draftConfig?.setDirty('calendar', true); }; + if (notInstalled === true) { + return ( +
+ +
+ ); + } + return (
diff --git a/webui/src/pages/services/EmailPage.jsx b/webui/src/pages/services/EmailPage.jsx index 603f990..8698d15 100644 --- a/webui/src/pages/services/EmailPage.jsx +++ b/webui/src/pages/services/EmailPage.jsx @@ -1,6 +1,7 @@ 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 { emailAPI, cellAPI, peerAPI, servicesAPI } from '../../services/api'; +import ServiceNotInstalledBanner from '../../components/ServiceNotInstalledBanner'; import { useConfig } from '../../contexts/ConfigContext'; import { useDraftConfig } from '../../contexts/DraftConfigContext'; import { useAuth } from '../../contexts/AuthContext'; @@ -79,6 +80,7 @@ function Toast({ msg, type }) { export default function EmailPage() { const { user } = useAuth(); const isAdmin = user?.role === 'admin'; + const [notInstalled, setNotInstalled] = useState(null); const [activeTab, setActiveTab] = useState('overview'); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const draftConfig = useDraftConfig(); @@ -104,6 +106,15 @@ export default function EmailPage() { // Peer state const [peerData, setPeerData] = useState(null); + useEffect(() => { + servicesAPI.listActive() + .then(resp => { + const ids = (resp.data || []).map(s => s.id); + setNotInstalled(!ids.includes('email')); + }) + .catch(() => setNotInstalled(false)); + }, []); + useEffect(() => { if (service_configs.email) setEmailCfg({ ...EMAIL_DEFAULTS, ...service_configs.email }); }, [service_configs.email]); @@ -180,6 +191,14 @@ export default function EmailPage() { draftConfig?.setDirty('email', true); }; + if (notInstalled === true) { + return ( +
+ +
+ ); + } + return (
diff --git a/webui/src/pages/services/FilesPage.jsx b/webui/src/pages/services/FilesPage.jsx index 523ae05..44c417b 100644 --- a/webui/src/pages/services/FilesPage.jsx +++ b/webui/src/pages/services/FilesPage.jsx @@ -1,6 +1,7 @@ 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 { fileAPI, cellAPI, peerAPI, servicesAPI } from '../../services/api'; +import ServiceNotInstalledBanner from '../../components/ServiceNotInstalledBanner'; import { useConfig } from '../../contexts/ConfigContext'; import { useDraftConfig } from '../../contexts/DraftConfigContext'; import { useAuth } from '../../contexts/AuthContext'; @@ -76,6 +77,7 @@ function Toast({ msg, type }) { export default function FilesPage() { const { user } = useAuth(); const isAdmin = user?.role === 'admin'; + const [notInstalled, setNotInstalled] = useState(null); const [activeTab, setActiveTab] = useState('overview'); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const draftConfig = useDraftConfig(); @@ -98,6 +100,15 @@ export default function FilesPage() { const [toast, setToast] = useState(null); const [peerData, setPeerData] = useState(null); + useEffect(() => { + servicesAPI.listActive() + .then(resp => { + const ids = (resp.data || []).map(s => s.id); + setNotInstalled(!ids.includes('files')); + }) + .catch(() => setNotInstalled(false)); + }, []); + useEffect(() => { if (service_configs.files) setFilesCfg({ ...FILES_DEFAULTS, ...service_configs.files }); }, [service_configs.files]); @@ -169,6 +180,14 @@ export default function FilesPage() { draftConfig?.setDirty('files', true); }; + if (notInstalled === true) { + return ( +
+ +
+ ); + } + return (
diff --git a/webui/src/services/api.js b/webui/src/services/api.js index 1baf303..ba26253 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -271,6 +271,7 @@ export const servicesAPI = { startService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/start`), stopService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/stop`), restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`), + listActive: () => api.get('/api/services/active'), }; // Accounts API (peer service account provisioning via AccountManager)