feat: Admin UI — Accounts tab on service pages (Step 6)
Unit Tests / test (push) Failing after 11s

Admins previously had no UI path to provision per-peer accounts for
email, calendar, and files: they had to hit the AccountManager API
routes directly.  This change wires those routes to a dedicated Accounts
tab on each service page so any peer can be granted or revoked service
access in two clicks.

- webui/src/services/api.js: add accountsAPI with list/provision/
  deprovision/getCredentials, pointing to
  /api/services/catalog/{serviceId}/accounts
- webui/src/components/ServiceAccountsPanel.jsx: new reusable panel;
  handles credential reveal, removal confirmation, load-error state,
  and humanized credential labels
- EmailPage, CalendarPage, FilesPage: Overview/Accounts tab nav (admin
  only); Accounts tab renders ServiceAccountsPanel; AdminConfigSection
  is hidden while on the Accounts tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 20:29:57 -04:00
parent 16fb362df7
commit ad5731073d
5 changed files with 320 additions and 7 deletions
@@ -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 (
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
{msg}
</div>
);
}
function CopyBtn({ text }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="ml-1 shrink-0 text-gray-400 hover:text-gray-600"
title="Copy to clipboard"
aria-label="Copy to clipboard"
>
{copied ? <CheckCheck className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</button>
);
}
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 (
<div className="space-y-4">
<Toast {...(toast || {})} />
{/* Provision form */}
<div className="card">
<div className="flex items-center gap-2 mb-4">
<Users className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Provision Account</h3>
</div>
<p className="text-sm text-gray-500 mb-3">
Create a {serviceName.toLowerCase()} account for a peer. The peer uses their dashboard password to authenticate.
</p>
<div className="space-y-2">
<label htmlFor={`provision-${serviceId}`} className="block text-sm font-medium text-gray-700">
Peer username
</label>
<div className="flex gap-2">
<input
id={`provision-${serviceId}`}
type="text"
value={newUsername}
onChange={e => 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"
/>
<button
onClick={handleProvision}
disabled={provisioning || !newUsername.trim()}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Plus className="h-4 w-4" />
{provisioning ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
{/* Account list */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-medium text-gray-900">
Provisioned Accounts
{accounts.length > 0 && (
<span className="ml-2 text-xs font-normal text-gray-400">({accounts.length})</span>
)}
</h3>
</div>
{loading ? (
<p className="text-sm text-gray-400 py-4 text-center">Loading</p>
) : loadError ? (
<p className="text-sm text-red-500 py-4 text-center">
Failed to load accounts. Check your connection and try again.
</p>
) : accounts.length === 0 ? (
<p className="text-sm text-gray-400 py-4 text-center">
No peer accounts provisioned yet. Use the form above to create one.
</p>
) : (
<div className="space-y-2">
{accounts.map(username => (
<div key={username} className="rounded-lg border border-gray-100 bg-gray-50 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm font-medium text-gray-800">{username}</span>
<div className="flex items-center gap-3">
<button
onClick={() => toggleCredentials(username)}
className="inline-flex items-center gap-1 text-xs text-primary-600 hover:text-primary-800 font-medium"
>
{credentials[username]
? <><EyeOff className="h-3.5 w-3.5" /> Hide</>
: <><Eye className="h-3.5 w-3.5" /> Credentials</>}
</button>
<button
onClick={() => handleDeprovision(username)}
disabled={removing === username}
className="inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-800 font-medium disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
{removing === username ? 'Removing…' : 'Remove'}
</button>
</div>
</div>
{credentials[username] && (
<div className="border-t border-gray-200 px-3 py-2 bg-white space-y-1">
{Object.entries(credentials[username]).map(([k, v]) => (
<div key={k} className="flex flex-wrap items-start gap-x-2 gap-y-0.5 text-xs py-0.5">
<span className="text-gray-500 w-28 shrink-0">{CRED_LABELS[k] || k}</span>
<div className="flex items-start min-w-0 flex-1">
<span className="font-mono text-gray-700 break-all">{String(v)}</span>
<CopyBtn text={String(v)} />
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
+27 -2
View File
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields'; import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateCalendarConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; import { detectPortConflicts, validateCalendarConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
const CAL_DEFAULTS = { port: 5232, data_dir: '' }; const CAL_DEFAULTS = { port: 5232, data_dir: '' };
@@ -68,6 +69,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 [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();
@@ -160,11 +162,31 @@ export default function CalendarPage() {
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
<div className="mb-8"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1> <h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1>
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p> <p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
</div> </div>
{isAdmin && (
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}>
{tab.label}
</button>
))}
</nav>
</div>
)}
{activeTab === 'accounts' && isAdmin ? (
<ServiceAccountsPanel serviceId="calendar" serviceName="Calendar" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Connection info */} {/* Connection info */}
<div className="card"> <div className="card">
@@ -287,7 +309,10 @@ export default function CalendarPage() {
)} )}
</div> </div>
{isAdmin && ( </div>
)}
{isAdmin && activeTab !== 'accounts' && (
<AdminConfigSection <AdminConfigSection
calCfg={calCfg} calCfg={calCfg}
onChange={handleChange} onChange={handleChange}
+27 -3
View File
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields'; import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateEmailConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; import { detectPortConflicts, validateEmailConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
const EMAIL_DEFAULTS = { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 }; const EMAIL_DEFAULTS = { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 };
@@ -78,6 +79,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 [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();
@@ -182,11 +184,31 @@ export default function EmailPage() {
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
<div className="mb-8"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1> <h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p> <p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
</div> </div>
{isAdmin && (
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}>
{tab.label}
</button>
))}
</nav>
</div>
)}
{activeTab === 'accounts' && isAdmin ? (
<ServiceAccountsPanel serviceId="email" serviceName="Email" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IMAP */} {/* IMAP */}
<div className="card"> <div className="card">
@@ -291,8 +313,10 @@ export default function EmailPage() {
)} )}
</div> </div>
{/* Admin config form */} </div>
{isAdmin && ( )}
{isAdmin && activeTab !== 'accounts' && (
<AdminConfigSection <AdminConfigSection
emailCfg={emailCfg} emailCfg={emailCfg}
onChange={handleChange} onChange={handleChange}
+27 -2
View File
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields'; import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateFilesConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; import { detectPortConflicts, validateFilesConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
const FILES_DEFAULTS = { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 }; const FILES_DEFAULTS = { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 };
@@ -75,6 +76,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 [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();
@@ -171,11 +173,31 @@ export default function FilesPage() {
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
<div className="mb-8"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1> <h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p> <p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
</div> </div>
{isAdmin && (
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}>
{tab.label}
</button>
))}
</nav>
</div>
)}
{activeTab === 'accounts' && isAdmin ? (
<ServiceAccountsPanel serviceId="files" serviceName="Files" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File manager */} {/* File manager */}
<div className="card"> <div className="card">
@@ -291,7 +313,10 @@ export default function FilesPage() {
)} )}
</div> </div>
{isAdmin && ( </div>
)}
{isAdmin && activeTab !== 'accounts' && (
<AdminConfigSection <AdminConfigSection
filesCfg={filesCfg} filesCfg={filesCfg}
onChange={handleChange} onChange={handleChange}
+14
View File
@@ -273,6 +273,20 @@ export const servicesAPI = {
restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`), restartService: (serviceName) => 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 // Cell-to-cell connections API
export const cellLinkAPI = { export const cellLinkAPI = {
getInvite: () => api.get('/api/cells/invite'), getInvite: () => api.get('/api/cells/invite'),