Allow editing non-Administrators group access from the Groups tab.

Load a group into the form to change scope and permissions; keep Administrators locked like delete.
This commit is contained in:
2026-07-18 15:29:22 +02:00
parent 5d6f65f3cd
commit b93b7519ec
3 changed files with 306 additions and 37 deletions
+28 -5
View File
@@ -316,6 +316,17 @@
background: var(--ctp-surface0);
}
.groups-li-selected {
border-color: var(--ctp-mauve);
background: var(--ctp-mantle);
}
.groups-li-actions {
display: flex;
flex-shrink: 0;
gap: 0.4rem;
}
.groups-li-title {
font-weight: 650;
color: var(--ctp-text);
@@ -341,7 +352,9 @@
line-height: 1.35;
}
.groups-delete {
.groups-edit,
.groups-delete,
.groups-new {
appearance: none;
border: 1px solid var(--ctp-surface1);
background: transparent;
@@ -363,12 +376,16 @@
color: var(--ctp-text);
}
.groups-delete:hover {
.groups-edit:hover,
.groups-delete:hover,
.groups-new:hover {
background: var(--ctp-mauve-hover);
color: var(--ctp-mauve);
}
.groups-delete:focus-visible {
.groups-edit:focus-visible,
.groups-delete:focus-visible,
.groups-new:focus-visible {
outline: 2px solid var(--ctp-mauve);
outline-offset: 2px;
border-radius: 0.2rem;
@@ -404,10 +421,16 @@
font-size: 0.9rem;
}
.groups-form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 0.15rem;
}
.groups-save {
appearance: none;
align-self: flex-start;
margin-top: 0.15rem;
padding: 0.55rem 1rem;
border-radius: 0.35rem;
border: 1px solid var(--ctp-mauve);
+179
View File
@@ -574,6 +574,185 @@ describe('App', () => {
)
})
it('edits a non-Administrators group access from the Groups tab', async () => {
const user = userEvent.setup()
const operatorsGroup = {
name: 'Operators',
scope_kind: 'node' as const,
scope_name: 'worker-1',
permissions: ['nodes.read', 'jobs.read'],
}
const updatedOperatorsGroup = {
...operatorsGroup,
permissions: ['nodes.read', 'jobs.read', 'jobs.run'],
}
const administratorsGroup = {
name: 'Administrators',
scope_kind: 'group' as const,
scope_name: 'Admin Group',
permissions: ['users.manage', 'roles.manage'],
}
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: [administratorsGroup, updatedOperatorsGroup],
}),
})
}
if (url.includes('/api/v1/groups')) {
return Promise.resolve({
ok: true,
json: async () => ({
groups: [administratorsGroup, operatorsGroup],
}),
})
}
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('Operators')).toBeInTheDocument()
expect(screen.getByText('Administrators')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1)
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
expect(deleteButtons[0]).toBeDisabled()
expect(deleteButtons[1]).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByText('Edit group')).toBeInTheDocument()
expect(screen.getByLabelText('Group name')).toHaveValue('Operators')
expect(screen.getByLabelText('Group name')).toHaveAttribute('readonly')
expect(screen.getByLabelText('Scope name')).toHaveValue('worker-1')
expect(screen.getByLabelText('jobs.read')).toBeChecked()
expect(screen.getByLabelText('jobs.run')).not.toBeChecked()
await user.click(screen.getByLabelText('jobs.run'))
await user.click(screen.getByRole('button', { name: 'Save changes' }))
expect(await screen.findByText('Create group')).toBeInTheDocument()
expect(
screen.getByText('Permissions: nodes.read, jobs.read, jobs.run'),
).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/groups'),
expect.objectContaining({ method: 'POST' }),
)
})
it('creates a new group from the Groups tab', async () => {
const user = userEvent.setup()
const createdGroup = {
name: 'Readers',
scope_kind: 'node' as const,
scope_name: 'edge-1',
permissions: ['nodes.read'],
}
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: [createdGroup] }),
})
}
if (url.includes('/api/v1/groups')) {
return Promise.resolve({
ok: true,
json: async () => ({ groups: [] }),
})
}
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('Create group')).toBeInTheDocument()
await user.type(screen.getByLabelText('Group name'), 'Readers')
await user.type(screen.getByLabelText('Scope name'), 'edge-1')
await user.click(screen.getByLabelText('nodes.read'))
await user.click(screen.getByRole('button', { name: 'Save group' }))
expect(await screen.findByText('Readers')).toBeInTheDocument()
expect(screen.getByText('Permissions: nodes.read')).toBeInTheDocument()
})
it('rejects invalid idle timeout on Security save', async () => {
const user = userEvent.setup()
stubFetchOk()
+99 -32
View File
@@ -208,12 +208,15 @@ function GroupsConfigTab() {
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
const [groupsLoadError, setGroupsLoadError] = useState<string | null>(null)
const [editingGroupName, setEditingGroupName] = 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)
const isEditing = editingGroupName !== null
useEffect(() => {
let isCancelled = false
@@ -245,6 +248,27 @@ function GroupsConfigTab() {
}
}, [])
function clearGroupForm() {
setEditingGroupName(null)
setDraftGroupName('')
setDraftScopeKind('node')
setDraftScopeName('')
setDraftPermissions([])
setSubmitError(null)
}
function handleEditGroup(group: Group) {
if (group.name === ADMINISTRATORS_GROUP_NAME) {
return
}
setEditingGroupName(group.name)
setDraftGroupName(group.name)
setDraftScopeKind(group.scope_kind)
setDraftScopeName(group.scope_name)
setDraftPermissions([...group.permissions])
setSubmitError(null)
}
function togglePermission(permission: string, checked: boolean) {
setDraftPermissions((prev) => {
if (checked) {
@@ -261,6 +285,11 @@ function GroupsConfigTab() {
async function handleSaveGroup() {
setSubmitError(null)
if (draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
setSubmitError('Administrators group cannot be created or modified here')
return
}
try {
const response = await upsertGroup({
name: draftGroupName,
@@ -270,10 +299,7 @@ function GroupsConfigTab() {
})
setGroups(response.groups)
setDraftGroupName('')
setDraftScopeKind('node')
setDraftScopeName('')
setDraftPermissions([])
clearGroupForm()
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to save group',
@@ -287,6 +313,9 @@ function GroupsConfigTab() {
try {
const response = await deleteGroup(groupName)
setGroups(response.groups)
if (editingGroupName === groupName) {
clearGroupForm()
}
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to delete group',
@@ -308,38 +337,60 @@ function GroupsConfigTab() {
<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"
disabled={group.name === ADMINISTRATORS_GROUP_NAME}
title={
group.name === ADMINISTRATORS_GROUP_NAME
? 'Administrators group cannot be deleted'
: undefined
{groups.map((group) => {
const isAdministrators = group.name === ADMINISTRATORS_GROUP_NAME
const isSelected = editingGroupName === group.name
return (
<li
key={group.name}
className={
isSelected ? 'groups-li groups-li-selected' : 'groups-li'
}
onClick={() => void handleDeleteGroup(group.name)}
>
Delete
</button>
</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>
<div className="groups-li-actions">
{!isAdministrators ? (
<button
type="button"
className="groups-edit"
onClick={() => handleEditGroup(group)}
>
Edit
</button>
) : null}
<button
type="button"
className="groups-delete"
disabled={isAdministrators}
title={
isAdministrators
? 'Administrators group cannot be deleted'
: undefined
}
onClick={() => void handleDeleteGroup(group.name)}
>
Delete
</button>
</div>
</li>
)
})}
</ul>
)}
</div>
<div className="groups-form">
<p className="config-hint">Create or update a group</p>
<p className="config-hint">
{isEditing ? 'Edit group' : 'Create group'}
</p>
{submitError ? (
<p className="config-empty groups-error" role="alert">
@@ -353,6 +404,7 @@ function GroupsConfigTab() {
id="group-name"
className="config-input"
value={draftGroupName}
readOnly={isEditing}
onChange={(event) => {
setDraftGroupName(event.target.value)
}}
@@ -408,9 +460,24 @@ function GroupsConfigTab() {
</div>
</div>
<button type="button" className="groups-save" onClick={() => void handleSaveGroup()}>
Save group
</button>
<div className="groups-form-actions">
{isEditing ? (
<button
type="button"
className="groups-new"
onClick={() => clearGroupForm()}
>
New group
</button>
) : null}
<button
type="button"
className="groups-save"
onClick={() => void handleSaveGroup()}
>
{isEditing ? 'Save changes' : 'Save group'}
</button>
</div>
</div>
</div>
)