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, }; }