feat: add user groups column to users table (#10284)

* refactor: extract UserRoleCell into separate component

* wip: add placeholder Groups column

* fix: remove redundant css styles

* refactor: update EditRolesButton to use Sets to detect selections

* wip: commit progress for updated roles column

* wip: commit current role pill progress

* fix: update state sync logic

* chore: add groupsByUserId query options factory

* fix: update return value of select function

* chore: drill groups data down to cell component

* wip: commit current cell progress

* fix: remove redundant classes

* wip: commit current styling progress

* fix: update line height for CTA

* fix: update spacing

* chore: add tooltip for Groups column header

* fix: remove tsbuild file

* refactor: consolidate tooltip components

* fix: update font size defaults inside theme

* fix: expand hoverable/clickable area of groups cell

* fix: remove possible undefined cases from HelpTooltip

* chore: add popover functionality to groups

* wip: commit progress on groups tooltip

* fix: remove zero-height group name visual bug

* feat: get basic version of user group tooltips done

* perf: move sort order callback outside loop

* fix: update spacing for tooltip

* feat: make popovers entirely hover-based

* fix: disable scroll locking for popover

* docs: add comments explaining some pitfalls with Popover component

* refactor: simplify userRoleCell implementation

* feat: complete main feature

* fix: prevent scroll lock for role tooltips

* fix: change import to type import

* refactor: simplify how groups are clustered

* refactor: update UserRoleCell to use Popover

* refactor: remove unnecessary fragment

* chore: add id/aria support for Popover

* refactor: update UserGroupsCell to use Popover

* chore: redo visual design for UserGroupsCell

* fix: shrink UserGroupsCell text

* fix: update UsersTable test to include groups info
This commit is contained in:
Michael Smith
2023-10-19 14:31:48 -04:00
committed by GitHub
parent 557adab224
commit ab2904a676
17 changed files with 654 additions and 282 deletions

View File

@ -1,4 +1,4 @@
import { QueryClient } from "react-query";
import { QueryClient, UseQueryOptions } from "react-query";
import * as API from "api/api";
import { checkAuthorization } from "api/api";
import {
@ -25,6 +25,43 @@ export const group = (groupId: string) => {
};
};
export type GroupsByUserId = Readonly<Map<string, readonly Group[]>>;
export function groupsByUserId(organizationId: string) {
return {
...groups(organizationId),
select: (allGroups) => {
// Sorting here means that nothing has to be sorted for the individual
// user arrays later
const sorted = [...allGroups].sort((g1, g2) => {
const key =
g1.display_name && g2.display_name ? "display_name" : "name";
if (g1[key] === g2[key]) {
return 0;
}
return g1[key] < g2[key] ? -1 : 1;
});
const userIdMapper = new Map<string, Group[]>();
for (const group of sorted) {
for (const user of group.members) {
let groupsForUser = userIdMapper.get(user.id);
if (groupsForUser === undefined) {
groupsForUser = [];
userIdMapper.set(user.id, groupsForUser);
}
groupsForUser.push(group);
}
}
return userIdMapper as GroupsByUserId;
},
} satisfies UseQueryOptions<Group[], unknown, GroupsByUserId>;
}
export const groupPermissions = (groupId: string) => {
return {
queryKey: [...getGroupQueryKey(groupId), "permissions"],

View File

@ -5,16 +5,23 @@ import { FC } from "react";
import { css, type Interpolation, type Theme } from "@emotion/react";
export type AvatarProps = MuiAvatarProps & {
size?: "sm" | "md" | "xl";
size?: "xs" | "sm" | "md" | "xl";
colorScheme?: "light" | "darken";
fitImage?: boolean;
};
const sizeStyles = {
xs: (theme) => ({
width: theme.spacing(2),
height: theme.spacing(2),
fontSize: theme.spacing(1),
fontWeight: 700,
}),
sm: (theme) => ({
width: theme.spacing(3),
height: theme.spacing(3),
fontSize: theme.spacing(1.5),
fontWeight: 600,
}),
md: {},
xl: (theme) => ({

View File

@ -88,7 +88,7 @@ export const HelpPopover: FC<
export const HelpTooltip: FC<PropsWithChildren<HelpTooltipProps>> = ({
children,
open,
open = false,
size = "medium",
icon: Icon = HelpIcon,
iconClassName,
@ -96,7 +96,7 @@ export const HelpTooltip: FC<PropsWithChildren<HelpTooltipProps>> = ({
}) => {
const theme = useTheme();
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(Boolean(open));
const [isOpen, setIsOpen] = useState(open);
const id = isOpen ? "help-popover" : undefined;
const onClose = () => {

View File

@ -5,6 +5,7 @@ import {
createContext,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react";
@ -19,11 +20,14 @@ type TriggerMode = "hover" | "click";
type TriggerRef = React.RefObject<HTMLElement>;
type TriggerElement = ReactElement<{
onClick?: () => void;
ref: TriggerRef;
onClick?: () => void;
"aria-haspopup"?: boolean;
"aria-owns"?: string | undefined;
}>;
type PopoverContextValue = {
id: string;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
triggerRef: TriggerRef;
@ -39,9 +43,17 @@ export const Popover = (props: {
mode?: TriggerMode;
isDefaultOpen?: boolean;
}) => {
const hookId = useId();
const [isOpen, setIsOpen] = useState(props.isDefaultOpen ?? false);
const triggerRef = useRef<HTMLElement>(null);
const value = { isOpen, setIsOpen, triggerRef, mode: props.mode ?? "click" };
const value: PopoverContextValue = {
isOpen,
setIsOpen,
triggerRef,
id: `${hookId}-popover`,
mode: props.mode ?? "click",
};
return (
<PopoverContext.Provider value={value}>
@ -62,10 +74,7 @@ export const usePopover = () => {
return context;
};
export const PopoverTrigger = (props: {
children: TriggerElement;
hover?: boolean;
}) => {
export const PopoverTrigger = (props: { children: TriggerElement }) => {
const popover = usePopover();
const clickProps = {
@ -85,6 +94,8 @@ export const PopoverTrigger = (props: {
return cloneElement(props.children, {
...(popover.mode === "click" ? clickProps : hoverProps),
"aria-haspopup": true,
"aria-owns": popover.isOpen ? popover.id : undefined,
ref: popover.triggerRef,
});
};
@ -118,10 +129,10 @@ export const PopoverContent = (
<MuiPopover
disablePortal
css={(theme) => ({
// When it is on hover mode, and the moude is moving from the trigger to
// When it is on hover mode, and the mode is moving from the trigger to
// the popover, if there is any space, the popover will be closed. I
// found this is a limitation on how MUI structured the component. It is
// not a big issue for now but we can reavaluate it in the future.
// not a big issue for now but we can re-evaluate it in the future.
marginTop: hoverMode ? undefined : theme.spacing(1),
pointerEvents: hoverMode ? "none" : undefined,
"& .MuiPaper-root": {
@ -133,6 +144,7 @@ export const PopoverContent = (
{...horizontalProps(horizontal)}
{...modeProps(popover)}
{...props}
id={popover.id}
open={popover.isOpen}
onClose={() => popover.setIsOpen(false)}
anchorEl={popover.triggerRef.current}
@ -143,10 +155,10 @@ export const PopoverContent = (
const modeProps = (popover: PopoverContextValue) => {
if (popover.mode === "hover") {
return {
onMouseEnter: () => {
onPointerEnter: () => {
popover.setIsOpen(true);
},
onMouseLeave: () => {
onPointerLeave: () => {
popover.setIsOpen(false);
},
};

View File

@ -1,23 +1,10 @@
import { User } from "api/typesGenerated";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { nonInitialPage } from "components/PaginationWidget/utils";
import { useMe } from "hooks/useMe";
import { usePermissions } from "hooks/usePermissions";
import { FC, ReactNode, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useSearchParams, useNavigate } from "react-router-dom";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { pageTitle } from "utils/page";
import { UsersPageView } from "./UsersPageView";
import { useStatusFilterMenu } from "./UsersFilter";
import { useFilter } from "components/Filter/filter";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { type FC, type ReactNode, useState } from "react";
import { type User } from "api/typesGenerated";
import { roles } from "api/queries/roles";
import { groupsByUserId } from "api/queries/groups";
import { getErrorMessage } from "api/errors";
import { deploymentConfig } from "api/queries/deployment";
import { prepareQuery } from "utils/filters";
import { usePagination } from "hooks";
import {
users,
suspendUser,
@ -27,38 +14,55 @@ import {
updateRoles,
authMethods,
} from "api/queries/users";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { getErrorMessage } from "api/errors";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useOrganizationId, usePagination } from "hooks";
import { useMe } from "hooks/useMe";
import { usePermissions } from "hooks/usePermissions";
import { useStatusFilterMenu } from "./UsersFilter";
import { useFilter } from "components/Filter/filter";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { generateRandomString } from "utils/random";
import { prepareQuery } from "utils/filters";
import { Helmet } from "react-helmet-async";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { nonInitialPage } from "components/PaginationWidget/utils";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { pageTitle } from "utils/page";
import { UsersPageView } from "./UsersPageView";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
export const UsersPage: FC<{ children?: ReactNode }> = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const searchParamsResult = useSearchParams();
const { entitlements } = useDashboard();
const [searchParams] = searchParamsResult;
const filter = searchParams.get("filter") ?? "";
const pagination = usePagination({
searchParamsResult,
});
const pagination = usePagination({ searchParamsResult });
const usersQuery = useQuery(
users({
q: prepareQuery(filter),
q: prepareQuery(searchParams.get("filter") ?? ""),
limit: pagination.limit,
offset: pagination.offset,
}),
);
const organizationId = useOrganizationId();
const groupsByUserIdQuery = useQuery(groupsByUserId(organizationId));
const authMethodsQuery = useQuery(authMethods());
const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions();
const rolesQuery = useQuery(roles());
const { data: deploymentValues } = useQuery({
...deploymentConfig(),
enabled: viewDeploymentValues,
});
// Indicates if oidc roles are synced from the oidc idp.
// Assign 'false' if unknown.
const oidcRoleSyncEnabled =
viewDeploymentValues &&
deploymentValues?.config.oidc?.user_role_field !== "";
const me = useMe();
const useFilterResult = useFilter({
searchParamsResult,
@ -74,36 +78,47 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
status: option?.value,
}),
});
const authMethodsQuery = useQuery(authMethods());
const isLoading =
usersQuery.isLoading || rolesQuery.isLoading || authMethodsQuery.isLoading;
const [confirmSuspendUser, setConfirmSuspendUser] = useState<User>();
const [userToSuspend, setUserToSuspend] = useState<User>();
const suspendUserMutation = useMutation(suspendUser(queryClient));
const [confirmActivateUser, setConfirmActivateUser] = useState<User>();
const [userToActivate, setUserToActivate] = useState<User>();
const activateUserMutation = useMutation(activateUser(queryClient));
const [confirmDeleteUser, setConfirmDeleteUser] = useState<User>();
const [userToDelete, setUserToDelete] = useState<User>();
const deleteUserMutation = useMutation(deleteUser(queryClient));
const [confirmResetPassword, setConfirmResetPassword] = useState<{
user: User;
newPassword: string;
}>();
const updatePasswordMutation = useMutation(updatePassword());
const updatePasswordMutation = useMutation(updatePassword());
const updateRolesMutation = useMutation(updateRoles(queryClient));
// Indicates if oidc roles are synced from the oidc idp.
// Assign 'false' if unknown.
const oidcRoleSyncEnabled =
viewDeploymentValues &&
deploymentValues?.config.oidc?.user_role_field !== "";
const isLoading =
usersQuery.isLoading ||
rolesQuery.isLoading ||
authMethodsQuery.isLoading ||
groupsByUserIdQuery.isLoading;
return (
<>
<Helmet>
<title>{pageTitle("Users")}</title>
</Helmet>
<UsersPageView
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
roles={rolesQuery.data}
users={usersQuery.data?.users}
groupsByUserId={groupsByUserIdQuery.data}
authMethods={authMethodsQuery.data}
onListWorkspaces={(user) => {
navigate(
@ -116,9 +131,9 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
"/audit?filter=" + encodeURIComponent(`username:${user.username}`),
);
}}
onDeleteUser={setConfirmDeleteUser}
onSuspendUser={setConfirmSuspendUser}
onActivateUser={setConfirmActivateUser}
onDeleteUser={setUserToDelete}
onSuspendUser={setUserToSuspend}
onActivateUser={setUserToActivate}
onResetUserPassword={(user) => {
setConfirmResetPassword({
user,
@ -147,9 +162,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
filterProps={{
filter: useFilterResult,
error: usersQuery.error,
menus: {
status: statusMenu,
},
menus: { status: statusMenu },
}}
count={usersQuery.data?.count}
page={pagination.page}
@ -158,48 +171,44 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
/>
<DeleteDialog
key={confirmDeleteUser?.username}
isOpen={confirmDeleteUser !== undefined}
key={userToDelete?.username}
isOpen={userToDelete !== undefined}
confirmLoading={deleteUserMutation.isLoading}
name={confirmDeleteUser?.username ?? ""}
name={userToDelete?.username ?? ""}
entity="user"
onCancel={() => setUserToDelete(undefined)}
onConfirm={async () => {
try {
await deleteUserMutation.mutateAsync(confirmDeleteUser!.id);
setConfirmDeleteUser(undefined);
await deleteUserMutation.mutateAsync(userToDelete!.id);
setUserToDelete(undefined);
displaySuccess("Successfully deleted the user.");
} catch (e) {
displayError(getErrorMessage(e, "Error deleting user."));
}
}}
onCancel={() => {
setConfirmDeleteUser(undefined);
}}
/>
<ConfirmDialog
type="delete"
hideCancel={false}
open={confirmSuspendUser !== undefined}
open={userToSuspend !== undefined}
confirmLoading={suspendUserMutation.isLoading}
title="Suspend user"
confirmText="Suspend"
onClose={() => setUserToSuspend(undefined)}
onConfirm={async () => {
try {
await suspendUserMutation.mutateAsync(confirmSuspendUser!.id);
setConfirmSuspendUser(undefined);
await suspendUserMutation.mutateAsync(userToSuspend!.id);
setUserToSuspend(undefined);
displaySuccess("Successfully suspended the user.");
} catch (e) {
displayError(getErrorMessage(e, "Error suspending user."));
}
}}
onClose={() => {
setConfirmSuspendUser(undefined);
}}
description={
<>
Do you want to suspend the user{" "}
<strong>{confirmSuspendUser?.username ?? ""}</strong>?
<strong>{userToSuspend?.username ?? ""}</strong>?
</>
}
/>
@ -207,26 +216,24 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
<ConfirmDialog
type="success"
hideCancel={false}
open={confirmActivateUser !== undefined}
open={userToActivate !== undefined}
confirmLoading={activateUserMutation.isLoading}
title="Activate user"
confirmText="Activate"
onClose={() => setUserToActivate(undefined)}
onConfirm={async () => {
try {
await activateUserMutation.mutateAsync(confirmActivateUser!.id);
setConfirmActivateUser(undefined);
await activateUserMutation.mutateAsync(userToActivate!.id);
setUserToActivate(undefined);
displaySuccess("Successfully activated the user.");
} catch (e) {
displayError(getErrorMessage(e, "Error activating user."));
}
}}
onClose={() => {
setConfirmActivateUser(undefined);
}}
description={
<>
Do you want to activate{" "}
<strong>{confirmActivateUser?.username ?? ""}</strong>?
<strong>{userToActivate?.username ?? ""}</strong>?
</>
}
/>

View File

@ -1,5 +1,7 @@
import { ComponentProps, FC } from "react";
import * as TypesGen from "api/typesGenerated";
import { type ComponentProps, type FC } from "react";
import type * as TypesGen from "api/typesGenerated";
import { type GroupsByUserId } from "api/queries/groups";
import { UsersTable } from "./UsersTable/UsersTable";
import { UsersFilter } from "./UsersFilter";
import {
@ -12,10 +14,10 @@ export interface UsersPageViewProps {
users?: TypesGen.User[];
roles?: TypesGen.AssignableRoles[];
isUpdatingUserRoles?: boolean;
canEditUsers?: boolean;
canEditUsers: boolean;
oidcRoleSyncEnabled: boolean;
canViewActivity?: boolean;
isLoading?: boolean;
isLoading: boolean;
authMethods?: TypesGen.AuthMethods;
onSuspendUser: (user: TypesGen.User) => void;
onDeleteUser: (user: TypesGen.User) => void;
@ -30,6 +32,8 @@ export interface UsersPageViewProps {
filterProps: ComponentProps<typeof UsersFilter>;
isNonInitialPage: boolean;
actorID: string;
groupsByUserId: GroupsByUserId | undefined;
// Pagination
count?: number;
page: number;
@ -60,6 +64,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
limit,
onPageChange,
page,
groupsByUserId,
}) => {
return (
<>
@ -77,6 +82,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
<UsersTable
users={users}
roles={roles}
groupsByUserId={groupsByUserId}
onSuspendUser={onSuspendUser}
onDeleteUser={onDeleteUser}
onListWorkspaces={onListWorkspaces}

View File

@ -17,10 +17,12 @@ const meta: Meta<typeof EditRolesButton> = {
export default meta;
type Story = StoryObj<typeof EditRolesButton>;
const selectedRoleNames = new Set([MockUserAdminRole.name, MockOwnerRole.name]);
export const Open: Story = {
args: {
selectedRoleNames,
roles: MockSiteRoles,
selectedRoles: [MockUserAdminRole, MockOwnerRole],
},
parameters: {
chromatic: { delay: 300 },
@ -30,8 +32,8 @@ export const Open: Story = {
export const Loading: Story = {
args: {
isLoading: true,
selectedRoleNames,
roles: MockSiteRoles,
selectedRoles: [MockUserAdminRole, MockOwnerRole],
userLoginType: "password",
oidcRoleSync: false,
},

View File

@ -61,7 +61,7 @@ const Option: React.FC<{
export interface EditRolesButtonProps {
isLoading: boolean;
roles: Role[];
selectedRoles: Role[];
selectedRoleNames: Set<string>;
onChange: (roles: Role["name"][]) => void;
isDefaultOpen?: boolean;
oidcRoleSync: boolean;
@ -70,7 +70,7 @@ export interface EditRolesButtonProps {
export const EditRolesButton: FC<EditRolesButtonProps> = ({
roles,
selectedRoles,
selectedRoleNames,
onChange,
isLoading,
isDefaultOpen = false,
@ -78,11 +78,11 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
oidcRoleSync,
}) => {
const styles = useStyles();
const selectedRoleNames = selectedRoles.map((role) => role.name);
const handleChange = (roleName: string) => {
if (selectedRoleNames.includes(roleName)) {
onChange(selectedRoleNames.filter((role) => role !== roleName));
if (selectedRoleNames.has(roleName)) {
const serialized = [...selectedRoleNames];
onChange(serialized.filter((role) => role !== roleName));
return;
}
@ -126,7 +126,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
<Option
key={role.name}
onChange={handleChange}
isChecked={selectedRoleNames.includes(role.name)}
isChecked={selectedRoleNames.has(role.name)}
value={role.name}
name={role.display_name}
description={roleDescriptions[role.name] ?? ""}

View File

@ -0,0 +1,58 @@
import { FC } from "react";
import {
HelpTooltip,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "components/HelpTooltip/HelpTooltip";
import { docs } from "utils/docs";
type ColumnHeader = "roles" | "groups";
type TooltipData = {
title: string;
text: string;
links: readonly { text: string; href: string }[];
};
export const Language = {
roles: {
title: "What is a role?",
text:
"Coder role-based access control (RBAC) provides fine-grained access management. " +
"View our docs on how to use the available roles.",
links: [{ text: "User Roles", href: docs("/admin/users#roles") }],
},
groups: {
title: "What is a group?",
text:
"Groups can be used with template RBAC to give groups of users access " +
"to specific templates. View our docs on how to use groups.",
links: [{ text: "User Groups", href: docs("/admin/groups") }],
},
} as const satisfies Record<ColumnHeader, TooltipData>;
type Props = {
variant: ColumnHeader;
};
export const TableColumnHelpTooltip: FC<Props> = ({ variant }) => {
const variantLang = Language[variant];
return (
<HelpTooltip size="small">
<HelpTooltipTitle>{variantLang.title}</HelpTooltipTitle>
<HelpTooltipText>{variantLang.text}</HelpTooltipText>
<HelpTooltipLinksGroup>
{variantLang.links.map((link) => (
<HelpTooltipLink key={link.text} href={link.href}>
{link.text}
</HelpTooltipLink>
))}
</HelpTooltipLinksGroup>
</HelpTooltip>
);
};

View File

@ -0,0 +1,118 @@
import { useTheme } from "@emotion/react";
import { type Group } from "api/typesGenerated";
import { Stack } from "components/Stack/Stack";
import { Avatar } from "components/Avatar/Avatar";
import { OverflowY } from "components/OverflowY/OverflowY";
import TableCell from "@mui/material/TableCell";
import Button from "@mui/material/Button";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import GroupIcon from "@mui/icons-material/Group";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "components/Popover/Popover";
type GroupsCellProps = {
userGroups: readonly Group[] | undefined;
};
export function UserGroupsCell({ userGroups }: GroupsCellProps) {
const theme = useTheme();
return (
<TableCell>
{userGroups === undefined ? (
// Felt right to add emphasis to the undefined state for semantics
// ("hey, this isn't normal"), but the default italics looked weird in
// the table UI
<em css={{ fontStyle: "normal" }}>N/A</em>
) : (
<Popover mode="hover">
<PopoverTrigger>
<Button
css={{
justifyContent: "flex-start",
fontSize: theme.typography.body2.fontSize,
lineHeight: theme.typography.body2.lineHeight,
fontWeight: 400,
border: "none",
padding: 0,
"&:hover": {
border: "none",
backgroundColor: "transparent",
},
}}
>
<Stack
spacing={0}
direction="row"
css={{ columnGap: theme.spacing(1), alignItems: "center" }}
>
<GroupIcon
sx={{
width: "1rem",
height: "1rem",
opacity: userGroups.length > 0 ? 0.8 : 0.5,
}}
/>
<span>
{userGroups.length} Group{userGroups.length !== 1 && "s"}
</span>
</Stack>
</Button>
</PopoverTrigger>
<PopoverContent disableScrollLock disableRestoreFocus>
<OverflowY maxHeight={400}>
<List
component="ul"
css={{
display: "flex",
flexFlow: "column nowrap",
fontSize: theme.typography.body2.fontSize,
padding: theme.spacing(0.5, 0.25),
gap: theme.spacing(0),
}}
>
{userGroups.map((group) => {
const groupName = group.display_name || group.name;
return (
<ListItem
key={group.id}
css={{
columnGap: theme.spacing(1.25),
alignItems: "center",
}}
>
<Avatar size="xs" src={group.avatar_url} alt={groupName}>
{groupName}
</Avatar>
<span
css={{
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
lineHeight: 1,
margin: 0,
}}
>
{groupName || <em>N/A</em>}
</span>
</ListItem>
);
})}
</List>
</OverflowY>
</PopoverContent>
</Popover>
)}
</TableCell>
);
}

View File

@ -0,0 +1,171 @@
/**
* @file Defines the visual logic for the Roles cell in the Users page table.
*
* The previous implementation tried to dynamically truncate the number of roles
* that would get displayed in a cell, only truncating if there were more roles
* than room in the cell. But there was a problem that information can't
* exist on the first render, because the DOM nodes haven't been made yet.
*
* The only way to avoid UI flickering was by juggling between useLayoutEffect
* for direct DOM node mutations for any renders that had new data, and normal
* state logic for all other renders. It was clunky, and required duplicating
* the logic in two places (making things easy to accidentally break), so we
* went with a simpler design. If we decide we really do need to display the
* users like that, though, know that it will be painful
*/
import { useTheme } from "@emotion/react";
import { type User, type Role } from "api/typesGenerated";
import { EditRolesButton } from "./EditRolesButton";
import { Pill } from "components/Pill/Pill";
import TableCell from "@mui/material/TableCell";
import Stack from "@mui/material/Stack";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "components/Popover/Popover";
type UserRoleCellProps = {
canEditUsers: boolean;
allAvailableRoles: Role[] | undefined;
user: User;
isLoading: boolean;
oidcRoleSyncEnabled: boolean;
onUserRolesUpdate: (user: User, newRoleNames: string[]) => void;
};
export function UserRoleCell({
canEditUsers,
allAvailableRoles,
user,
isLoading,
oidcRoleSyncEnabled,
onUserRolesUpdate,
}: UserRoleCellProps) {
const theme = useTheme();
const [mainDisplayRole = fallbackRole, ...extraRoles] =
sortRolesByAccessLevel(user.roles ?? []);
const hasOwnerRole = mainDisplayRole.name === "owner";
return (
<TableCell>
<Stack direction="row" spacing={1}>
{canEditUsers && (
<EditRolesButton
roles={sortRolesByAccessLevel(allAvailableRoles ?? [])}
selectedRoleNames={getSelectedRoleNames(user.roles)}
isLoading={isLoading}
userLoginType={user.login_type}
oidcRoleSync={oidcRoleSyncEnabled}
onChange={(roles) => {
// Remove the fallback role because it is only for the UI
const rolesWithoutFallback = roles.filter(
(role) => role !== fallbackRole.name,
);
onUserRolesUpdate(user, rolesWithoutFallback);
}}
/>
)}
<Pill
text={mainDisplayRole.display_name}
css={{
backgroundColor: hasOwnerRole
? theme.palette.info.dark
: theme.palette.background.paperLight,
borderColor: hasOwnerRole
? theme.palette.info.light
: theme.palette.divider,
}}
/>
{extraRoles.length > 0 && <OverflowRolePill roles={extraRoles} />}
</Stack>
</TableCell>
);
}
type OverflowRolePillProps = {
roles: readonly Role[];
};
function OverflowRolePill({ roles }: OverflowRolePillProps) {
const theme = useTheme();
return (
<Popover mode="hover">
<PopoverTrigger>
<Pill
text={`+${roles.length} more`}
css={{
backgroundColor: theme.palette.background.paperLight,
borderColor: theme.palette.divider,
}}
/>
</PopoverTrigger>
<PopoverContent
disableRestoreFocus
disableScrollLock
css={{
".MuiPaper-root": {
display: "flex",
flexFlow: "row wrap",
columnGap: theme.spacing(1),
rowGap: theme.spacing(1.5),
padding: theme.spacing(1.5, 2),
alignContent: "space-around",
},
}}
>
{roles.map((role) => (
<Pill
key={role.name}
text={role.display_name || role.name}
css={{
backgroundColor: theme.palette.background.paperLight,
borderColor: theme.palette.divider,
}}
/>
))}
</PopoverContent>
</Popover>
);
}
const fallbackRole: Role = {
name: "member",
display_name: "Member",
} as const;
const roleNamesByAccessLevel: readonly string[] = [
"owner",
"user-admin",
"template-admin",
"auditor",
];
function sortRolesByAccessLevel(roles: Role[]) {
if (roles.length === 0) {
return roles;
}
return [...roles].sort(
(r1, r2) =>
roleNamesByAccessLevel.indexOf(r1.name) -
roleNamesByAccessLevel.indexOf(r2.name),
);
}
function getSelectedRoleNames(roles: readonly Role[]) {
const roleNameSet = new Set(roles.map((role) => role.name));
if (roleNameSet.size === 0) {
roleNameSet.add(fallbackRole.name);
}
return roleNameSet;
}

View File

@ -1,31 +0,0 @@
import { FC } from "react";
import {
HelpTooltip,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "components/HelpTooltip/HelpTooltip";
import { docs } from "utils/docs";
export const Language = {
title: "What is a role?",
text:
"Coder role-based access control (RBAC) provides fine-grained access management. " +
"View our docs on how to use the available roles.",
link: "User Roles",
};
export const UserRoleHelpTooltip: FC = () => {
return (
<HelpTooltip size="small">
<HelpTooltipTitle>{Language.title}</HelpTooltipTitle>
<HelpTooltipText>{Language.text}</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href={docs("/admin/users#roles")}>
{Language.link}
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltip>
);
};

View File

@ -3,10 +3,16 @@ import {
MockUser2,
MockAssignableSiteRoles,
MockAuthMethods,
MockGroup,
} from "testHelpers/entities";
import { UsersTable } from "./UsersTable";
import type { Meta, StoryObj } from "@storybook/react";
const mockGroupsByUserId = new Map([
[MockUser.id, [MockGroup]],
[MockUser2.id, [MockGroup]],
]);
const meta: Meta<typeof UsersTable> = {
title: "pages/UsersPage/UsersTable",
component: UsersTable,
@ -24,6 +30,7 @@ export const Example: Story = {
users: [MockUser, MockUser2],
roles: MockAssignableSiteRoles,
canEditUsers: false,
groupsByUserId: mockGroupsByUserId,
},
};
@ -58,6 +65,7 @@ export const Editable: Story = {
roles: MockAssignableSiteRoles,
canEditUsers: true,
canViewActivity: true,
groupsByUserId: mockGroupsByUserId,
},
};

View File

@ -1,30 +1,34 @@
import { type FC } from "react";
import type * as TypesGen from "api/typesGenerated";
import { type GroupsByUserId } from "api/queries/groups";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { FC } from "react";
import * as TypesGen from "api/typesGenerated";
import { Stack } from "components/Stack/Stack";
import { UserRoleHelpTooltip } from "./UserRoleHelpTooltip";
import { TableColumnHelpTooltip } from "./TableColumnHelpTooltip";
import { UsersTableBody } from "./UsersTableBody";
export const Language = {
usernameLabel: "User",
rolesLabel: "Roles",
groupsLabel: "Groups",
statusLabel: "Status",
lastSeenLabel: "Last Seen",
loginTypeLabel: "Login Type",
};
} as const;
export interface UsersTableProps {
users?: TypesGen.User[];
roles?: TypesGen.AssignableRoles[];
users: TypesGen.User[] | undefined;
roles: TypesGen.AssignableRoles[] | undefined;
groupsByUserId: GroupsByUserId | undefined;
isUpdatingUserRoles?: boolean;
canEditUsers?: boolean;
canEditUsers: boolean;
canViewActivity?: boolean;
isLoading?: boolean;
isLoading: boolean;
onSuspendUser: (user: TypesGen.User) => void;
onActivateUser: (user: TypesGen.User) => void;
onDeleteUser: (user: TypesGen.User) => void;
@ -59,29 +63,42 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
actorID,
oidcRoleSyncEnabled,
authMethods,
groupsByUserId,
}) => {
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="30%">{Language.usernameLabel}</TableCell>
<TableCell width="40%">
<TableCell width="29%">{Language.usernameLabel}</TableCell>
<TableCell width="29%">
<Stack direction="row" spacing={1} alignItems="center">
<span>{Language.rolesLabel}</span>
<UserRoleHelpTooltip />
<TableColumnHelpTooltip variant="roles" />
</Stack>
</TableCell>
<TableCell width="15%">{Language.loginTypeLabel}</TableCell>
<TableCell width="15%">{Language.statusLabel}</TableCell>
<TableCell width="14%">
<Stack direction="row" spacing={1} alignItems="center">
<span>{Language.groupsLabel}</span>
<TableColumnHelpTooltip variant="groups" />
</Stack>
</TableCell>
<TableCell width="14%">{Language.loginTypeLabel}</TableCell>
<TableCell width="14%">{Language.statusLabel}</TableCell>
{/* 1% is a trick to make the table cell width fit the content */}
{canEditUsers && <TableCell width="1%" />}
</TableRow>
</TableHead>
<TableBody>
<UsersTableBody
users={users}
roles={roles}
groupsByUserId={groupsByUserId}
isLoading={isLoading}
canEditUsers={canEditUsers}
canViewActivity={canViewActivity}

View File

@ -8,7 +8,6 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type * as TypesGen from "api/typesGenerated";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { Pill } from "components/Pill/Pill";
import { AvatarData } from "components/AvatarData/AvatarData";
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
import { EmptyState } from "components/EmptyState/EmptyState";
@ -17,36 +16,26 @@ import {
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { TableRowMenu } from "components/TableRowMenu/TableRowMenu";
import { Stack } from "components/Stack/Stack";
import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges";
import { EditRolesButton } from "./EditRolesButton";
import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined";
import KeyOutlined from "@mui/icons-material/KeyOutlined";
import GitHub from "@mui/icons-material/GitHub";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import { UserRoleCell } from "./UserRoleCell";
import { type GroupsByUserId } from "api/queries/groups";
import { UserGroupsCell } from "./UserGroupsCell";
dayjs.extend(relativeTime);
const isOwnerRole = (role: TypesGen.Role): boolean => {
return role.name === "owner";
};
const roleOrder = ["owner", "user-admin", "template-admin", "auditor"];
const sortRoles = (roles: TypesGen.Role[]) => {
return roles.slice(0).sort((a, b) => {
return roleOrder.indexOf(a.name) - roleOrder.indexOf(b.name);
});
};
interface UsersTableBodyProps {
users?: TypesGen.User[];
users: TypesGen.User[] | undefined;
groupsByUserId: GroupsByUserId | undefined;
authMethods?: TypesGen.AuthMethods;
roles?: TypesGen.AssignableRoles[];
isUpdatingUserRoles?: boolean;
canEditUsers?: boolean;
isLoading?: boolean;
canEditUsers: boolean;
isLoading: boolean;
canViewActivity?: boolean;
onSuspendUser: (user: TypesGen.User) => void;
onDeleteUser: (user: TypesGen.User) => void;
@ -86,6 +75,7 @@ export const UsersTableBody: FC<
isNonInitialPage,
actorID,
oidcRoleSyncEnabled,
groupsByUserId,
}) => {
return (
<ChooseOne>
@ -97,15 +87,23 @@ export const UsersTableBody: FC<
<AvatarDataSkeleton />
</Box>
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
{canEditUsers && (
<TableCell>
<Skeleton variant="text" width="25%" />
@ -114,6 +112,7 @@ export const UsersTableBody: FC<
</TableRowSkeleton>
</TableLoaderSkeleton>
</Cond>
<Cond condition={!users || users.length === 0}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
@ -125,6 +124,7 @@ export const UsersTableBody: FC<
</TableCell>
</TableRow>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
@ -136,125 +136,91 @@ export const UsersTableBody: FC<
</Cond>
</ChooseOne>
</Cond>
<Cond>
<>
{users &&
users.map((user) => {
// When the user has no role we want to show they are a Member
const fallbackRole: TypesGen.Role = {
name: "member",
display_name: "Member",
};
const userRoles =
user.roles.length === 0
? [fallbackRole]
: sortRoles(user.roles);
{users?.map((user) => (
<TableRow key={user.id} data-testid={`user-${user.id}`}>
<TableCell>
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
</TableCell>
return (
<TableRow key={user.id} data-testid={`user-${user.id}`}>
<TableCell>
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{canEditUsers && (
<EditRolesButton
roles={roles ? sortRoles(roles) : []}
selectedRoles={userRoles}
isLoading={Boolean(isUpdatingUserRoles)}
userLoginType={user.login_type}
oidcRoleSync={oidcRoleSyncEnabled}
onChange={(roles) => {
// Remove the fallback role because it is only for the UI
const rolesWithoutFallback = roles.filter(
(role) => role !== fallbackRole.name,
);
onUpdateUserRoles(user, rolesWithoutFallback);
}}
/>
)}
{userRoles.map((role) => (
<Pill
key={role.name}
text={role.display_name}
css={[
styles.rolePill,
isOwnerRole(role) && styles.rolePillOwner,
]}
/>
))}
</Stack>
</TableCell>
<TableCell>
<LoginType
authMethods={authMethods!}
value={user.login_type}
/>
</TableCell>
<TableCell
css={[
styles.status,
user.status === "suspended" && styles.suspended,
]}
>
<Box>{user.status}</Box>
<LastSeen value={user.last_seen_at} sx={{ fontSize: 12 }} />
</TableCell>
<UserRoleCell
canEditUsers={canEditUsers}
allAvailableRoles={roles}
user={user}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
isLoading={Boolean(isUpdatingUserRoles)}
onUserRolesUpdate={onUpdateUserRoles}
/>
{canEditUsers && (
<TableCell>
<TableRowMenu
data={user}
menuItems={[
// Return either suspend or activate depending on status
user.status === "active" || user.status === "dormant"
? {
label: <>Suspend&hellip;</>,
onClick: onSuspendUser,
disabled: false,
}
: {
label: <>Activate&hellip;</>,
onClick: onActivateUser,
disabled: false,
},
{
label: <>Delete&hellip;</>,
onClick: onDeleteUser,
disabled: user.id === actorID,
},
{
label: <>Reset password&hellip;</>,
onClick: onResetUserPassword,
disabled: user.login_type !== "password",
},
{
label: "View workspaces",
onClick: onListWorkspaces,
disabled: false,
},
{
label: (
<>
View activity
{!canViewActivity && <EnterpriseBadge />}
</>
),
onClick: onViewActivity,
disabled: !canViewActivity,
},
]}
/>
</TableCell>
)}
</TableRow>
);
})}
</>
<UserGroupsCell userGroups={groupsByUserId?.get(user.id)} />
<TableCell>
<LoginType authMethods={authMethods!} value={user.login_type} />
</TableCell>
<TableCell
css={[
styles.status,
user.status === "suspended" && styles.suspended,
]}
>
<Box>{user.status}</Box>
<LastSeen value={user.last_seen_at} sx={{ fontSize: 12 }} />
</TableCell>
{canEditUsers && (
<TableCell>
<TableRowMenu
data={user}
menuItems={[
// Return either suspend or activate depending on status
user.status === "active" || user.status === "dormant"
? {
label: <>Suspend&hellip;</>,
onClick: onSuspendUser,
disabled: false,
}
: {
label: <>Activate&hellip;</>,
onClick: onActivateUser,
disabled: false,
},
{
label: <>Delete&hellip;</>,
onClick: onDeleteUser,
disabled: user.id === actorID,
},
{
label: <>Reset password&hellip;</>,
onClick: onResetUserPassword,
disabled: user.login_type !== "password",
},
{
label: "View workspaces",
onClick: onListWorkspaces,
disabled: false,
},
{
label: (
<>
View activity
{!canViewActivity && <EnterpriseBadge />}
</>
),
onClick: onViewActivity,
disabled: !canViewActivity,
},
]}
/>
</TableCell>
)}
</TableRow>
))}
</Cond>
</ChooseOne>
);
@ -349,12 +315,4 @@ const styles = {
suspended: (theme) => ({
color: theme.palette.text.secondary,
}),
rolePill: (theme) => ({
backgroundColor: theme.palette.background.paperLight,
borderColor: theme.palette.divider,
}),
rolePillOwner: (theme) => ({
backgroundColor: theme.palette.info.dark,
borderColor: theme.palette.info.light,
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -1,7 +1,7 @@
export const borderRadius = 8;
export const MONOSPACE_FONT_FAMILY =
"'IBM Plex Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'Liberation Mono', 'Monaco', 'Courier New', Courier, monospace";
export const BODY_FONT_FAMILY = `"Inter", sans-serif`;
export const BODY_FONT_FAMILY = `"Inter", system-ui, sans-serif`;
export const navHeight = 62;
export const containerWidth = 1380;
export const containerWidthMedium = 1080;

View File

@ -74,13 +74,15 @@ export let dark = createTheme({
},
typography: {
fontFamily: BODY_FONT_FAMILY,
body1: {
fontSize: 16,
lineHeight: "24px",
fontSize: "1rem" /* 16px at default scaling */,
lineHeight: "1.5rem" /* 24px at default scaling */,
},
body2: {
fontSize: 14,
lineHeight: "20px",
fontSize: "0.875rem" /* 14px at default scaling */,
lineHeight: "1.25rem" /* 20px at default scaling */,
},
},
shape: {