diff --git a/site/src/components/CollapsibleSummary/CollapsibleSummary.tsx b/site/src/components/CollapsibleSummary/CollapsibleSummary.tsx index 37631efd60..9f73edea06 100644 --- a/site/src/components/CollapsibleSummary/CollapsibleSummary.tsx +++ b/site/src/components/CollapsibleSummary/CollapsibleSummary.tsx @@ -1,6 +1,6 @@ import { cva, type VariantProps } from "class-variance-authority"; import { ChevronRightIcon } from "lucide-react"; -import { type FC, type ReactNode, useState } from "react"; +import { type FC, type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "#/utils/cn"; const collapsibleSummaryVariants = cva( @@ -42,6 +42,10 @@ interface CollapsibleSummaryProps * The size of the component */ size?: "md" | "sm"; + /** + * Will scroll the children into view whenever the component is opened + */ + scrollIntoViewOnOpen?: boolean; } export const CollapsibleSummary: FC = ({ @@ -50,9 +54,20 @@ export const CollapsibleSummary: FC = ({ defaultOpen = false, className, size, + scrollIntoViewOnOpen, }) => { const [isOpen, setIsOpen] = useState(defaultOpen); + const lastState = useRef(defaultOpen); + const ref = useRef(null); + + useEffect(() => { + if (lastState.current !== isOpen && isOpen && scrollIntoViewOnOpen) { + ref.current?.scrollIntoView({ behavior: "smooth" }); + } + lastState.current = isOpen; + }, [isOpen, scrollIntoViewOnOpen]); + return (
- {isOpen &&
{children}
} + {isOpen && ( +
+ {children} +
+ )}
); }; diff --git a/site/src/components/Icons/EditSquare.tsx b/site/src/components/Icons/EditSquare.tsx deleted file mode 100644 index 86140b6bc0..0000000000 --- a/site/src/components/Icons/EditSquare.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { ComponentProps, JSX } from "react"; - -export const EditSquare = (props: ComponentProps<"svg">): JSX.Element => ( - - - -); diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index 36f6604f72..af2af2bb4d 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -91,7 +91,7 @@ const OrganizationSettingsLayout: FC = () => { organizationPermissions, }} > -
+
@@ -122,11 +122,9 @@ const OrganizationSettingsLayout: FC = () => {
-
- }> - - -
+ }> + +
); diff --git a/site/src/modules/management/OrganizationSidebar.tsx b/site/src/modules/management/OrganizationSidebar.tsx index 3c20b9e9f7..080aff02ed 100644 --- a/site/src/modules/management/OrganizationSidebar.tsx +++ b/site/src/modules/management/OrganizationSidebar.tsx @@ -13,7 +13,7 @@ export const OrganizationSidebar: FC = () => { useOrganizationSettings(); return ( - + { return ( -
- -
- }> - - +
+
+ +
+ }> + + +
-
+ ); }; diff --git a/site/src/modules/roles/RoleSelector.stories.tsx b/site/src/modules/roles/RoleSelector.stories.tsx index 7eac7c868e..4e3a26e2f9 100644 --- a/site/src/modules/roles/RoleSelector.stories.tsx +++ b/site/src/modules/roles/RoleSelector.stories.tsx @@ -2,10 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; import { assignableRole, + MockAgentsAccessRole, MockAuditorRole, + MockOrganizationAdminRole, + MockOrganizationAuditorRole, + MockOrganizationTemplateAdminRole, + MockOrganizationUserAdminRole, MockOwnerRole, MockTemplateAdminRole, MockUserAdminRole, + MockWorkspaceCreationBanRole, mockApiError, } from "#/testHelpers/entities"; import { RoleSelector } from "./RoleSelector"; @@ -68,3 +74,18 @@ export const WithError: Story = { error: mockApiError({ message: "Failed to fetch assignable roles." }), }, }; + +const orgMemberRoles = [ + assignableRole(MockOrganizationAdminRole, true), + assignableRole(MockOrganizationUserAdminRole, true), + assignableRole(MockOrganizationTemplateAdminRole, true), + assignableRole(MockOrganizationAuditorRole, true), + assignableRole(MockAgentsAccessRole, true), + assignableRole(MockWorkspaceCreationBanRole, true), +]; + +export const OrganizationMemberRoles: Story = { + args: { + availableRoles: orgMemberRoles, + }, +}; diff --git a/site/src/modules/roles/RoleSelector.tsx b/site/src/modules/roles/RoleSelector.tsx index 99eac5bdcf..93e1e2510b 100644 --- a/site/src/modules/roles/RoleSelector.tsx +++ b/site/src/modules/roles/RoleSelector.tsx @@ -4,10 +4,13 @@ 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 { CollapsibleSummary } from "#/components/CollapsibleSummary/CollapsibleSummary"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { cn } from "#/utils/cn"; import { roleDescriptions } from "./index"; +const advancedRoleNames = ["organization-workspace-creation-ban"]; + type RoleSelectorProps = { hideLabel?: boolean; loading?: boolean; @@ -25,9 +28,6 @@ export const RoleSelector: FC = ({ selectedRoles, onChange, }) => { - const baseId = useId(); - const selectableRoles = availableRoles.filter((r) => r.name !== "member"); - if (loading) { return ( @@ -49,6 +49,12 @@ export const RoleSelector: FC = ({ ); } + const { selectableRoles = [], advancedRoles = [] } = Object.groupBy( + availableRoles.filter((r) => r.name !== "member"), + (it) => + advancedRoleNames.includes(it.name) ? "advancedRoles" : "selectableRoles", + ); + if (selectableRoles.length === 0) { return null; } @@ -66,39 +72,12 @@ export const RoleSelector: FC = ({ return ( {selectableRoles.length > 0 && ( -
- {selectableRoles.map((role) => { - const checkboxId = `${baseId}-${role.name}`; - return ( - - ); - })} -
+ )} @@ -106,6 +85,86 @@ export const RoleSelector: FC = ({ ); }; +type RoleSelectorListProps = { + selectableRoles: AssignableRoles[]; + advancedRoles: AssignableRoles[]; + selectedRoles: Set; + handleToggle: (roleName: string) => void; +}; + +const RoleSelectorList: React.FC = ({ + selectableRoles, + advancedRoles, + selectedRoles, + handleToggle, +}) => { + return ( +
+ {selectableRoles.map((role) => ( + handleToggle(role.name)} + /> + ))} + {advancedRoles.length > 0 && ( + + {advancedRoles.map((role) => ( + handleToggle(role.name)} + /> + ))} + + )} +
+ ); +}; + +type RoleCheckboxProps = { + role: AssignableRoles; + selected: boolean; + onToggle: () => void; +}; + +const RoleCheckbox: React.FC = ({ + role, + selected, + onToggle, +}) => { + const checkboxId = useId(); + + return ( + + ); +}; + type RoleSelectorLayoutProps = { hideLabel?: boolean; children: React.ReactNode; diff --git a/site/src/modules/roles/index.ts b/site/src/modules/roles/index.ts index 949b488fdf..222ecee800 100644 --- a/site/src/modules/roles/index.ts +++ b/site/src/modules/roles/index.ts @@ -11,6 +11,16 @@ export const roleDescriptions: Record = { "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.", + "organization-admin": + "Organization admin can manage all resources within this organization.", + "organization-user-admin": + "Organization user admin can manage members and groups within this organization.", + "organization-template-admin": + "Organization template admin can manage templates and workspaces within this organization.", + "organization-auditor": + "Organization auditor can access audit logs for this organization.", + "organization-workspace-creation-ban": + "Prevents this user from creating new workspaces in this organization.", member: "Everybody is a member. This is a shared and default role for all users.", }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx index 91ee12d7a8..4e42c5f503 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, within } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import type { SlimRole } from "#/api/typesGenerated"; @@ -7,7 +7,7 @@ import { MockOrganization, MockOrganizationAuditorRole, MockOrganizationPermissions, - MockUserOwner, + MockUserMember, } from "#/testHelpers/entities"; import { renderWithOrganizationSettingsLayout, @@ -57,7 +57,7 @@ const removeMember = async () => { }); await user.click(menuButton); - const removeOption = await screen.findByRole("menuitem", { name: "Remove" }); + const removeOption = await screen.findByRole("menuitem", { name: "Remove…" }); await user.click(removeOption); const dialog = await within(document.body).findByRole("dialog"); @@ -65,21 +65,26 @@ const removeMember = async () => { }; const updateUserRole = async (role: SlimRole) => { - // Get the first user in the table + const user = userEvent.setup(); + + // Get the second user in the table (the first user is "me" and has + // no action menu). const users = await screen.findAllByText(/.*@coder.com/); - const userRow = users[0].closest("tr"); + const userRow = users[1].closest("tr"); if (!userRow) { throw new Error("Error on get the first user row"); } - // Click on the "edit icon" to display the role options - const editButton = within(userRow).getByLabelText("Edit user roles"); - fireEvent.click(editButton); + // Open the Edit roles dialog + const editButton = within(userRow).getByLabelText("Open menu"); + await user.click(editButton); + await user.click(await screen.findByText("Edit roles")); // Click on the role option - const fieldset = await screen.findByTitle("Available roles"); - const roleOption = within(fieldset).getByText(role.display_name); - fireEvent.click(roleOption); + const dialog = await screen.findByRole("dialog"); + const roleOption = within(dialog).getByText(role.display_name); + await user.click(roleOption); + await user.click(await screen.findByText("Confirm")); return { userRow, @@ -93,7 +98,7 @@ describe("OrganizationMembersPage", () => { await renderPage(); await removeMember(); await screen.findByText( - /User "TestUser2" removed from organization "My Organization" successfully\./, + /"TestUser2" has been removed from "My Organization"\./, ); }); }); @@ -104,11 +109,11 @@ describe("OrganizationMembersPage", () => { it("updates the roles", async () => { server.use( http.put( - `/api/v2/organizations/:organizationId/members/${MockUserOwner.id}/roles`, + `/api/v2/organizations/:organizationId/members/${MockUserMember.id}/roles`, async () => { return HttpResponse.json({ - ...MockUserOwner, - roles: [...MockUserOwner.roles, MockOrganizationAuditorRole], + ...MockUserMember, + roles: [...MockUserMember.roles, MockOrganizationAuditorRole], }); }, ), @@ -116,7 +121,7 @@ describe("OrganizationMembersPage", () => { await renderPage(); await updateUserRole(MockOrganizationAuditorRole); - await screen.findByText(/Roles of "TestUser" updated successfully\./); + await screen.findByText(/TestUser2's roles have been updated\./); }); }); @@ -124,7 +129,7 @@ describe("OrganizationMembersPage", () => { it("shows an error message", async () => { server.use( http.put( - `/api/v2/organizations/:organizationId/members/${MockUserOwner.id}/roles`, + `/api/v2/organizations/:organizationId/members/${MockUserMember.id}/roles`, () => { return HttpResponse.json( { message: "Error on updating the user roles." }, diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 688ba70956..d887044b0b 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -2,7 +2,7 @@ import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; -import { getErrorMessage } from "#/api/errors"; +import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { groupsByUserIdInOrganization } from "#/api/queries/groups"; import { addOrganizationMember, @@ -24,6 +24,7 @@ import { shouldShowAISeatColumn } from "#/modules/dashboard/entitlements"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import { RoleSelectorDialog } from "#/modules/roles/RoleSelectorDialog"; import { pageTitle } from "#/utils/page"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; @@ -62,15 +63,18 @@ const OrganizationMembersPage: FC = () => { const addMemberMutation = useMutation( addOrganizationMember(queryClient, organizationName), ); - const removeMemberMutation = useMutation( - removeOrganizationMember(queryClient, organizationName), - ); + + const [memberToEditRoles, setMemberToEditRoles] = + useState(); const updateMemberRolesMutation = useMutation( updateOrganizationMemberRoles(queryClient, organizationName), ); - const [memberToDelete, setMemberToDelete] = + const [memberToRemove, setMemberToRemove] = useState(); + const removeMemberMutation = useMutation( + removeOrganizationMember(queryClient, organizationName), + ); if (!organization) { return ; @@ -95,10 +99,6 @@ const OrganizationMembersPage: FC = () => { <> {title} { removeMemberMutation.error ?? updateMemberRolesMutation.error } - isUpdatingMemberRoles={updateMemberRolesMutation.isPending} - showAISeatColumn={showAISeatColumn} - me={me} - members={members} + filterProps={{ filter: filterProps }} + organizationName={organizationName} membersQuery={membersQuery} + members={members} + showAISeatColumn={showAISeatColumn} addMembers={async (users: User[]) => { // TODO: Replace with a batch endpoint (POST /organizations/{org}/members) // to add all users in a single request instead of N individual calls. @@ -121,28 +121,49 @@ const OrganizationMembersPage: FC = () => { ); void membersQuery.refetch(); }} - removeMember={setMemberToDelete} - updateMemberRoles={async ( - member: OrganizationMemberWithUserData, - newRoles: string[], - ) => { - await updateMemberRolesMutation.mutateAsync({ - userId: member.user_id, - roles: newRoles, - }); + onEditMemberRoles={setMemberToEditRoles} + isUpdatingMemberRoles={updateMemberRolesMutation.isPending} + removeMember={setMemberToRemove} + me={me.id} + canEditMembers={organizationPermissions.editMembers} + canViewMembers={organizationPermissions.viewMembers} + canViewActivity={entitlements.features.audit_log.enabled} + /> + + setMemberToEditRoles(undefined)} + onUpdateRoles={async (roles) => { + try { + await updateMemberRolesMutation.mutateAsync({ + userId: memberToEditRoles!.user_id, + roles, + }); + toast.success( + `${memberToEditRoles!.username}'s roles have been updated.`, + ); + setMemberToEditRoles(undefined); + } catch (e) { + toast.error(getErrorMessage(e, "Error updating member roles."), { + description: getErrorDetail(e), + }); + } }} + isUpdatingRoles={updateMemberRolesMutation.isPending} /> setMemberToDelete(undefined)} + open={memberToRemove !== undefined} + onClose={() => setMemberToRemove(undefined)} title="Remove member" confirmText="Remove" onConfirm={() => { - if (memberToDelete) { + if (memberToRemove) { const mutation = removeMemberMutation.mutateAsync( - memberToDelete.user_id, + memberToRemove.user_id, { onSuccess: () => { membersQuery.refetch(); @@ -150,15 +171,15 @@ const OrganizationMembersPage: FC = () => { }, ); toast.promise(mutation, { - loading: `Removing member "${memberToDelete.username}" from organization "${organization.display_name}"...`, - success: `User "${memberToDelete.username}" removed from organization "${organization.display_name}" successfully.`, + loading: `Removing "${memberToRemove.username}" from "${organization.display_name}"...`, + success: `"${memberToRemove.username}" has been removed from "${organization.display_name}".`, error: (error) => getErrorMessage( error, - `Failed to remove user "${memberToDelete.username}" from organization "${organization.display_name}".`, + `Failed to remove "${memberToRemove.username}" from "${organization.display_name}".`, ), }); - setMemberToDelete(undefined); + setMemberToRemove(undefined); } }} description={ diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx index 2c9e85b496..b4dd455ba4 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; import { mockSuccessResult } from "#/components/PaginationWidget/PaginationContainer.mocks"; import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; import { @@ -15,7 +14,6 @@ const meta: Meta = { title: "pages/OrganizationMembersPageView", component: OrganizationMembersPageView, args: { - canEditMembers: true, error: undefined, filterProps: { filter: { @@ -27,9 +25,11 @@ const meta: Meta = { used: false, }, }, - isUpdatingMemberRoles: false, - canViewMembers: true, - me: MockUserOwner, + organizationName: "friends", + membersQuery: { + ...mockSuccessResult, + totalRecords: 2, + } as UsePaginatedQueryResult, members: [ { ...MockOrganizationMember, @@ -38,13 +38,14 @@ const meta: Meta = { }, { ...MockOrganizationMember2, groups: [] }, ], - membersQuery: { - ...mockSuccessResult, - totalRecords: 2, - } as UsePaginatedQueryResult, addMembers: () => Promise.resolve(), + onEditMemberRoles: () => Promise.resolve(), + isUpdatingMemberRoles: false, removeMember: () => Promise.resolve(), - updateMemberRoles: () => Promise.resolve(), + me: MockUserOwner.id, + canEditMembers: true, + canViewMembers: true, + canViewActivity: false, }, }; @@ -57,28 +58,6 @@ export const WithAIAddonColumn: Story = { args: { showAISeatColumn: true, }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const header = await canvas.findByRole("columnheader", { - name: /AI add-on/i, - }); - - await expect(header).toBeVisible(); - }, -}; - -export const WithoutAIAddonColumn: Story = { - args: { - showAISeatColumn: false, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await canvas.findByRole("columnheader", { name: "User" }); - - await expect( - canvas.queryByRole("columnheader", { name: /AI add-on/i }), - ).not.toBeInTheDocument(); - }, }; export const NoMembers: Story = { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 46d1ed1756..3a6b2e1654 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -1,20 +1,9 @@ -import { - EllipsisVerticalIcon, - TriangleAlertIcon, - UserPlusIcon, -} from "lucide-react"; -import { type FC, useState } from "react"; +import { TriangleAlertIcon, UserPlusIcon } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import type { - Group, - OrganizationMemberWithUserData, - SlimRole, - User, -} from "#/api/typesGenerated"; +import type { User } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Avatar } from "#/components/Avatar/Avatar"; -import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; import { Dialog, @@ -22,15 +11,8 @@ import { DialogFooter, DialogTitle, } from "#/components/Dialog/Dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; import type { useFilter } from "#/components/Filter/Filter"; import { UsersFilter } from "#/components/Filter/UsersFilter"; -import { Loader } from "#/components/Loader/Loader"; import { MultiUserSelect } from "#/components/MultiUserSelect/MultiUserSelect"; import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer"; import { @@ -38,62 +20,34 @@ import { SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; -import { AISeatCell } from "#/modules/users/AISeatCell"; -import { UserGroupsCell } from "#/modules/users/UserGroupsCell"; -import { TableColumnHelpPopover } from "./UserTable/TableColumnHelpPopover"; -import { UserRoleCell } from "./UserTable/UserRoleCell"; +import { + OrganizationMembersTable, + type OrganizationMembersTableProps, +} from "./OrganizationMembersTable"; -interface OrganizationMembersPageViewProps { - allAvailableRoles: readonly SlimRole[] | undefined; - canEditMembers: boolean; - canViewMembers: boolean; +type OrganizationMembersPageViewProps = OrganizationMembersTableProps & { error: unknown; filterProps: { filter: ReturnType }; - isUpdatingMemberRoles: boolean; - showAISeatColumn?: boolean; - me: User; - members: Array | undefined; membersQuery: PaginationResultInfo & { isPlaceholderData: boolean; }; addMembers: (users: User[]) => Promise; - removeMember: (member: OrganizationMemberWithUserData) => void; - updateMemberRoles: ( - member: OrganizationMemberWithUserData, - newRoles: string[], - ) => Promise; -} + canViewMembers?: boolean; +}; -interface OrganizationMemberTableEntry extends OrganizationMemberWithUserData { - groups: readonly Group[] | undefined; -} - -export const OrganizationMembersPageView: FC< +export const OrganizationMembersPageView: React.FC< OrganizationMembersPageViewProps > = ({ - allAvailableRoles, - canEditMembers, - canViewMembers, error, filterProps, - isUpdatingMemberRoles, - showAISeatColumn, - me, membersQuery, - members, + canViewMembers, addMembers, - removeMember, - updateMemberRoles, + ...props }) => { + const { canEditMembers } = props; + return (
@@ -116,109 +70,7 @@ export const OrganizationMembersPageView: FC<
)} - - - - User - -
- Roles - -
-
- -
- Groups - -
-
- {showAISeatColumn && ( - -
- AI add-on - -
-
- )} - -
-
- - {members ? ( - members.map((member) => ( - - - - } - title={member.name || member.username} - subtitle={member.email} - /> - - { - // React doesn't mind uncaught errors in event handlers, - // but testing-library does. - try { - await updateMemberRoles(member, roles); - toast.success( - `Roles of "${member.username}" updated successfully.`, - ); - } catch {} - }} - /> - - {showAISeatColumn && ( - - )} - -
- {member.user_id !== me.id && canEditMembers && ( - - - - - - removeMember(member)} - > - Remove - - - - )} -
-
-
- )) - ) : ( - - - - - - )} -
-
+
@@ -229,7 +81,7 @@ interface AddUsersDialogProps { onSubmit: (users: User[]) => Promise; } -const AddUsersDialog: FC = ({ onSubmit }) => { +const AddUsersDialog: React.FC = ({ onSubmit }) => { const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [filter, setFilter] = useState(""); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersTable.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersTable.tsx new file mode 100644 index 0000000000..b9141c532d --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersTable.tsx @@ -0,0 +1,203 @@ +import { EllipsisVerticalIcon } from "lucide-react"; +import { Link } from "react-router"; +import type { + Group, + OrganizationMemberWithUserData, +} from "#/api/typesGenerated"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { PremiumBadge } from "#/components/Badges/Badges"; +import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { Loader } from "#/components/Loader/Loader"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +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"; + +export type OrganizationMembersTableProps = { + // State + organizationName: string; + members: Array | undefined; + showAISeatColumn?: boolean; + + // Actions + onEditMemberRoles: (member: OrganizationMemberWithUserData) => void; + isUpdatingMemberRoles: boolean; + removeMember: (member: OrganizationMemberWithUserData) => void; + + // Permissions + /** + * Used to disable the UI of actions that users cannot perform on themselves, + * like delete. + */ + me: string; + canEditMembers: boolean; + canViewActivity: boolean; +}; + +type OrganizationMemberTableEntry = OrganizationMemberWithUserData & { + groups: readonly Group[] | undefined; +}; + +export const OrganizationMembersTable: React.FC< + OrganizationMembersTableProps +> = (props) => { + const { showAISeatColumn } = props; + + return ( + + + + User + +
+ Roles + +
+
+ +
+ Groups + +
+
+ {showAISeatColumn && ( + +
+ AI add-on + +
+
+ )} +
+
+ + + +
+ ); +}; + +const OrganizationMembersTableBody: React.FC = ({ + organizationName, + members, + showAISeatColumn, + + isUpdatingMemberRoles, + removeMember, + onEditMemberRoles, + + me, + canEditMembers, + canViewActivity, +}) => { + if (!members) { + return ( + + + + + + ); + } + + return ( + <> + {members.map((member) => ( + + + + } + title={member.name || member.username} + subtitle={member.email} + /> + + + + {showAISeatColumn && } + +
+ {member.user_id !== me && canEditMembers && ( + + + + + + + + View workspaces + + + + {canViewActivity && ( + + + View activity {!canViewActivity && } + + + )} + + onEditMemberRoles(member)} + > + Edit roles + + + + + removeMember(member)} + > + Remove… + + + + )} +
+
+
+ ))} + + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx deleted file mode 100644 index 8dc3bed218..0000000000 --- a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { userEvent, within } from "storybook/test"; -import { - MockAgentsAccessRole, - MockOrganizationAdminRole, - MockOrganizationAuditorRole, - MockOrganizationTemplateAdminRole, - MockOrganizationUserAdminRole, - MockOwnerRole, - MockSiteRoles, - MockUserAdminRole, - MockWorkspaceCreationBanRole, -} from "#/testHelpers/entities"; -import { withDesktopViewport } from "#/testHelpers/storybook"; -import { EditRolesButton } from "./EditRolesButton"; - -const meta: Meta = { - title: "pages/UsersPage/EditRolesButton", - component: EditRolesButton, - args: { - selectedRoleNames: new Set([MockUserAdminRole.name, MockOwnerRole.name]), - roles: MockSiteRoles, - }, - decorators: [withDesktopViewport], -}; - -export default meta; -type Story = StoryObj; - -export const Closed: Story = {}; - -export const Open: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button")); - }, -}; - -export const Loading: Story = { - args: { - isLoading: true, - userLoginType: "password", - oidcRoleSync: false, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button")); - }, -}; - -export const CannotSetRoles: Story = { - args: { - userLoginType: "oidc", - oidcRoleSync: true, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.hover(canvas.getByLabelText("More info")); - }, -}; - -export const AdvancedOpen: Story = { - args: { - selectedRoleNames: new Set([MockWorkspaceCreationBanRole.name]), - roles: MockSiteRoles, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button")); - }, -}; - -export const OrgRoles: Story = { - args: { - selectedRoleNames: new Set([MockAgentsAccessRole.name]), - roles: [ - MockOrganizationAdminRole, - MockOrganizationUserAdminRole, - MockOrganizationTemplateAdminRole, - MockOrganizationAuditorRole, - MockAgentsAccessRole, - ], - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button")); - }, -}; diff --git a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx deleted file mode 100644 index e2feafc3c5..0000000000 --- a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import Checkbox from "@mui/material/Checkbox"; -import { UserIcon } from "lucide-react"; -import { type FC, useEffect, useState } from "react"; -import type { SlimRole } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { CollapsibleSummary } from "#/components/CollapsibleSummary/CollapsibleSummary"; -import { - HelpPopover, - HelpPopoverContent, - HelpPopoverIconTrigger, - HelpPopoverText, - HelpPopoverTitle, -} from "#/components/HelpPopover/HelpPopover"; -import { EditSquare } from "#/components/Icons/EditSquare"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "#/components/Popover/Popover"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; - -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 OptionProps { - value: string; - name: string; - description: string; - isChecked: boolean; - onChange: (roleName: string) => void; -} - -const Option: FC = ({ - value, - name, - description, - isChecked, - onChange, -}) => { - return ( - - ); -}; - -interface EditRolesButtonProps { - isLoading: boolean; - roles: readonly SlimRole[]; - selectedRoleNames: Set; - onChange: (roles: SlimRole["name"][]) => void; - oidcRoleSync: boolean; - userLoginType?: string; -} - -export const EditRolesButton: FC = (props) => { - const { userLoginType, oidcRoleSync } = props; - const canSetRoles = - userLoginType !== "oidc" || (userLoginType === "oidc" && !oidcRoleSync); - - if (!canSetRoles) { - return ( - - - - Externally controlled - - Roles for this user are controlled by the OIDC identity provider. - - - - ); - } - - return ; -}; - -const EnabledEditRolesButton: FC = ({ - roles, - selectedRoleNames, - onChange, - isLoading, -}) => { - const handleChange = (roleName: string) => { - if (selectedRoleNames.has(roleName)) { - const serialized = [...selectedRoleNames]; - onChange(serialized.filter((role) => role !== roleName)); - return; - } - - onChange([...selectedRoleNames, roleName]); - }; - const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); - - const filteredRoles = roles.filter( - (role) => role.name !== "organization-workspace-creation-ban", - ); - const advancedRoles = roles.filter( - (role) => role.name === "organization-workspace-creation-ban", - ); - - // make sure the advanced roles are always visible if the user has one of these roles - useEffect(() => { - if (selectedRoleNames.has("organization-workspace-creation-ban")) { - setIsAdvancedOpen(true); - } - }, [selectedRoleNames]); - - return ( - - - - - - - - Edit user roles - - - -
-
- {filteredRoles.map((role) => ( -
-
-
-
- -
- Member - - {roleDescriptions.member} - -
-
-
-
-
- ); -}; diff --git a/site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpPopover.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpPopover.tsx deleted file mode 100644 index 120cb01bae..0000000000 --- a/site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpPopover.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { FC } from "react"; -import { - HelpPopover, - HelpPopoverContent, - HelpPopoverIconTrigger, - HelpPopoverLink, - HelpPopoverLinksGroup, - HelpPopoverText, - HelpPopoverTitle, -} from "#/components/HelpPopover/HelpPopover"; -import { docs } from "#/utils/docs"; - -type ColumnHeader = "roles" | "groups" | "ai_addon"; - -type TooltipData = { - title: string; - text: string; - links: readonly { text: string; href: string }[]; -}; - -const tooltipData: Record = { - 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/groups-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/users/groups-roles") }], - }, - ai_addon: { - title: "What is the AI add-on?", - text: - "Users with access to AI features like AI Bridge or Tasks " + - "who are actively consuming a seat.", - links: [], - }, -}; - -type Props = { - variant: ColumnHeader; -}; - -export const TableColumnHelpPopover: FC = ({ variant }) => { - const data = tooltipData[variant]; - - return ( - - - - {data.title} - {data.text} - {data.links.length > 0 && ( - - {data.links.map((link) => ( - - {link.text} - - ))} - - )} - - - ); -}; diff --git a/site/src/pages/OrganizationSettingsPage/UserTable/UserRoleCell.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/UserRoleCell.tsx deleted file mode 100644 index 453be6ec49..0000000000 --- a/site/src/pages/OrganizationSettingsPage/UserTable/UserRoleCell.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * @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 { type Interpolation, type Theme, useTheme } from "@emotion/react"; -import type { FC } from "react"; -import type { LoginType, SlimRole } from "#/api/typesGenerated"; -import { Pill } from "#/components/Pill/Pill"; -import { TableCell } from "#/components/Table/Table"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { EditRolesButton } from "./EditRolesButton"; - -type UserRoleCellProps = { - isLoading: boolean; - canEditUsers: boolean; - allAvailableRoles: readonly SlimRole[] | undefined; - userLoginType?: LoginType; - inheritedRoles?: readonly SlimRole[]; - roles: readonly SlimRole[]; - oidcRoleSyncEnabled: boolean; - onEditRoles: (newRoleNames: string[]) => void; -}; - -export const UserRoleCell: FC = ({ - isLoading, - canEditUsers, - allAvailableRoles, - userLoginType, - inheritedRoles, - roles, - oidcRoleSyncEnabled, - onEditRoles, -}) => { - const mergedRoles = getTieredRoles(inheritedRoles ?? [], roles); - const [mainDisplayRole = fallbackRole, ...extraRoles] = - sortRolesByAccessLevel(mergedRoles ?? []); - const hasOwnerRole = - mainDisplayRole.name === "owner" || - mainDisplayRole.name === "organization-admin"; - - const displayName = mainDisplayRole.display_name || mainDisplayRole.name; - - return ( - -
- {canEditUsers && ( - { - // Remove the fallback role because it is only for the UI - const rolesWithoutFallback = roles.filter( - (role) => role !== fallbackRole.name, - ); - - onEditRoles(rolesWithoutFallback); - }} - /> - )} - - - {mainDisplayRole.global ? ( - - - {displayName}* - - - This user has this role for all organizations. - - - ) : ( - displayName - )} - - - {extraRoles.length > 0 && } -
-
- ); -}; - -type OverflowRolePillProps = { - roles: readonly TieredSlimRole[]; -}; - -const OverflowRolePill: FC = ({ roles }) => { - const theme = useTheme(); - - return ( - - - - - +{roles.length} more - - - - - {roles.map((role) => ( - - {role.global ? ( - - {role.display_name || role.name}* - - ) : ( - role.display_name || role.name - )} - - ))} - - - - ); -}; - -const styles = { - globalRoleBadge: (theme) => ({ - backgroundColor: theme.roles.active.background, - borderColor: theme.roles.active.outline, - }), - ownerRoleBadge: (theme) => ({ - backgroundColor: theme.roles.notice.background, - borderColor: theme.roles.notice.outline, - }), - roleBadge: (theme) => ({ - backgroundColor: theme.experimental.l2.background, - borderColor: theme.experimental.l2.outline, - }), -} satisfies Record>; - -const fallbackRole: TieredSlimRole = { - name: "member", - display_name: "Member", -} as const; - -const roleNamesByAccessLevel: readonly string[] = [ - "owner", - "organization-admin", - "user-admin", - "organization-user-admin", - "template-admin", - "organization-template-admin", - "auditor", - "organization-auditor", - "agents-access", -]; - -// Roles not in the priority list should sort after all known roles. -const roleSortComparator = (name: string) => - roleNamesByAccessLevel.includes(name) - ? roleNamesByAccessLevel.indexOf(name) - : Number.POSITIVE_INFINITY; - -function sortRolesByAccessLevel( - roles: readonly T[], -): readonly T[] { - if (roles.length === 0) { - return roles; - } - - return [...roles].sort( - (r1, r2) => roleSortComparator(r1.name) - roleSortComparator(r2.name), - ); -} - -function getSelectedRoleNames(roles: readonly SlimRole[]) { - const roleNameSet = new Set(roles.map((role) => role.name)); - if (roleNameSet.size === 0) { - roleNameSet.add(fallbackRole.name); - } - - return roleNameSet; -} - -interface TieredSlimRole extends SlimRole { - global?: boolean; -} - -function getTieredRoles( - globalRoles: readonly SlimRole[], - localRoles: readonly SlimRole[], -) { - const roles = new Map(); - - for (const role of globalRoles) { - roles.set(role.name, { - ...role, - global: true, - }); - } - for (const role of localRoles) { - if (roles.has(role.name)) { - continue; - } - roles.set(role.name, role); - } - - return [...roles.values()]; -}