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:
+288
-59
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user