From 020f2b8e6b85dd99590186e798d8f82fc41a8e22 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 3 Dec 2024 15:34:32 -0600 Subject: [PATCH] Conditionally render edit/delete options for Roles (#49731) 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 --- .../MissingPermissionsTooltip.tsx | 39 ++++ .../MissingPermissionsTooltip/index.ts | 19 ++ .../teleport/src/Roles/RoleList/RoleList.tsx | 24 +- .../teleport/src/Roles/Roles.story.tsx | 7 + .../teleport/src/Roles/Roles.test.tsx | 211 ++++++++++++++++++ web/packages/teleport/src/Roles/Roles.tsx | 48 ++-- web/packages/teleport/src/Roles/useRoles.ts | 3 + 7 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 web/packages/shared/components/MissingPermissionsTooltip/MissingPermissionsTooltip.tsx create mode 100644 web/packages/shared/components/MissingPermissionsTooltip/index.ts create mode 100644 web/packages/teleport/src/Roles/Roles.test.tsx diff --git a/web/packages/shared/components/MissingPermissionsTooltip/MissingPermissionsTooltip.tsx b/web/packages/shared/components/MissingPermissionsTooltip/MissingPermissionsTooltip.tsx new file mode 100644 index 0000000000000..aaef46ec0baf6 --- /dev/null +++ b/web/packages/shared/components/MissingPermissionsTooltip/MissingPermissionsTooltip.tsx @@ -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 . + */ + +import { Box, Text, Flex } from 'design'; + +export const MissingPermissionsTooltip = ({ + missingPermissions, +}: { + missingPermissions: string[]; +}) => { + return ( + + You do not have all of the required permissions. + + Missing permissions: + + {missingPermissions.map(perm => ( + {perm} + ))} + + + + ); +}; diff --git a/web/packages/shared/components/MissingPermissionsTooltip/index.ts b/web/packages/shared/components/MissingPermissionsTooltip/index.ts new file mode 100644 index 0000000000000..26c2679b0e46f --- /dev/null +++ b/web/packages/shared/components/MissingPermissionsTooltip/index.ts @@ -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 . + */ + +export { MissingPermissionsTooltip } from './MissingPermissionsTooltip'; diff --git a/web/packages/teleport/src/Roles/RoleList/RoleList.tsx b/web/packages/teleport/src/Roles/RoleList/RoleList.tsx index d537eba3a43ad..5641442e43d78 100644 --- a/web/packages/teleport/src/Roles/RoleList/RoleList.tsx +++ b/web/packages/teleport/src/Roles/RoleList/RoleList.tsx @@ -24,6 +24,7 @@ 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, @@ -31,13 +32,18 @@ export function RoleList({ onSearchChange, search, serversidePagination, + rolesAcl, }: { onEdit(id: string): void; onDelete(id: string): void; onSearchChange(search: string): void; search: string; serversidePagination: SeversidePagination; + rolesAcl: Access; }) { + const canEdit = rolesAcl.edit; + const canDelete = rolesAcl.remove; + return ( ( onEdit(role.id)} onDelete={() => onDelete(role.id)} /> @@ -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 ; + } return ( - Edit... - Delete... + {props.canEdit && Edit} + {props.canDelete && ( + Delete + )} ); diff --git a/web/packages/teleport/src/Roles/Roles.story.tsx b/web/packages/teleport/src/Roles/Roles.story.tsx index c0d197a8f0196..f5be3186c0eaf 100644 --- a/web/packages/teleport/src/Roles/Roles.story.tsx +++ b/web/packages/teleport/src/Roles/Roles.story.tsx @@ -81,4 +81,11 @@ const sample = { remove: () => null, create: () => null, update: () => null, + rolesAcl: { + list: true, + create: true, + remove: true, + edit: true, + read: true, + }, }; diff --git a/web/packages/teleport/src/Roles/Roles.test.tsx b/web/packages/teleport/src/Roles/Roles.test.tsx new file mode 100644 index 0000000000000..1edae5ee235e6 --- /dev/null +++ b/web/packages/teleport/src/Roles/Roles.test.tsx @@ -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 . + */ + +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( + + + + + + ); + + 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( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('create_new_role_button')).toBeDisabled(); + }); + }); + + test('all options available', async () => { + const ctx = createTeleportContext(); + + render( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + await waitFor(() => { + expect(screen.getByText('cool-role')).toBeInTheDocument(); + }); + const menuItems = screen.queryAllByRole('menuitem'); + expect(menuItems).toHaveLength(0); + }); +}); diff --git a/web/packages/teleport/src/Roles/Roles.tsx b/web/packages/teleport/src/Roles/Roles.tsx index d04034b475609..3bf968247b6ba 100644 --- a/web/packages/teleport/src/Roles/Roles.tsx +++ b/web/packages/teleport/src/Roles/Roles.tsx @@ -22,6 +22,8 @@ import { P } from 'design/Text/Text'; import { useAsync } from 'shared/hooks/useAsync'; import { Danger } from 'design/Alert'; import { useTheme } from 'styled-components'; +import { MissingPermissionsTooltip } from 'shared/components/MissingPermissionsTooltip'; +import { HoverTooltip } from 'shared/components/ToolTip'; import { FeatureBox, @@ -55,7 +57,7 @@ export function RolesContainer() { const useNewRoleEditor = storageService.getUseNewRoleEditor(); export function Roles(props: State) { - const { remove, create, update, fetch } = props; + const { remove, create, update, fetch, rolesAcl } = props; const [search, setSearch] = useState(''); const serverSidePagination = useServerSidePagination({ @@ -142,24 +144,41 @@ export function Roles(props: State) { } } + const canCreate = rolesAcl.create; + return ( - + Roles - + + {serverSidePagination.attempt.status === 'failed' && ( @@ -172,6 +191,7 @@ export function Roles(props: State) { search={search} onEdit={handleEdit} onDelete={resources.remove} + rolesAcl={rolesAcl} /> diff --git a/web/packages/teleport/src/Roles/useRoles.ts b/web/packages/teleport/src/Roles/useRoles.ts index 9a926a9c28f59..6c4e9cc5f0f47 100644 --- a/web/packages/teleport/src/Roles/useRoles.ts +++ b/web/packages/teleport/src/Roles/useRoles.ts @@ -24,6 +24,8 @@ import { YamlSupportedResourceKind } from 'teleport/services/yaml/types'; import type { UrlListRolesParams } from 'teleport/config'; export function useRoles(ctx: TeleportContext) { + const rolesAcl = ctx.storeUser.getRoleAccess(); + async function create(role: Partial) { return ctx.resourceService.createRole(await toYaml(role)); } @@ -45,6 +47,7 @@ export function useRoles(ctx: TeleportContext) { create, update, remove, + rolesAcl, }; }