Skip to content

Commit

Permalink
Conditionally render edit/delete options for Roles (#49728)
Browse files Browse the repository at this point in the history
This PR updates the Roles page to conditionally render the edit/delete
options in the menu for roles depending on the users permissions. If
neither exist, the menu isn't shown at all

This also removes the ability to click 'create new role' if the user
cannot create a new role
  • Loading branch information
avatus authored Dec 3, 2024
1 parent 04bd48c commit 23b99b5
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { Box, Text, Flex } from 'design';

export const MissingPermissionsTooltip = ({
missingPermissions,
}: {
missingPermissions: string[];
}) => {
return (
<Box>
<Text mb={1}>You do not have all of the required permissions.</Text>
<Box mb={1}>
<Text bold>Missing permissions:</Text>
<Flex gap={2}>
{missingPermissions.map(perm => (
<Text key={perm}>{perm}</Text>
))}
</Flex>
</Box>
</Box>
);
};
19 changes: 19 additions & 0 deletions web/packages/shared/components/MissingPermissionsTooltip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

export { MissingPermissionsTooltip } from './MissingPermissionsTooltip';
24 changes: 21 additions & 3 deletions web/packages/teleport/src/Roles/RoleList/RoleList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,26 @@ import { SearchPanel } from 'shared/components/Search';

import { SeversidePagination } from 'teleport/components/hooks/useServersidePagination';
import { RoleResource } from 'teleport/services/resources';
import { Access } from 'teleport/services/user';

export function RoleList({
onEdit,
onDelete,
onSearchChange,
search,
serversidePagination,
rolesAcl,
}: {
onEdit(id: string): void;
onDelete(id: string): void;
onSearchChange(search: string): void;
search: string;
serversidePagination: SeversidePagination<RoleResource>;
rolesAcl: Access;
}) {
const canEdit = rolesAcl.edit;
const canDelete = rolesAcl.remove;

return (
<Table
data={serversidePagination.fetchedData.agents}
Expand Down Expand Up @@ -68,6 +74,8 @@ export function RoleList({
altKey: 'options-btn',
render: (role: RoleResource) => (
<ActionCell
canDelete={canDelete}
canEdit={canEdit}
onEdit={() => onEdit(role.id)}
onDelete={() => onDelete(role.id)}
/>
Expand All @@ -80,12 +88,22 @@ export function RoleList({
);
}

const ActionCell = (props: { onEdit(): void; onDelete(): void }) => {
const ActionCell = (props: {
canEdit: boolean;
canDelete: boolean;
onEdit(): void;
onDelete(): void;
}) => {
if (!(props.canEdit || props.canDelete)) {
return <Cell align="right" />;
}
return (
<Cell align="right">
<MenuButton>
<MenuItem onClick={props.onEdit}>Edit...</MenuItem>
<MenuItem onClick={props.onDelete}>Delete...</MenuItem>
{props.canEdit && <MenuItem onClick={props.onEdit}>Edit</MenuItem>}
{props.canDelete && (
<MenuItem onClick={props.onDelete}>Delete</MenuItem>
)}
</MenuButton>
</Cell>
);
Expand Down
7 changes: 7 additions & 0 deletions web/packages/teleport/src/Roles/Roles.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,11 @@ const sample = {
remove: () => null,
create: () => null,
update: () => null,
rolesAcl: {
list: true,
create: true,
remove: true,
edit: true,
read: true,
},
};
211 changes: 211 additions & 0 deletions web/packages/teleport/src/Roles/Roles.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { MemoryRouter } from 'react-router';
import { render, screen, fireEvent, waitFor } from 'design/utils/testing';

import { ContextProvider } from 'teleport';
import { createTeleportContext } from 'teleport/mocks/contexts';

import { Roles } from './Roles';
import { State } from './useRoles';

describe('Roles list', () => {
const defaultState: State = {
create: jest.fn(),
fetch: jest.fn(),
remove: jest.fn(),
update: jest.fn(),
rolesAcl: {
read: true,
remove: true,
create: true,
edit: true,
list: true,
},
};

beforeEach(() => {
jest.spyOn(defaultState, 'fetch').mockResolvedValue({
startKey: '',
items: [
{
content: '',
id: '1',
kind: 'role',
name: 'cool-role',
description: 'coolest-role',
},
],
});
});

afterEach(() => {
jest.clearAllMocks();
});

test('button is enabled if user has create perms', async () => {
const ctx = createTeleportContext();
render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Roles {...defaultState} />
</ContextProvider>
</MemoryRouter>
);

await waitFor(() => {
expect(screen.getByTestId('create_new_role_button')).toBeEnabled();
});
});

test('displays disabled create button', async () => {
const ctx = createTeleportContext();
const testState = {
...defaultState,
rolesAcl: {
...defaultState.rolesAcl,
create: false,
},
};

render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Roles {...testState} />
</ContextProvider>
</MemoryRouter>
);

await waitFor(() => {
expect(screen.getByTestId('create_new_role_button')).toBeDisabled();
});
});

test('all options available', async () => {
const ctx = createTeleportContext();

render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Roles {...defaultState} />
</ContextProvider>
</MemoryRouter>
);

await waitFor(() => {
expect(
screen.getByRole('button', { name: /options/i })
).toBeInTheDocument();
});
const optionsButton = screen.getByRole('button', { name: /options/i });
fireEvent.click(optionsButton);
const menuItems = screen.queryAllByRole('menuitem');
expect(menuItems).toHaveLength(2);
});

test('hides edit button if no access', async () => {
const ctx = createTeleportContext();
const testState = {
...defaultState,
rolesAcl: {
...defaultState.rolesAcl,
edit: false,
},
};

render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Roles {...testState} />
</ContextProvider>
</MemoryRouter>
);

await waitFor(() => {
expect(
screen.getByRole('button', { name: /options/i })
).toBeInTheDocument();
});
const optionsButton = screen.getByRole('button', { name: /options/i });
fireEvent.click(optionsButton);
const menuItems = screen.queryAllByRole('menuitem');
expect(menuItems).toHaveLength(1);
expect(menuItems.every(item => item.textContent.includes('Edit'))).not.toBe(
true
);
});

test('hides delete button if no access', async () => {
const ctx = createTeleportContext();
const testState = {
...defaultState,
rolesAcl: {
...defaultState.rolesAcl,
remove: false,
},
};

render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Roles {...testState} />
</ContextProvider>
</MemoryRouter>
);

await waitFor(() => {
expect(
screen.getByRole('button', { name: /options/i })
).toBeInTheDocument();
});
const optionsButton = screen.getByRole('button', { name: /options/i });
fireEvent.click(optionsButton);
const menuItems = screen.queryAllByRole('menuitem');
expect(menuItems).toHaveLength(1);
expect(
menuItems.every(item => item.textContent.includes('Delete'))
).not.toBe(true);
});

test('hides Options button if no permissions to edit or delete', async () => {
const ctx = createTeleportContext();
const testState = {
...defaultState,
rolesAcl: {
...defaultState.rolesAcl,
remove: false,
edit: false,
},
};

render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Roles {...testState} />
</ContextProvider>
</MemoryRouter>
);

await waitFor(() => {
expect(screen.getByText('cool-role')).toBeInTheDocument();
});
const menuItems = screen.queryAllByRole('menuitem');
expect(menuItems).toHaveLength(0);
});
});
Loading

0 comments on commit 23b99b5

Please sign in to comment.