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 * as API from "api/api";
|
||||||
import { checkAuthorization } from "api/api";
|
import { checkAuthorization } from "api/api";
|
||||||
import {
|
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) => {
|
export const groupPermissions = (groupId: string) => {
|
||||||
return {
|
return {
|
||||||
queryKey: [...getGroupQueryKey(groupId), "permissions"],
|
queryKey: [...getGroupQueryKey(groupId), "permissions"],
|
||||||
|
@ -5,16 +5,23 @@ import { FC } from "react";
|
|||||||
import { css, type Interpolation, type Theme } from "@emotion/react";
|
import { css, type Interpolation, type Theme } from "@emotion/react";
|
||||||
|
|
||||||
export type AvatarProps = MuiAvatarProps & {
|
export type AvatarProps = MuiAvatarProps & {
|
||||||
size?: "sm" | "md" | "xl";
|
size?: "xs" | "sm" | "md" | "xl";
|
||||||
colorScheme?: "light" | "darken";
|
colorScheme?: "light" | "darken";
|
||||||
fitImage?: boolean;
|
fitImage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
|
xs: (theme) => ({
|
||||||
|
width: theme.spacing(2),
|
||||||
|
height: theme.spacing(2),
|
||||||
|
fontSize: theme.spacing(1),
|
||||||
|
fontWeight: 700,
|
||||||
|
}),
|
||||||
sm: (theme) => ({
|
sm: (theme) => ({
|
||||||
width: theme.spacing(3),
|
width: theme.spacing(3),
|
||||||
height: theme.spacing(3),
|
height: theme.spacing(3),
|
||||||
fontSize: theme.spacing(1.5),
|
fontSize: theme.spacing(1.5),
|
||||||
|
fontWeight: 600,
|
||||||
}),
|
}),
|
||||||
md: {},
|
md: {},
|
||||||
xl: (theme) => ({
|
xl: (theme) => ({
|
||||||
|
@ -88,7 +88,7 @@ export const HelpPopover: FC<
|
|||||||
|
|
||||||
export const HelpTooltip: FC<PropsWithChildren<HelpTooltipProps>> = ({
|
export const HelpTooltip: FC<PropsWithChildren<HelpTooltipProps>> = ({
|
||||||
children,
|
children,
|
||||||
open,
|
open = false,
|
||||||
size = "medium",
|
size = "medium",
|
||||||
icon: Icon = HelpIcon,
|
icon: Icon = HelpIcon,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
@ -96,7 +96,7 @@ export const HelpTooltip: FC<PropsWithChildren<HelpTooltipProps>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const [isOpen, setIsOpen] = useState(Boolean(open));
|
const [isOpen, setIsOpen] = useState(open);
|
||||||
const id = isOpen ? "help-popover" : undefined;
|
const id = isOpen ? "help-popover" : undefined;
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@ -19,11 +20,14 @@ type TriggerMode = "hover" | "click";
|
|||||||
type TriggerRef = React.RefObject<HTMLElement>;
|
type TriggerRef = React.RefObject<HTMLElement>;
|
||||||
|
|
||||||
type TriggerElement = ReactElement<{
|
type TriggerElement = ReactElement<{
|
||||||
onClick?: () => void;
|
|
||||||
ref: TriggerRef;
|
ref: TriggerRef;
|
||||||
|
onClick?: () => void;
|
||||||
|
"aria-haspopup"?: boolean;
|
||||||
|
"aria-owns"?: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type PopoverContextValue = {
|
type PopoverContextValue = {
|
||||||
|
id: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
triggerRef: TriggerRef;
|
triggerRef: TriggerRef;
|
||||||
@ -39,9 +43,17 @@ export const Popover = (props: {
|
|||||||
mode?: TriggerMode;
|
mode?: TriggerMode;
|
||||||
isDefaultOpen?: boolean;
|
isDefaultOpen?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const hookId = useId();
|
||||||
const [isOpen, setIsOpen] = useState(props.isDefaultOpen ?? false);
|
const [isOpen, setIsOpen] = useState(props.isDefaultOpen ?? false);
|
||||||
const triggerRef = useRef<HTMLElement>(null);
|
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 (
|
return (
|
||||||
<PopoverContext.Provider value={value}>
|
<PopoverContext.Provider value={value}>
|
||||||
@ -62,10 +74,7 @@ export const usePopover = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PopoverTrigger = (props: {
|
export const PopoverTrigger = (props: { children: TriggerElement }) => {
|
||||||
children: TriggerElement;
|
|
||||||
hover?: boolean;
|
|
||||||
}) => {
|
|
||||||
const popover = usePopover();
|
const popover = usePopover();
|
||||||
|
|
||||||
const clickProps = {
|
const clickProps = {
|
||||||
@ -85,6 +94,8 @@ export const PopoverTrigger = (props: {
|
|||||||
|
|
||||||
return cloneElement(props.children, {
|
return cloneElement(props.children, {
|
||||||
...(popover.mode === "click" ? clickProps : hoverProps),
|
...(popover.mode === "click" ? clickProps : hoverProps),
|
||||||
|
"aria-haspopup": true,
|
||||||
|
"aria-owns": popover.isOpen ? popover.id : undefined,
|
||||||
ref: popover.triggerRef,
|
ref: popover.triggerRef,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -118,10 +129,10 @@ export const PopoverContent = (
|
|||||||
<MuiPopover
|
<MuiPopover
|
||||||
disablePortal
|
disablePortal
|
||||||
css={(theme) => ({
|
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
|
// 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
|
// 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),
|
marginTop: hoverMode ? undefined : theme.spacing(1),
|
||||||
pointerEvents: hoverMode ? "none" : undefined,
|
pointerEvents: hoverMode ? "none" : undefined,
|
||||||
"& .MuiPaper-root": {
|
"& .MuiPaper-root": {
|
||||||
@ -133,6 +144,7 @@ export const PopoverContent = (
|
|||||||
{...horizontalProps(horizontal)}
|
{...horizontalProps(horizontal)}
|
||||||
{...modeProps(popover)}
|
{...modeProps(popover)}
|
||||||
{...props}
|
{...props}
|
||||||
|
id={popover.id}
|
||||||
open={popover.isOpen}
|
open={popover.isOpen}
|
||||||
onClose={() => popover.setIsOpen(false)}
|
onClose={() => popover.setIsOpen(false)}
|
||||||
anchorEl={popover.triggerRef.current}
|
anchorEl={popover.triggerRef.current}
|
||||||
@ -143,10 +155,10 @@ export const PopoverContent = (
|
|||||||
const modeProps = (popover: PopoverContextValue) => {
|
const modeProps = (popover: PopoverContextValue) => {
|
||||||
if (popover.mode === "hover") {
|
if (popover.mode === "hover") {
|
||||||
return {
|
return {
|
||||||
onMouseEnter: () => {
|
onPointerEnter: () => {
|
||||||
popover.setIsOpen(true);
|
popover.setIsOpen(true);
|
||||||
},
|
},
|
||||||
onMouseLeave: () => {
|
onPointerLeave: () => {
|
||||||
popover.setIsOpen(false);
|
popover.setIsOpen(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,23 +1,10 @@
|
|||||||
import { User } from "api/typesGenerated";
|
import { type FC, type ReactNode, useState } from "react";
|
||||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
|
||||||
import { nonInitialPage } from "components/PaginationWidget/utils";
|
import { type User } from "api/typesGenerated";
|
||||||
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 { roles } from "api/queries/roles";
|
import { roles } from "api/queries/roles";
|
||||||
|
import { groupsByUserId } from "api/queries/groups";
|
||||||
|
import { getErrorMessage } from "api/errors";
|
||||||
import { deploymentConfig } from "api/queries/deployment";
|
import { deploymentConfig } from "api/queries/deployment";
|
||||||
import { prepareQuery } from "utils/filters";
|
|
||||||
import { usePagination } from "hooks";
|
|
||||||
import {
|
import {
|
||||||
users,
|
users,
|
||||||
suspendUser,
|
suspendUser,
|
||||||
@ -27,38 +14,55 @@ import {
|
|||||||
updateRoles,
|
updateRoles,
|
||||||
authMethods,
|
authMethods,
|
||||||
} from "api/queries/users";
|
} 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 { 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 }> = () => {
|
export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const searchParamsResult = useSearchParams();
|
const searchParamsResult = useSearchParams();
|
||||||
const { entitlements } = useDashboard();
|
const { entitlements } = useDashboard();
|
||||||
const [searchParams] = searchParamsResult;
|
const [searchParams] = searchParamsResult;
|
||||||
const filter = searchParams.get("filter") ?? "";
|
|
||||||
const pagination = usePagination({
|
const pagination = usePagination({ searchParamsResult });
|
||||||
searchParamsResult,
|
|
||||||
});
|
|
||||||
const usersQuery = useQuery(
|
const usersQuery = useQuery(
|
||||||
users({
|
users({
|
||||||
q: prepareQuery(filter),
|
q: prepareQuery(searchParams.get("filter") ?? ""),
|
||||||
limit: pagination.limit,
|
limit: pagination.limit,
|
||||||
offset: pagination.offset,
|
offset: pagination.offset,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const organizationId = useOrganizationId();
|
||||||
|
const groupsByUserIdQuery = useQuery(groupsByUserId(organizationId));
|
||||||
|
const authMethodsQuery = useQuery(authMethods());
|
||||||
|
|
||||||
const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions();
|
const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions();
|
||||||
const rolesQuery = useQuery(roles());
|
const rolesQuery = useQuery(roles());
|
||||||
const { data: deploymentValues } = useQuery({
|
const { data: deploymentValues } = useQuery({
|
||||||
...deploymentConfig(),
|
...deploymentConfig(),
|
||||||
enabled: viewDeploymentValues,
|
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 me = useMe();
|
||||||
const useFilterResult = useFilter({
|
const useFilterResult = useFilter({
|
||||||
searchParamsResult,
|
searchParamsResult,
|
||||||
@ -74,36 +78,47 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||||||
status: option?.value,
|
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 suspendUserMutation = useMutation(suspendUser(queryClient));
|
||||||
|
|
||||||
const [confirmActivateUser, setConfirmActivateUser] = useState<User>();
|
const [userToActivate, setUserToActivate] = useState<User>();
|
||||||
const activateUserMutation = useMutation(activateUser(queryClient));
|
const activateUserMutation = useMutation(activateUser(queryClient));
|
||||||
|
|
||||||
const [confirmDeleteUser, setConfirmDeleteUser] = useState<User>();
|
const [userToDelete, setUserToDelete] = useState<User>();
|
||||||
const deleteUserMutation = useMutation(deleteUser(queryClient));
|
const deleteUserMutation = useMutation(deleteUser(queryClient));
|
||||||
|
|
||||||
const [confirmResetPassword, setConfirmResetPassword] = useState<{
|
const [confirmResetPassword, setConfirmResetPassword] = useState<{
|
||||||
user: User;
|
user: User;
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
}>();
|
}>();
|
||||||
const updatePasswordMutation = useMutation(updatePassword());
|
|
||||||
|
|
||||||
|
const updatePasswordMutation = useMutation(updatePassword());
|
||||||
const updateRolesMutation = useMutation(updateRoles(queryClient));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{pageTitle("Users")}</title>
|
<title>{pageTitle("Users")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<UsersPageView
|
<UsersPageView
|
||||||
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
|
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
|
||||||
roles={rolesQuery.data}
|
roles={rolesQuery.data}
|
||||||
users={usersQuery.data?.users}
|
users={usersQuery.data?.users}
|
||||||
|
groupsByUserId={groupsByUserIdQuery.data}
|
||||||
authMethods={authMethodsQuery.data}
|
authMethods={authMethodsQuery.data}
|
||||||
onListWorkspaces={(user) => {
|
onListWorkspaces={(user) => {
|
||||||
navigate(
|
navigate(
|
||||||
@ -116,9 +131,9 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||||||
"/audit?filter=" + encodeURIComponent(`username:${user.username}`),
|
"/audit?filter=" + encodeURIComponent(`username:${user.username}`),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onDeleteUser={setConfirmDeleteUser}
|
onDeleteUser={setUserToDelete}
|
||||||
onSuspendUser={setConfirmSuspendUser}
|
onSuspendUser={setUserToSuspend}
|
||||||
onActivateUser={setConfirmActivateUser}
|
onActivateUser={setUserToActivate}
|
||||||
onResetUserPassword={(user) => {
|
onResetUserPassword={(user) => {
|
||||||
setConfirmResetPassword({
|
setConfirmResetPassword({
|
||||||
user,
|
user,
|
||||||
@ -147,9 +162,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||||||
filterProps={{
|
filterProps={{
|
||||||
filter: useFilterResult,
|
filter: useFilterResult,
|
||||||
error: usersQuery.error,
|
error: usersQuery.error,
|
||||||
menus: {
|
menus: { status: statusMenu },
|
||||||
status: statusMenu,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
count={usersQuery.data?.count}
|
count={usersQuery.data?.count}
|
||||||
page={pagination.page}
|
page={pagination.page}
|
||||||
@ -158,48 +171,44 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteDialog
|
<DeleteDialog
|
||||||
key={confirmDeleteUser?.username}
|
key={userToDelete?.username}
|
||||||
isOpen={confirmDeleteUser !== undefined}
|
isOpen={userToDelete !== undefined}
|
||||||
confirmLoading={deleteUserMutation.isLoading}
|
confirmLoading={deleteUserMutation.isLoading}
|
||||||
name={confirmDeleteUser?.username ?? ""}
|
name={userToDelete?.username ?? ""}
|
||||||
entity="user"
|
entity="user"
|
||||||
|
onCancel={() => setUserToDelete(undefined)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await deleteUserMutation.mutateAsync(confirmDeleteUser!.id);
|
await deleteUserMutation.mutateAsync(userToDelete!.id);
|
||||||
setConfirmDeleteUser(undefined);
|
setUserToDelete(undefined);
|
||||||
displaySuccess("Successfully deleted the user.");
|
displaySuccess("Successfully deleted the user.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
displayError(getErrorMessage(e, "Error deleting user."));
|
displayError(getErrorMessage(e, "Error deleting user."));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
|
||||||
setConfirmDeleteUser(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
type="delete"
|
type="delete"
|
||||||
hideCancel={false}
|
hideCancel={false}
|
||||||
open={confirmSuspendUser !== undefined}
|
open={userToSuspend !== undefined}
|
||||||
confirmLoading={suspendUserMutation.isLoading}
|
confirmLoading={suspendUserMutation.isLoading}
|
||||||
title="Suspend user"
|
title="Suspend user"
|
||||||
confirmText="Suspend"
|
confirmText="Suspend"
|
||||||
|
onClose={() => setUserToSuspend(undefined)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await suspendUserMutation.mutateAsync(confirmSuspendUser!.id);
|
await suspendUserMutation.mutateAsync(userToSuspend!.id);
|
||||||
setConfirmSuspendUser(undefined);
|
setUserToSuspend(undefined);
|
||||||
displaySuccess("Successfully suspended the user.");
|
displaySuccess("Successfully suspended the user.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
displayError(getErrorMessage(e, "Error suspending user."));
|
displayError(getErrorMessage(e, "Error suspending user."));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
|
||||||
setConfirmSuspendUser(undefined);
|
|
||||||
}}
|
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Do you want to suspend the user{" "}
|
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
|
<ConfirmDialog
|
||||||
type="success"
|
type="success"
|
||||||
hideCancel={false}
|
hideCancel={false}
|
||||||
open={confirmActivateUser !== undefined}
|
open={userToActivate !== undefined}
|
||||||
confirmLoading={activateUserMutation.isLoading}
|
confirmLoading={activateUserMutation.isLoading}
|
||||||
title="Activate user"
|
title="Activate user"
|
||||||
confirmText="Activate"
|
confirmText="Activate"
|
||||||
|
onClose={() => setUserToActivate(undefined)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await activateUserMutation.mutateAsync(confirmActivateUser!.id);
|
await activateUserMutation.mutateAsync(userToActivate!.id);
|
||||||
setConfirmActivateUser(undefined);
|
setUserToActivate(undefined);
|
||||||
displaySuccess("Successfully activated the user.");
|
displaySuccess("Successfully activated the user.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
displayError(getErrorMessage(e, "Error activating user."));
|
displayError(getErrorMessage(e, "Error activating user."));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
|
||||||
setConfirmActivateUser(undefined);
|
|
||||||
}}
|
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Do you want to activate{" "}
|
Do you want to activate{" "}
|
||||||
<strong>{confirmActivateUser?.username ?? ""}</strong>?
|
<strong>{userToActivate?.username ?? ""}</strong>?
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { ComponentProps, FC } from "react";
|
import { type ComponentProps, type FC } from "react";
|
||||||
import * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
|
import { type GroupsByUserId } from "api/queries/groups";
|
||||||
|
|
||||||
import { UsersTable } from "./UsersTable/UsersTable";
|
import { UsersTable } from "./UsersTable/UsersTable";
|
||||||
import { UsersFilter } from "./UsersFilter";
|
import { UsersFilter } from "./UsersFilter";
|
||||||
import {
|
import {
|
||||||
@ -12,10 +14,10 @@ export interface UsersPageViewProps {
|
|||||||
users?: TypesGen.User[];
|
users?: TypesGen.User[];
|
||||||
roles?: TypesGen.AssignableRoles[];
|
roles?: TypesGen.AssignableRoles[];
|
||||||
isUpdatingUserRoles?: boolean;
|
isUpdatingUserRoles?: boolean;
|
||||||
canEditUsers?: boolean;
|
canEditUsers: boolean;
|
||||||
oidcRoleSyncEnabled: boolean;
|
oidcRoleSyncEnabled: boolean;
|
||||||
canViewActivity?: boolean;
|
canViewActivity?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading: boolean;
|
||||||
authMethods?: TypesGen.AuthMethods;
|
authMethods?: TypesGen.AuthMethods;
|
||||||
onSuspendUser: (user: TypesGen.User) => void;
|
onSuspendUser: (user: TypesGen.User) => void;
|
||||||
onDeleteUser: (user: TypesGen.User) => void;
|
onDeleteUser: (user: TypesGen.User) => void;
|
||||||
@ -30,6 +32,8 @@ export interface UsersPageViewProps {
|
|||||||
filterProps: ComponentProps<typeof UsersFilter>;
|
filterProps: ComponentProps<typeof UsersFilter>;
|
||||||
isNonInitialPage: boolean;
|
isNonInitialPage: boolean;
|
||||||
actorID: string;
|
actorID: string;
|
||||||
|
groupsByUserId: GroupsByUserId | undefined;
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
count?: number;
|
count?: number;
|
||||||
page: number;
|
page: number;
|
||||||
@ -60,6 +64,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
|
|||||||
limit,
|
limit,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
page,
|
page,
|
||||||
|
groupsByUserId,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -77,6 +82,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
|
|||||||
<UsersTable
|
<UsersTable
|
||||||
users={users}
|
users={users}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
|
groupsByUserId={groupsByUserId}
|
||||||
onSuspendUser={onSuspendUser}
|
onSuspendUser={onSuspendUser}
|
||||||
onDeleteUser={onDeleteUser}
|
onDeleteUser={onDeleteUser}
|
||||||
onListWorkspaces={onListWorkspaces}
|
onListWorkspaces={onListWorkspaces}
|
||||||
|
@ -17,10 +17,12 @@ const meta: Meta<typeof EditRolesButton> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof EditRolesButton>;
|
type Story = StoryObj<typeof EditRolesButton>;
|
||||||
|
|
||||||
|
const selectedRoleNames = new Set([MockUserAdminRole.name, MockOwnerRole.name]);
|
||||||
|
|
||||||
export const Open: Story = {
|
export const Open: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
selectedRoleNames,
|
||||||
roles: MockSiteRoles,
|
roles: MockSiteRoles,
|
||||||
selectedRoles: [MockUserAdminRole, MockOwnerRole],
|
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
chromatic: { delay: 300 },
|
chromatic: { delay: 300 },
|
||||||
@ -30,8 +32,8 @@ export const Open: Story = {
|
|||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
args: {
|
args: {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
selectedRoleNames,
|
||||||
roles: MockSiteRoles,
|
roles: MockSiteRoles,
|
||||||
selectedRoles: [MockUserAdminRole, MockOwnerRole],
|
|
||||||
userLoginType: "password",
|
userLoginType: "password",
|
||||||
oidcRoleSync: false,
|
oidcRoleSync: false,
|
||||||
},
|
},
|
||||||
|
@ -61,7 +61,7 @@ const Option: React.FC<{
|
|||||||
export interface EditRolesButtonProps {
|
export interface EditRolesButtonProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
selectedRoles: Role[];
|
selectedRoleNames: Set<string>;
|
||||||
onChange: (roles: Role["name"][]) => void;
|
onChange: (roles: Role["name"][]) => void;
|
||||||
isDefaultOpen?: boolean;
|
isDefaultOpen?: boolean;
|
||||||
oidcRoleSync: boolean;
|
oidcRoleSync: boolean;
|
||||||
@ -70,7 +70,7 @@ export interface EditRolesButtonProps {
|
|||||||
|
|
||||||
export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
||||||
roles,
|
roles,
|
||||||
selectedRoles,
|
selectedRoleNames,
|
||||||
onChange,
|
onChange,
|
||||||
isLoading,
|
isLoading,
|
||||||
isDefaultOpen = false,
|
isDefaultOpen = false,
|
||||||
@ -78,11 +78,11 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||||||
oidcRoleSync,
|
oidcRoleSync,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const selectedRoleNames = selectedRoles.map((role) => role.name);
|
|
||||||
|
|
||||||
const handleChange = (roleName: string) => {
|
const handleChange = (roleName: string) => {
|
||||||
if (selectedRoleNames.includes(roleName)) {
|
if (selectedRoleNames.has(roleName)) {
|
||||||
onChange(selectedRoleNames.filter((role) => role !== roleName));
|
const serialized = [...selectedRoleNames];
|
||||||
|
onChange(serialized.filter((role) => role !== roleName));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||||||
<Option
|
<Option
|
||||||
key={role.name}
|
key={role.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isChecked={selectedRoleNames.includes(role.name)}
|
isChecked={selectedRoleNames.has(role.name)}
|
||||||
value={role.name}
|
value={role.name}
|
||||||
name={role.display_name}
|
name={role.display_name}
|
||||||
description={roleDescriptions[role.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,
|
MockUser2,
|
||||||
MockAssignableSiteRoles,
|
MockAssignableSiteRoles,
|
||||||
MockAuthMethods,
|
MockAuthMethods,
|
||||||
|
MockGroup,
|
||||||
} from "testHelpers/entities";
|
} from "testHelpers/entities";
|
||||||
import { UsersTable } from "./UsersTable";
|
import { UsersTable } from "./UsersTable";
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
|
||||||
|
const mockGroupsByUserId = new Map([
|
||||||
|
[MockUser.id, [MockGroup]],
|
||||||
|
[MockUser2.id, [MockGroup]],
|
||||||
|
]);
|
||||||
|
|
||||||
const meta: Meta<typeof UsersTable> = {
|
const meta: Meta<typeof UsersTable> = {
|
||||||
title: "pages/UsersPage/UsersTable",
|
title: "pages/UsersPage/UsersTable",
|
||||||
component: UsersTable,
|
component: UsersTable,
|
||||||
@ -24,6 +30,7 @@ export const Example: Story = {
|
|||||||
users: [MockUser, MockUser2],
|
users: [MockUser, MockUser2],
|
||||||
roles: MockAssignableSiteRoles,
|
roles: MockAssignableSiteRoles,
|
||||||
canEditUsers: false,
|
canEditUsers: false,
|
||||||
|
groupsByUserId: mockGroupsByUserId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -58,6 +65,7 @@ export const Editable: Story = {
|
|||||||
roles: MockAssignableSiteRoles,
|
roles: MockAssignableSiteRoles,
|
||||||
canEditUsers: true,
|
canEditUsers: true,
|
||||||
canViewActivity: 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 Table from "@mui/material/Table";
|
||||||
import TableBody from "@mui/material/TableBody";
|
import TableBody from "@mui/material/TableBody";
|
||||||
import TableCell from "@mui/material/TableCell";
|
import TableCell from "@mui/material/TableCell";
|
||||||
import TableContainer from "@mui/material/TableContainer";
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
import TableHead from "@mui/material/TableHead";
|
import TableHead from "@mui/material/TableHead";
|
||||||
import TableRow from "@mui/material/TableRow";
|
import TableRow from "@mui/material/TableRow";
|
||||||
import { FC } from "react";
|
|
||||||
import * as TypesGen from "api/typesGenerated";
|
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
import { UserRoleHelpTooltip } from "./UserRoleHelpTooltip";
|
import { TableColumnHelpTooltip } from "./TableColumnHelpTooltip";
|
||||||
import { UsersTableBody } from "./UsersTableBody";
|
import { UsersTableBody } from "./UsersTableBody";
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
usernameLabel: "User",
|
usernameLabel: "User",
|
||||||
rolesLabel: "Roles",
|
rolesLabel: "Roles",
|
||||||
|
groupsLabel: "Groups",
|
||||||
statusLabel: "Status",
|
statusLabel: "Status",
|
||||||
lastSeenLabel: "Last Seen",
|
lastSeenLabel: "Last Seen",
|
||||||
loginTypeLabel: "Login Type",
|
loginTypeLabel: "Login Type",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export interface UsersTableProps {
|
export interface UsersTableProps {
|
||||||
users?: TypesGen.User[];
|
users: TypesGen.User[] | undefined;
|
||||||
roles?: TypesGen.AssignableRoles[];
|
roles: TypesGen.AssignableRoles[] | undefined;
|
||||||
|
groupsByUserId: GroupsByUserId | undefined;
|
||||||
isUpdatingUserRoles?: boolean;
|
isUpdatingUserRoles?: boolean;
|
||||||
canEditUsers?: boolean;
|
canEditUsers: boolean;
|
||||||
canViewActivity?: boolean;
|
canViewActivity?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading: boolean;
|
||||||
onSuspendUser: (user: TypesGen.User) => void;
|
onSuspendUser: (user: TypesGen.User) => void;
|
||||||
onActivateUser: (user: TypesGen.User) => void;
|
onActivateUser: (user: TypesGen.User) => void;
|
||||||
onDeleteUser: (user: TypesGen.User) => void;
|
onDeleteUser: (user: TypesGen.User) => void;
|
||||||
@ -59,29 +63,42 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
|
|||||||
actorID,
|
actorID,
|
||||||
oidcRoleSyncEnabled,
|
oidcRoleSyncEnabled,
|
||||||
authMethods,
|
authMethods,
|
||||||
|
groupsByUserId,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell width="30%">{Language.usernameLabel}</TableCell>
|
<TableCell width="29%">{Language.usernameLabel}</TableCell>
|
||||||
<TableCell width="40%">
|
|
||||||
|
<TableCell width="29%">
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<span>{Language.rolesLabel}</span>
|
<span>{Language.rolesLabel}</span>
|
||||||
<UserRoleHelpTooltip />
|
<TableColumnHelpTooltip variant="roles" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</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 */}
|
{/* 1% is a trick to make the table cell width fit the content */}
|
||||||
{canEditUsers && <TableCell width="1%" />}
|
{canEditUsers && <TableCell width="1%" />}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<UsersTableBody
|
<UsersTableBody
|
||||||
users={users}
|
users={users}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
|
groupsByUserId={groupsByUserId}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
canEditUsers={canEditUsers}
|
canEditUsers={canEditUsers}
|
||||||
canViewActivity={canViewActivity}
|
canViewActivity={canViewActivity}
|
||||||
|
@ -8,7 +8,6 @@ import dayjs from "dayjs";
|
|||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||||
import { Pill } from "components/Pill/Pill";
|
|
||||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||||
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
|
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
|
||||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||||
@ -17,36 +16,26 @@ import {
|
|||||||
TableRowSkeleton,
|
TableRowSkeleton,
|
||||||
} from "components/TableLoader/TableLoader";
|
} from "components/TableLoader/TableLoader";
|
||||||
import { TableRowMenu } from "components/TableRowMenu/TableRowMenu";
|
import { TableRowMenu } from "components/TableRowMenu/TableRowMenu";
|
||||||
import { Stack } from "components/Stack/Stack";
|
|
||||||
import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges";
|
import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges";
|
||||||
import { EditRolesButton } from "./EditRolesButton";
|
|
||||||
import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined";
|
import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined";
|
||||||
import KeyOutlined from "@mui/icons-material/KeyOutlined";
|
import KeyOutlined from "@mui/icons-material/KeyOutlined";
|
||||||
import GitHub from "@mui/icons-material/GitHub";
|
import GitHub from "@mui/icons-material/GitHub";
|
||||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
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);
|
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 {
|
interface UsersTableBodyProps {
|
||||||
users?: TypesGen.User[];
|
users: TypesGen.User[] | undefined;
|
||||||
|
groupsByUserId: GroupsByUserId | undefined;
|
||||||
authMethods?: TypesGen.AuthMethods;
|
authMethods?: TypesGen.AuthMethods;
|
||||||
roles?: TypesGen.AssignableRoles[];
|
roles?: TypesGen.AssignableRoles[];
|
||||||
isUpdatingUserRoles?: boolean;
|
isUpdatingUserRoles?: boolean;
|
||||||
canEditUsers?: boolean;
|
canEditUsers: boolean;
|
||||||
isLoading?: boolean;
|
isLoading: boolean;
|
||||||
canViewActivity?: boolean;
|
canViewActivity?: boolean;
|
||||||
onSuspendUser: (user: TypesGen.User) => void;
|
onSuspendUser: (user: TypesGen.User) => void;
|
||||||
onDeleteUser: (user: TypesGen.User) => void;
|
onDeleteUser: (user: TypesGen.User) => void;
|
||||||
@ -86,6 +75,7 @@ export const UsersTableBody: FC<
|
|||||||
isNonInitialPage,
|
isNonInitialPage,
|
||||||
actorID,
|
actorID,
|
||||||
oidcRoleSyncEnabled,
|
oidcRoleSyncEnabled,
|
||||||
|
groupsByUserId,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ChooseOne>
|
<ChooseOne>
|
||||||
@ -97,15 +87,23 @@ export const UsersTableBody: FC<
|
|||||||
<AvatarDataSkeleton />
|
<AvatarDataSkeleton />
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton variant="text" width="25%" />
|
<Skeleton variant="text" width="25%" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton variant="text" width="25%" />
|
<Skeleton variant="text" width="25%" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton variant="text" width="25%" />
|
<Skeleton variant="text" width="25%" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton variant="text" width="25%" />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
{canEditUsers && (
|
{canEditUsers && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton variant="text" width="25%" />
|
<Skeleton variant="text" width="25%" />
|
||||||
@ -114,6 +112,7 @@ export const UsersTableBody: FC<
|
|||||||
</TableRowSkeleton>
|
</TableRowSkeleton>
|
||||||
</TableLoaderSkeleton>
|
</TableLoaderSkeleton>
|
||||||
</Cond>
|
</Cond>
|
||||||
|
|
||||||
<Cond condition={!users || users.length === 0}>
|
<Cond condition={!users || users.length === 0}>
|
||||||
<ChooseOne>
|
<ChooseOne>
|
||||||
<Cond condition={isNonInitialPage}>
|
<Cond condition={isNonInitialPage}>
|
||||||
@ -125,6 +124,7 @@ export const UsersTableBody: FC<
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</Cond>
|
</Cond>
|
||||||
|
|
||||||
<Cond>
|
<Cond>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={999}>
|
<TableCell colSpan={999}>
|
||||||
@ -136,125 +136,91 @@ export const UsersTableBody: FC<
|
|||||||
</Cond>
|
</Cond>
|
||||||
</ChooseOne>
|
</ChooseOne>
|
||||||
</Cond>
|
</Cond>
|
||||||
|
|
||||||
<Cond>
|
<Cond>
|
||||||
<>
|
{users?.map((user) => (
|
||||||
{users &&
|
<TableRow key={user.id} data-testid={`user-${user.id}`}>
|
||||||
users.map((user) => {
|
<TableCell>
|
||||||
// When the user has no role we want to show they are a Member
|
<AvatarData
|
||||||
const fallbackRole: TypesGen.Role = {
|
title={user.username}
|
||||||
name: "member",
|
subtitle={user.email}
|
||||||
display_name: "Member",
|
src={user.avatar_url}
|
||||||
};
|
/>
|
||||||
const userRoles =
|
</TableCell>
|
||||||
user.roles.length === 0
|
|
||||||
? [fallbackRole]
|
|
||||||
: sortRoles(user.roles);
|
|
||||||
|
|
||||||
return (
|
<UserRoleCell
|
||||||
<TableRow key={user.id} data-testid={`user-${user.id}`}>
|
canEditUsers={canEditUsers}
|
||||||
<TableCell>
|
allAvailableRoles={roles}
|
||||||
<AvatarData
|
user={user}
|
||||||
title={user.username}
|
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
|
||||||
subtitle={user.email}
|
isLoading={Boolean(isUpdatingUserRoles)}
|
||||||
src={user.avatar_url}
|
onUserRolesUpdate={onUpdateUserRoles}
|
||||||
/>
|
/>
|
||||||
</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>
|
|
||||||
|
|
||||||
{canEditUsers && (
|
<UserGroupsCell userGroups={groupsByUserId?.get(user.id)} />
|
||||||
<TableCell>
|
|
||||||
<TableRowMenu
|
<TableCell>
|
||||||
data={user}
|
<LoginType authMethods={authMethods!} value={user.login_type} />
|
||||||
menuItems={[
|
</TableCell>
|
||||||
// Return either suspend or activate depending on status
|
|
||||||
user.status === "active" || user.status === "dormant"
|
<TableCell
|
||||||
? {
|
css={[
|
||||||
label: <>Suspend…</>,
|
styles.status,
|
||||||
onClick: onSuspendUser,
|
user.status === "suspended" && styles.suspended,
|
||||||
disabled: false,
|
]}
|
||||||
}
|
>
|
||||||
: {
|
<Box>{user.status}</Box>
|
||||||
label: <>Activate…</>,
|
<LastSeen value={user.last_seen_at} sx={{ fontSize: 12 }} />
|
||||||
onClick: onActivateUser,
|
</TableCell>
|
||||||
disabled: false,
|
|
||||||
},
|
{canEditUsers && (
|
||||||
{
|
<TableCell>
|
||||||
label: <>Delete…</>,
|
<TableRowMenu
|
||||||
onClick: onDeleteUser,
|
data={user}
|
||||||
disabled: user.id === actorID,
|
menuItems={[
|
||||||
},
|
// Return either suspend or activate depending on status
|
||||||
{
|
user.status === "active" || user.status === "dormant"
|
||||||
label: <>Reset password…</>,
|
? {
|
||||||
onClick: onResetUserPassword,
|
label: <>Suspend…</>,
|
||||||
disabled: user.login_type !== "password",
|
onClick: onSuspendUser,
|
||||||
},
|
disabled: false,
|
||||||
{
|
}
|
||||||
label: "View workspaces",
|
: {
|
||||||
onClick: onListWorkspaces,
|
label: <>Activate…</>,
|
||||||
disabled: false,
|
onClick: onActivateUser,
|
||||||
},
|
disabled: false,
|
||||||
{
|
},
|
||||||
label: (
|
{
|
||||||
<>
|
label: <>Delete…</>,
|
||||||
View activity
|
onClick: onDeleteUser,
|
||||||
{!canViewActivity && <EnterpriseBadge />}
|
disabled: user.id === actorID,
|
||||||
</>
|
},
|
||||||
),
|
{
|
||||||
onClick: onViewActivity,
|
label: <>Reset password…</>,
|
||||||
disabled: !canViewActivity,
|
onClick: onResetUserPassword,
|
||||||
},
|
disabled: user.login_type !== "password",
|
||||||
]}
|
},
|
||||||
/>
|
{
|
||||||
</TableCell>
|
label: "View workspaces",
|
||||||
)}
|
onClick: onListWorkspaces,
|
||||||
</TableRow>
|
disabled: false,
|
||||||
);
|
},
|
||||||
})}
|
{
|
||||||
</>
|
label: (
|
||||||
|
<>
|
||||||
|
View activity
|
||||||
|
{!canViewActivity && <EnterpriseBadge />}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
onClick: onViewActivity,
|
||||||
|
disabled: !canViewActivity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</Cond>
|
</Cond>
|
||||||
</ChooseOne>
|
</ChooseOne>
|
||||||
);
|
);
|
||||||
@ -349,12 +315,4 @@ const styles = {
|
|||||||
suspended: (theme) => ({
|
suspended: (theme) => ({
|
||||||
color: theme.palette.text.secondary,
|
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>>;
|
} satisfies Record<string, Interpolation<Theme>>;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
export const borderRadius = 8;
|
export const borderRadius = 8;
|
||||||
export const MONOSPACE_FONT_FAMILY =
|
export const MONOSPACE_FONT_FAMILY =
|
||||||
"'IBM Plex Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'Liberation Mono', 'Monaco', 'Courier New', Courier, monospace";
|
"'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 navHeight = 62;
|
||||||
export const containerWidth = 1380;
|
export const containerWidth = 1380;
|
||||||
export const containerWidthMedium = 1080;
|
export const containerWidthMedium = 1080;
|
||||||
|
@ -74,13 +74,15 @@ export let dark = createTheme({
|
|||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: BODY_FONT_FAMILY,
|
fontFamily: BODY_FONT_FAMILY,
|
||||||
|
|
||||||
body1: {
|
body1: {
|
||||||
fontSize: 16,
|
fontSize: "1rem" /* 16px at default scaling */,
|
||||||
lineHeight: "24px",
|
lineHeight: "1.5rem" /* 24px at default scaling */,
|
||||||
},
|
},
|
||||||
|
|
||||||
body2: {
|
body2: {
|
||||||
fontSize: 14,
|
fontSize: "0.875rem" /* 14px at default scaling */,
|
||||||
lineHeight: "20px",
|
lineHeight: "1.25rem" /* 20px at default scaling */,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
shape: {
|
shape: {
|
||||||
|
Reference in New Issue
Block a user