diff --git a/webui/src/components/ServiceAccountsPanel.jsx b/webui/src/components/ServiceAccountsPanel.jsx
new file mode 100644
index 0000000..8ffb380
--- /dev/null
+++ b/webui/src/components/ServiceAccountsPanel.jsx
@@ -0,0 +1,225 @@
+import { useState, useEffect } from 'react';
+import { Users, Plus, Trash2, Eye, EyeOff, CheckCircle, XCircle, Copy, CheckCheck } from 'lucide-react';
+import { accountsAPI } from '../services/api';
+
+const CRED_LABELS = {
+ username: 'Username',
+ password: 'Password',
+ imap_host: 'IMAP server',
+ imap_port: 'IMAP port',
+ smtp_host: 'SMTP server',
+ smtp_port: 'SMTP port',
+ caldav_url: 'CalDAV URL',
+ webdav_url: 'WebDAV URL',
+ server: 'Server',
+ address: 'Email address',
+};
+
+function Toast({ msg, type }) {
+ if (!msg) return null;
+ return (
+
+ {type === 'error' ? : }
+ {msg}
+
+ );
+}
+
+function CopyBtn({ text }) {
+ const [copied, setCopied] = useState(false);
+ return (
+
+ );
+}
+
+export default function ServiceAccountsPanel({ serviceId, serviceName }) {
+ const [accounts, setAccounts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(false);
+ const [newUsername, setNewUsername] = useState('');
+ const [provisioning, setProvisioning] = useState(false);
+ const [removing, setRemoving] = useState(null);
+ const [credentials, setCredentials] = useState({});
+ const [toast, setToast] = useState(null);
+
+ const showToast = (msg, type = 'success') => {
+ setToast({ msg, type });
+ setTimeout(() => setToast(null), 3500);
+ };
+
+ const load = async () => {
+ setLoading(true);
+ setLoadError(false);
+ try {
+ const r = await accountsAPI.list(serviceId);
+ setAccounts(r.data.accounts || []);
+ } catch {
+ setAccounts([]);
+ setLoadError(true);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { load(); }, [serviceId]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleProvision = async () => {
+ const username = newUsername.trim();
+ if (!username) return;
+ setProvisioning(true);
+ try {
+ await accountsAPI.provision(serviceId, username);
+ showToast(`${serviceName} account created for ${username}`);
+ setNewUsername('');
+ load();
+ } catch (err) {
+ showToast(err?.response?.data?.error || 'Failed to provision account', 'error');
+ } finally {
+ setProvisioning(false);
+ }
+ };
+
+ const handleDeprovision = async (username) => {
+ if (!window.confirm(`Remove the ${serviceName.toLowerCase()} account for "${username}"? This cannot be undone.`)) return;
+ setRemoving(username);
+ try {
+ await accountsAPI.deprovision(serviceId, username);
+ showToast(`Account removed for ${username}`);
+ setCredentials(prev => { const n = { ...prev }; delete n[username]; return n; });
+ load();
+ } catch (err) {
+ showToast(err?.response?.data?.error || 'Failed to remove account', 'error');
+ } finally {
+ setRemoving(null);
+ }
+ };
+
+ const toggleCredentials = async (username) => {
+ if (credentials[username]) {
+ setCredentials(prev => { const n = { ...prev }; delete n[username]; return n; });
+ return;
+ }
+ try {
+ const r = await accountsAPI.getCredentials(serviceId, username);
+ setCredentials(prev => ({ ...prev, [username]: r.data }));
+ } catch (err) {
+ showToast(err?.response?.data?.error || 'Failed to load credentials', 'error');
+ }
+ };
+
+ return (
+
+
+
+ {/* Provision form */}
+
+
+
+
Provision Account
+
+
+ Create a {serviceName.toLowerCase()} account for a peer. The peer uses their dashboard password to authenticate.
+
+
+
+
+
setNewUsername(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleProvision()}
+ placeholder="e.g. alice"
+ className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none"
+ />
+
+
+
+
+
+ {/* Account list */}
+
+
+
+ Provisioned Accounts
+ {accounts.length > 0 && (
+ ({accounts.length})
+ )}
+
+
+
+ {loading ? (
+
Loading…
+ ) : loadError ? (
+
+ Failed to load accounts. Check your connection and try again.
+
+ ) : accounts.length === 0 ? (
+
+ No peer accounts provisioned yet. Use the form above to create one.
+
+ ) : (
+
+ {accounts.map(username => (
+
+
+
{username}
+
+
+
+
+
+
+ {credentials[username] && (
+
+ {Object.entries(credentials[username]).map(([k, v]) => (
+
+
{CRED_LABELS[k] || k}
+
+ {String(v)}
+
+
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/webui/src/pages/services/CalendarPage.jsx b/webui/src/pages/services/CalendarPage.jsx
index eb0cab7..066a268 100644
--- a/webui/src/pages/services/CalendarPage.jsx
+++ b/webui/src/pages/services/CalendarPage.jsx
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateCalendarConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
+import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
const CAL_DEFAULTS = { port: 5232, data_dir: '' };
@@ -68,6 +69,7 @@ function Toast({ msg, type }) {
export default function CalendarPage() {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
+ const [activeTab, setActiveTab] = useState('overview');
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig();
@@ -160,11 +162,31 @@ export default function CalendarPage() {
-
+
Calendar & Contacts
Radicale CalDAV / CardDAV server
+ {isAdmin && (
+
+
+
+ )}
+
+ {activeTab === 'accounts' && isAdmin ? (
+
+ ) : (
{/* Connection info */}
@@ -287,7 +309,10 @@ export default function CalendarPage() {
)}
- {isAdmin && (
+
+ )}
+
+ {isAdmin && activeTab !== 'accounts' && (
-
+
Email Services
Postfix (SMTP) + Dovecot (IMAP)
+ {isAdmin && (
+
+
+
+ )}
+
+ {activeTab === 'accounts' && isAdmin ? (
+
+ ) : (
{/* IMAP */}
@@ -291,8 +313,10 @@ export default function EmailPage() {
)}
- {/* Admin config form */}
- {isAdmin && (
+
+ )}
+
+ {isAdmin && activeTab !== 'accounts' && (
-
+
File Storage
FileGator (browser) + WebDAV (native clients)
+ {isAdmin && (
+
+
+
+ )}
+
+ {activeTab === 'accounts' && isAdmin ? (
+
+ ) : (
{/* File manager */}
@@ -291,7 +313,10 @@ export default function FilesPage() {
)}
- {isAdmin && (
+
+ )}
+
+ {isAdmin && activeTab !== 'accounts' && (
api.post(`/api/services/bus/services/${serviceName}/restart`),
};
+// Accounts API (peer service account provisioning via AccountManager)
+export const accountsAPI = {
+ list: (serviceId) => api.get(`/api/services/catalog/${serviceId}/accounts`),
+ provision: (serviceId, username, password) =>
+ api.post(`/api/services/catalog/${serviceId}/accounts`, {
+ username,
+ ...(password ? { password } : {}),
+ }),
+ deprovision: (serviceId, username) =>
+ api.delete(`/api/services/catalog/${serviceId}/accounts/${username}`),
+ getCredentials: (serviceId, username) =>
+ api.get(`/api/services/catalog/${serviceId}/accounts/${username}/credentials`),
+};
+
// Cell-to-cell connections API
export const cellLinkAPI = {
getInvite: () => api.get('/api/cells/invite'),