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'),