Skip to content

Commit 964842c

Browse files
aaleksee-akamaimjac0bs
andauthored
feat: [UIE-8136] - IAM RBAC: add new users table component (part 1) (linode#11367)
* feat: [UIE-8136] - add new users table component (part 1) * Update changeset - file name must match changeset type --------- Co-authored-by: mjac0bs <[email protected]>
1 parent 81e55d5 commit 964842c

File tree

12 files changed

+720
-36
lines changed

12 files changed

+720
-36
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
Add new users table component for IAM ([#11367](https://github.com/linode/manager/pull/11367))

packages/manager/src/features/IAM/IAMLanding.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { RouteComponentProps } from 'react-router-dom';
1313
type Props = RouteComponentProps<{}>;
1414

1515
const Users = React.lazy(() =>
16-
import('./Users/Users').then((module) => ({
16+
import('./Users/UsersTable/Users').then((module) => ({
1717
default: module.UsersLanding,
1818
}))
1919
);

packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const UserDetailsLanding = () => {
7474
<p>UIE-8138 - User Roles - Assigned Roles Table</p>
7575
</SafeTabPanel>
7676
<SafeTabPanel index={++idx}>
77-
<p>Resources</p>
77+
<p>UIE-8139 - User Roles - Resources Table</p>
7878
</SafeTabPanel>
7979
</TabPanels>
8080
</Tabs>

packages/manager/src/features/IAM/Users/Users.tsx

Lines changed: 0 additions & 34 deletions
This file was deleted.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import React from 'react';
2+
3+
import { profileFactory } from 'src/factories';
4+
import { accountUserFactory } from 'src/factories/accountUsers';
5+
import { HttpResponse, http, server } from 'src/mocks/testServer';
6+
import {
7+
mockMatchMedia,
8+
renderWithTheme,
9+
wrapWithTableBody,
10+
} from 'src/utilities/testHelpers';
11+
12+
import { UserRow } from './UserRow';
13+
14+
// Because the table row hides certain columns on small viewport sizes,
15+
// we must use this.
16+
beforeAll(() => mockMatchMedia());
17+
18+
describe('UserRow', () => {
19+
it('renders a username and email', () => {
20+
const user = accountUserFactory.build();
21+
22+
const { getByText } = renderWithTheme(
23+
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={user} />)
24+
);
25+
26+
expect(getByText(user.username)).toBeVisible();
27+
expect(getByText(user.email)).toBeVisible();
28+
});
29+
it('renders only a username, email, and account access status for a Proxy user', async () => {
30+
const mockLogin = {
31+
login_datetime: '2022-02-09T16:19:26',
32+
};
33+
const proxyUser = accountUserFactory.build({
34+
35+
last_login: mockLogin,
36+
restricted: true,
37+
user_type: 'proxy',
38+
username: 'proxyUsername',
39+
});
40+
41+
server.use(
42+
// Mock the active profile for the child account.
43+
http.get('*/profile', () => {
44+
return HttpResponse.json(profileFactory.build({ user_type: 'child' }));
45+
})
46+
);
47+
48+
const { findByText, queryByText } = renderWithTheme(
49+
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={proxyUser} />)
50+
);
51+
52+
// Renders Username, Email, and Account Access fields for a proxy user.
53+
expect(await findByText('proxyUsername')).toBeInTheDocument();
54+
expect(await findByText('[email protected]')).toBeInTheDocument();
55+
56+
// Does not render the Last Login for a proxy user.
57+
expect(queryByText('2022-02-09T16:19:26')).not.toBeInTheDocument();
58+
});
59+
60+
it('renders "Never" if last_login is null', () => {
61+
const user = accountUserFactory.build({ last_login: null });
62+
63+
const { getByText } = renderWithTheme(
64+
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={user} />)
65+
);
66+
67+
expect(getByText('Never')).toBeVisible();
68+
});
69+
it('renders a timestamp of the last_login if it was successful', async () => {
70+
// Because we are unit testing a timestamp, set our timezone to UTC
71+
server.use(
72+
http.get('*/profile', () => {
73+
return HttpResponse.json(profileFactory.build({ timezone: 'utc' }));
74+
})
75+
);
76+
77+
const user = accountUserFactory.build({
78+
last_login: {
79+
login_datetime: '2023-10-17T21:17:40',
80+
status: 'successful',
81+
},
82+
});
83+
84+
const { findByText } = renderWithTheme(
85+
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={user} />)
86+
);
87+
88+
const date = await findByText('2023-10-17 21:17');
89+
90+
expect(date).toBeVisible();
91+
});
92+
it('renders a timestamp and "Failed" of the last_login if it was failed', async () => {
93+
// Because we are unit testing a timestamp, set our timezone to UTC
94+
server.use(
95+
http.get('*/profile', () => {
96+
return HttpResponse.json(profileFactory.build({ timezone: 'utc' }));
97+
})
98+
);
99+
100+
const user = accountUserFactory.build({
101+
last_login: {
102+
login_datetime: '2023-10-17T21:17:40',
103+
status: 'failed',
104+
},
105+
});
106+
107+
const { findByText, getByText } = renderWithTheme(
108+
wrapWithTableBody(<UserRow onDelete={vi.fn()} user={user} />)
109+
);
110+
111+
const date = await findByText('2023-10-17 21:17');
112+
const status = getByText('Failed');
113+
114+
expect(date).toBeVisible();
115+
expect(status).toBeVisible();
116+
});
117+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Box, Chip, Stack, Typography } from '@linode/ui';
2+
import { useTheme } from '@mui/material/styles';
3+
import React from 'react';
4+
import { Link } from 'react-router-dom';
5+
6+
import { Avatar } from 'src/components/Avatar/Avatar';
7+
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
8+
import { MaskableText } from 'src/components/MaskableText/MaskableText';
9+
import { StatusIcon } from 'src/components/StatusIcon/StatusIcon';
10+
import { TableCell } from 'src/components/TableCell';
11+
import { TableRow } from 'src/components/TableRow';
12+
import { useProfile } from 'src/queries/profile/profile';
13+
import { capitalize } from 'src/utilities/capitalize';
14+
15+
import { UsersActionMenu } from './UsersActionMenu';
16+
17+
import type { User } from '@linode/api-v4';
18+
19+
interface Props {
20+
onDelete: (username: string) => void;
21+
user: User;
22+
}
23+
24+
export const UserRow = ({ onDelete, user }: Props) => {
25+
const theme = useTheme();
26+
27+
const { data: profile } = useProfile();
28+
29+
const isProxyUser = Boolean(user.user_type === 'proxy');
30+
31+
return (
32+
<TableRow data-qa-table-row={user.username} key={user.username}>
33+
<TableCell>
34+
<Stack alignItems="center" direction="row" spacing={1.5}>
35+
<Avatar
36+
color={
37+
user.username !== profile?.username
38+
? theme.palette.primary.dark
39+
: undefined
40+
}
41+
username={user.username}
42+
/>
43+
<Typography>
44+
<MaskableText isToggleable text={user.username}>
45+
<Link to={`/iam/users/${user.username}/details`}>
46+
{user.username}
47+
</Link>
48+
</MaskableText>
49+
</Typography>
50+
<Box display="flex" flexGrow={1} />
51+
{user.tfa_enabled && <Chip color="success" label="2FA" />}
52+
</Stack>
53+
</TableCell>
54+
<TableCell sx={{ display: { sm: 'table-cell', xs: 'none' } }}>
55+
<MaskableText isToggleable text={user.email} />
56+
</TableCell>
57+
{!isProxyUser && (
58+
<TableCell sx={{ display: { lg: 'table-cell', xs: 'none' } }}>
59+
<LastLogin last_login={user.last_login} />
60+
</TableCell>
61+
)}
62+
<TableCell actionCell>
63+
<UsersActionMenu
64+
isProxyUser={isProxyUser}
65+
onDelete={onDelete}
66+
username={user.username}
67+
/>
68+
</TableCell>
69+
</TableRow>
70+
);
71+
};
72+
73+
/**
74+
* Display information about a Users last login
75+
*
76+
* - The component renders "Never" if last_login is `null`
77+
* - The component renders a date if last_login is a success
78+
* - The component renders a date and a status if last_login is a failure
79+
*/
80+
const LastLogin = (props: Pick<User, 'last_login'>) => {
81+
const { last_login } = props;
82+
83+
if (last_login === null) {
84+
return <Typography>Never</Typography>;
85+
}
86+
87+
if (last_login.status === 'successful') {
88+
return <DateTimeDisplay value={last_login.login_datetime} />;
89+
}
90+
91+
return (
92+
<Stack alignItems="center" direction="row" spacing={1}>
93+
<DateTimeDisplay value={last_login.login_datetime} />
94+
<Typography>&#8212;</Typography>
95+
<StatusIcon status="error" />
96+
<Typography>{capitalize(last_login.status)}</Typography>
97+
</Stack>
98+
);
99+
};
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Box, Button, Paper } from '@linode/ui';
2+
import { useMediaQuery } from '@mui/material';
3+
import { useTheme } from '@mui/material/styles';
4+
import React from 'react';
5+
6+
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
7+
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
8+
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
9+
import { Table } from 'src/components/Table';
10+
import { TableBody } from 'src/components/TableBody';
11+
import { useOrder } from 'src/hooks/useOrder';
12+
import { usePagination } from 'src/hooks/usePagination';
13+
import { useAccountUsers } from 'src/queries/account/users';
14+
15+
import { UsersLandingTableBody } from './UsersLandingTableBody';
16+
import { UsersLandingTableHead } from './UsersLandingTableHead';
17+
18+
import type { Filter } from '@linode/api-v4';
19+
20+
export const UsersLanding = () => {
21+
const theme = useTheme();
22+
const pagination = usePagination(1, 'account-users');
23+
const order = useOrder();
24+
25+
const usersFilter: Filter = {
26+
['+order']: order.order,
27+
['+order_by']: order.orderBy,
28+
};
29+
30+
// Since this query is disabled for restricted users, use isLoading.
31+
const { data: users, error, isLoading } = useAccountUsers({
32+
filters: usersFilter,
33+
params: {
34+
page: pagination.page,
35+
page_size: pagination.pageSize,
36+
},
37+
});
38+
39+
const isSmDown = useMediaQuery(theme.breakpoints.down('sm'));
40+
const isLgDown = useMediaQuery(theme.breakpoints.up('lg'));
41+
42+
const numColsLg = isLgDown ? 4 : 3;
43+
44+
const numCols = isSmDown ? 2 : numColsLg;
45+
46+
const handleDelete = (username: string) => {
47+
// mock
48+
};
49+
50+
const handleSearch = async (value: string) => {
51+
// mock
52+
};
53+
54+
return (
55+
<React.Fragment>
56+
<DocumentTitleSegment segment="Users & Grants" />
57+
<Paper sx={(theme) => ({ marginTop: theme.spacing(2) })}>
58+
<Box
59+
sx={(theme) => ({
60+
alignItems: 'center',
61+
display: 'flex',
62+
justifyContent: 'space-between',
63+
marginBottom: theme.spacing(2),
64+
})}
65+
>
66+
<DebouncedSearchTextField
67+
clearable
68+
debounceTime={250}
69+
hideLabel
70+
label="Filter"
71+
onSearch={handleSearch}
72+
placeholder="Filter"
73+
sx={{ width: 320 }}
74+
value=""
75+
/>
76+
<Button buttonType="primary">Add a User</Button>
77+
</Box>
78+
<Table aria-label="List of Users">
79+
<UsersLandingTableHead order={order} />
80+
<TableBody>
81+
<UsersLandingTableBody
82+
error={error}
83+
isLoading={isLoading}
84+
numCols={numCols}
85+
onDelete={handleDelete}
86+
users={users?.data}
87+
/>
88+
</TableBody>
89+
</Table>
90+
<PaginationFooter
91+
count={users?.results ?? 0}
92+
eventCategory="users landing"
93+
handlePageChange={pagination.handlePageChange}
94+
handleSizeChange={pagination.handlePageSizeChange}
95+
page={pagination.page}
96+
pageSize={pagination.pageSize}
97+
/>
98+
</Paper>
99+
</React.Fragment>
100+
);
101+
};

0 commit comments

Comments
 (0)