From e11b9ef84d65659e7520e5f0da3e945e77e26f0a Mon Sep 17 00:00:00 2001 From: Anastasiia Alekseenko Date: Tue, 12 Nov 2024 11:38:04 +0100 Subject: [PATCH 1/2] feat: [UIE-8136] - add new users table component (part 1) --- .../pr-11367-added-1733488193445.md | 5 + .../manager/src/features/IAM/IAMLanding.tsx | 2 +- .../features/IAM/Users/UserDetailsLanding.tsx | 2 +- .../manager/src/features/IAM/Users/Users.tsx | 34 ----- .../IAM/Users/UsersTable/UserRow.test.tsx | 117 +++++++++++++++ .../features/IAM/Users/UsersTable/UserRow.tsx | 99 ++++++++++++ .../features/IAM/Users/UsersTable/Users.tsx | 101 +++++++++++++ .../Users/UsersTable/UsersActionMenu.test.tsx | 142 ++++++++++++++++++ .../IAM/Users/UsersTable/UsersActionMenu.tsx | 64 ++++++++ .../UsersTable/UsersLandingTableBody.test.tsx | 97 ++++++++++++ .../UsersTable/UsersLandingTableBody.tsx | 41 +++++ .../UsersTable/UsersLandingTableHead.tsx | 52 +++++++ 12 files changed, 720 insertions(+), 36 deletions(-) create mode 100644 packages/manager/.changeset/pr-11367-added-1733488193445.md delete mode 100644 packages/manager/src/features/IAM/Users/Users.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/Users.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableBody.test.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableBody.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableHead.tsx diff --git a/packages/manager/.changeset/pr-11367-added-1733488193445.md b/packages/manager/.changeset/pr-11367-added-1733488193445.md new file mode 100644 index 00000000000..140a762b7d1 --- /dev/null +++ b/packages/manager/.changeset/pr-11367-added-1733488193445.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +New users table component for iam ([#11367](https://github.com/linode/manager/pull/11367)) diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 715755962b6..c8d34ed2bda 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -13,7 +13,7 @@ import type { RouteComponentProps } from 'react-router-dom'; type Props = RouteComponentProps<{}>; const Users = React.lazy(() => - import('./Users/Users').then((module) => ({ + import('./Users/UsersTable/Users').then((module) => ({ default: module.UsersLanding, })) ); diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 5e89e1f9908..fbe9ef3aa06 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -74,7 +74,7 @@ export const UserDetailsLanding = () => {

UIE-8138 - User Roles - Assigned Roles Table

-

Resources

+

UIE-8139 - User Roles - Resources Table

diff --git a/packages/manager/src/features/IAM/Users/Users.tsx b/packages/manager/src/features/IAM/Users/Users.tsx deleted file mode 100644 index e2fd573d1cf..00000000000 --- a/packages/manager/src/features/IAM/Users/Users.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { useHistory } from 'react-router-dom'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; -import { useProfile } from 'src/queries/profile/profile'; - -export const UsersLanding = () => { - const history = useHistory(); - const { data: profile } = useProfile(); - - const username = profile?.username; - - const actions: Action[] = [ - { - onClick: () => { - history.push(`/iam/users/${username}/details`); - }, - title: 'View User Details', - }, - { - onClick: () => { - history.push(`/iam/users/${username}/roles`); - }, - title: 'View User Roles', - }, - ]; - - return ( - <> -

Users Table - UIE-8136

- - - - ); -}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx new file mode 100644 index 00000000000..1522117850d --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; + +import { profileFactory } from 'src/factories'; +import { accountUserFactory } from 'src/factories/accountUsers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { + mockMatchMedia, + renderWithTheme, + wrapWithTableBody, +} from 'src/utilities/testHelpers'; + +import { UserRow } from './UserRow'; + +// Because the table row hides certain columns on small viewport sizes, +// we must use this. +beforeAll(() => mockMatchMedia()); + +describe('UserRow', () => { + it('renders a username and email', () => { + const user = accountUserFactory.build(); + + const { getByText } = renderWithTheme( + wrapWithTableBody() + ); + + expect(getByText(user.username)).toBeVisible(); + expect(getByText(user.email)).toBeVisible(); + }); + it('renders only a username, email, and account access status for a Proxy user', async () => { + const mockLogin = { + login_datetime: '2022-02-09T16:19:26', + }; + const proxyUser = accountUserFactory.build({ + email: 'proxy@proxy.com', + last_login: mockLogin, + restricted: true, + user_type: 'proxy', + username: 'proxyUsername', + }); + + server.use( + // Mock the active profile for the child account. + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build({ user_type: 'child' })); + }) + ); + + const { findByText, queryByText } = renderWithTheme( + wrapWithTableBody() + ); + + // Renders Username, Email, and Account Access fields for a proxy user. + expect(await findByText('proxyUsername')).toBeInTheDocument(); + expect(await findByText('proxy@proxy.com')).toBeInTheDocument(); + + // Does not render the Last Login for a proxy user. + expect(queryByText('2022-02-09T16:19:26')).not.toBeInTheDocument(); + }); + + it('renders "Never" if last_login is null', () => { + const user = accountUserFactory.build({ last_login: null }); + + const { getByText } = renderWithTheme( + wrapWithTableBody() + ); + + expect(getByText('Never')).toBeVisible(); + }); + it('renders a timestamp of the last_login if it was successful', async () => { + // Because we are unit testing a timestamp, set our timezone to UTC + server.use( + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); + }) + ); + + const user = accountUserFactory.build({ + last_login: { + login_datetime: '2023-10-17T21:17:40', + status: 'successful', + }, + }); + + const { findByText } = renderWithTheme( + wrapWithTableBody() + ); + + const date = await findByText('2023-10-17 21:17'); + + expect(date).toBeVisible(); + }); + it('renders a timestamp and "Failed" of the last_login if it was failed', async () => { + // Because we are unit testing a timestamp, set our timezone to UTC + server.use( + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build({ timezone: 'utc' })); + }) + ); + + const user = accountUserFactory.build({ + last_login: { + login_datetime: '2023-10-17T21:17:40', + status: 'failed', + }, + }); + + const { findByText, getByText } = renderWithTheme( + wrapWithTableBody() + ); + + const date = await findByText('2023-10-17 21:17'); + const status = getByText('Failed'); + + expect(date).toBeVisible(); + expect(status).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx new file mode 100644 index 00000000000..27352f4ee0e --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx @@ -0,0 +1,99 @@ +import { Box, Chip, Stack, Typography } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { Avatar } from 'src/components/Avatar/Avatar'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { useProfile } from 'src/queries/profile/profile'; +import { capitalize } from 'src/utilities/capitalize'; + +import { UsersActionMenu } from './UsersActionMenu'; + +import type { User } from '@linode/api-v4'; + +interface Props { + onDelete: (username: string) => void; + user: User; +} + +export const UserRow = ({ onDelete, user }: Props) => { + const theme = useTheme(); + + const { data: profile } = useProfile(); + + const isProxyUser = Boolean(user.user_type === 'proxy'); + + return ( + + + + + + + + {user.username} + + + + + {user.tfa_enabled && } + + + + + + {!isProxyUser && ( + + + + )} + + + + + ); +}; + +/** + * Display information about a Users last login + * + * - The component renders "Never" if last_login is `null` + * - The component renders a date if last_login is a success + * - The component renders a date and a status if last_login is a failure + */ +const LastLogin = (props: Pick) => { + const { last_login } = props; + + if (last_login === null) { + return Never; + } + + if (last_login.status === 'successful') { + return ; + } + + return ( + + + + + {capitalize(last_login.status)} + + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx new file mode 100644 index 00000000000..b4bcb50bf78 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -0,0 +1,101 @@ +import { Box, Button, Paper } from '@linode/ui'; +import { useMediaQuery } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import React from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { useOrder } from 'src/hooks/useOrder'; +import { usePagination } from 'src/hooks/usePagination'; +import { useAccountUsers } from 'src/queries/account/users'; + +import { UsersLandingTableBody } from './UsersLandingTableBody'; +import { UsersLandingTableHead } from './UsersLandingTableHead'; + +import type { Filter } from '@linode/api-v4'; + +export const UsersLanding = () => { + const theme = useTheme(); + const pagination = usePagination(1, 'account-users'); + const order = useOrder(); + + const usersFilter: Filter = { + ['+order']: order.order, + ['+order_by']: order.orderBy, + }; + + // Since this query is disabled for restricted users, use isLoading. + const { data: users, error, isLoading } = useAccountUsers({ + filters: usersFilter, + params: { + page: pagination.page, + page_size: pagination.pageSize, + }, + }); + + const isSmDown = useMediaQuery(theme.breakpoints.down('sm')); + const isLgDown = useMediaQuery(theme.breakpoints.up('lg')); + + const numColsLg = isLgDown ? 4 : 3; + + const numCols = isSmDown ? 2 : numColsLg; + + const handleDelete = (username: string) => { + // mock + }; + + const handleSearch = async (value: string) => { + // mock + }; + + return ( + + + ({ marginTop: theme.spacing(2) })}> + ({ + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacing(2), + })} + > + + + + + + + + +
+ +
+
+ ); +}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx new file mode 100644 index 00000000000..9015e52053f --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx @@ -0,0 +1,142 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { profileFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UsersActionMenu } from './UsersActionMenu'; + +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({}), +})); + +// Mock useProfile +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); + return { + ...actual, + useProfile: queryMocks.useProfile, + }; +}); + +const mockHistory = { + push: vi.fn(), + replace: vi.fn(), +}; + +// Mock useHistory +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: vi.fn(() => mockHistory), + }; +}); + +const mockOnDelete = vi.fn(); + +describe('UsersActionMenu', () => { + it('should render proxy user actions correctly', () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ username: 'current_user' }), + }); + + const { getByRole, getByText, queryByText } = renderWithTheme( + + ); + + // Check if "Manage Access" action is present + const actionBtn = getByRole('button'); + expect(actionBtn).toBeInTheDocument(); + fireEvent.click(actionBtn); + + const manageAccessButton = getByText('Manage Access'); + expect(manageAccessButton).toBeInTheDocument(); + + // Check if only the proxy user action is rendered + expect(queryByText('View User Details')).not.toBeInTheDocument(); + expect(queryByText('View User Roles')).not.toBeInTheDocument(); + expect(queryByText('Delete User')).not.toBeInTheDocument(); + + // Click "Manage Access" and verify history.push is called with the correct URL + fireEvent.click(manageAccessButton); + expect(mockHistory.push).toHaveBeenCalledWith('/iam/users/test_user/roles'); + }); + + it('should render non-proxy user actions correctly', () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ username: 'current_user' }), + }); + + const { getByRole, getByText } = renderWithTheme( + + ); + + const actionBtn = getByRole('button'); + expect(actionBtn).toBeInTheDocument(); + fireEvent.click(actionBtn); + + // Check if "View User Details" action is present + const viewDetailsButton = getByText('View User Details'); + expect(viewDetailsButton).toBeInTheDocument(); + + // Click "View User Details" and verify history.push is called with the correct URL + fireEvent.click(viewDetailsButton); + expect(mockHistory.push).toHaveBeenCalledWith( + '/iam/users/test_user/details' + ); + + // Check if "View User Roles" action is present + const viewRolesButton = getByText('View User Roles'); + expect(viewRolesButton).toBeInTheDocument(); + + // Click "View User Roles" and verify history.push is called with the correct URL + fireEvent.click(viewRolesButton); + expect(mockHistory.push).toHaveBeenCalledWith('/iam/users/test_user/roles'); + + // Check if "Delete User" action is present + const deleteUserButton = getByText('Delete User'); + expect(deleteUserButton).toBeInTheDocument(); + + // Click "Delete User" and verify onDelete is called with the correct username + fireEvent.click(deleteUserButton); + expect(mockOnDelete).toHaveBeenCalledWith('test_user'); + }); + + it("should disable 'Delete User' action for the currently active user", () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ username: 'current_user' }), + }); + + const { getByRole, getByTestId } = renderWithTheme( + + ); + + const actionBtn = getByRole('button'); + expect(actionBtn).toBeInTheDocument(); + fireEvent.click(actionBtn); + + // Check if "Delete User" action is present but disabled + const deleteUserButton = getByTestId('Delete User'); + expect(deleteUserButton).toBeInTheDocument(); + expect(deleteUserButton).toHaveAttribute('aria-disabled', 'true'); + + // Check for tooltip text + const tooltip = getByRole('button', { + name: "You can't delete the currently active user.", + }); + expect(tooltip).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx new file mode 100644 index 00000000000..9e69964d3b0 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { useHistory } from 'react-router-dom'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { useProfile } from 'src/queries/profile/profile'; + +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +interface Props { + isProxyUser: boolean; + onDelete: (username: string) => void; + username: string; +} + +export const UsersActionMenu = ({ isProxyUser, onDelete, username }: Props) => { + const history = useHistory(); + + const { data: profile } = useProfile(); + const profileUsername = profile?.username; + + const proxyUserActions: Action[] = [ + { + onClick: () => { + history.push(`/iam/users/${username}/roles`); + }, + title: 'Manage Access', + }, + ]; + + const nonProxyUserActions: Action[] = [ + { + onClick: () => { + history.push(`/iam/users/${username}/details`); + }, + title: 'View User Details', + }, + { + onClick: () => { + history.push(`/iam/users/${username}/roles`); + }, + title: 'View User Roles', + }, + { + disabled: username === profileUsername, + onClick: () => { + onDelete(username); + }, + title: 'Delete User', + tooltip: + username === profileUsername + ? "You can't delete the currently active user." + : undefined, + }, + ]; + + const actions = isProxyUser ? proxyUserActions : nonProxyUserActions; + + return ( + + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableBody.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableBody.test.tsx new file mode 100644 index 00000000000..135dd0d1cff --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableBody.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; + +import { accountUserFactory } from 'src/factories/accountUsers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UsersLandingTableBody } from './UsersLandingTableBody'; + +import type { APIError } from '@linode/api-v4'; + +const mockOnDelete = vi.fn(); +const numCols = 3; + +describe('UsersLandingTableBody', () => { + it('renders loading state', () => { + const { getByTestId } = renderWithTheme( + + + + +
+ ); + + const loadingRow = getByTestId('table-row-loading'); + expect(loadingRow).toBeInTheDocument(); + expect(loadingRow).toHaveAttribute( + 'aria-label', + 'Table content is loading' + ); + }); + + it('renders error state', () => { + const error: APIError[] = [{ reason: 'Something went wrong' }]; + + const { getByTestId } = renderWithTheme( + + + + +
+ ); + + const errorRow = getByTestId('table-row-error'); + expect(errorRow).toBeInTheDocument(); + }); + + it('renders empty state', () => { + const { getByTestId } = renderWithTheme( + + + + +
+ ); + + const emptyRow = getByTestId('table-row-empty'); + expect(emptyRow).toBeInTheDocument(); + }); + + it('renders user rows', () => { + const users = accountUserFactory.buildList(3); + + const { getByText } = renderWithTheme( + + + + +
+ ); + + expect(getByText('user-7')).toBeInTheDocument(); + expect(getByText('user-6')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableBody.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableBody.tsx new file mode 100644 index 00000000000..03548ce8b97 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableBody.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; + +import { UserRow } from './UserRow'; + +import type { APIError, User } from '@linode/api-v4'; + +interface Props { + error: APIError[] | null; + isLoading: boolean; + numCols: number; + onDelete: (username: string) => void; + users: User[] | undefined; +} + +export const UsersLandingTableBody = (props: Props) => { + const { error, isLoading, numCols, onDelete, users } = props; + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!users || users.length === 0) { + return ; + } + + return ( + <> + {users.map((user) => ( + + ))} + + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableHead.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableHead.tsx new file mode 100644 index 00000000000..4f048ab5b1c --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingTableHead.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell'; + +export type SortOrder = 'asc' | 'desc'; + +export interface Order { + handleOrderChange: (key: string, order?: SortOrder | undefined) => void; + order: SortOrder; + orderBy: string; +} + +interface Props { + order: Order; +} + +export const UsersLandingTableHead = ({ order }: Props) => { + return ( + + + + Username + + + Email Address + + + Last Login + + + + + ); +}; From f1afcd7ffe9645cc8cca97fa878faae5191ad609 Mon Sep 17 00:00:00 2001 From: mjac0bs Date: Tue, 10 Dec 2024 09:20:01 -0800 Subject: [PATCH 2/2] Update changeset - file name must match changeset type --- packages/manager/.changeset/pr-11367-added-1733488193445.md | 5 ----- .../.changeset/pr-11367-upcoming-features-1733488193445.md | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 packages/manager/.changeset/pr-11367-added-1733488193445.md create mode 100644 packages/manager/.changeset/pr-11367-upcoming-features-1733488193445.md diff --git a/packages/manager/.changeset/pr-11367-added-1733488193445.md b/packages/manager/.changeset/pr-11367-added-1733488193445.md deleted file mode 100644 index 140a762b7d1..00000000000 --- a/packages/manager/.changeset/pr-11367-added-1733488193445.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -New users table component for iam ([#11367](https://github.com/linode/manager/pull/11367)) diff --git a/packages/manager/.changeset/pr-11367-upcoming-features-1733488193445.md b/packages/manager/.changeset/pr-11367-upcoming-features-1733488193445.md new file mode 100644 index 00000000000..72e8aeab48a --- /dev/null +++ b/packages/manager/.changeset/pr-11367-upcoming-features-1733488193445.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add new users table component for IAM ([#11367](https://github.com/linode/manager/pull/11367))