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:
+28
-5
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user