diff --git a/web/packages/shared/components/ToolTip/HoverTooltip.tsx b/web/packages/shared/components/ToolTip/HoverTooltip.tsx index 2195dce9ee313..e242070ba809f 100644 --- a/web/packages/shared/components/ToolTip/HoverTooltip.tsx +++ b/web/packages/shared/components/ToolTip/HoverTooltip.tsx @@ -28,7 +28,7 @@ type OriginProps = { export const HoverTooltip: React.FC< PropsWithChildren<{ - tipContent: string | undefined; + tipContent?: React.ReactNode; showOnlyOnOverflow?: boolean; className?: string; anchorOrigin?: OriginProps; diff --git a/web/packages/teleport/src/Users/UserList/UserList.tsx b/web/packages/teleport/src/Users/UserList/UserList.tsx index 53861cd969245..6a626f119500b 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 77ff4c39a91b9..c2916695e22f2 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, }; }); @@ -142,6 +152,7 @@ test('Users not equal to MAU Notice', async () => { EmailPasswordReset: null, showMauInfo: true, onDismissUsersMauNotice: jest.fn(), + usersAcl: defaultAcl, }; const { rerender } = render( @@ -205,6 +216,7 @@ describe('email password reset integration', () => { EmailPasswordReset: null, showMauInfo: false, onDismissUsersMauNotice: () => null, + usersAcl: defaultAcl, }; }); @@ -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: '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 c03a61418824e..0421af98483e5 100644 --- a/web/packages/teleport/src/Users/Users.tsx +++ b/web/packages/teleport/src/Users/Users.tsx @@ -17,8 +17,18 @@ */ import React from 'react'; -import { Indicator, Box, Alert, ButtonPrimary, Link, ButtonIcon } from 'design'; +import { + Indicator, + Box, + Text, + Alert, + ButtonPrimary, + Link, + Flex, + ButtonIcon, +} from 'design'; import { Cross } from 'design/Icon'; +import { HoverTooltip } from 'shared/components/ToolTip'; import { FeatureBox, @@ -47,6 +57,7 @@ export function Users(props: State) { onStartDelete, onStartEdit, onStartReset, + usersAcl, showMauInfo, onDismissUsersMauNotice, onClose, @@ -61,16 +72,64 @@ 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 && ( - - Create New User - + + {/* TODO (avatus): extract this into a new "missing permissions" component. This will + require us to change the internals of HoverTooltip to allow more arbitrary styling of the popover. + */} + + You do not have all of the required permissions. + + + You are missing permissions: + + {requiredPermissions + .filter(perm => !perm.value) + .map(perm => ( + {`users.${perm.label}`} + ))} + + + + ) + } + > + + Create New User + + )} {InviteCollaborators && ( } {attempt.isSuccess && ( { attemptActions.do(() => ctx.userService.fetchUsers().then(setUsers)); }, []); + // if the cluster has billing enabled, and usageBasedBilling, and they haven't acknowledged + // the info yet + const showMauInfo = + ctx.getFeatureFlags().billing && + cfg.isUsageBasedBilling && + !storageService.getUsersMauAcknowledged(); + + const usersAcl = ctx.storeUser.getUserAccess(); + return { attempt, users, fetchRoles, + usersAcl, operation, onStartCreate, onStartDelete,