Skip to content

Commit

Permalink
[v16] Conditionally render edit/delete options for Roles (#49904)
Browse files Browse the repository at this point in the history
Backport #49728

Manual backport due to all the changes that have happened to roles
lately (and some missing components)
  • Loading branch information
avatus authored Dec 6, 2024
1 parent 92cd4b1 commit 1cfe01a
Show file tree
Hide file tree
Showing 7 changed files with 324 additions and 8 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 @@ -80,4 +80,11 @@ const sample = {
fetch: async () => ({ items: roles, startKey: '' }),
remove: () => null,
save: () => null,
rolesAcl: {
list: true,
create: true,
remove: true,
edit: true,
read: true,
},
};
210 changes: 210 additions & 0 deletions web/packages/teleport/src/Roles/Roles.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* 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 = {
save: jest.fn(),
fetch: jest.fn(),
remove: 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.getByText(/create new role/i)).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.getByText(/create new role/i)).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 1cfe01a

Please sign in to comment.