From 66a20ff2fbfe840538bf642ba40359afa838c343 Mon Sep 17 00:00:00 2001 From: Michael Myers Date: Mon, 2 Dec 2024 10:45:34 -0600 Subject: [PATCH] Conditionally render User edit/delete actions in the web UI --- .../teleport/src/Users/UserList/UserList.tsx | 30 +++- .../teleport/src/Users/Users.story.tsx | 8 + .../teleport/src/Users/Users.test.tsx | 169 +++++++++++++++++- web/packages/teleport/src/Users/Users.tsx | 69 +++++-- web/packages/teleport/src/Users/useUsers.ts | 3 + 5 files changed, 262 insertions(+), 17 deletions(-) diff --git a/web/packages/teleport/src/Users/UserList/UserList.tsx b/web/packages/teleport/src/Users/UserList/UserList.tsx index 34fb89d08e426..00ad7f5de34cf 100644 --- a/web/packages/teleport/src/Users/UserList/UserList.tsx +++ b/web/packages/teleport/src/Users/UserList/UserList.tsx @@ -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({ @@ -29,6 +29,7 @@ export default function UserList({ onEdit, onDelete, onReset, + usersAcl, }: Props) { return ( ( void; onReset: (user: User) => void; onDelete: (user: User) => void; + acl: Access; }) => { + const canEdit = acl.edit; + const canDelete = acl.remove; + + if (!(canEdit || canDelete)) { + return ; + } + if (user.isBot || !user.isLocal) { return ; } @@ -131,11 +142,15 @@ const ActionCell = ({ return ( - onEdit(user)}>Edit... - onReset(user)}> - Reset Authentication... - - onDelete(user)}>Delete... + {canEdit && onEdit(user)}>Edit...} + {canEdit && ( + onReset(user)}> + Reset Authentication... + + )} + {canDelete && ( + onDelete(user)}>Delete... + )} ); @@ -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; }; diff --git a/web/packages/teleport/src/Users/Users.story.tsx b/web/packages/teleport/src/Users/Users.story.tsx index fc905715582c2..eaccc82097e9a 100644 --- a/web/packages/teleport/src/Users/Users.story.tsx +++ b/web/packages/teleport/src/Users/Users.story.tsx @@ -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, + }, }; diff --git a/web/packages/teleport/src/Users/Users.test.tsx b/web/packages/teleport/src/Users/Users.test.tsx index 154047002d29e..0d5d02ee7e710 100644 --- a/web/packages/teleport/src/Users/Users.test.tsx +++ b/web/packages/teleport/src/Users/Users.test.tsx @@ -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(); @@ -59,6 +68,7 @@ describe('invite collaborators integration', () => { EmailPasswordReset: null, showMauInfo: false, onDismissUsersMauNotice: () => null, + usersAcl: defaultAcl, }; }); @@ -138,6 +148,7 @@ test('Users not equal to MAU Notice', async () => { EmailPasswordReset: null, showMauInfo: true, onDismissUsersMauNotice: jest.fn(), + usersAcl: defaultAcl, }; const user = userEvent.setup(); @@ -192,6 +203,7 @@ describe('email password reset integration', () => { EmailPasswordReset: null, showMauInfo: false, onDismissUsersMauNotice: () => null, + usersAcl: defaultAcl, }; }); @@ -232,3 +244,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: 'alice@example.com', 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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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); + }); +}); diff --git a/web/packages/teleport/src/Users/Users.tsx b/web/packages/teleport/src/Users/Users.tsx index f36b8d70bf6d0..16c007045752c 100644 --- a/web/packages/teleport/src/Users/Users.tsx +++ b/web/packages/teleport/src/Users/Users.tsx @@ -17,7 +17,8 @@ */ import React from 'react'; -import { Indicator, Box, Alert, Button, Link } from 'design'; +import { Indicator, Text, Flex, Box, Alert, Button, Link } from 'design'; +import { HoverTooltip } from 'shared/components/ToolTip'; import { FeatureBox, @@ -46,6 +47,7 @@ export function Users(props: State) { onStartDelete, onStartEdit, onStartReset, + usersAcl, showMauInfo, onDismissUsersMauNotice, onClose, @@ -60,22 +62,68 @@ export function Users(props: State) { EmailPasswordReset, onEmailPasswordResetClose, } = props; + + const requiredPermissions = Object.entries(usersAcl) + .map(([key, value]) => { + if (key === 'edit') { + return { value, label: 'update' }; + } + if (key === 'create') { + return { value, label: 'create' }; + } + }) + .filter(Boolean); + + const isMissingPermissions = requiredPermissions.some(v => !v.value); + return ( - + Users {attempt.isSuccess && ( <> {!InviteCollaborators && ( - + + )} {InviteCollaborators && (