From b93b7519ece126c29593ffb67c70fff3d9d4d4da Mon Sep 17 00:00:00 2001 From: squid Date: Sat, 18 Jul 2026 15:29:22 +0200 Subject: [PATCH] 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. --- webui/src/App.css | 33 ++++++-- webui/src/App.test.tsx | 179 +++++++++++++++++++++++++++++++++++++++++ webui/src/App.tsx | 131 ++++++++++++++++++++++-------- 3 files changed, 306 insertions(+), 37 deletions(-) diff --git a/webui/src/App.css b/webui/src/App.css index a7c1f76..f021aa5 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -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); diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index 734fe27..92dce67 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -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() + + 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() + + 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() diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 747ad65..0cf0267 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -208,12 +208,15 @@ function GroupsConfigTab() { const [isLoadingGroups, setIsLoadingGroups] = useState(true) const [groupsLoadError, setGroupsLoadError] = useState(null) + const [editingGroupName, setEditingGroupName] = useState(null) const [draftGroupName, setDraftGroupName] = useState('') const [draftScopeKind, setDraftScopeKind] = useState('node') const [draftScopeName, setDraftScopeName] = useState('') const [draftPermissions, setDraftPermissions] = useState([]) const [submitError, setSubmitError] = useState(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() {

No groups configured yet.

) : (
    - {groups.map((group) => ( -
  • -
    -
    {group.name}
    -
    - Scope: {group.scope_kind}: {group.scope_name} -
    -
    - Permissions: {group.permissions.join(', ')} -
    -
    - -
  • - ))} +
    +
    {group.name}
    +
    + Scope: {group.scope_kind}: {group.scope_name} +
    +
    + Permissions: {group.permissions.join(', ')} +
    +
    +
    + {!isAdministrators ? ( + + ) : null} + +
    + + ) + })}
)}
-

Create or update a group

+

+ {isEditing ? 'Edit group' : 'Create group'} +

{submitError ? (

@@ -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() {

- +
+ {isEditing ? ( + + ) : null} + +
)