Skip to content

Commit

Permalink
[v16] Conditionally render User edit/delete actions in the web UI
Browse files Browse the repository at this point in the history
Backport #49645
  • Loading branch information
avatus committed Dec 3, 2024
1 parent ccbbd3e commit 46f207f
Show file tree
Hide file tree
Showing 9 changed files with 330 additions and 67 deletions.
2 changes: 1 addition & 1 deletion web/packages/design/src/Menu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const fromTheme = (props: ThemedMenuItemProps) => {
};
};

const MenuItem = styled.div<MenuItemProps>`
const MenuItem = styled.div.attrs({ role: 'menuitem' })<MenuItemProps>`
min-height: 40px;
box-sizing: border-box;
cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
Expand Down
2 changes: 1 addition & 1 deletion web/packages/shared/components/ToolTip/HoverTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type OriginProps = {

export const HoverTooltip: React.FC<
PropsWithChildren<{
tipContent: string | undefined;
tipContent?: React.ReactNode;
showOnlyOnOverflow?: boolean;
className?: string;
anchorOrigin?: OriginProps;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ test('all participant modes are properly listed and in the correct order', () =>
);

// Make sure that the menu items are in the order of observer -> moderator -> peer.
const menuItems = screen.queryAllByRole<HTMLAnchorElement>('link');
const menuItems = screen.queryAllByRole('menuitem');
expect(menuItems).toHaveLength(3);
expect(menuItems[0]).toHaveTextContent('As an Observer');
expect(menuItems[1]).toHaveTextContent('As a Moderator');
Expand Down
30 changes: 24 additions & 6 deletions web/packages/teleport/src/Users/UserList/UserList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import React from 'react';
import { Cell, LabelCell } from 'design/DataTable';
import { MenuButton, MenuItem } from 'shared/components/MenuAction';

import { User, UserOrigin } from 'teleport/services/user';
import { Access, User, UserOrigin } from 'teleport/services/user';
import { ClientSearcheableTableWithQueryParamSupport } from 'teleport/components/ClientSearcheableTableWithQueryParamSupport';

export default function UserList({
Expand All @@ -29,6 +29,7 @@ export default function UserList({
onEdit,
onDelete,
onReset,
usersAcl,
}: Props) {
return (
<ClientSearcheableTableWithQueryParamSupport
Expand Down Expand Up @@ -72,6 +73,7 @@ export default function UserList({
altKey: 'options-btn',
render: user => (
<ActionCell
acl={usersAcl}
user={user}
onEdit={onEdit}
onReset={onReset}
Expand Down Expand Up @@ -118,24 +120,37 @@ const ActionCell = ({
onEdit,
onReset,
onDelete,
acl,
}: {
user: User;
onEdit: (user: User) => void;
onReset: (user: User) => void;
onDelete: (user: User) => void;
acl: Access;
}) => {
const canEdit = acl.edit;
const canDelete = acl.remove;

if (!(canEdit || canDelete)) {
return <Cell align="right" />;
}

if (user.isBot || !user.isLocal) {
return <Cell align="right" />;
}

return (
<Cell align="right">
<MenuButton>
<MenuItem onClick={() => onEdit(user)}>Edit...</MenuItem>
<MenuItem onClick={() => onReset(user)}>
Reset Authentication...
</MenuItem>
<MenuItem onClick={() => onDelete(user)}>Delete...</MenuItem>
{canEdit && <MenuItem onClick={() => onEdit(user)}>Edit...</MenuItem>}
{canEdit && (
<MenuItem onClick={() => onReset(user)}>
Reset Authentication...
</MenuItem>
)}
{canDelete && (
<MenuItem onClick={() => onDelete(user)}>Delete...</MenuItem>
)}
</MenuButton>
</Cell>
);
Expand All @@ -147,4 +162,7 @@ type Props = {
onEdit(user: User): void;
onDelete(user: User): void;
onReset(user: User): void;
// determines if the viewer is able to edit/delete users. This is used
// to conditionally render the edit/delete buttons in the ActionCell
usersAcl: Access;
};
8 changes: 8 additions & 0 deletions web/packages/teleport/src/Users/Users.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,12 @@ const sample = {
EmailPasswordReset: null,
showMauInfo: false,
onDismissUsersMauNotice: () => null,
canEditUsers: true,
usersAcl: {
read: true,
edit: false,
remove: true,
list: true,
create: true,
},
};
169 changes: 168 additions & 1 deletion web/packages/teleport/src/Users/Users.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,23 @@

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

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

import { Users } from './Users';
import { State } from './useUsers';

const defaultAcl: Access = {
read: true,
edit: true,
remove: true,
list: true,
create: true,
};

describe('invite collaborators integration', () => {
const ctx = createTeleportContext();

Expand Down Expand Up @@ -59,6 +68,7 @@ describe('invite collaborators integration', () => {
EmailPasswordReset: null,
showMauInfo: false,
onDismissUsersMauNotice: () => null,
usersAcl: defaultAcl,
};
});

Expand Down Expand Up @@ -142,6 +152,7 @@ test('Users not equal to MAU Notice', async () => {
EmailPasswordReset: null,
showMauInfo: true,
onDismissUsersMauNotice: jest.fn(),
usersAcl: defaultAcl,
};

const { rerender } = render(
Expand Down Expand Up @@ -205,6 +216,7 @@ describe('email password reset integration', () => {
EmailPasswordReset: null,
showMauInfo: false,
onDismissUsersMauNotice: () => null,
usersAcl: defaultAcl,
};
});

Expand Down Expand Up @@ -245,3 +257,158 @@ describe('email password reset integration', () => {
expect(screen.getByTestId('new-reset-ui')).toBeInTheDocument();
});
});

describe('permission handling', () => {
const ctx = createTeleportContext();

let props: State;
beforeEach(() => {
props = {
attempt: {
message: 'success',
isSuccess: true,
isProcessing: false,
isFailed: false,
},
users: [
{
name: 'tester',
roles: [],
isLocal: true,
},
],
fetchRoles: () => Promise.resolve([]),
operation: {
type: 'reset',
user: { name: '[email protected]', roles: ['foo'] },
},

onStartCreate: () => undefined,
onStartDelete: () => undefined,
onStartEdit: () => undefined,
onStartReset: () => undefined,
onStartInviteCollaborators: () => undefined,
onClose: () => undefined,
onDelete: () => undefined,
onCreate: () => undefined,
onUpdate: () => undefined,
onReset: () => undefined,
onInviteCollaboratorsClose: () => undefined,
InviteCollaborators: null,
inviteCollaboratorsOpen: false,
onEmailPasswordResetClose: () => undefined,
EmailPasswordReset: null,
showMauInfo: false,
onDismissUsersMauNotice: () => null,
usersAcl: defaultAcl,
};
});

test('displays a disabled Create Users button if lacking permissions', async () => {
const testProps = {
...props,
usersAcl: {
...defaultAcl,
edit: false,
},
};
render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Users {...testProps} />
</ContextProvider>
</MemoryRouter>
);

expect(screen.getByTestId('create_new_users_button')).toBeDisabled();
});

test('edit and reset options not available in the menu', async () => {
const testProps = {
...props,
usersAcl: {
...defaultAcl,
edit: false,
},
};
render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Users {...testProps} />
</ContextProvider>
</MemoryRouter>
);

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

test('all options are available in the menu', async () => {
const testProps = {
...props,
usersAcl: {
read: true,
list: true,
edit: true,
create: true,
remove: true,
},
};
render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Users {...testProps} />
</ContextProvider>
</MemoryRouter>
);

expect(screen.getByText('tester')).toBeInTheDocument();
const optionsButton = screen.getByRole('button', { name: /options/i });
fireEvent.click(optionsButton);
const menuItems = screen.queryAllByRole('menuitem');
expect(menuItems).toHaveLength(3);
expect(menuItems.some(item => item.textContent.includes('Delete'))).toBe(
true
);
expect(
menuItems.some(item => item.textContent.includes('Reset Auth'))
).toBe(true);
expect(menuItems.some(item => item.textContent.includes('Edit'))).toBe(
true
);
});

test('delete is not available in menu', async () => {
const testProps = {
...props,
usersAcl: {
read: true,
list: true,
edit: true,
create: true,
remove: false,
},
};
render(
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Users {...testProps} />
</ContextProvider>
</MemoryRouter>
);

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

0 comments on commit 46f207f

Please sign in to comment.