More webui layout, navigation, preparations of user/roles/groups/security

This commit is contained in:
2026-07-16 22:00:02 +02:00
parent 324367abd7
commit 63deb02d39
13 changed files with 1352 additions and 31 deletions
+295 -18
View File
@@ -1,5 +1,20 @@
import { useEffect, useState } from 'react'
import { fetchStatus } from './api/client'
import {
deleteGroup,
fetchGroups,
fetchStatus,
type Group,
type GroupScopeKind,
upsertGroup,
} from './api/client'
import {
navigateTo,
parseLocation,
pathFor,
subscribeToLocation,
type ConfigTabId,
type SectionId,
} from './navigation'
import {
applyThemeFromPreference,
getSystemPrefersDark,
@@ -9,8 +24,6 @@ import {
} from './theme'
import './App.css'
type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'vms', label: 'VMs' },
@@ -24,18 +37,10 @@ const SECTION_TITLES: Record<SectionId, string> = {
configuration: 'Configuration',
}
type ConfigTabId =
| 'overview'
| 'users'
| 'groups'
| 'security'
| 'integrations'
| 'network'
| 'advanced'
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'roles', label: 'Roles' },
{ id: 'groups', label: 'Groups' },
{ id: 'security', label: 'Security' },
{ id: 'integrations', label: 'Integrations' },
@@ -43,6 +48,17 @@ const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
{ id: 'advanced', label: 'Advanced' },
]
const DEFAULT_PERMISSIONS = [
'nodes.read',
'nodes.exec',
'nodes.update',
'jobs.read',
'jobs.run',
'users.manage',
'secrets.manage',
'roles.manage',
] as const
function CogIcon() {
return (
<svg
@@ -122,6 +138,213 @@ function OverviewConfigTab({
)
}
function GroupsConfigTab() {
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
const [groupsLoadError, setGroupsLoadError] = useState<string | null>(null)
const [draftGroupName, setDraftGroupName] = useState('')
const [draftScopeKind, setDraftScopeKind] = useState<GroupScopeKind>('node')
const [draftScopeName, setDraftScopeName] = useState('')
const [draftPermissions, setDraftPermissions] = useState<string[]>([])
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
let isCancelled = false
async function loadGroups() {
setIsLoadingGroups(true)
setGroupsLoadError(null)
try {
const response = await fetchGroups()
if (!isCancelled) {
setGroups(response.groups)
}
} catch (err) {
if (!isCancelled) {
setGroupsLoadError(
err instanceof Error ? err.message : 'Failed to load groups',
)
}
} finally {
if (!isCancelled) {
setIsLoadingGroups(false)
}
}
}
void loadGroups()
return () => {
isCancelled = true
}
}, [])
function togglePermission(permission: string, checked: boolean) {
setDraftPermissions((prev) => {
if (checked) {
if (prev.includes(permission)) {
return prev
}
return [...prev, permission]
}
return prev.filter((item) => item !== permission)
})
}
async function handleSaveGroup() {
setSubmitError(null)
try {
const response = await upsertGroup({
name: draftGroupName,
scope_kind: draftScopeKind,
scope_name: draftScopeName,
permissions: draftPermissions,
})
setGroups(response.groups)
setDraftGroupName('')
setDraftScopeKind('node')
setDraftScopeName('')
setDraftPermissions([])
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to save group',
)
}
}
async function handleDeleteGroup(groupName: string) {
setSubmitError(null)
try {
const response = await deleteGroup(groupName)
setGroups(response.groups)
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to delete group',
)
}
}
return (
<div className="groups-panel">
<div className="groups-list">
<p className="config-hint">Configured groups</p>
{isLoadingGroups ? (
<p className="config-empty">Loading</p>
) : groupsLoadError ? (
<p className="config-empty" role="alert">
{groupsLoadError}
</p>
) : groups.length === 0 ? (
<p className="config-empty">No groups configured yet.</p>
) : (
<ul className="groups-ul">
{groups.map((group) => (
<li key={group.name} className="groups-li">
<div className="groups-li-content">
<div className="groups-li-title">{group.name}</div>
<div className="groups-li-meta">
Scope: {group.scope_kind}: {group.scope_name}
</div>
<div className="groups-li-meta">
Permissions: {group.permissions.join(', ')}
</div>
</div>
<button
type="button"
className="groups-delete"
onClick={() => void handleDeleteGroup(group.name)}
>
Delete
</button>
</li>
))}
</ul>
)}
</div>
<div className="groups-form">
<p className="config-hint">Create or update a group</p>
{submitError ? (
<p className="config-empty groups-error" role="alert">
{submitError}
</p>
) : null}
<div className="config-field">
<label htmlFor="group-name">Group name</label>
<input
id="group-name"
className="config-input"
value={draftGroupName}
onChange={(event) => {
setDraftGroupName(event.target.value)
}}
/>
</div>
<div className="config-field">
<label htmlFor="scope-kind">Scope kind</label>
<select
id="scope-kind"
className="theme-select"
value={draftScopeKind}
onChange={(event) => {
setDraftScopeKind(event.target.value as GroupScopeKind)
}}
>
<option value="node">node</option>
<option value="group">group</option>
</select>
</div>
<div className="config-field">
<label htmlFor="scope-name">Scope name</label>
<input
id="scope-name"
className="config-input"
value={draftScopeName}
onChange={(event) => {
setDraftScopeName(event.target.value)
}}
/>
</div>
<div className="config-field">
<label>Permissions</label>
<div className="permission-checkboxes">
{DEFAULT_PERMISSIONS.map((permission) => {
const checked = draftPermissions.includes(permission)
return (
<label key={permission} className="permission-option">
<input
type="checkbox"
aria-label={permission}
checked={checked}
onChange={(event) => {
togglePermission(permission, event.target.checked)
}}
/>
<code>{permission}</code>
</label>
)
})}
</div>
</div>
<button type="button" className="groups-save" onClick={() => void handleSaveGroup()}>
Save group
</button>
</div>
</div>
)
}
function ConfigTabPanelContent({
activeConfigTab,
draftThemePreference,
@@ -140,6 +363,27 @@ function ConfigTabPanelContent({
)
}
if (activeConfigTab === 'roles') {
return (
<div className="roles-panel">
<p className="config-hint">
Built-in permissions that roles can grant.
</p>
<ul className="permission-catalog">
{DEFAULT_PERMISSIONS.map((permission) => (
<li key={permission}>
<code>{permission}</code>
</li>
))}
</ul>
</div>
)
}
if (activeConfigTab === 'groups') {
return <GroupsConfigTab />
}
if (activeConfigTab === 'security') {
return (
<p className="config-empty">
@@ -153,14 +397,16 @@ function ConfigTabPanelContent({
}
function ConfigurationPanel({
activeConfigTab,
onConfigTabChange,
themePreference,
onThemePreferenceChange,
}: {
activeConfigTab: ConfigTabId
onConfigTabChange: (tab: ConfigTabId) => void
themePreference: ThemePreference
onThemePreferenceChange: (preference: ThemePreference) => void
}) {
const [activeConfigTab, setActiveConfigTab] =
useState<ConfigTabId>('overview')
const [draftThemePreference, setDraftThemePreference] =
useState<ThemePreference>(themePreference)
@@ -187,7 +433,7 @@ function ConfigurationPanel({
aria-selected={isSelected}
aria-controls="config-tab-panel"
tabIndex={isSelected ? 0 : -1}
onClick={() => setActiveConfigTab(tab.id)}
onClick={() => onConfigTabChange(tab.id)}
>
{tab.label}
</button>
@@ -231,10 +477,24 @@ function ConfigurationPanel({
}
function App() {
const [activeSection, setActiveSection] = useState<SectionId>('overview')
const initialLocation = parseLocation(window.location.pathname)
const [activeSection, setActiveSection] = useState<SectionId>(
initialLocation.section,
)
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
initialLocation.configTab,
)
const [backendVersion, setBackendVersion] = useState('…')
const { themePreference, updateThemePreference } = useThemePreference()
useEffect(() => {
return subscribeToLocation((pathname) => {
const location = parseLocation(pathname)
setActiveSection(location.section)
setActiveConfigTab(location.configTab)
})
}, [])
useEffect(() => {
let isCancelled = false
@@ -258,6 +518,21 @@ function App() {
}
}, [])
function goToSection(section: SectionId) {
const nextTab = section === 'configuration' ? activeConfigTab : 'overview'
setActiveSection(section)
if (section !== 'configuration') {
setActiveConfigTab('overview')
}
navigateTo(pathFor(section, section === 'configuration' ? nextTab : 'overview'))
}
function goToConfigTab(tab: ConfigTabId) {
setActiveSection('configuration')
setActiveConfigTab(tab)
navigateTo(pathFor('configuration', tab))
}
return (
<div className="app-shell">
<aside className="sidebar" aria-label="Main menu">
@@ -278,7 +553,7 @@ function App() {
: 'nav-item'
}
aria-current={activeSection === item.id ? 'page' : undefined}
onClick={() => setActiveSection(item.id)}
onClick={() => goToSection(item.id)}
>
{item.label}
</button>
@@ -296,7 +571,7 @@ function App() {
aria-current={
activeSection === 'configuration' ? 'page' : undefined
}
onClick={() => setActiveSection('configuration')}
onClick={() => goToSection('configuration')}
>
<CogIcon />
Configuration
@@ -308,6 +583,8 @@ function App() {
<h2>{SECTION_TITLES[activeSection]}</h2>
{activeSection === 'configuration' ? (
<ConfigurationPanel
activeConfigTab={activeConfigTab}
onConfigTabChange={goToConfigTab}
themePreference={themePreference}
onThemePreferenceChange={updateThemePreference}
/>