feat: Phase 4 — dynamic nav + service visibility based on installed services
Unit Tests / test (push) Successful in 11m24s
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:
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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 & 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 || {})} />
|
||||||
|
|||||||
@@ -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 || {})} />
|
||||||
|
|||||||
@@ -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 || {})} />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user