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);
|
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 {
|
.groups-li-title {
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
color: var(--ctp-text);
|
color: var(--ctp-text);
|
||||||
@@ -341,7 +352,9 @@
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groups-delete {
|
.groups-edit,
|
||||||
|
.groups-delete,
|
||||||
|
.groups-new {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 1px solid var(--ctp-surface1);
|
border: 1px solid var(--ctp-surface1);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -363,12 +376,16 @@
|
|||||||
color: var(--ctp-text);
|
color: var(--ctp-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.groups-delete:hover {
|
.groups-edit:hover,
|
||||||
|
.groups-delete:hover,
|
||||||
|
.groups-new:hover {
|
||||||
background: var(--ctp-mauve-hover);
|
background: var(--ctp-mauve-hover);
|
||||||
color: var(--ctp-mauve);
|
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: 2px solid var(--ctp-mauve);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 0.2rem;
|
border-radius: 0.2rem;
|
||||||
@@ -404,10 +421,16 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.groups-form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
.groups-save {
|
.groups-save {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
align-self: flex-start;
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
padding: 0.55rem 1rem;
|
padding: 0.55rem 1rem;
|
||||||
border-radius: 0.35rem;
|
border-radius: 0.35rem;
|
||||||
border: 1px solid var(--ctp-mauve);
|
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 () => {
|
it('rejects invalid idle timeout on Security save', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
stubFetchOk()
|
stubFetchOk()
|
||||||
|
|||||||
+79
-12
@@ -208,12 +208,15 @@ function GroupsConfigTab() {
|
|||||||
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
|
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
|
||||||
const [groupsLoadError, setGroupsLoadError] = useState<string | null>(null)
|
const [groupsLoadError, setGroupsLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [editingGroupName, setEditingGroupName] = useState<string | null>(null)
|
||||||
const [draftGroupName, setDraftGroupName] = useState('')
|
const [draftGroupName, setDraftGroupName] = useState('')
|
||||||
const [draftScopeKind, setDraftScopeKind] = useState<GroupScopeKind>('node')
|
const [draftScopeKind, setDraftScopeKind] = useState<GroupScopeKind>('node')
|
||||||
const [draftScopeName, setDraftScopeName] = useState('')
|
const [draftScopeName, setDraftScopeName] = useState('')
|
||||||
const [draftPermissions, setDraftPermissions] = useState<string[]>([])
|
const [draftPermissions, setDraftPermissions] = useState<string[]>([])
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isEditing = editingGroupName !== null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isCancelled = false
|
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) {
|
function togglePermission(permission: string, checked: boolean) {
|
||||||
setDraftPermissions((prev) => {
|
setDraftPermissions((prev) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -261,6 +285,11 @@ function GroupsConfigTab() {
|
|||||||
async function handleSaveGroup() {
|
async function handleSaveGroup() {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
|
|
||||||
|
if (draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
|
||||||
|
setSubmitError('Administrators group cannot be created or modified here')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await upsertGroup({
|
const response = await upsertGroup({
|
||||||
name: draftGroupName,
|
name: draftGroupName,
|
||||||
@@ -270,10 +299,7 @@ function GroupsConfigTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setGroups(response.groups)
|
setGroups(response.groups)
|
||||||
setDraftGroupName('')
|
clearGroupForm()
|
||||||
setDraftScopeKind('node')
|
|
||||||
setDraftScopeName('')
|
|
||||||
setDraftPermissions([])
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(
|
setSubmitError(
|
||||||
err instanceof Error ? err.message : 'Failed to save group',
|
err instanceof Error ? err.message : 'Failed to save group',
|
||||||
@@ -287,6 +313,9 @@ function GroupsConfigTab() {
|
|||||||
try {
|
try {
|
||||||
const response = await deleteGroup(groupName)
|
const response = await deleteGroup(groupName)
|
||||||
setGroups(response.groups)
|
setGroups(response.groups)
|
||||||
|
if (editingGroupName === groupName) {
|
||||||
|
clearGroupForm()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(
|
setSubmitError(
|
||||||
err instanceof Error ? err.message : 'Failed to delete group',
|
err instanceof Error ? err.message : 'Failed to delete group',
|
||||||
@@ -308,8 +337,16 @@ function GroupsConfigTab() {
|
|||||||
<p className="config-empty">No groups configured yet.</p>
|
<p className="config-empty">No groups configured yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="groups-ul">
|
<ul className="groups-ul">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => {
|
||||||
<li key={group.name} className="groups-li">
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="groups-li-content">
|
<div className="groups-li-content">
|
||||||
<div className="groups-li-title">{group.name}</div>
|
<div className="groups-li-title">{group.name}</div>
|
||||||
<div className="groups-li-meta">
|
<div className="groups-li-meta">
|
||||||
@@ -319,12 +356,22 @@ function GroupsConfigTab() {
|
|||||||
Permissions: {group.permissions.join(', ')}
|
Permissions: {group.permissions.join(', ')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="groups-li-actions">
|
||||||
|
{!isAdministrators ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-edit"
|
||||||
|
onClick={() => handleEditGroup(group)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="groups-delete"
|
className="groups-delete"
|
||||||
disabled={group.name === ADMINISTRATORS_GROUP_NAME}
|
disabled={isAdministrators}
|
||||||
title={
|
title={
|
||||||
group.name === ADMINISTRATORS_GROUP_NAME
|
isAdministrators
|
||||||
? 'Administrators group cannot be deleted'
|
? 'Administrators group cannot be deleted'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -332,14 +379,18 @@ function GroupsConfigTab() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="groups-form">
|
<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 ? (
|
{submitError ? (
|
||||||
<p className="config-empty groups-error" role="alert">
|
<p className="config-empty groups-error" role="alert">
|
||||||
@@ -353,6 +404,7 @@ function GroupsConfigTab() {
|
|||||||
id="group-name"
|
id="group-name"
|
||||||
className="config-input"
|
className="config-input"
|
||||||
value={draftGroupName}
|
value={draftGroupName}
|
||||||
|
readOnly={isEditing}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setDraftGroupName(event.target.value)
|
setDraftGroupName(event.target.value)
|
||||||
}}
|
}}
|
||||||
@@ -408,9 +460,24 @@ function GroupsConfigTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="groups-save" onClick={() => void handleSaveGroup()}>
|
<div className="groups-form-actions">
|
||||||
Save group
|
{isEditing ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-new"
|
||||||
|
onClick={() => clearGroupForm()}
|
||||||
|
>
|
||||||
|
New group
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-save"
|
||||||
|
onClick={() => void handleSaveGroup()}
|
||||||
|
>
|
||||||
|
{isEditing ? 'Save changes' : 'Save group'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user