diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index d34ad24a6abd0..3dc6759c12484 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -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>; + +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(); + 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; +} + export const groupPermissions = (groupId: string) => { return { queryKey: [...getGroupQueryKey(groupId), "permissions"], diff --git a/site/src/components/Avatar/Avatar.tsx b/site/src/components/Avatar/Avatar.tsx index 02dbb2b141480..89de1f174ba4b 100644 --- a/site/src/components/Avatar/Avatar.tsx +++ b/site/src/components/Avatar/Avatar.tsx @@ -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) => ({ diff --git a/site/src/components/HelpTooltip/HelpTooltip.tsx b/site/src/components/HelpTooltip/HelpTooltip.tsx index a9c01acb44fa3..b79babde64700 100644 --- a/site/src/components/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/HelpTooltip/HelpTooltip.tsx @@ -88,7 +88,7 @@ export const HelpPopover: FC< export const HelpTooltip: FC> = ({ children, - open, + open = false, size = "medium", icon: Icon = HelpIcon, iconClassName, @@ -96,7 +96,7 @@ export const HelpTooltip: FC> = ({ }) => { const theme = useTheme(); const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(Boolean(open)); + const [isOpen, setIsOpen] = useState(open); const id = isOpen ? "help-popover" : undefined; const onClose = () => { diff --git a/site/src/components/Popover/Popover.tsx b/site/src/components/Popover/Popover.tsx index aa9b39183e6c2..5a936ac21a29e 100644 --- a/site/src/components/Popover/Popover.tsx +++ b/site/src/components/Popover/Popover.tsx @@ -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; 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>; 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(null); - const value = { isOpen, setIsOpen, triggerRef, mode: props.mode ?? "click" }; + + const value: PopoverContextValue = { + isOpen, + setIsOpen, + triggerRef, + id: `${hookId}-popover`, + mode: props.mode ?? "click", + }; return ( @@ -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 = ( ({ - // 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); }, }; diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index c6c3e2ba19919..7d09df412b32e 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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(); + const [userToSuspend, setUserToSuspend] = useState(); const suspendUserMutation = useMutation(suspendUser(queryClient)); - const [confirmActivateUser, setConfirmActivateUser] = useState(); + const [userToActivate, setUserToActivate] = useState(); const activateUserMutation = useMutation(activateUser(queryClient)); - const [confirmDeleteUser, setConfirmDeleteUser] = useState(); + const [userToDelete, setUserToDelete] = useState(); 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 ( <> {pageTitle("Users")} + { 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 }> = () => { /> 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); - }} /> 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{" "} - {confirmSuspendUser?.username ?? ""}? + {userToSuspend?.username ?? ""}? } /> @@ -207,26 +216,24 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { 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{" "} - {confirmActivateUser?.username ?? ""}? + {userToActivate?.username ?? ""}? } /> diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 56cd39e85d446..5ea743d210781 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -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; isNonInitialPage: boolean; actorID: string; + groupsByUserId: GroupsByUserId | undefined; + // Pagination count?: number; page: number; @@ -60,6 +64,7 @@ export const UsersPageView: FC> = ({ limit, onPageChange, page, + groupsByUserId, }) => { return ( <> @@ -77,6 +82,7 @@ export const UsersPageView: FC> = ({ = { export default meta; type Story = StoryObj; +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, }, diff --git a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx index 980122ba5f55d..eca9bcf389f9f 100644 --- a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx +++ b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx @@ -61,7 +61,7 @@ const Option: React.FC<{ export interface EditRolesButtonProps { isLoading: boolean; roles: Role[]; - selectedRoles: Role[]; + selectedRoleNames: Set; onChange: (roles: Role["name"][]) => void; isDefaultOpen?: boolean; oidcRoleSync: boolean; @@ -70,7 +70,7 @@ export interface EditRolesButtonProps { export const EditRolesButton: FC = ({ roles, - selectedRoles, + selectedRoleNames, onChange, isLoading, isDefaultOpen = false, @@ -78,11 +78,11 @@ export const EditRolesButton: FC = ({ 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 = ({