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
+69
View File
@@ -60,6 +60,24 @@
border: none;
}
.sidebar-logs {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.75rem 0.75rem;
flex-shrink: 0;
}
.sidebar-category-label {
margin: 0 0 0.15rem;
padding: 0 0.75rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ctp-subtext0);
}
.sidebar-footer {
display: flex;
flex-direction: column;
@@ -395,6 +413,21 @@
cursor: default;
}
.node-delete {
padding: 0.35rem 0.7rem;
border: 1px solid var(--ctp-red);
border-radius: 6px;
background: transparent;
color: var(--ctp-red);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.node-delete:hover {
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
}
.node-test-result {
margin: 0;
font-size: 0.85rem;
@@ -875,3 +908,39 @@
word-break: break-all;
font-size: 0.85rem;
}
.logs-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.logs-filters {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.logs-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.logs-table th,
.logs-table td {
padding: 0.55rem 0.65rem;
text-align: left;
border-bottom: 1px solid var(--ctp-surface0);
vertical-align: top;
}
.logs-table th {
font-weight: 600;
color: var(--ctp-subtext0);
font-size: 0.8rem;
}
.logs-table code {
font-size: 0.85em;
}
+113 -3
View File
@@ -30,6 +30,7 @@ function stubFetchOk() {
'nodes.read',
'nodes.exec',
'nodes.update',
'nodes.delete',
'jobs.read',
'jobs.run',
'users.manage',
@@ -70,6 +71,7 @@ function stubFetchOk() {
'nodes.read',
'nodes.exec',
'nodes.update',
'nodes.delete',
'jobs.read',
'jobs.run',
'users.manage',
@@ -373,6 +375,7 @@ describe('App', () => {
'nodes.read',
'nodes.exec',
'nodes.update',
'nodes.delete',
'jobs.read',
'jobs.run',
'users.manage',
@@ -466,6 +469,7 @@ describe('App', () => {
'nodes.read',
'nodes.exec',
'nodes.update',
'nodes.delete',
'jobs.read',
'jobs.run',
'users.manage',
@@ -749,14 +753,13 @@ describe('App', () => {
expect(await screen.findByText('Operators')).toBeInTheDocument()
expect(screen.getByText('Administrators')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1)
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(2)
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
expect(deleteButtons[0]).toBeDisabled()
expect(deleteButtons[1]).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Edit' }))
await user.click(screen.getAllByRole('button', { name: 'Edit' })[1])
expect(screen.getByText('Edit group')).toBeInTheDocument()
expect(screen.getByLabelText('Group name')).toHaveValue('Operators')
@@ -778,6 +781,113 @@ describe('App', () => {
)
})
it('edits Administrators group permissions from the Groups tab', async () => {
const user = userEvent.setup()
const administratorsGroup = {
name: 'Administrators',
scope_kind: 'group' as const,
scope_name: 'Admin Group',
permissions: ['users.manage', 'roles.manage'],
}
const updatedAdministratorsGroup = {
...administratorsGroup,
permissions: ['users.manage', 'roles.manage', 'nodes.delete'],
}
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
}),
})
}
if (url.includes('/health')) {
return Promise.resolve({
ok: true,
json: async () => ({ status: 'ok' }),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
})
}
if (url.includes('/api/v1/groups') && init?.method === 'POST') {
return Promise.resolve({
ok: true,
json: async () => ({
groups: [updatedAdministratorsGroup],
}),
})
}
if (url.includes('/api/v1/groups')) {
return Promise.resolve({
ok: true,
json: async () => ({
groups: [administratorsGroup],
}),
})
}
return Promise.resolve({ ok: true, json: async () => ({}) })
})
vi.stubGlobal('fetch', fetchMock)
render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.click(screen.getByRole('tab', { name: 'Groups' }))
expect(await screen.findByText('Administrators')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByText('Edit group')).toBeInTheDocument()
expect(screen.getByLabelText('Group name')).toHaveValue('Administrators')
expect(screen.getByLabelText('Group name')).toHaveAttribute('readonly')
expect(screen.getByLabelText('users.manage')).toBeChecked()
expect(screen.getByLabelText('nodes.delete')).not.toBeChecked()
await user.click(screen.getByLabelText('nodes.delete'))
await user.click(screen.getByRole('button', { name: 'Save changes' }))
expect(await screen.findByText('Create group')).toBeInTheDocument()
expect(
screen.getByText(
'Permissions: users.manage, roles.manage, nodes.delete',
),
).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/groups'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
group: {
name: 'Administrators',
scope_kind: 'group',
scope_name: 'Admin Group',
permissions: ['users.manage', 'roles.manage', 'nodes.delete'],
},
}),
}),
)
})
it('creates a new group from the Groups tab', async () => {
const user = userEvent.setup()
const createdGroup = {
+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}
+65
View File
@@ -1,10 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
deleteGroup,
deleteNode,
deleteUser,
fetchGroups,
fetchHealth,
fetchNetwork,
fetchNodeLogs,
fetchSecurity,
fetchStatus,
fetchUsers,
@@ -326,6 +328,69 @@ describe('deleteUser', () => {
})
})
describe('deleteNode', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends DELETE by id with reauth credentials and returns nodes', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ nodes: [] }),
})
await deleteNode(
'node-1',
{ current_password: 'correct horse battery staple extra' },
fetchMock,
)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes/node-1',
expect.objectContaining({
method: 'DELETE',
credentials: 'include',
body: JSON.stringify({
current_password: 'correct horse battery staple extra',
totp_code: '',
}),
}),
)
})
})
describe('fetchNodeLogs', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns parsed logs payload with optional kind filter', async () => {
const events = [
{
id: 'e1',
at: '2026-01-01T00:00:00Z',
action: 'create',
actor: 'Admin',
node_id: 'n1',
node_name: 'ct1',
node_kind: 'container',
},
]
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ events }),
})
const payload = await fetchNodeLogs('container', fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/node-logs?kind=container',
expect.objectContaining({ credentials: 'include' }),
)
expect(payload.events).toEqual(events)
})
})
describe('fetchSecurity', () => {
afterEach(() => {
vi.restoreAllMocks()
+51
View File
@@ -675,6 +675,28 @@ export async function createNode(
return (await response.json()) as NodeResponse
}
export async function deleteNode(
id: string,
credentials: ReauthCredentials,
fetchImpl: typeof fetch = fetch,
): Promise<NodesResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}`,
{
method: 'DELETE',
body: JSON.stringify({
current_password: credentials.current_password,
totp_code: credentials.totp_code ?? '',
}),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodesResponse
}
export async function fetchNode(
id: string,
fetchImpl: typeof fetch = fetch,
@@ -709,3 +731,32 @@ export async function testNodeSSH(
}
return (await response.json()) as NodeSSHTestResponse
}
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
export type NodeAuditEvent = {
id: string
at: string
action: NodeAuditAction
actor: string
node_id: string
node_name: string
node_kind: NodeKind
detail?: string
}
export type NodeLogsResponse = {
events: ReadonlyArray<NodeAuditEvent>
}
export async function fetchNodeLogs(
kind?: NodeKind,
fetchImpl: typeof fetch = fetch,
): Promise<NodeLogsResponse> {
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
const response = await apiFetch(`/api/v1/node-logs${query}`, {}, fetchImpl)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeLogsResponse
}
+2
View File
@@ -11,6 +11,7 @@ describe('pathFor', () => {
expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add')
expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add')
expect(pathFor('profile')).toBe('/profile')
expect(pathFor('logs')).toBe('/logs')
expect(pathFor('setup')).toBe('/setup')
expect(pathFor('login')).toBe('/login')
expect(pathFor('configuration')).toBe('/configuration')
@@ -31,6 +32,7 @@ describe('parseLocation', () => {
'/containers/add',
'/docker/overview',
'/profile',
'/logs',
'/setup',
'/login',
'/configuration',
+8
View File
@@ -3,6 +3,7 @@ export type SectionId =
| 'vms'
| 'docker'
| 'containers'
| 'logs'
| 'profile'
| 'configuration'
| 'setup'
@@ -92,6 +93,10 @@ export function parseLocation(pathname: string): AppLocation {
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
}
if (first === 'logs' && segments.length === 1) {
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
}
if (RESOURCE_SECTION_IDS.has(first)) {
const section = first as ResourceSectionId
if (segments.length === 1) {
@@ -141,6 +146,9 @@ export function pathFor(
if (section === 'profile') {
return '/profile'
}
if (section === 'logs') {
return '/logs'
}
if (isResourceSectionId(section)) {
if (resourceTab === 'overview') {
return `/${section}`