diff --git a/.gitignore b/.gitignore index 88850ed504..65dd97caf7 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ site/.swc .gen-golden # Build +bin/ build/ dist/ out/ diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 96f3f24d3e..38205be20d 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1332,11 +1332,12 @@ export async function createUser( await expect(addedRow).toBeVisible(); // Give them a role - await addedRow.getByLabel("Edit user roles").click(); + await addedRow.getByLabel("Open menu").click(); + await page.getByText("Edit roles").click(); for (const role of roles) { - await page.getByRole("group").getByText(role, { exact: true }).click(); + await page.getByRole("dialog").getByText(role, { exact: true }).click(); } - await page.mouse.click(10, 10); // close the popover by clicking outside of it + await page.getByText("Confirm").click(); await page.goto(returnTo, { waitUntil: "domcontentloaded" }); return { name, username, email, password, roles }; diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index 344c346f7e..d1cbfeb10b 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -108,10 +108,7 @@ export const DialogFooter: React.FC> = ({ ); }; -/** - * @lintignore I'll be using this right away in another PR, just trying to break things up - */ -export interface DialogActionsProps { +type DialogActionsProps = { /** Text to display in the confirm button */ confirmText?: React.ReactNode; /** Whether or not confirm is loading, also disables cancel when true */ @@ -127,11 +124,10 @@ export interface DialogActionsProps { cancelText?: string; /** Called when cancel is clicked */ onCancel?: () => void; -} +}; /** * Quickly handles most modals actions, some combination of a cancel and confirm button - * @lintignore I'll be using this right away in another PR, just trying to break things up */ export const DialogActions: React.FC = ({ confirmText = "Confirm", diff --git a/site/src/pages/CreateUserPage/RoleSelector.stories.tsx b/site/src/modules/roles/RoleSelector.stories.tsx similarity index 81% rename from site/src/pages/CreateUserPage/RoleSelector.stories.tsx rename to site/src/modules/roles/RoleSelector.stories.tsx index 3e73126423..7eac7c868e 100644 --- a/site/src/pages/CreateUserPage/RoleSelector.stories.tsx +++ b/site/src/modules/roles/RoleSelector.stories.tsx @@ -11,11 +11,11 @@ import { import { RoleSelector } from "./RoleSelector"; const meta: Meta = { - title: "pages/CreateUserPage/RoleSelector", + title: "modules/roles/RoleSelector", component: RoleSelector, args: { onChange: action("change"), - selectedRoles: [], + selectedRoles: new Set(), }, }; @@ -38,33 +38,33 @@ const someNonAssignable = [ export const Default: Story = { args: { - roles: allAssignable, + availableRoles: allAssignable, }, }; export const WithSelections: Story = { args: { - roles: allAssignable, - selectedRoles: [MockUserAdminRole.name, MockAuditorRole.name], + availableRoles: allAssignable, + selectedRoles: new Set([MockUserAdminRole.name, MockAuditorRole.name]), }, }; export const WithNonAssignableRoles: Story = { args: { - roles: someNonAssignable, + availableRoles: someNonAssignable, }, }; export const Loading: Story = { args: { - roles: [], + availableRoles: [], loading: true, }, }; export const WithError: Story = { args: { - roles: [], + availableRoles: [], error: mockApiError({ message: "Failed to fetch assignable roles." }), }, }; diff --git a/site/src/modules/roles/RoleSelector.tsx b/site/src/modules/roles/RoleSelector.tsx new file mode 100644 index 0000000000..99eac5bdcf --- /dev/null +++ b/site/src/modules/roles/RoleSelector.tsx @@ -0,0 +1,154 @@ +import { UserIcon } from "lucide-react"; +import { type FC, useId } from "react"; +import { getErrorMessage } from "#/api/errors"; +import type { AssignableRoles } from "#/api/typesGenerated"; +import { Alert, AlertTitle } from "#/components/Alert/Alert"; +import { Checkbox } from "#/components/Checkbox/Checkbox"; +import { Skeleton } from "#/components/Skeleton/Skeleton"; +import { cn } from "#/utils/cn"; +import { roleDescriptions } from "./index"; + +type RoleSelectorProps = { + hideLabel?: boolean; + loading?: boolean; + error?: unknown; + availableRoles?: AssignableRoles[]; + selectedRoles: Set; + onChange: (roles: Set) => void; +}; + +export const RoleSelector: FC = ({ + hideLabel, + loading, + error, + availableRoles = [], + selectedRoles, + onChange, +}) => { + const baseId = useId(); + const selectableRoles = availableRoles.filter((r) => r.name !== "member"); + + if (loading) { + return ( + + + + + ); + } + + if (error) { + return ( + + + + {getErrorMessage(error, "Failed to load roles.")} + + + + ); + } + + if (selectableRoles.length === 0) { + return null; + } + + const handleToggle = (roleName: string) => { + const newRoles = new Set(selectedRoles); + if (newRoles.has(roleName)) { + newRoles.delete(roleName); + } else { + newRoles.add(roleName); + } + onChange(newRoles); + }; + + return ( + + {selectableRoles.length > 0 && ( +
+ {selectableRoles.map((role) => { + const checkboxId = `${baseId}-${role.name}`; + return ( + + ); + })} +
+ )} + + +
+ ); +}; + +type RoleSelectorLayoutProps = { + hideLabel?: boolean; + children: React.ReactNode; +}; + +const RoleSelectorLayout: React.FC = ({ + hideLabel, + children, +}) => { + return ( +
+ {!hideLabel && Roles} + {children} +
+ ); +}; + +const MemberRole: React.FC = () => { + return ( +
+ +
+ Member + {roleDescriptions.member} +
+
+ ); +}; + +const RoleSelectorSkeleton: React.FC = () => { + return ( +
+
+ {Array.from({ length: 4 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ); +}; diff --git a/site/src/modules/roles/RoleSelectorDialog.tsx b/site/src/modules/roles/RoleSelectorDialog.tsx new file mode 100644 index 0000000000..4a306b018d --- /dev/null +++ b/site/src/modules/roles/RoleSelectorDialog.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import type { AssignableRoles, SlimRole } from "#/api/typesGenerated"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { + Dialog, + DialogActions, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { getRoleNames } from "./index"; +import { RoleSelector } from "./RoleSelector"; + +type RoleSelectorDialogProps = { + /** + * The user who is currently being edited. The dialog will be hidden if no + * no user is provided. + */ + user?: ThingWithRoles; + /** The roles available in this context that can be given or removed from the user */ + availableRoles?: AssignableRoles[]; + + onCancel: () => void; + onUpdateRoles: (roles: string[]) => Promise; + isUpdatingRoles: boolean; +}; + +type ThingWithRoles = { + username: string; + email: string; + roles: readonly SlimRole[]; + avatar_url?: string; +}; + +export const RoleSelectorDialog: React.FC = ({ + user, + availableRoles = [], + onCancel, + onUpdateRoles, + isUpdatingRoles, +}) => { + if (!user) { + return null; + } + + return ( + + ); +}; + +const ActiveRoleSelectorDialog: React.FC> = ({ + user, + availableRoles, + onCancel, + onUpdateRoles, + isUpdatingRoles, +}) => { + const [selectedRoles, setSelectedRoles] = useState>( + () => new Set(getRoleNames(user.roles)), + ); + + return ( + { + if (!isOpen) { + onCancel(); + } + }} + > + + +
+ Edit roles + +
+
+ + + onUpdateRoles([...selectedRoles])} + confirmLoading={isUpdatingRoles} + /> + +
+
+ ); +}; diff --git a/site/src/modules/roles/index.ts b/site/src/modules/roles/index.ts new file mode 100644 index 0000000000..949b488fdf --- /dev/null +++ b/site/src/modules/roles/index.ts @@ -0,0 +1,77 @@ +import type { SlimRole } from "#/api/typesGenerated"; + +export type ScopedSlimRole = SlimRole & { + global?: boolean; +}; + +export const roleDescriptions: Record = { + owner: + "Owner can manage all resources, including users, groups, templates, and workspaces.", + "user-admin": "User admin can manage all users and groups.", + "template-admin": "Template admin can manage all templates and workspaces.", + auditor: "Auditor can access the audit logs.", + "agents-access": "Grants access to Coder Agents chat.", + member: + "Everybody is a member. This is a shared and default role for all users.", +}; + +export const memberRole: ScopedSlimRole = { + name: "member", + display_name: "Member", +} as const; + +export function getRoleNames(roles: readonly SlimRole[]): string[] { + return roles.map((role) => role.name); +} + +export function combineGlobalAndOrgRoles( + globalRoles: readonly SlimRole[], + orgRoles: readonly SlimRole[], +): ScopedSlimRole[] { + return [ + ...globalRoles.map((it) => ({ ...it, global: true })), + ...orgRoles.map((it) => ({ ...it, global: false })), + ]; +} + +const roleNamesByAccessLevel: readonly string[] = [ + "owner", + "organization-admin", + "user-admin", + "organization-user-admin", + "template-admin", + "organization-template-admin", + "auditor", + "organization-auditor", + "agents-access", + "member", + "organization-member", +]; + +export function sortRoles( + roles: readonly Role[], +): readonly Role[] { + if (roles.length < 2) { + return roles; + } + + return [...roles].sort((a, b) => { + const aAccessLevel = roleNamesByAccessLevel.indexOf(a.name); + const bAccessLevel = roleNamesByAccessLevel.indexOf(b.name); + + // a is not in the access level list, but b is, so b should come first + if (aAccessLevel === -1 && bAccessLevel !== -1) { + return 1; + } + // b is not in the access level list, but a is, so a should come first + if (bAccessLevel === -1 && aAccessLevel !== -1) { + return -1; + } + // Neither is in the access level list, so sort them alphabetically + if (aAccessLevel === -1 && bAccessLevel === -1) { + return a.name.localeCompare(b.name); + } + // Both are in the access level list, so sort them by access level + return aAccessLevel - bAccessLevel; + }); +} diff --git a/site/src/pages/UsersPage/UsersTable/UserGroupsCell.tsx b/site/src/modules/users/UserGroupsCell.tsx similarity index 100% rename from site/src/pages/UsersPage/UsersTable/UserGroupsCell.tsx rename to site/src/modules/users/UserGroupsCell.tsx diff --git a/site/src/modules/users/UserHelpPopovers.tsx b/site/src/modules/users/UserHelpPopovers.tsx new file mode 100644 index 0000000000..3b798050f9 --- /dev/null +++ b/site/src/modules/users/UserHelpPopovers.tsx @@ -0,0 +1,66 @@ +import type { FC } from "react"; +import { + HelpPopover, + HelpPopoverContent, + HelpPopoverIconTrigger, + HelpPopoverLink, + HelpPopoverLinksGroup, + HelpPopoverText, + HelpPopoverTitle, +} from "#/components/HelpPopover/HelpPopover"; +import { docs } from "#/utils/docs"; + +export const RolesHelpPopover: FC = () => { + return ( + + + + What is a role? + + Coder role-based access control (RBAC) provides fine-grained access + management. View our docs on how to use the available roles. + + + + User Roles + + + + + ); +}; + +export const GroupsHelpPopover: FC = () => { + return ( + + + + What is a group? + + Groups can be used with template RBAC to give groups of users access + to specific templates. View our docs on how to use groups. + + + + Groups + + + + + ); +}; + +export const AiAddonHelpPopover: FC = () => { + return ( + + + + What is the AI add-on? + + Users with access to AI features like AI Bridge or Tasks who are + actively consuming a seat. + + + + ); +}; diff --git a/site/src/modules/users/UserRoleCell.tsx b/site/src/modules/users/UserRoleCell.tsx new file mode 100644 index 0000000000..ac2825a708 --- /dev/null +++ b/site/src/modules/users/UserRoleCell.tsx @@ -0,0 +1,90 @@ +import type { SlimRole } from "#/api/typesGenerated"; +import { Badge } from "#/components/Badge/Badge"; +import { TableCell } from "#/components/Table/Table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { + combineGlobalAndOrgRoles, + memberRole, + type ScopedSlimRole, + sortRoles, +} from "#/modules/roles"; + +type UserRoleCellProps = { + globalRoles?: readonly SlimRole[]; + roles: readonly SlimRole[]; +}; + +export const UserRoleCell: React.FC = ({ + globalRoles = [], + roles, +}) => { + const mergedRoles = combineGlobalAndOrgRoles(globalRoles, roles); + const [mainDisplayRole = memberRole, ...extraRoles] = sortRoles(mergedRoles); + + return ( + +
+ + + {extraRoles.length > 0 && } +
+
+ ); +}; + +type MoreRolePillProps = { + roles: readonly ScopedSlimRole[]; +}; + +const MoreRolePill: React.FC = ({ roles }) => { + return ( + + + + +{roles.length} more + + + + {roles.map((role) => ( + + ))} + + + + ); +}; + +type RoleBadgeProps = { + role: ScopedSlimRole; +}; + +const RoleBadge: React.FC = ({ role }) => { + const displayName = role.display_name || role.name; + const isOwnerRole = + role.name === "owner" || role.name === "organization-admin"; + + return ( + + {role.global ? ( + + + {displayName}* + + + This user has this role for all organizations. + + + ) : ( + displayName + )} + + ); +}; diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 10c3a84985..dd2537bda1 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -21,6 +21,7 @@ import { SelectValue, } from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; +import { RoleSelector } from "#/modules/roles/RoleSelector"; import { cn } from "#/utils/cn"; import { displayNameValidator, @@ -28,7 +29,6 @@ import { nameValidator, onChangeTrimmed, } from "#/utils/formUtils"; -import { RoleSelector } from "./RoleSelector"; const loginTypeOptions = { password: { @@ -81,10 +81,10 @@ type CreateUserFormData = { readonly login_type: TypesGen.LoginType; readonly password: string; readonly service_account: boolean; - readonly roles: string[]; + readonly roles: Set; }; -interface CreateUserFormProps { +type CreateUserFormProps = { error?: unknown; isLoading: boolean; onSubmit: (user: CreateUserFormData) => void; @@ -95,7 +95,7 @@ interface CreateUserFormProps { availableRoles?: TypesGen.AssignableRoles[]; rolesLoading?: boolean; rolesError?: unknown; -} +}; // Stable reference for empty org options to avoid re-render loops // in the render-time state adjustment pattern. @@ -133,7 +133,7 @@ export const CreateUserForm: FC = ({ : "00000000-0000-0000-0000-000000000000", login_type: defaultLoginType, service_account: defaultLoginType === "none", - roles: [], + roles: new Set(), }, validationSchema, onSubmit, @@ -241,6 +241,7 @@ export const CreateUserForm: FC = ({ > + {availableLoginTypes.map((key) => { const opt = loginTypeOptions[key]; @@ -345,30 +346,13 @@ export const CreateUserForm: FC = ({ /> )} - {rolesLoading ? ( - {}} - /> - ) : rolesError ? ( - {}} - /> - ) : ( - availableRoles && - availableRoles.length > 0 && ( - form.setFieldValue("roles", roles)} - /> - ) - )} + form.setFieldValue("roles", roles)} + /> diff --git a/site/src/pages/CreateUserPage/CreateUserPage.tsx b/site/src/pages/CreateUserPage/CreateUserPage.tsx index 6149fd5d5d..8d2e12cfd6 100644 --- a/site/src/pages/CreateUserPage/CreateUserPage.tsx +++ b/site/src/pages/CreateUserPage/CreateUserPage.tsx @@ -38,7 +38,7 @@ const CreateUserPage: FC = () => { password: user.password, user_status: null, service_account: user.service_account, - roles: user.roles, + roles: [...user.roles], }, { onSuccess: () => { diff --git a/site/src/pages/CreateUserPage/RoleSelector.tsx b/site/src/pages/CreateUserPage/RoleSelector.tsx deleted file mode 100644 index a68615202f..0000000000 --- a/site/src/pages/CreateUserPage/RoleSelector.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { UserIcon } from "lucide-react"; -import { type FC, useId } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { AssignableRoles } from "#/api/typesGenerated"; -import { Alert, AlertTitle } from "#/components/Alert/Alert"; -import { Checkbox } from "#/components/Checkbox/Checkbox"; -import { Skeleton } from "#/components/Skeleton/Skeleton"; -import { cn } from "#/utils/cn"; - -const roleDescriptions: Record = { - owner: - "Owner can manage all resources, including users, groups, templates, and workspaces.", - "user-admin": "User admin can manage all users and groups.", - "template-admin": "Template admin can manage all templates and workspaces.", - auditor: "Auditor can access the audit logs.", - "agents-access": "Grants access to Coder Agents chat.", - member: - "Everybody is a member. This is a shared and default role for all users.", -}; - -interface RoleSelectorProps { - roles: AssignableRoles[]; - selectedRoles: string[]; - onChange: (roles: string[]) => void; - loading?: boolean; - error?: unknown; -} - -export const RoleSelector: FC = ({ - roles, - selectedRoles, - onChange, - loading, - error, -}) => { - const baseId = useId(); - const selectableRoles = roles.filter((r) => r.name !== "member"); - - const handleToggle = (roleName: string) => { - if (selectedRoles.includes(roleName)) { - onChange(selectedRoles.filter((r) => r !== roleName)); - } else { - onChange([...selectedRoles, roleName]); - } - }; - - if (loading) { - return ( -
- Roles -
-
- {Array.from({ length: 4 }, (_, i) => ( -
- -
- - -
-
- ))} -
-
-
- -
- Member - {roleDescriptions.member} -
-
-
- ); - } - - if (error) { - return ( -
- Roles - - - {getErrorMessage(error, "Failed to load roles.")} - - -
- ); - } - - return ( -
- Roles - {selectableRoles.length > 0 && ( -
-
- {selectableRoles.map((role) => { - const checkboxId = `${baseId}-${role.name}`; - return ( - - ); - })} -
-
- )} -
- -
- Member - {roleDescriptions.member} -
-
-
- ); -}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index b8716fb436..46d1ed1756 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -48,7 +48,7 @@ import { } from "#/components/Table/Table"; import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; import { AISeatCell } from "#/modules/users/AISeatCell"; -import { UserGroupsCell } from "#/pages/UsersPage/UsersTable/UserGroupsCell"; +import { UserGroupsCell } from "#/modules/users/UserGroupsCell"; import { TableColumnHelpPopover } from "./UserTable/TableColumnHelpPopover"; import { UserRoleCell } from "./UserTable/UserRoleCell"; diff --git a/site/src/pages/UsersPage/UsersPage.stories.tsx b/site/src/pages/UsersPage/UsersPage.stories.tsx index 125f51d96c..23eaaaa98b 100644 --- a/site/src/pages/UsersPage/UsersPage.stories.tsx +++ b/site/src/pages/UsersPage/UsersPage.stories.tsx @@ -72,9 +72,6 @@ const meta: Meta = { component: UsersPage, parameters, decorators: [withToaster, withAuthProvider, withDashboardProvider], - args: { - defaultNewPassword: "edWbqYiaVpEiEWwI", - }, }; export default meta; @@ -377,8 +374,10 @@ export const UpdateUserRoleSuccess: Story = { count: 60, }); - await user.click(within(userRow).getByLabelText("Edit user roles")); + await user.click(within(userRow).getByLabelText("Open menu")); + await user.click(screen.getByText("Edit roles")); await user.click(screen.getByLabelText("Auditor", { exact: false })); + await user.click(screen.getByText("Confirm")); await screen.findByText(/roles updated successfully/); }, }; @@ -393,8 +392,10 @@ export const UpdateUserRoleError: Story = { } spyOn(API, "updateUserRoles").mockRejectedValue({}); - await user.click(within(userRow).getByLabelText("Edit user roles")); + await user.click(within(userRow).getByLabelText("Open menu")); + await user.click(screen.getByText("Edit roles")); await user.click(screen.getByLabelText("Auditor", { exact: false })); + await user.click(screen.getByText("Confirm")); await screen.findByText(/Error updating user roles/); }, }; diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 5772f378ad..3e70e8f01f 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -1,6 +1,6 @@ -import { type FC, useState } from "react"; +import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { useNavigate, useSearchParams } from "react-router"; +import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { deploymentConfig } from "#/api/queries/deployment"; @@ -8,7 +8,6 @@ import { groupsByUserId } from "#/api/queries/groups"; import { roles } from "#/api/queries/roles"; import { activateUser, - authMethods, deleteUser, paginatedUsers, suspendUser, @@ -20,31 +19,23 @@ import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog" import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; import { useFilter } from "#/components/Filter/Filter"; import { useStatusFilterMenu } from "#/components/Filter/UsersFilter"; -import { isNonInitialPage } from "#/components/PaginationWidget/utils"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; import { shouldShowAISeatColumn } from "#/modules/dashboard/entitlements"; import { useDashboard } from "#/modules/dashboard/useDashboard"; +import { RoleSelectorDialog } from "#/modules/roles/RoleSelectorDialog"; import { pageTitle } from "#/utils/page"; import { generateRandomString } from "#/utils/random"; import { ResetPasswordDialog } from "./ResetPasswordDialog"; import { UsersPageView } from "./UsersPageView"; -type UserPageProps = { - // Used by Storybook to prevent generating a new password each time the story - // loads, avoiding Chromatic snapshot differences. - defaultNewPassword?: string; -}; - -const UsersPage: FC = ({ defaultNewPassword }) => { +const UsersPage: React.FC = () => { const queryClient = useQueryClient(); - const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { entitlements } = useDashboard(); const showAISeatColumn = shouldShowAISeatColumn(entitlements); const groupsByUserIdQuery = useQuery(groupsByUserId()); - const authMethodsQuery = useQuery(authMethods()); const { permissions, user: me } = useAuthenticated(); const { @@ -74,22 +65,29 @@ const UsersPage: FC = ({ defaultNewPassword }) => { }), }); - const [userToSuspend, setUserToSuspend] = useState(); + const [userToSuspend, setUserToSuspend] = useState( + undefined, + ); const suspendUserMutation = useMutation(suspendUser(queryClient)); - const [userToActivate, setUserToActivate] = useState(); + const [userToActivate, setUserToActivate] = useState( + undefined, + ); const activateUserMutation = useMutation(activateUser(queryClient)); - const [userToDelete, setUserToDelete] = useState(); + const [userToDelete, setUserToDelete] = useState(undefined); const deleteUserMutation = useMutation(deleteUser(queryClient)); + const [userToEditRoles, setUserToEditRoles] = useState( + undefined, + ); + const updateUserRolesMutation = useMutation(updateRoles(queryClient)); + const [confirmResetPassword, setConfirmResetPassword] = useState<{ user: User; newPassword: string; }>(); - const updatePasswordMutation = useMutation(updatePassword()); - const updateRolesMutation = useMutation(updateRoles(queryClient)); // Indicates if oidc roles are synced from the oidc idp. // Assign 'false' if unknown. @@ -100,7 +98,6 @@ const UsersPage: FC = ({ defaultNewPassword }) => { const isLoading = usersQuery.isLoading || rolesQuery.isLoading || - authMethodsQuery.isLoading || groupsByUserIdQuery.isLoading; return ( @@ -108,54 +105,56 @@ const UsersPage: FC = ({ defaultNewPassword }) => { {pageTitle("Users")} { - navigate( - `/workspaces?filter=${encodeURIComponent(`owner:${user.username}`)}`, - ); - }} - onViewActivity={(user) => { - navigate( - `/audit?filter=${encodeURIComponent(`username:${user.username}`)}`, - ); - }} - onDeleteUser={setUserToDelete} - onSuspendUser={setUserToSuspend} - onActivateUser={setUserToActivate} - onResetUserPassword={(user) => { - setConfirmResetPassword({ - user, - newPassword: defaultNewPassword ?? generateRandomString(12), - }); - }} - onUpdateUserRoles={async (userId, roles) => { - try { - await updateRolesMutation.mutateAsync({ userId, roles }); - toast.success("User roles updated successfully."); - } catch (e) { - toast.error(getErrorMessage(e, "Error updating user roles."), { - description: getErrorDetail(e), - }); - } - }} - isUpdatingUserRoles={updateRolesMutation.isPending} isLoading={isLoading} - canEditUsers={canEditUsers} - canViewActivity={entitlements.features.audit_log.enabled} - showAISeatColumn={showAISeatColumn} - isNonInitialPage={isNonInitialPage(searchParams)} - actorID={me.id} filterProps={{ filter: useFilterResult, error: usersQuery.error, menus: { status: statusMenu }, }} usersQuery={usersQuery} + groupsByUserId={groupsByUserIdQuery.data} + showAISeatColumn={showAISeatColumn} + onEditUserRoles={setUserToEditRoles} + isUpdatingUserRoles={updateUserRolesMutation.isPending} + onResetUserPassword={(user) => { + setConfirmResetPassword({ + user, + newPassword: + process.env.STORYBOOK === "true" + ? "hello-storybook" + : generateRandomString(12), + }); + }} + onSuspendUser={setUserToSuspend} + onActivateUser={setUserToActivate} + onDeleteUser={setUserToDelete} + me={me.id} canCreateUser={canCreateUser} + canEditUsers={canEditUsers} + canViewActivity={entitlements.features.audit_log.enabled} + oidcRoleSyncEnabled={oidcRoleSyncEnabled} + /> + + setUserToEditRoles(undefined)} + onUpdateRoles={async (roles) => { + try { + await updateUserRolesMutation.mutateAsync({ + userId: userToEditRoles!.id, + roles, + }); + toast.success("User roles updated successfully."); + setUserToEditRoles(undefined); + } catch (e) { + toast.error(getErrorMessage(e, "Error updating user roles."), { + description: getErrorDetail(e), + }); + } + }} + isUpdatingRoles={updateUserRolesMutation.isPending} /> = { title: "pages/UsersPageView", component: UsersPageView, args: { - isNonInitialPage: false, - users: [ - { ...MockUserOwner, has_ai_seat: false }, - { ...MockUserMember, has_ai_seat: false }, - ], - roles: MockAssignableSiteRoles, canEditUsers: true, filterProps: defaultFilterProps, - authMethods: MockAuthMethodsPasswordOnly, usersQuery: { ...mockSuccessResult, totalRecords: 2, - } as UsePaginatedQueryResult, + data: { + count: 2, + users: [ + { ...MockUserOwner, has_ai_seat: false }, + { ...MockUserMember, has_ai_seat: false }, + ], + }, + }, }, }; @@ -64,32 +61,40 @@ export const Member: Story = { export const Empty: Story = { args: { - users: [], usersQuery: { ...mockSuccessResult, totalRecords: 0, - } as UsePaginatedQueryResult, + data: { + count: 0, + users: [], + }, + }, }, }; export const EmptyPage: Story = { args: { - users: [], - isNonInitialPage: true, usersQuery: { ...mockSuccessResult, totalRecords: 0, - } as UsePaginatedQueryResult, + data: { + count: 0, + users: [], + }, + }, }, }; export const WithError: Story = { args: { - users: undefined, usersQuery: { ...mockSuccessResult, totalRecords: 0, - } as UsePaginatedQueryResult, + data: { + count: 0, + users: [], + }, + }, filterProps: { ...defaultFilterProps, error: mockApiError({ diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 389afef267..c0f27cb09d 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -1,7 +1,6 @@ import { UserPlusIcon } from "lucide-react"; import type { ComponentProps, FC } from "react"; -import { Link as RouterLink } from "react-router"; -import type { GroupsByUserId } from "#/api/queries/groups"; +import { Link } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { UsersFilter } from "#/components/Filter/UsersFilter"; @@ -14,62 +13,19 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { UsersTable } from "./UsersTable/UsersTable"; +import { UsersTable, type UsersTableProps } from "./UsersTable"; -interface UsersPageViewProps { - users?: readonly TypesGen.User[]; - roles?: TypesGen.AssignableRoles[]; - isUpdatingUserRoles?: boolean; - canEditUsers: boolean; - oidcRoleSyncEnabled: boolean; - canViewActivity?: boolean; - showAISeatColumn?: boolean; - isLoading: boolean; - authMethods?: TypesGen.AuthMethods; - onSuspendUser: (user: TypesGen.User) => void; - onDeleteUser: (user: TypesGen.User) => void; - onListWorkspaces: (user: TypesGen.User) => void; - onViewActivity: (user: TypesGen.User) => void; - onActivateUser: (user: TypesGen.User) => void; - onResetUserPassword: (user: TypesGen.User) => void; - onUpdateUserRoles: ( - userId: string, - roles: TypesGen.SlimRole["name"][], - ) => void; +type UsersPageViewProps = Omit & { filterProps: ComponentProps; - isNonInitialPage: boolean; - actorID: string; - groupsByUserId: GroupsByUserId | undefined; - usersQuery: PaginationResult; - - // TODO: Refactor these out once we remove the multi-organization experiment. - canViewOrganizations?: boolean; + usersQuery: PaginationResult; canCreateUser?: boolean; -} +}; export const UsersPageView: FC = ({ - users, - roles, - onSuspendUser, - onDeleteUser, - onListWorkspaces, - onViewActivity, - onActivateUser, - onResetUserPassword, - onUpdateUserRoles, - isUpdatingUserRoles, - canEditUsers, - oidcRoleSyncEnabled, - canViewActivity, - showAISeatColumn, - isLoading, filterProps, - isNonInitialPage, - actorID, - authMethods, - groupsByUserId, usersQuery, canCreateUser, + ...props }) => { return ( <> @@ -77,10 +33,10 @@ export const UsersPageView: FC = ({ actions={ canCreateUser && ( ) } @@ -94,27 +50,7 @@ export const UsersPageView: FC = ({ - + ); diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.stories.tsx b/site/src/pages/UsersPage/UsersTable.stories.tsx similarity index 89% rename from site/src/pages/UsersPage/UsersTable/UsersTable.stories.tsx rename to site/src/pages/UsersPage/UsersTable.stories.tsx index 1d1536ca4c..f656fd8b8e 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.stories.tsx +++ b/site/src/pages/UsersPage/UsersTable.stories.tsx @@ -1,8 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { - MockAssignableSiteRoles, MockAuditorRole, - MockAuthMethodsPasswordOnly, MockGroup, MockMemberRole, MockTemplateAdminRole, @@ -20,10 +18,7 @@ const mockGroupsByUserId = new Map([ const meta: Meta = { title: "pages/UsersPage/UsersTable", component: UsersTable, - args: { - isNonInitialPage: false, - authMethods: MockAuthMethodsPasswordOnly, - }, + args: {}, }; export default meta; @@ -35,7 +30,6 @@ export const Example: Story = { { ...MockUserOwner, has_ai_seat: false }, { ...MockUserMember, has_ai_seat: false }, ], - roles: MockAssignableSiteRoles, canEditUsers: false, groupsByUserId: mockGroupsByUserId, }, @@ -47,7 +41,6 @@ export const ExampleWithAISeatColumn: Story = { { ...MockUserOwner, has_ai_seat: true }, { ...MockUserMember, has_ai_seat: false }, ], - roles: MockAssignableSiteRoles, canEditUsers: false, groupsByUserId: mockGroupsByUserId, showAISeatColumn: true, @@ -90,7 +83,6 @@ export const Editable: Story = { has_ai_seat: false, }, ], - roles: MockAssignableSiteRoles, canEditUsers: true, canViewActivity: true, groupsByUserId: mockGroupsByUserId, @@ -133,7 +125,6 @@ export const EditableWithAISeatColumn: Story = { has_ai_seat: false, }, ], - roles: MockAssignableSiteRoles, canEditUsers: true, canViewActivity: true, groupsByUserId: mockGroupsByUserId, @@ -144,14 +135,12 @@ export const EditableWithAISeatColumn: Story = { export const Empty: Story = { args: { users: [], - roles: MockAssignableSiteRoles, }, }; export const Loading: Story = { args: { users: [], - roles: MockAssignableSiteRoles, isLoading: true, }, parameters: { diff --git a/site/src/pages/UsersPage/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable.tsx new file mode 100644 index 0000000000..18400743ad --- /dev/null +++ b/site/src/pages/UsersPage/UsersTable.tsx @@ -0,0 +1,313 @@ +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { EllipsisVerticalIcon, TrashIcon } from "lucide-react"; +import { Link } from "react-router"; +import type { GroupsByUserId } from "#/api/queries/groups"; +import type * as TypesGen from "#/api/typesGenerated"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton"; +import { PremiumBadge } from "#/components/Badges/Badges"; +import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { LastSeen } from "#/components/LastSeen/LastSeen"; +import { Skeleton } from "#/components/Skeleton/Skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { + TableLoaderSkeleton, + TableRowSkeleton, +} from "#/components/TableLoader/TableLoader"; +import { AISeatCell } from "#/modules/users/AISeatCell"; +import { UserGroupsCell } from "#/modules/users/UserGroupsCell"; +import { + AiAddonHelpPopover, + GroupsHelpPopover, + RolesHelpPopover, +} from "#/modules/users/UserHelpPopovers"; +import { UserRoleCell } from "#/modules/users/UserRoleCell"; +import { cn } from "#/utils/cn"; + +dayjs.extend(relativeTime); + +export type UsersTableProps = { + // State + isLoading: boolean; + users: readonly TypesGen.User[] | undefined; + groupsByUserId: GroupsByUserId | undefined; + showAISeatColumn?: boolean; + + // Actions + onEditUserRoles: (user: TypesGen.User) => void; + isUpdatingUserRoles?: boolean; + onResetUserPassword: (user: TypesGen.User) => void; + onSuspendUser: (user: TypesGen.User) => void; + onActivateUser: (user: TypesGen.User) => void; + onDeleteUser: (user: TypesGen.User) => void; + + // Permissions + /** + * Used to disable the UI of actions that users cannot perform on themselves, + * like delete. + */ + me: string; + canEditUsers: boolean; + canViewActivity?: boolean; + /** User roles cannot be edited if OIDC Role Sync is enabled. */ + oidcRoleSyncEnabled?: boolean; +}; + +export const UsersTable: React.FC = (props) => { + const { showAISeatColumn } = props; + + return ( + + + + User + +
+ Roles + +
+
+ +
+ Groups + +
+
+ {showAISeatColumn && ( + +
+ AI add-on + +
+
+ )} + Status +
+
+ + + + +
+ ); +}; + +const UsersTableBody: React.FC = ({ + isLoading, + users, + groupsByUserId, + showAISeatColumn, + + onEditUserRoles, + isUpdatingUserRoles, + onResetUserPassword, + onSuspendUser, + onActivateUser, + onDeleteUser, + + me, + canEditUsers, + canViewActivity, + oidcRoleSyncEnabled, +}) => { + if (isLoading) { + return ( + + ); + } + + if (!users || users.length === 0) { + return ( + + +
+ +
+
+
+ ); + } + + return ( + <> + {users?.map((user) => ( + + + + + + + + + + {showAISeatColumn && } + + +
{user.status}
+ {(user.status === "active" || user.status === "dormant") && ( + + )} +
+ + {canEditUsers && ( + + + + + + + + + + View workspaces + + + + {canViewActivity && ( + + + View activity {!canViewActivity && } + + + )} + + + Edit + + + onEditUserRoles(user)} + > + Edit roles + + + {user.status !== "suspended" && ( + onResetUserPassword(user)} + > + Reset password… + + )} + + {user.status === "active" || user.status === "dormant" ? ( + onSuspendUser(user)} + > + Suspend… + + ) : ( + onActivateUser(user)}> + Activate… + + )} + + + + onDeleteUser(user)} + disabled={user.id === me} + > + + Delete… + + + + + )} +
+ ))} + + ); +}; + +type UsersTableSkeletonProps = { + showAISeatColumn?: boolean; + canEditUsers: boolean; +}; + +const UsersTableSkeleton: React.FC = ({ + showAISeatColumn, + canEditUsers, +}) => { + return ( + + + + + + + + + + + + + + + {showAISeatColumn && ( + + + + )} + + + + + + {canEditUsers && ( + + + + )} + + + ); +}; diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx deleted file mode 100644 index bc7e366a56..0000000000 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { FC } from "react"; -import type { GroupsByUserId } from "#/api/queries/groups"; -import type * as TypesGen from "#/api/typesGenerated"; -import { - Table, - TableBody, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; -import { TableColumnHelpPopover } from "../../OrganizationSettingsPage/UserTable/TableColumnHelpPopover"; -import { UsersTableBody } from "./UsersTableBody"; - -interface UsersTableProps { - users: readonly TypesGen.User[] | undefined; - roles: TypesGen.AssignableRoles[] | undefined; - groupsByUserId: GroupsByUserId | undefined; - isUpdatingUserRoles?: boolean; - canEditUsers: boolean; - canViewActivity?: boolean; - showAISeatColumn?: boolean; - isLoading: boolean; - onSuspendUser: (user: TypesGen.User) => void; - onActivateUser: (user: TypesGen.User) => void; - onDeleteUser: (user: TypesGen.User) => void; - onListWorkspaces: (user: TypesGen.User) => void; - onViewActivity: (user: TypesGen.User) => void; - onResetUserPassword: (user: TypesGen.User) => void; - onUpdateUserRoles: ( - userId: string, - roles: TypesGen.SlimRole["name"][], - ) => void; - isNonInitialPage: boolean; - actorID: string; - oidcRoleSyncEnabled: boolean; - authMethods?: TypesGen.AuthMethods; -} - -export const UsersTable: FC = ({ - users, - roles, - onSuspendUser, - onDeleteUser, - onListWorkspaces, - onViewActivity, - onActivateUser, - onResetUserPassword, - onUpdateUserRoles, - isUpdatingUserRoles, - canEditUsers, - canViewActivity, - showAISeatColumn, - isLoading, - isNonInitialPage, - actorID, - oidcRoleSyncEnabled, - authMethods, - groupsByUserId, -}) => { - return ( - - - - User - -
- Roles - -
-
- -
- Groups - -
-
- {showAISeatColumn && ( - -
- AI add-on - -
-
- )} - Login Type - Status - {canEditUsers && } -
-
- - - - -
- ); -}; diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx deleted file mode 100644 index 2f915c0e50..0000000000 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { - BanIcon, - EllipsisVerticalIcon, - KeyIcon, - ShieldIcon, - TrashIcon, - UserLockIcon, -} from "lucide-react"; -import type { FC } from "react"; -import { useNavigate } from "react-router"; -import type { GroupsByUserId } from "#/api/queries/groups"; -import type * as TypesGen from "#/api/typesGenerated"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton"; -import { PremiumBadge } from "#/components/Badges/Badges"; -import { Button } from "#/components/Button/Button"; -import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { EmptyState } from "#/components/EmptyState/EmptyState"; -import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; -import { LastSeen } from "#/components/LastSeen/LastSeen"; -import { Skeleton } from "#/components/Skeleton/Skeleton"; -import { TableCell, TableRow } from "#/components/Table/Table"; -import { - TableLoaderSkeleton, - TableRowSkeleton, -} from "#/components/TableLoader/TableLoader"; -import { AISeatCell } from "#/modules/users/AISeatCell"; -import { cn } from "#/utils/cn"; -import { UserRoleCell } from "../../OrganizationSettingsPage/UserTable/UserRoleCell"; -import { UserGroupsCell } from "./UserGroupsCell"; - -dayjs.extend(relativeTime); - -interface UsersTableBodyProps { - users: readonly TypesGen.User[] | undefined; - groupsByUserId: GroupsByUserId | undefined; - authMethods?: TypesGen.AuthMethods; - roles?: TypesGen.AssignableRoles[]; - isUpdatingUserRoles?: boolean; - canEditUsers: boolean; - isLoading: boolean; - canViewActivity?: boolean; - showAISeatColumn?: boolean; - onSuspendUser: (user: TypesGen.User) => void; - onDeleteUser: (user: TypesGen.User) => void; - onListWorkspaces: (user: TypesGen.User) => void; - onViewActivity: (user: TypesGen.User) => void; - onActivateUser: (user: TypesGen.User) => void; - onResetUserPassword: (user: TypesGen.User) => void; - onUpdateUserRoles: ( - userId: string, - roles: TypesGen.SlimRole["name"][], - ) => void; - isNonInitialPage: boolean; - actorID: string; - // oidcRoleSyncEnabled should be set to false if unknown. - // This is used to determine if the oidc roles are synced from the oidc idp and - // editing via the UI should be disabled. - oidcRoleSyncEnabled: boolean; -} - -export const UsersTableBody: FC = ({ - users, - authMethods, - roles, - onSuspendUser, - onDeleteUser, - onListWorkspaces, - onViewActivity, - onActivateUser, - onResetUserPassword, - onUpdateUserRoles, - isUpdatingUserRoles, - canEditUsers, - canViewActivity, - showAISeatColumn, - isLoading, - isNonInitialPage, - actorID, - oidcRoleSyncEnabled, - groupsByUserId, -}) => { - const navigate = useNavigate(); - - return ( - - - - - - - - - - - - - - - - - {showAISeatColumn && ( - - - - )} - - - - - - - - - - {canEditUsers && ( - - - - )} - - - - - - - - - -
- -
-
-
-
- - - - -
- -
-
-
-
-
-
- - - {users?.map((user) => ( - - - - - - onUpdateUserRoles(user.id, roles)} - /> - - - - {showAISeatColumn && } - - - - - - -
{user.status}
- {(user.status === "active" || user.status === "dormant") && ( - - )} -
- - {canEditUsers && ( - - - - - - - {user.status === "active" || user.status === "dormant" ? ( - onSuspendUser(user)} - > - Suspend… - - ) : ( - onActivateUser(user)}> - Activate… - - )} - - onListWorkspaces(user)}> - View workspaces - - - {canViewActivity && ( - onViewActivity(user)} - disabled={!canViewActivity} - > - View activity {!canViewActivity && } - - )} - - navigate(user.username)}> - Edit - - - {user.login_type === "password" && ( - onResetUserPassword(user)} - disabled={user.login_type !== "password"} - > - Reset password… - - )} - - - - onDeleteUser(user)} - disabled={user.id === actorID} - > - - Delete… - - - - - )} -
- ))} -
-
- ); -}; - -interface LoginTypeProps { - authMethods: TypesGen.AuthMethods; - value: TypesGen.LoginType; -} - -const LoginType: FC = ({ authMethods, value }) => { - let displayName: string = value; - let icon = <>; - - if (value === "password") { - displayName = "Password"; - icon = ; - } else if (value === "none") { - displayName = "None"; - icon = ; - } else if (value === "github") { - displayName = "GitHub"; - icon = ; - } else if (value === "token") { - displayName = "Token"; - icon = ; - } else if (value === "oidc") { - displayName = - authMethods.oidc.signInText === "" ? "OIDC" : authMethods.oidc.signInText; - icon = - authMethods.oidc.iconUrl === "" ? ( - - ) : ( - Open ID Connect icon - ); - } - - return ( -
- {icon} - {displayName} -
- ); -}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 9e95bcb590..7a2e5cb132 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -511,11 +511,6 @@ export const MockSiteRoles = [ MockAuditorRole, MockWorkspaceCreationBanRole, ]; -export const MockAssignableSiteRoles = [ - assignableRole(MockUserAdminRole, true), - assignableRole(MockAuditorRole, true), - assignableRole(MockWorkspaceCreationBanRole, true), -]; export const MockUserOwner: TypesGen.User = { id: "test-user",