feat: update OrganizationMembersPage role editing to match new designs (#24858)

This commit is contained in:
Kayla はな
2026-05-05 13:18:01 -06:00
committed by GitHub
parent e48d12160f
commit 21a877df84
17 changed files with 465 additions and 904 deletions
@@ -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>
);
};
-12
View File
@@ -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,
},
};
+95 -36
View File
@@ -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;
+10
View File
@@ -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&hellip;
</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()];
}