Add nodes.delete with reauth, activity logs, and Administrators editing.

Allow deleting managed hosts with mandatory inline re-auth, record node mutations in an audit log with a sidebar Logs view, and let Administrators group permissions be edited from the Groups tab.
This commit is contained in:
2026-07-18 16:54:13 +02:00
parent f4dc8f63d7
commit 77be394407
17 changed files with 1326 additions and 71 deletions
+288 -59
View File
@@ -6,6 +6,7 @@ import {
createNode,
createUser,
deleteGroup,
deleteNode,
deleteUser,
DEFAULT_NETWORK_SETTINGS,
DEFAULT_SECURITY_SETTINGS,
@@ -13,6 +14,7 @@ import {
fetchGroups,
fetchMe,
fetchNetwork,
fetchNodeLogs,
fetchNodes,
fetchSecurity,
fetchSetupStatus,
@@ -28,6 +30,7 @@ import {
type MeResponse,
type NetworkSettings,
type Node,
type NodeAuditEvent,
type NodeKind,
type ReauthCredentials,
type SecuritySettings,
@@ -76,6 +79,7 @@ const SECTION_TITLES: Record<SectionId, string> = {
vms: 'Virtual Machines',
docker: 'Docker',
containers: 'Containers',
logs: 'Logs',
profile: 'Profile',
configuration: 'Configuration',
setup: 'Setup',
@@ -109,6 +113,7 @@ const DEFAULT_PERMISSIONS = [
'nodes.read',
'nodes.exec',
'nodes.update',
'nodes.delete',
'jobs.read',
'jobs.run',
'users.manage',
@@ -1034,9 +1039,6 @@ function GroupsConfigTab() {
}
function handleEditGroup(group: Group) {
if (group.name === ADMINISTRATORS_GROUP_NAME) {
return
}
setEditingGroupName(group.name)
setDraftGroupName(group.name)
setDraftScopeKind(group.scope_kind)
@@ -1061,8 +1063,8 @@ function GroupsConfigTab() {
async function handleSaveGroup() {
setSubmitError(null)
if (draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
setSubmitError('Administrators group cannot be created or modified here')
if (!isEditing && draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
setSubmitError('Administrators group cannot be created here')
return
}
@@ -1133,15 +1135,13 @@ function GroupsConfigTab() {
</div>
</div>
<div className="groups-li-actions">
{!isAdministrators ? (
<button
type="button"
className="groups-edit"
onClick={() => handleEditGroup(group)}
>
Edit
</button>
) : null}
<button
type="button"
className="groups-edit"
onClick={() => handleEditGroup(group)}
>
Edit
</button>
<button
type="button"
className="groups-delete"
@@ -1770,10 +1770,12 @@ function ResourceOverviewTab({
section,
canRead,
canExec,
canDelete,
}: {
section: ResourceSectionId
canRead: boolean
canExec: boolean
canDelete: boolean
}) {
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
const [isLoading, setIsLoading] = useState(true)
@@ -1782,6 +1784,34 @@ function ResourceOverviewTab({
const [testResults, setTestResults] = useState(
{} as Record<string, { ok: boolean; message: string }>,
)
const [me, setMe] = useState<MeResponse | null>(null)
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(
null,
)
const requireTotp = Boolean(me?.totp_confirmed && me?.totp_enabled)
useEffect(() => {
let isCancelled = false
async function loadMe() {
try {
const response = await fetchMe()
if (!isCancelled) {
setMe(response)
}
} catch {
if (!isCancelled) {
setMe(null)
}
}
}
void loadMe()
return () => {
isCancelled = true
}
}, [])
useEffect(() => {
if (!canRead) {
@@ -1846,6 +1876,19 @@ function ResourceOverviewTab({
}
}
async function handleDeleteConfirm(credentials: ReauthCredentials) {
if (!pendingDeleteNodeId) {
return
}
const response = await deleteNode(pendingDeleteNodeId, credentials)
setNodes(
response.nodes.filter(
(node) => node.kind === RESOURCE_SECTION_KIND[section],
),
)
setPendingDeleteNodeId(null)
}
if (!canRead) {
return (
<p className="config-hint">
@@ -1872,52 +1915,79 @@ function ResourceOverviewTab({
}
return (
<ul className="node-list">
{nodes.map((node) => {
const testResult = testResults[node.id]
return (
<li key={node.id} className="node-list-item">
<div className="node-list-heading">
<strong>{node.name}</strong>
<code className="node-id">{node.id}</code>
</div>
<p className="config-hint">
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
{node.key_algo}
</p>
{canExec ? (
<div className="node-actions">
<button
type="button"
className="node-test-ssh"
disabled={testingNodeId === node.id}
onClick={() => {
void handleTestSSH(node.id)
}}
>
{testingNodeId === node.id
? 'Testing SSH…'
: 'Test SSH connection'}
</button>
{testResult ? (
<p
className={
testResult.ok
? 'node-test-result node-test-result-ok'
: 'node-test-result node-test-result-fail'
}
role="status"
>
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
{testResult.message}
</p>
) : null}
<>
{pendingDeleteNodeId ? (
<ReauthModal
title="Confirm delete"
hint="Re-enter your password to delete this host."
requireTotp={requireTotp}
confirmLabel="Delete host"
onCancel={() => {
setPendingDeleteNodeId(null)
}}
onConfirm={handleDeleteConfirm}
/>
) : null}
<ul className="node-list">
{nodes.map((node) => {
const testResult = testResults[node.id]
return (
<li key={node.id} className="node-list-item">
<div className="node-list-heading">
<strong>{node.name}</strong>
<code className="node-id">{node.id}</code>
</div>
) : null}
</li>
)
})}
</ul>
<p className="config-hint">
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
{node.key_algo}
</p>
{canExec || canDelete ? (
<div className="node-actions">
{canExec ? (
<button
type="button"
className="node-test-ssh"
disabled={testingNodeId === node.id}
onClick={() => {
void handleTestSSH(node.id)
}}
>
{testingNodeId === node.id
? 'Testing SSH…'
: 'Test SSH connection'}
</button>
) : null}
{canDelete ? (
<button
type="button"
className="node-delete"
onClick={() => {
setPendingDeleteNodeId(node.id)
}}
>
Delete
</button>
) : null}
{testResult ? (
<p
className={
testResult.ok
? 'node-test-result node-test-result-ok'
: 'node-test-result node-test-result-fail'
}
role="status"
>
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
{testResult.message}
</p>
) : null}
</div>
) : null}
</li>
)
})}
</ul>
</>
)
}
@@ -2270,6 +2340,7 @@ function ResourceSectionPanel({
const canRead = Boolean(me?.permissions?.includes('nodes.read'))
const canExec = Boolean(me?.permissions?.includes('nodes.exec'))
const canDelete = Boolean(me?.permissions?.includes('nodes.delete'))
const tabs = resourceTabsFor(section)
return (
@@ -2313,6 +2384,7 @@ function ResourceSectionPanel({
section={section}
canRead={canRead}
canExec={canExec}
canDelete={canDelete}
/>
) : null}
{activeResourceTab === 'security' ? (
@@ -2330,6 +2402,137 @@ function ResourceSectionPanel({
)
}
function LogsPanel() {
type KindFilter = 'all' | NodeKind
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
const [events, setEvents] = useState<ReadonlyArray<NodeAuditEvent>>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [canRead, setCanRead] = useState(false)
useEffect(() => {
let isCancelled = false
async function load() {
setIsLoading(true)
setLoadError(null)
try {
const me = await fetchMe()
if (isCancelled) {
return
}
const allowed = Boolean(me?.permissions?.includes('nodes.read'))
setCanRead(allowed)
if (!allowed) {
setEvents([])
return
}
const response = await fetchNodeLogs(
kindFilter === 'all' ? undefined : kindFilter,
)
if (!isCancelled) {
setEvents(response.events)
}
} catch (err) {
if (!isCancelled) {
setLoadError(
err instanceof Error ? err.message : 'Failed to load logs',
)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void load()
return () => {
isCancelled = true
}
}, [kindFilter])
const filters: ReadonlyArray<{ id: KindFilter; label: string }> = [
{ id: 'all', label: 'All' },
{ id: 'container', label: 'Containers' },
{ id: 'docker', label: 'Docker' },
{ id: 'vm', label: 'Virtual Machines' },
]
if (!isLoading && !canRead) {
return (
<p className="config-hint">
You need the <code>nodes.read</code> permission to view node activity
logs.
</p>
)
}
return (
<div className="logs-panel">
<div className="logs-filters" role="tablist" aria-label="Log kind filter">
{filters.map((filter) => {
const isSelected = kindFilter === filter.id
return (
<button
key={filter.id}
type="button"
role="tab"
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
onClick={() => setKindFilter(filter.id)}
>
{filter.label}
</button>
)
})}
</div>
{isLoading ? <p className="config-hint">Loading</p> : null}
{loadError ? <p className="config-error">{loadError}</p> : null}
{!isLoading && !loadError && events.length === 0 ? (
<p className="config-hint">No node activity recorded yet.</p>
) : null}
{!isLoading && events.length > 0 ? (
<table className="logs-table">
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Action</th>
<th scope="col">Actor</th>
<th scope="col">Node</th>
<th scope="col">Kind</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.id}>
<td>{formatUserTimestamp(event.at)}</td>
<td>
<code>{event.action}</code>
</td>
<td>{event.actor}</td>
<td>
<strong>{event.node_name}</strong>
<div>
<code className="node-id">{event.node_id}</code>
</div>
</td>
<td>{event.node_kind}</td>
<td>{event.detail || '—'}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
)
}
function ConfigurationPanel({
activeConfigTab,
onConfigTabChange,
@@ -2432,6 +2635,7 @@ function App() {
)
const [backendVersion, setBackendVersion] = useState('…')
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
const [canReadLogs, setCanReadLogs] = useState(false)
const { themePreference, updateThemePreference } = useThemePreference()
const profileLabel = profileDisplayName ?? 'Profile'
@@ -2471,6 +2675,7 @@ function App() {
}
setProfileDisplayName(me.username)
setCanReadLogs(Boolean(me.permissions?.includes('nodes.read')))
setBootState({ kind: 'ready', username: me.username })
if (
window.location.pathname === '/setup' ||
@@ -2577,6 +2782,9 @@ function App() {
<LoginPage
onLoggedIn={(username) => {
setProfileDisplayName(username)
void fetchMe().then((me) => {
setCanReadLogs(Boolean(me?.permissions?.includes('nodes.read')))
})
setBootState({ kind: 'ready', username })
navigateTo('/')
setActiveSection('overview')
@@ -2628,6 +2836,24 @@ function App() {
))}
</nav>
{canReadLogs ? (
<div className="sidebar-logs">
<p className="sidebar-category-label">Logs</p>
<button
type="button"
className={
activeSection === 'logs'
? 'nav-item nav-item-active'
: 'nav-item'
}
aria-current={activeSection === 'logs' ? 'page' : undefined}
onClick={() => goToSection('logs')}
>
Activity
</button>
</div>
) : null}
<div className="sidebar-footer">
<button
type="button"
@@ -2675,10 +2901,13 @@ function App() {
<ProfilePanel
onSignedOut={() => {
setProfileDisplayName(null)
setCanReadLogs(false)
setBootState({ kind: 'login' })
navigateTo('/login')
}}
/>
) : activeSection === 'logs' ? (
<LogsPanel />
) : isResourceSectionId(activeSection) ? (
<ResourceSectionPanel
section={activeSection}