mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: update OrganizationMembersPage role editing to match new designs (#24858)
This commit is contained in:
@@ -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<CollapsibleSummaryProps> = ({
|
||||
@@ -50,9 +54,20 @@ export const CollapsibleSummary: FC<CollapsibleSummaryProps> = ({
|
||||
defaultOpen = false,
|
||||
className,
|
||||
size,
|
||||
scrollIntoViewOnOpen,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const lastState = useRef<boolean>(defaultOpen);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastState.current !== isOpen && isOpen && scrollIntoViewOnOpen) {
|
||||
ref.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
lastState.current = isOpen;
|
||||
}, [isOpen, scrollIntoViewOnOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
@@ -85,7 +100,11 @@ export const CollapsibleSummary: FC<CollapsibleSummaryProps> = ({
|
||||
<span className="[&:first-letter]:uppercase">{label}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && <div className="flex flex-col gap-4">{children}</div>}
|
||||
{isOpen && (
|
||||
<div className="flex flex-col gap-4" ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ComponentProps, JSX } from "react";
|
||||
|
||||
export const EditSquare = (props: ComponentProps<"svg">): JSX.Element => (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
viewBox="0 0 48 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9 47.4q-1.2 0-2.1-.9-.9-.9-.9-2.1v-30q0-1.2.9-2.1.9-.9 2.1-.9h20.25l-3 3H9v30h30V27l3-3v20.4q0 1.2-.9 2.1-.9.9-2.1.9Zm15-18Zm9.1-17.6 2.15 2.1L21 28.1v4.3h4.25l14.3-14.3 2.1 2.1L26.5 35.4H18v-8.5Zm8.55 8.4-8.55-8.4 5-5q.85-.85 2.125-.85t2.125.9l4.2 4.25q.85.9.85 2.125t-.9 2.075Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -91,7 +91,7 @@ const OrganizationSettingsLayout: FC = () => {
|
||||
organizationPermissions,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
@@ -122,11 +122,9 @@ const OrganizationSettingsLayout: FC = () => {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="h-px border-none bg-border" />
|
||||
<div className="flex flex-col flex-1 min-h-0 pl-10">
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</OrganizationSettingsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export const OrganizationSidebar: FC = () => {
|
||||
useOrganizationSettings();
|
||||
|
||||
return (
|
||||
<BaseSidebar className="pt-10">
|
||||
<BaseSidebar>
|
||||
<OrganizationSidebarView
|
||||
activeOrganization={organization}
|
||||
orgPermissions={organizationPermissions}
|
||||
|
||||
@@ -5,14 +5,16 @@ import { OrganizationSidebar } from "./OrganizationSidebar";
|
||||
|
||||
const OrganizationSidebarLayout: FC = () => {
|
||||
return (
|
||||
<div className="flex flex-row flex-1 min-h-0 w-full">
|
||||
<OrganizationSidebar />
|
||||
<div className="flex flex-col items-center flex-1 min-h-0 h-full overflow-y-auto w-full px-10 pt-10">
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<section className="px-10 max-w-screen-2xl mx-auto">
|
||||
<div className="flex flex-row gap-28 py-10">
|
||||
<OrganizationSidebar />
|
||||
<div className="grow">
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<RoleSelectorProps> = ({
|
||||
selectedRoles,
|
||||
onChange,
|
||||
}) => {
|
||||
const baseId = useId();
|
||||
const selectableRoles = availableRoles.filter((r) => r.name !== "member");
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<RoleSelectorLayout>
|
||||
@@ -49,6 +49,12 @@ export const RoleSelector: FC<RoleSelectorProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
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<RoleSelectorProps> = ({
|
||||
return (
|
||||
<RoleSelectorLayout hideLabel={hideLabel}>
|
||||
{selectableRoles.length > 0 && (
|
||||
<div className="border border-border border-solid rounded-md overflow-y-auto max-h-72 p-3 flex flex-col gap-2">
|
||||
{selectableRoles.map((role) => {
|
||||
const checkboxId = `${baseId}-${role.name}`;
|
||||
return (
|
||||
<label
|
||||
key={role.name}
|
||||
htmlFor={checkboxId}
|
||||
className={cn(
|
||||
"flex items-start gap-2",
|
||||
role.assignable
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={selectedRoles.has(role.name)}
|
||||
onCheckedChange={() => handleToggle(role.name)}
|
||||
disabled={!role.assignable}
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{role.display_name || role.name}
|
||||
</span>
|
||||
<span className="text-sm text-content-secondary">
|
||||
{roleDescriptions[role.name] ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RoleSelectorList
|
||||
selectableRoles={selectableRoles}
|
||||
advancedRoles={advancedRoles}
|
||||
selectedRoles={selectedRoles}
|
||||
handleToggle={handleToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MemberRole />
|
||||
@@ -106,6 +85,86 @@ export const RoleSelector: FC<RoleSelectorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
type RoleSelectorListProps = {
|
||||
selectableRoles: AssignableRoles[];
|
||||
advancedRoles: AssignableRoles[];
|
||||
selectedRoles: Set<string>;
|
||||
handleToggle: (roleName: string) => void;
|
||||
};
|
||||
|
||||
const RoleSelectorList: React.FC<RoleSelectorListProps> = ({
|
||||
selectableRoles,
|
||||
advancedRoles,
|
||||
selectedRoles,
|
||||
handleToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div className="border border-border border-solid rounded-md overflow-y-auto max-h-72 p-3 flex flex-col gap-2">
|
||||
{selectableRoles.map((role) => (
|
||||
<RoleCheckbox
|
||||
key={role.name}
|
||||
role={role}
|
||||
selected={selectedRoles.has(role.name)}
|
||||
onToggle={() => handleToggle(role.name)}
|
||||
/>
|
||||
))}
|
||||
{advancedRoles.length > 0 && (
|
||||
<CollapsibleSummary label="Advanced roles" scrollIntoViewOnOpen>
|
||||
{advancedRoles.map((role) => (
|
||||
<RoleCheckbox
|
||||
key={role.name}
|
||||
role={role}
|
||||
selected={selectedRoles.has(role.name)}
|
||||
onToggle={() => handleToggle(role.name)}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleSummary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RoleCheckboxProps = {
|
||||
role: AssignableRoles;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const RoleCheckbox: React.FC<RoleCheckboxProps> = ({
|
||||
role,
|
||||
selected,
|
||||
onToggle,
|
||||
}) => {
|
||||
const checkboxId = useId();
|
||||
|
||||
return (
|
||||
<label
|
||||
key={role.name}
|
||||
htmlFor={checkboxId}
|
||||
className={cn(
|
||||
"flex items-start gap-2",
|
||||
role.assignable ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={selected}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={!role.assignable}
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{role.display_name || role.name}
|
||||
</span>
|
||||
<span className="text-sm text-content-secondary">
|
||||
{roleDescriptions[role.name] ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
type RoleSelectorLayoutProps = {
|
||||
hideLabel?: boolean;
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -11,6 +11,16 @@ export const roleDescriptions: Record<string, string> = {
|
||||
"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.",
|
||||
};
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -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<OrganizationMemberWithUserData>();
|
||||
const updateMemberRolesMutation = useMutation(
|
||||
updateOrganizationMemberRoles(queryClient, organizationName),
|
||||
);
|
||||
|
||||
const [memberToDelete, setMemberToDelete] =
|
||||
const [memberToRemove, setMemberToRemove] =
|
||||
useState<OrganizationMemberWithUserData>();
|
||||
const removeMemberMutation = useMutation(
|
||||
removeOrganizationMember(queryClient, organizationName),
|
||||
);
|
||||
|
||||
if (!organization) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
@@ -95,10 +99,6 @@ const OrganizationMembersPage: FC = () => {
|
||||
<>
|
||||
{title}
|
||||
<OrganizationMembersPageView
|
||||
allAvailableRoles={organizationRolesQuery.data}
|
||||
canEditMembers={organizationPermissions.editMembers}
|
||||
canViewMembers={organizationPermissions.viewMembers}
|
||||
filterProps={{ filter: filterProps }}
|
||||
error={
|
||||
membersQuery.error ??
|
||||
organizationRolesQuery.error ??
|
||||
@@ -107,11 +107,11 @@ const OrganizationMembersPage: FC = () => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<RoleSelectorDialog
|
||||
key={memberToEditRoles?.username}
|
||||
user={memberToEditRoles}
|
||||
availableRoles={organizationRolesQuery.data}
|
||||
onCancel={() => 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}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
type="delete"
|
||||
open={memberToDelete !== undefined}
|
||||
onClose={() => 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={
|
||||
|
||||
@@ -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<typeof OrganizationMembersPageView> = {
|
||||
title: "pages/OrganizationMembersPageView",
|
||||
component: OrganizationMembersPageView,
|
||||
args: {
|
||||
canEditMembers: true,
|
||||
error: undefined,
|
||||
filterProps: {
|
||||
filter: {
|
||||
@@ -27,9 +25,11 @@ const meta: Meta<typeof OrganizationMembersPageView> = {
|
||||
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<typeof OrganizationMembersPageView> = {
|
||||
},
|
||||
{ ...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 = {
|
||||
|
||||
@@ -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<typeof useFilter> };
|
||||
isUpdatingMemberRoles: boolean;
|
||||
showAISeatColumn?: boolean;
|
||||
me: User;
|
||||
members: Array<OrganizationMemberTableEntry> | undefined;
|
||||
membersQuery: PaginationResultInfo & {
|
||||
isPlaceholderData: boolean;
|
||||
};
|
||||
addMembers: (users: User[]) => Promise<void>;
|
||||
removeMember: (member: OrganizationMemberWithUserData) => void;
|
||||
updateMemberRoles: (
|
||||
member: OrganizationMemberWithUserData,
|
||||
newRoles: string[],
|
||||
) => Promise<void>;
|
||||
}
|
||||
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 (
|
||||
<div className="w-full max-w-screen-2xl pb-10">
|
||||
<SettingsHeader>
|
||||
@@ -116,109 +70,7 @@ export const OrganizationMembersPageView: FC<
|
||||
</div>
|
||||
)}
|
||||
<PaginationContainer query={membersQuery} paginationUnitLabel="members">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-2/6">User</TableHead>
|
||||
<TableHead className="w-2/6">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>Roles</span>
|
||||
<TableColumnHelpPopover variant="roles" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className={showAISeatColumn ? "w-1/6" : "w-2/6"}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>Groups</span>
|
||||
<TableColumnHelpPopover variant="groups" />
|
||||
</div>
|
||||
</TableHead>
|
||||
{showAISeatColumn && (
|
||||
<TableHead className="w-1/6">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>AI add-on</span>
|
||||
<TableColumnHelpPopover variant="ai_addon" />
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="w-px whitespace-nowrap text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members ? (
|
||||
members.map((member) => (
|
||||
<TableRow key={member.user_id} className="align-baseline">
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
fallback={member.username}
|
||||
src={member.avatar_url}
|
||||
size="lg"
|
||||
/>
|
||||
}
|
||||
title={member.name || member.username}
|
||||
subtitle={member.email}
|
||||
/>
|
||||
</TableCell>
|
||||
<UserRoleCell
|
||||
inheritedRoles={member.global_roles}
|
||||
roles={member.roles}
|
||||
allAvailableRoles={allAvailableRoles}
|
||||
oidcRoleSyncEnabled={false}
|
||||
isLoading={isUpdatingMemberRoles}
|
||||
canEditUsers={canEditMembers}
|
||||
onEditRoles={async (roles) => {
|
||||
// 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 {}
|
||||
}}
|
||||
/>
|
||||
<UserGroupsCell userGroups={member.groups} />
|
||||
{showAISeatColumn && (
|
||||
<AISeatCell hasAISeat={member.has_ai_seat} />
|
||||
)}
|
||||
<TableCell className="w-px whitespace-nowrap text-right">
|
||||
<div className="flex justify-end">
|
||||
{member.user_id !== me.id && canEditMembers && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<EllipsisVerticalIcon aria-hidden="true" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => removeMember(member)}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<Loader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<OrganizationMembersTable {...props} />
|
||||
</PaginationContainer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +81,7 @@ interface AddUsersDialogProps {
|
||||
onSubmit: (users: User[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const AddUsersDialog: FC<AddUsersDialogProps> = ({ onSubmit }) => {
|
||||
const AddUsersDialog: React.FC<AddUsersDialogProps> = ({ onSubmit }) => {
|
||||
const [addUserDialogOpen, setAddUserDialogOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
@@ -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<OrganizationMemberTableEntry> | 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 (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-max">User</TableHead>
|
||||
<TableHead className="w-1/6">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>Roles</span>
|
||||
<RolesHelpPopover />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-1/6">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>Groups</span>
|
||||
<GroupsHelpPopover />
|
||||
</div>
|
||||
</TableHead>
|
||||
{showAISeatColumn && (
|
||||
<TableHead className="w-1/6">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>AI add-on</span>
|
||||
<AiAddonHelpPopover />
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<OrganizationMembersTableBody {...props} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationMembersTableBody: React.FC<OrganizationMembersTableProps> = ({
|
||||
organizationName,
|
||||
members,
|
||||
showAISeatColumn,
|
||||
|
||||
isUpdatingMemberRoles,
|
||||
removeMember,
|
||||
onEditMemberRoles,
|
||||
|
||||
me,
|
||||
canEditMembers,
|
||||
canViewActivity,
|
||||
}) => {
|
||||
if (!members) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<Loader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{members.map((member) => (
|
||||
<TableRow key={member.user_id} className="align-baseline">
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
fallback={member.username}
|
||||
src={member.avatar_url}
|
||||
size="lg"
|
||||
/>
|
||||
}
|
||||
title={member.name || member.username}
|
||||
subtitle={member.email}
|
||||
/>
|
||||
</TableCell>
|
||||
<UserRoleCell
|
||||
globalRoles={member.global_roles}
|
||||
roles={member.roles}
|
||||
/>
|
||||
<UserGroupsCell userGroups={member.groups} />
|
||||
{showAISeatColumn && <AISeatCell hasAISeat={member.has_ai_seat} />}
|
||||
<TableCell className="w-px whitespace-nowrap text-right">
|
||||
<div className="flex justify-end">
|
||||
{member.user_id !== me && canEditMembers && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<EllipsisVerticalIcon aria-hidden="true" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to={`/workspaces?filter=${encodeURIComponent(`owner:${member.username} organization:${organizationName}`)}`}
|
||||
>
|
||||
View workspaces
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canViewActivity && (
|
||||
<DropdownMenuItem asChild disabled={!canViewActivity}>
|
||||
<Link
|
||||
to={`/audit?filter=${encodeURIComponent(`username:${member.username} organization:${organizationName}`)}`}
|
||||
>
|
||||
View activity {!canViewActivity && <PremiumBadge />}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isUpdatingMemberRoles}
|
||||
onClick={() => onEditMemberRoles(member)}
|
||||
>
|
||||
Edit roles
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => removeMember(member)}
|
||||
>
|
||||
Remove…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<typeof EditRolesButton> = {
|
||||
title: "pages/UsersPage/EditRolesButton",
|
||||
component: EditRolesButton,
|
||||
args: {
|
||||
selectedRoleNames: new Set([MockUserAdminRole.name, MockOwnerRole.name]),
|
||||
roles: MockSiteRoles,
|
||||
},
|
||||
decorators: [withDesktopViewport],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EditRolesButton>;
|
||||
|
||||
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"));
|
||||
},
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
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<OptionProps> = ({
|
||||
value,
|
||||
name,
|
||||
description,
|
||||
isChecked,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<label htmlFor={name} className="cursor-pointer">
|
||||
<div className="flex items-start gap-4">
|
||||
<Checkbox
|
||||
id={name}
|
||||
size="small"
|
||||
className="p-0 relative top-px"
|
||||
value={value}
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
onChange(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-sm">{name}</strong>
|
||||
<span className="text-xs text-content-secondary">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
interface EditRolesButtonProps {
|
||||
isLoading: boolean;
|
||||
roles: readonly SlimRole[];
|
||||
selectedRoleNames: Set<string>;
|
||||
onChange: (roles: SlimRole["name"][]) => void;
|
||||
oidcRoleSync: boolean;
|
||||
userLoginType?: string;
|
||||
}
|
||||
|
||||
export const EditRolesButton: FC<EditRolesButtonProps> = (props) => {
|
||||
const { userLoginType, oidcRoleSync } = props;
|
||||
const canSetRoles =
|
||||
userLoginType !== "oidc" || (userLoginType === "oidc" && !oidcRoleSync);
|
||||
|
||||
if (!canSetRoles) {
|
||||
return (
|
||||
<HelpPopover>
|
||||
<HelpPopoverIconTrigger size="small" />
|
||||
<HelpPopoverContent>
|
||||
<HelpPopoverTitle>Externally controlled</HelpPopoverTitle>
|
||||
<HelpPopoverText>
|
||||
Roles for this user are controlled by the OIDC identity provider.
|
||||
</HelpPopoverText>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
}
|
||||
|
||||
return <EnabledEditRolesButton {...props} />;
|
||||
};
|
||||
|
||||
const EnabledEditRolesButton: FC<EditRolesButtonProps> = ({
|
||||
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 (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
aria-label="Edit user roles"
|
||||
size="icon"
|
||||
className="text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
<EditSquare />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<TooltipContent side="bottom">Edit user roles</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-96 bg-surface-secondary border-surface-quaternary"
|
||||
>
|
||||
<fieldset
|
||||
className="border-0 m-0 p-0 disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
title="Available roles"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-6 w-full">
|
||||
{filteredRoles.map((role) => (
|
||||
<Option
|
||||
key={role.name}
|
||||
onChange={handleChange}
|
||||
isChecked={selectedRoleNames.has(role.name)}
|
||||
value={role.name}
|
||||
name={role.display_name || role.name}
|
||||
description={roleDescriptions[role.name] ?? ""}
|
||||
/>
|
||||
))}
|
||||
{advancedRoles.length > 0 && (
|
||||
<CollapsibleSummary label="advanced" defaultOpen={isAdvancedOpen}>
|
||||
{advancedRoles.map((role) => (
|
||||
<Option
|
||||
key={role.name}
|
||||
onChange={handleChange}
|
||||
isChecked={selectedRoleNames.has(role.name)}
|
||||
value={role.name}
|
||||
name={role.display_name || role.name}
|
||||
description={roleDescriptions[role.name] ?? ""}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleSummary>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="p-6 border-0 border-t border-solid border-border text-sm">
|
||||
<div className="flex gap-4">
|
||||
<UserIcon />
|
||||
<div className="flex flex-col">
|
||||
<strong>Member</strong>
|
||||
<span className="text-xs text-content-secondary">
|
||||
{roleDescriptions.member}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -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<ColumnHeader, TooltipData> = {
|
||||
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<Props> = ({ variant }) => {
|
||||
const data = tooltipData[variant];
|
||||
|
||||
return (
|
||||
<HelpPopover>
|
||||
<HelpPopoverIconTrigger size="small" />
|
||||
<HelpPopoverContent>
|
||||
<HelpPopoverTitle>{data.title}</HelpPopoverTitle>
|
||||
<HelpPopoverText>{data.text}</HelpPopoverText>
|
||||
{data.links.length > 0 && (
|
||||
<HelpPopoverLinksGroup>
|
||||
{data.links.map((link) => (
|
||||
<HelpPopoverLink key={link.text} href={link.href}>
|
||||
{link.text}
|
||||
</HelpPopoverLink>
|
||||
))}
|
||||
</HelpPopoverLinksGroup>
|
||||
)}
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
};
|
||||
@@ -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<UserRoleCellProps> = ({
|
||||
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 (
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
{canEditUsers && (
|
||||
<EditRolesButton
|
||||
roles={sortRolesByAccessLevel(allAvailableRoles ?? [])}
|
||||
selectedRoleNames={getSelectedRoleNames(roles)}
|
||||
isLoading={isLoading}
|
||||
userLoginType={userLoginType}
|
||||
oidcRoleSync={oidcRoleSyncEnabled}
|
||||
onChange={(roles) => {
|
||||
// Remove the fallback role because it is only for the UI
|
||||
const rolesWithoutFallback = roles.filter(
|
||||
(role) => role !== fallbackRole.name,
|
||||
);
|
||||
|
||||
onEditRoles(rolesWithoutFallback);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Pill
|
||||
css={
|
||||
hasOwnerRole
|
||||
? styles.ownerRoleBadge
|
||||
: mainDisplayRole.global
|
||||
? styles.globalRoleBadge
|
||||
: styles.roleBadge
|
||||
}
|
||||
>
|
||||
{mainDisplayRole.global ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{displayName}*</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
This user has this role for all organizations.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Pill>
|
||||
|
||||
{extraRoles.length > 0 && <OverflowRolePill roles={extraRoles} />}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
type OverflowRolePillProps = {
|
||||
roles: readonly TieredSlimRole[];
|
||||
};
|
||||
|
||||
const OverflowRolePill: FC<OverflowRolePillProps> = ({ roles }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pill
|
||||
css={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderColor: theme.palette.divider,
|
||||
}}
|
||||
>
|
||||
+{roles.length} more
|
||||
</Pill>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="flex flex-row flex-wrap content-around gap-x-2 gap-y-3 px-4 py-3 border-surface-quaternary">
|
||||
{roles.map((role) => (
|
||||
<Pill
|
||||
key={role.name}
|
||||
css={role.global ? styles.globalRoleBadge : styles.roleBadge}
|
||||
>
|
||||
{role.global ? (
|
||||
<span title="This user has this role for all organizations.">
|
||||
{role.display_name || role.name}*
|
||||
</span>
|
||||
) : (
|
||||
role.display_name || role.name
|
||||
)}
|
||||
</Pill>
|
||||
))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, Interpolation<Theme>>;
|
||||
|
||||
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<T extends SlimRole>(
|
||||
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<string, TieredSlimRole>();
|
||||
|
||||
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()];
|
||||
}
|
||||
Reference in New Issue
Block a user