More webui layout, navigation, preparations of user/roles/groups/security
This commit is contained in:
+295
-18
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user