feat: Phase 4 — dynamic nav + service visibility based on installed services
Unit Tests / test (push) Successful in 11m24s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:15:02 -04:00
parent 87c321c1c9
commit a10fe11136
10 changed files with 308 additions and 109 deletions
+20
View File
@@ -20,6 +20,26 @@ def get_services_catalog():
return jsonify({'error': str(e)}), 500 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/<service_id>', methods=['GET']) @bp.route('/api/services/catalog/<service_id>', methods=['GET'])
def get_service_catalog_entry(service_id: str): def get_service_catalog_entry(service_id: str):
"""Return a single service manifest+config, or 404 if unknown.""" """Return a single service manifest+config, or 404 if unknown."""
+80
View File
@@ -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
+28 -8
View File
@@ -20,7 +20,7 @@ import {
AlertTriangle, AlertTriangle,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { healthAPI, cellAPI } from './services/api'; import { healthAPI, cellAPI, servicesAPI } from './services/api';
import { ConfigProvider } from './contexts/ConfigContext'; import { ConfigProvider } from './contexts/ConfigContext';
import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext'; import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext';
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
@@ -48,6 +48,12 @@ import Connectivity from './pages/Connectivity';
import Setup from './pages/Setup'; import Setup from './pages/Setup';
import SetupGuard from './components/SetupGuard'; 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 }) { function PendingRestartBanner({ pending, onApply, onCancel }) {
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
@@ -230,18 +236,32 @@ function AppCore() {
window.dispatchEvent(new CustomEvent('pic-config-discarded')); window.dispatchEvent(new CustomEvent('pic-config-discarded'));
}, []); }, []);
const coreServiceChildren = [ const [activeServiceChildren, setActiveServiceChildren] = useState([]);
{ name: 'Email', href: '/services/email', icon: Mail },
{ name: 'Calendar', href: '/services/calendar', icon: CalendarIcon }, const fetchActiveServices = useCallback(async () => {
{ name: 'Files', href: '/services/files', icon: FolderOpen }, 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 = [ const adminNavigation = [
{ name: 'Dashboard', href: '/', icon: Home }, { name: 'Dashboard', href: '/', icon: Home },
{ name: 'Peers', href: '/peers', icon: Users }, { name: 'Peers', href: '/peers', icon: Users },
{ name: 'Network Services', href: '/network', icon: Network }, { name: 'Network Services', href: '/network', icon: Network },
{ name: 'WireGuard', href: '/wireguard', icon: Shield }, { 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: 'Routing', href: '/routing', icon: Wifi },
{ name: 'Vault', href: '/vault', icon: Key }, { name: 'Vault', href: '/vault', icon: Key },
{ name: 'Containers', href: '/containers', icon: Package2 }, { name: 'Containers', href: '/containers', icon: Package2 },
@@ -255,7 +275,7 @@ function AppCore() {
const peerNavigation = [ const peerNavigation = [
{ name: 'Dashboard', href: '/', icon: Home }, { name: 'Dashboard', href: '/', icon: Home },
{ name: 'My Services', href: '/my-services', icon: Wifi }, { 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 }, { name: 'Account', href: '/account', icon: User },
]; ];
@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom';
import { Package } from 'lucide-react';
export default function ServiceNotInstalledBanner({ isAdmin = false }) {
return (
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
<div className="rounded-full bg-gray-100 p-4 mb-4">
<Package className="h-8 w-8 text-gray-400" />
</div>
<h2 className="text-lg font-semibold text-gray-700 mb-1">Service not installed</h2>
{isAdmin ? (
<p className="text-sm text-gray-500 mb-4">
This service isn't installed yet. Visit the Services catalog to install it.
</p>
) : (
<p className="text-sm text-gray-500">
This service hasn't been set up yet. Contact your cell administrator.
</p>
)}
{isAdmin && (
<Link
to="/services"
className="mt-2 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors"
>
Go to Services catalog
</Link>
)}
</div>
);
}
+79 -43
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Copy, Download, Wifi, Mail, Calendar, FolderOpen } from 'lucide-react'; import { Copy, Download, Wifi, Mail, Calendar, FolderOpen, Package } from 'lucide-react';
import { peerAPI } from '../services/api'; import { peerAPI, servicesAPI } from '../services/api';
function CopyButton({ text }) { function CopyButton({ text }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -39,6 +39,7 @@ export default function MyServices() {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [activeServiceIds, setActiveServiceIds] = useState([]);
useEffect(() => { useEffect(() => {
peerAPI.services() peerAPI.services()
@@ -47,6 +48,12 @@ export default function MyServices() {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}, []); }, []);
useEffect(() => {
servicesAPI.listActive()
.then(resp => setActiveServiceIds((resp.data || []).map(s => s.id)))
.catch(() => {});
}, []);
const downloadConfig = (filename, content) => { const downloadConfig = (filename, content) => {
const blob = new Blob([content], { type: 'text/plain' }); const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -114,52 +121,81 @@ export default function MyServices() {
)} )}
</div> </div>
<div className="card mb-4"> {activeServiceIds.length === 0 && (
<div className="flex items-center gap-2 mb-4"> <div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
<Mail className="h-5 w-5 text-primary-500" /> No optional services are installed yet. Contact your administrator.
<h2 className="text-base font-semibold text-gray-900">Email</h2>
</div> </div>
<InfoRow label="Address" value={email.address || '—'} /> )}
<InfoRow label="SMTP" value={email.smtp ? `${email.smtp.host}:${email.smtp.port}` : '—'} />
<InfoRow label="IMAP" value={email.imap ? `${email.imap.host}:${email.imap.port}` : '—'} />
{(email.smtp || email.imap) && (
<p className="text-xs text-gray-400 mt-3">
When setting up your mail client, use your dashboard username and password for authentication.
</p>
)}
</div>
<div className="card mb-4"> {activeServiceIds.includes('email') ? (
<div className="flex items-center gap-2 mb-4"> <div className="card mb-4">
<Calendar className="h-5 w-5 text-primary-500" /> <div className="flex items-center gap-2 mb-4">
<h2 className="text-base font-semibold text-gray-900">Calendar & Contacts</h2> <Mail className="h-5 w-5 text-primary-500" />
<h2 className="text-base font-semibold text-gray-900">Email</h2>
</div>
<InfoRow label="Address" value={email.address || '—'} />
<InfoRow label="SMTP" value={email.smtp ? `${email.smtp.host}:${email.smtp.port}` : '—'} />
<InfoRow label="IMAP" value={email.imap ? `${email.imap.host}:${email.imap.port}` : '—'} />
{(email.smtp || email.imap) && (
<p className="text-xs text-gray-400 mt-3">
When setting up your mail client, use your dashboard username and password for authentication.
</p>
)}
</div> </div>
<InfoRow label="CalDAV URL" value={caldav.url || '—'} /> ) : (
<InfoRow label="Username" value={caldav.username || '—'} /> <div className="card border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center py-8 text-center gap-2 mb-4">
{caldav.url && ( <Package className="h-6 w-6 text-gray-300" />
<p className="text-xs text-gray-400 mt-3"> <p className="text-sm text-gray-400">Email not installed</p>
Use this URL in your calendar client. Authenticate with your username and dashboard password.
</p>
)}
</div>
<div className="card mb-4">
<div className="flex items-center gap-2 mb-4">
<FolderOpen className="h-5 w-5 text-primary-500" />
<h2 className="text-base font-semibold text-gray-900">Files</h2>
</div> </div>
<InfoRow label="WebDAV URL" value={files.url || '—'} /> )}
<InfoRow label="Username" value={files.username || '—'} />
{files.url && (
<p className="text-xs text-gray-400 mt-3">
Mount this URL as a network drive or use a WebDAV client. Authenticate with your username and dashboard password.
</p>
)}
</div>
<p className="text-xs text-gray-400 mt-4"> {activeServiceIds.includes('calendar') ? (
Note: Changing your dashboard password does not update email, calendar, or files passwords. <div className="card mb-4">
</p> <div className="flex items-center gap-2 mb-4">
<Calendar className="h-5 w-5 text-primary-500" />
<h2 className="text-base font-semibold text-gray-900">Calendar &amp; Contacts</h2>
</div>
<InfoRow label="CalDAV URL" value={caldav.url || '—'} />
<InfoRow label="Username" value={caldav.username || '—'} />
{caldav.url && (
<p className="text-xs text-gray-400 mt-3">
Use this URL in your calendar client. Authenticate with your username and dashboard password.
</p>
)}
</div>
) : (
<div className="card border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center py-8 text-center gap-2 mb-4">
<Package className="h-6 w-6 text-gray-300" />
<p className="text-sm text-gray-400">Calendar &amp; Contacts not installed</p>
</div>
)}
{activeServiceIds.includes('files') ? (
<div className="card mb-4">
<div className="flex items-center gap-2 mb-4">
<FolderOpen className="h-5 w-5 text-primary-500" />
<h2 className="text-base font-semibold text-gray-900">Files</h2>
</div>
<InfoRow label="WebDAV URL" value={files.url || '—'} />
<InfoRow label="Username" value={files.username || '—'} />
{files.url && (
<p className="text-xs text-gray-400 mt-3">
Mount this URL as a network drive or use a WebDAV client. Authenticate with your username and dashboard password.
</p>
)}
</div>
) : (
<div className="card border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center py-8 text-center gap-2 mb-4">
<Package className="h-6 w-6 text-gray-300" />
<p className="text-sm text-gray-400">Files not installed</p>
</div>
)}
{(activeServiceIds.includes('email') || activeServiceIds.includes('calendar') || activeServiceIds.includes('files')) && (
<p className="text-xs text-gray-400 mt-4">
Note: Changing your dashboard password does not update email, calendar, or files passwords.
</p>
)}
</div> </div>
); );
} }
+10 -55
View File
@@ -1,5 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { import {
Package, Package,
Download, Download,
@@ -7,10 +6,6 @@ import {
RefreshCw, RefreshCw,
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
Mail,
Calendar as CalendarIcon,
FolderOpen,
ArrowRight,
} from 'lucide-react'; } from 'lucide-react';
import { storeAPI } from '../services/api'; 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); const [purge, setPurge] = useState(false);
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
@@ -72,7 +67,7 @@ function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
<div className="flex items-start gap-3 mb-4"> <div className="flex items-start gap-3 mb-4">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" /> <AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
<div> <div>
<h3 className="font-semibold text-gray-900">Remove {service.name}?</h3> <h3 className="font-semibold text-gray-900">Uninstall {service.name}?</h3>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
The service will be stopped and uninstalled. By default, data is kept on disk. The service will be stopped and uninstalled. By default, data is kept on disk.
</p> </p>
@@ -93,7 +88,7 @@ function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
onClick={() => onConfirm(purge)} 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" 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'}
</button> </button>
</div> </div>
</div> </div>
@@ -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" 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 ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
{removing ? 'Removing…' : 'Remove'} {removing ? 'Uninstalling…' : 'Uninstall'}
</button> </button>
) : ( ) : (
<button <button
@@ -149,38 +144,6 @@ function StoreServiceCard({ service, isInstalled, installedInfo, onInstall, onRe
); );
} }
// 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() { export default function ServicesIndex() {
const toasts = useToasts(); const toasts = useToasts();
@@ -231,6 +194,7 @@ export default function ServicesIndex() {
try { try {
await storeAPI.installService(service.id); await storeAPI.installService(service.id);
toastEvent(`${service.name} installed successfully`); toastEvent(`${service.name} installed successfully`);
window.dispatchEvent(new CustomEvent('pic-services-changed'));
await loadStore(); await loadStore();
} catch (err) { } catch (err) {
toastEvent(err.response?.data?.error || `Failed to install ${service.name}`, 'error'); 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' })); setOpState((s) => ({ ...s, [service.id]: 'removing' }));
try { try {
await storeAPI.removeService(service.id, purge); await storeAPI.removeService(service.id, purge);
toastEvent(`${service.name} removed`); toastEvent(`${service.name} uninstalled`);
window.dispatchEvent(new CustomEvent('pic-services-changed'));
await loadStore(); await loadStore();
} catch (err) { } 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 { } finally {
setOpState((s) => ({ ...s, [service.id]: null })); setOpState((s) => ({ ...s, [service.id]: null }));
} }
@@ -265,7 +230,7 @@ export default function ServicesIndex() {
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Services</h1> <h1 className="text-2xl font-bold text-gray-900">Services</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
Manage built-in services and browse optional add-ons Install and manage optional services for your cell
</p> </p>
</div> </div>
<button <button
@@ -278,16 +243,6 @@ export default function ServicesIndex() {
</button> </button>
</div> </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 */} {/* Add-ons from store */}
{isLoading && ( {isLoading && (
<div> <div>
@@ -369,7 +324,7 @@ export default function ServicesIndex() {
)} )}
{removeTarget && ( {removeTarget && (
<ConfirmRemoveDialog <ConfirmUninstallDialog
service={removeTarget} service={removeTarget}
onConfirm={handleRemoveConfirm} onConfirm={handleRemoveConfirm}
onCancel={() => setRemoveTarget(null)} onCancel={() => setRemoveTarget(null)}
+20 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; 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 { 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 { useConfig } from '../../contexts/ConfigContext';
import { useDraftConfig } from '../../contexts/DraftConfigContext'; import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@@ -69,6 +70,7 @@ function Toast({ msg, type }) {
export default function CalendarPage() { export default function CalendarPage() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const [notInstalled, setNotInstalled] = useState(null);
const [activeTab, setActiveTab] = useState('overview'); const [activeTab, setActiveTab] = useState('overview');
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig(); const draftConfig = useDraftConfig();
@@ -88,6 +90,15 @@ export default function CalendarPage() {
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const [peerData, setPeerData] = 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(() => { useEffect(() => {
if (service_configs.calendar) setCalCfg({ ...CAL_DEFAULTS, ...service_configs.calendar }); if (service_configs.calendar) setCalCfg({ ...CAL_DEFAULTS, ...service_configs.calendar });
}, [service_configs.calendar]); }, [service_configs.calendar]);
@@ -158,6 +169,14 @@ export default function CalendarPage() {
draftConfig?.setDirty('calendar', true); draftConfig?.setDirty('calendar', true);
}; };
if (notInstalled === true) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<ServiceNotInstalledBanner isAdmin={isAdmin} />
</div>
);
}
return ( return (
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
+20 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Mail, Users, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-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 { useConfig } from '../../contexts/ConfigContext';
import { useDraftConfig } from '../../contexts/DraftConfigContext'; import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@@ -79,6 +80,7 @@ function Toast({ msg, type }) {
export default function EmailPage() { export default function EmailPage() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const [notInstalled, setNotInstalled] = useState(null);
const [activeTab, setActiveTab] = useState('overview'); const [activeTab, setActiveTab] = useState('overview');
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig(); const draftConfig = useDraftConfig();
@@ -104,6 +106,15 @@ export default function EmailPage() {
// Peer state // Peer state
const [peerData, setPeerData] = useState(null); 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(() => { useEffect(() => {
if (service_configs.email) setEmailCfg({ ...EMAIL_DEFAULTS, ...service_configs.email }); if (service_configs.email) setEmailCfg({ ...EMAIL_DEFAULTS, ...service_configs.email });
}, [service_configs.email]); }, [service_configs.email]);
@@ -180,6 +191,14 @@ export default function EmailPage() {
draftConfig?.setDirty('email', true); draftConfig?.setDirty('email', true);
}; };
if (notInstalled === true) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<ServiceNotInstalledBanner isAdmin={isAdmin} />
</div>
);
}
return ( return (
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
+20 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; 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 { 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 { useConfig } from '../../contexts/ConfigContext';
import { useDraftConfig } from '../../contexts/DraftConfigContext'; import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@@ -76,6 +77,7 @@ function Toast({ msg, type }) {
export default function FilesPage() { export default function FilesPage() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const [notInstalled, setNotInstalled] = useState(null);
const [activeTab, setActiveTab] = useState('overview'); const [activeTab, setActiveTab] = useState('overview');
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig(); const draftConfig = useDraftConfig();
@@ -98,6 +100,15 @@ export default function FilesPage() {
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const [peerData, setPeerData] = 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(() => { useEffect(() => {
if (service_configs.files) setFilesCfg({ ...FILES_DEFAULTS, ...service_configs.files }); if (service_configs.files) setFilesCfg({ ...FILES_DEFAULTS, ...service_configs.files });
}, [service_configs.files]); }, [service_configs.files]);
@@ -169,6 +180,14 @@ export default function FilesPage() {
draftConfig?.setDirty('files', true); draftConfig?.setDirty('files', true);
}; };
if (notInstalled === true) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<ServiceNotInstalledBanner isAdmin={isAdmin} />
</div>
);
}
return ( return (
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
+1
View File
@@ -271,6 +271,7 @@ export const servicesAPI = {
startService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/start`), startService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/start`),
stopService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/stop`), stopService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/stop`),
restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`), restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`),
listActive: () => api.get('/api/services/active'),
}; };
// Accounts API (peer service account provisioning via AccountManager) // Accounts API (peer service account provisioning via AccountManager)