mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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:
@ -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"],
|
||||
|
@ -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) => ({
|
||||
|
@ -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 = () => {
|
||||
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
@ -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>?
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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] ?? ""}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
118
site/src/pages/UsersPage/UsersTable/UserGroupsCell.tsx
Normal file
118
site/src/pages/UsersPage/UsersTable/UserGroupsCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
171
site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx
Normal file
171
site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx
Normal 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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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…</>,
|
||||
onClick: onSuspendUser,
|
||||
disabled: false,
|
||||
}
|
||||
: {
|
||||
label: <>Activate…</>,
|
||||
onClick: onActivateUser,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: <>Delete…</>,
|
||||
onClick: onDeleteUser,
|
||||
disabled: user.id === actorID,
|
||||
},
|
||||
{
|
||||
label: <>Reset password…</>,
|
||||
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…</>,
|
||||
onClick: onSuspendUser,
|
||||
disabled: false,
|
||||
}
|
||||
: {
|
||||
label: <>Activate…</>,
|
||||
onClick: onActivateUser,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: <>Delete…</>,
|
||||
onClick: onDeleteUser,
|
||||
disabled: user.id === actorID,
|
||||
},
|
||||
{
|
||||
label: <>Reset password…</>,
|
||||
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>>;
|
||||
|
@ -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;
|
||||
|
@ -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: {
|
||||
|
Reference in New Issue
Block a user