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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & Contacts</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Calendar & 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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user