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 (
-
-
+ {props.canEdit && }
+ {props.canDelete && (
+
+ )}
);
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
-
+ ) : (
+ ''
+ )
}
- ml="auto"
- width="240px"
- onClick={handleCreate}
>
- Create New Role
-
+
+
{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,
};
}