Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [UIE-8136] - IAM RBAC: add new users table component (part 1) #11367

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Add new users table component for IAM ([#11367](https://github.com/linode/manager/pull/11367))
2 changes: 1 addition & 1 deletion packages/manager/src/features/IAM/IAMLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const UserDetailsLanding = () => {
<p>UIE-8138 - User Roles - Assigned Roles Table</p>
</SafeTabPanel>
<SafeTabPanel index={++idx}>
<p>Resources</p>
<p>UIE-8139 - User Roles - Resources Table</p>
</SafeTabPanel>
</TabPanels>
</Tabs>
Expand Down
34 changes: 0 additions & 34 deletions packages/manager/src/features/IAM/Users/Users.tsx

This file was deleted.

117 changes: 117 additions & 0 deletions packages/manager/src/features/IAM/Users/UsersTable/UserRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding test coverage


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(<UserRow onDelete={vi.fn()} user={user} />)
);

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(<UserRow onDelete={vi.fn()} user={proxyUser} />)
);

// 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(<UserRow onDelete={vi.fn()} user={user} />)
);

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(<UserRow onDelete={vi.fn()} user={user} />)
);

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(<UserRow onDelete={vi.fn()} user={user} />)
);

const date = await findByText('2023-10-17 21:17');
const status = getByText('Failed');

expect(date).toBeVisible();
expect(status).toBeVisible();
});
});
99 changes: 99 additions & 0 deletions packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TableRow data-qa-table-row={user.username} key={user.username}>
<TableCell>
<Stack alignItems="center" direction="row" spacing={1.5}>
<Avatar
color={
user.username !== profile?.username
? theme.palette.primary.dark
: undefined
}
username={user.username}
/>
<Typography>
<MaskableText isToggleable text={user.username}>
<Link to={`/iam/users/${user.username}/details`}>
{user.username}
</Link>
</MaskableText>
</Typography>
<Box display="flex" flexGrow={1} />
{user.tfa_enabled && <Chip color="success" label="2FA" />}
</Stack>
</TableCell>
<TableCell sx={{ display: { sm: 'table-cell', xs: 'none' } }}>
<MaskableText isToggleable text={user.email} />
</TableCell>
{!isProxyUser && (
<TableCell sx={{ display: { lg: 'table-cell', xs: 'none' } }}>
<LastLogin last_login={user.last_login} />
</TableCell>
)}
<TableCell actionCell>
<UsersActionMenu
isProxyUser={isProxyUser}
onDelete={onDelete}
username={user.username}
/>
</TableCell>
</TableRow>
);
};

/**
* 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<User, 'last_login'>) => {
const { last_login } = props;

if (last_login === null) {
return <Typography>Never</Typography>;
}

if (last_login.status === 'successful') {
return <DateTimeDisplay value={last_login.login_datetime} />;
}

return (
<Stack alignItems="center" direction="row" spacing={1}>
<DateTimeDisplay value={last_login.login_datetime} />
<Typography>&#8212;</Typography>
<StatusIcon status="error" />
<Typography>{capitalize(last_login.status)}</Typography>
</Stack>
);
};
101 changes: 101 additions & 0 deletions packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<React.Fragment>
<DocumentTitleSegment segment="Users & Grants" />
<Paper sx={(theme) => ({ marginTop: theme.spacing(2) })}>
<Box
sx={(theme) => ({
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between',
marginBottom: theme.spacing(2),
})}
>
<DebouncedSearchTextField
clearable
debounceTime={250}
hideLabel
label="Filter"
onSearch={handleSearch}
placeholder="Filter"
sx={{ width: 320 }}
value=""
/>
<Button buttonType="primary">Add a User</Button>
</Box>
<Table aria-label="List of Users">
<UsersLandingTableHead order={order} />
<TableBody>
<UsersLandingTableBody
error={error}
isLoading={isLoading}
numCols={numCols}
onDelete={handleDelete}
users={users?.data}
/>
</TableBody>
</Table>
<PaginationFooter
count={users?.results ?? 0}
eventCategory="users landing"
handlePageChange={pagination.handlePageChange}
handleSizeChange={pagination.handlePageSizeChange}
page={pagination.page}
pageSize={pagination.pageSize}
/>
</Paper>
</React.Fragment>
);
};
Loading
Loading