feat: update UsersPage role editing to match new designs (#24857)

This commit is contained in:
Kayla はな
2026-05-04 11:19:21 -06:00
committed by GitHub
parent 5612bb81cb
commit 162acaf8bf
23 changed files with 932 additions and 790 deletions
+1
View File
@@ -42,6 +42,7 @@ site/.swc
.gen-golden
# Build
bin/
build/
dist/
out/
+4 -3
View File
@@ -1332,11 +1332,12 @@ export async function createUser(
await expect(addedRow).toBeVisible();
// Give them a role
await addedRow.getByLabel("Edit user roles").click();
await addedRow.getByLabel("Open menu").click();
await page.getByText("Edit roles").click();
for (const role of roles) {
await page.getByRole("group").getByText(role, { exact: true }).click();
await page.getByRole("dialog").getByText(role, { exact: true }).click();
}
await page.mouse.click(10, 10); // close the popover by clicking outside of it
await page.getByText("Confirm").click();
await page.goto(returnTo, { waitUntil: "domcontentloaded" });
return { name, username, email, password, roles };
+2 -6
View File
@@ -108,10 +108,7 @@ export const DialogFooter: React.FC<React.ComponentPropsWithRef<"div">> = ({
);
};
/**
* @lintignore I'll be using this right away in another PR, just trying to break things up
*/
export interface DialogActionsProps {
type DialogActionsProps = {
/** Text to display in the confirm button */
confirmText?: React.ReactNode;
/** Whether or not confirm is loading, also disables cancel when true */
@@ -127,11 +124,10 @@ export interface DialogActionsProps {
cancelText?: string;
/** Called when cancel is clicked */
onCancel?: () => void;
}
};
/**
* Quickly handles most modals actions, some combination of a cancel and confirm button
* @lintignore I'll be using this right away in another PR, just trying to break things up
*/
export const DialogActions: React.FC<DialogActionsProps> = ({
confirmText = "Confirm",
@@ -11,11 +11,11 @@ import {
import { RoleSelector } from "./RoleSelector";
const meta: Meta<typeof RoleSelector> = {
title: "pages/CreateUserPage/RoleSelector",
title: "modules/roles/RoleSelector",
component: RoleSelector,
args: {
onChange: action("change"),
selectedRoles: [],
selectedRoles: new Set(),
},
};
@@ -38,33 +38,33 @@ const someNonAssignable = [
export const Default: Story = {
args: {
roles: allAssignable,
availableRoles: allAssignable,
},
};
export const WithSelections: Story = {
args: {
roles: allAssignable,
selectedRoles: [MockUserAdminRole.name, MockAuditorRole.name],
availableRoles: allAssignable,
selectedRoles: new Set([MockUserAdminRole.name, MockAuditorRole.name]),
},
};
export const WithNonAssignableRoles: Story = {
args: {
roles: someNonAssignable,
availableRoles: someNonAssignable,
},
};
export const Loading: Story = {
args: {
roles: [],
availableRoles: [],
loading: true,
},
};
export const WithError: Story = {
args: {
roles: [],
availableRoles: [],
error: mockApiError({ message: "Failed to fetch assignable roles." }),
},
};
+154
View File
@@ -0,0 +1,154 @@
import { UserIcon } from "lucide-react";
import { type FC, useId } from "react";
import { getErrorMessage } from "#/api/errors";
import type { AssignableRoles } from "#/api/typesGenerated";
import { Alert, AlertTitle } from "#/components/Alert/Alert";
import { Checkbox } from "#/components/Checkbox/Checkbox";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { cn } from "#/utils/cn";
import { roleDescriptions } from "./index";
type RoleSelectorProps = {
hideLabel?: boolean;
loading?: boolean;
error?: unknown;
availableRoles?: AssignableRoles[];
selectedRoles: Set<string>;
onChange: (roles: Set<string>) => void;
};
export const RoleSelector: FC<RoleSelectorProps> = ({
hideLabel,
loading,
error,
availableRoles = [],
selectedRoles,
onChange,
}) => {
const baseId = useId();
const selectableRoles = availableRoles.filter((r) => r.name !== "member");
if (loading) {
return (
<RoleSelectorLayout>
<RoleSelectorSkeleton />
<MemberRole />
</RoleSelectorLayout>
);
}
if (error) {
return (
<RoleSelectorLayout>
<Alert severity="error">
<AlertTitle>
{getErrorMessage(error, "Failed to load roles.")}
</AlertTitle>
</Alert>
</RoleSelectorLayout>
);
}
if (selectableRoles.length === 0) {
return null;
}
const handleToggle = (roleName: string) => {
const newRoles = new Set(selectedRoles);
if (newRoles.has(roleName)) {
newRoles.delete(roleName);
} else {
newRoles.add(roleName);
}
onChange(newRoles);
};
return (
<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>
)}
<MemberRole />
</RoleSelectorLayout>
);
};
type RoleSelectorLayoutProps = {
hideLabel?: boolean;
children: React.ReactNode;
};
const RoleSelectorLayout: React.FC<RoleSelectorLayoutProps> = ({
hideLabel,
children,
}) => {
return (
<div className="flex flex-col gap-2">
{!hideLabel && <span className="text-sm font-medium">Roles</span>}
{children}
</div>
);
};
const MemberRole: React.FC = () => {
return (
<div className="border-t border-border py-2 flex items-start gap-2 text-content-disabled">
<UserIcon className="size-4 mt-1 shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">Member</span>
<span className="text-sm">{roleDescriptions.member}</span>
</div>
</div>
);
};
const RoleSelectorSkeleton: React.FC = () => {
return (
<div className="border border-border border-solid rounded-md">
<div className="p-3 flex flex-col gap-2">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex items-start gap-2">
<Skeleton className="mt-1 shrink-0 size-4 rounded" />
<div className="flex flex-col gap-1 flex-1">
<Skeleton variant="text" className="w-24" />
<Skeleton variant="text" className="w-48" />
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,105 @@
import { useState } from "react";
import type { AssignableRoles, SlimRole } from "#/api/typesGenerated";
import { AvatarData } from "#/components/Avatar/AvatarData";
import {
Dialog,
DialogActions,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { getRoleNames } from "./index";
import { RoleSelector } from "./RoleSelector";
type RoleSelectorDialogProps = {
/**
* The user who is currently being edited. The dialog will be hidden if no
* no user is provided.
*/
user?: ThingWithRoles;
/** The roles available in this context that can be given or removed from the user */
availableRoles?: AssignableRoles[];
onCancel: () => void;
onUpdateRoles: (roles: string[]) => Promise<void>;
isUpdatingRoles: boolean;
};
type ThingWithRoles = {
username: string;
email: string;
roles: readonly SlimRole[];
avatar_url?: string;
};
export const RoleSelectorDialog: React.FC<RoleSelectorDialogProps> = ({
user,
availableRoles = [],
onCancel,
onUpdateRoles,
isUpdatingRoles,
}) => {
if (!user) {
return null;
}
return (
<ActiveRoleSelectorDialog
user={user}
availableRoles={availableRoles}
onCancel={onCancel}
onUpdateRoles={onUpdateRoles}
isUpdatingRoles={isUpdatingRoles}
/>
);
};
const ActiveRoleSelectorDialog: React.FC<Required<RoleSelectorDialogProps>> = ({
user,
availableRoles,
onCancel,
onUpdateRoles,
isUpdatingRoles,
}) => {
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(
() => new Set(getRoleNames(user.roles)),
);
return (
<Dialog
open
onOpenChange={(isOpen) => {
if (!isOpen) {
onCancel();
}
}}
>
<DialogContent>
<DialogHeader>
<div className="flex flex-row justify-between items-center">
<DialogTitle>Edit roles</DialogTitle>
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
</div>
</DialogHeader>
<RoleSelector
hideLabel
availableRoles={availableRoles}
selectedRoles={selectedRoles}
onChange={setSelectedRoles}
/>
<DialogFooter>
<DialogActions
onCancel={onCancel}
onConfirm={() => onUpdateRoles([...selectedRoles])}
confirmLoading={isUpdatingRoles}
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+77
View File
@@ -0,0 +1,77 @@
import type { SlimRole } from "#/api/typesGenerated";
export type ScopedSlimRole = SlimRole & {
global?: boolean;
};
export 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.",
};
export const memberRole: ScopedSlimRole = {
name: "member",
display_name: "Member",
} as const;
export function getRoleNames(roles: readonly SlimRole[]): string[] {
return roles.map((role) => role.name);
}
export function combineGlobalAndOrgRoles(
globalRoles: readonly SlimRole[],
orgRoles: readonly SlimRole[],
): ScopedSlimRole[] {
return [
...globalRoles.map((it) => ({ ...it, global: true })),
...orgRoles.map((it) => ({ ...it, global: false })),
];
}
const roleNamesByAccessLevel: readonly string[] = [
"owner",
"organization-admin",
"user-admin",
"organization-user-admin",
"template-admin",
"organization-template-admin",
"auditor",
"organization-auditor",
"agents-access",
"member",
"organization-member",
];
export function sortRoles<Role extends SlimRole>(
roles: readonly Role[],
): readonly Role[] {
if (roles.length < 2) {
return roles;
}
return [...roles].sort((a, b) => {
const aAccessLevel = roleNamesByAccessLevel.indexOf(a.name);
const bAccessLevel = roleNamesByAccessLevel.indexOf(b.name);
// a is not in the access level list, but b is, so b should come first
if (aAccessLevel === -1 && bAccessLevel !== -1) {
return 1;
}
// b is not in the access level list, but a is, so a should come first
if (bAccessLevel === -1 && aAccessLevel !== -1) {
return -1;
}
// Neither is in the access level list, so sort them alphabetically
if (aAccessLevel === -1 && bAccessLevel === -1) {
return a.name.localeCompare(b.name);
}
// Both are in the access level list, so sort them by access level
return aAccessLevel - bAccessLevel;
});
}
@@ -0,0 +1,66 @@
import type { FC } from "react";
import {
HelpPopover,
HelpPopoverContent,
HelpPopoverIconTrigger,
HelpPopoverLink,
HelpPopoverLinksGroup,
HelpPopoverText,
HelpPopoverTitle,
} from "#/components/HelpPopover/HelpPopover";
import { docs } from "#/utils/docs";
export const RolesHelpPopover: FC = () => {
return (
<HelpPopover>
<HelpPopoverIconTrigger size="small" />
<HelpPopoverContent>
<HelpPopoverTitle>What is a role?</HelpPopoverTitle>
<HelpPopoverText>
Coder role-based access control (RBAC) provides fine-grained access
management. View our docs on how to use the available roles.
</HelpPopoverText>
<HelpPopoverLinksGroup>
<HelpPopoverLink href={docs("/admin/users/groups-roles")}>
User Roles
</HelpPopoverLink>
</HelpPopoverLinksGroup>
</HelpPopoverContent>
</HelpPopover>
);
};
export const GroupsHelpPopover: FC = () => {
return (
<HelpPopover>
<HelpPopoverIconTrigger size="small" />
<HelpPopoverContent>
<HelpPopoverTitle>What is a group?</HelpPopoverTitle>
<HelpPopoverText>
Groups can be used with template RBAC to give groups of users access
to specific templates. View our docs on how to use groups.
</HelpPopoverText>
<HelpPopoverLinksGroup>
<HelpPopoverLink href={docs("/admin/users/groups-roles")}>
Groups
</HelpPopoverLink>
</HelpPopoverLinksGroup>
</HelpPopoverContent>
</HelpPopover>
);
};
export const AiAddonHelpPopover: FC = () => {
return (
<HelpPopover>
<HelpPopoverIconTrigger size="small" />
<HelpPopoverContent>
<HelpPopoverTitle>What is the AI add-on?</HelpPopoverTitle>
<HelpPopoverText>
Users with access to AI features like AI Bridge or Tasks who are
actively consuming a seat.
</HelpPopoverText>
</HelpPopoverContent>
</HelpPopover>
);
};
+90
View File
@@ -0,0 +1,90 @@
import type { SlimRole } from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { TableCell } from "#/components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import {
combineGlobalAndOrgRoles,
memberRole,
type ScopedSlimRole,
sortRoles,
} from "#/modules/roles";
type UserRoleCellProps = {
globalRoles?: readonly SlimRole[];
roles: readonly SlimRole[];
};
export const UserRoleCell: React.FC<UserRoleCellProps> = ({
globalRoles = [],
roles,
}) => {
const mergedRoles = combineGlobalAndOrgRoles(globalRoles, roles);
const [mainDisplayRole = memberRole, ...extraRoles] = sortRoles(mergedRoles);
return (
<TableCell>
<div className="flex flex-row gap-1 items-center">
<RoleBadge role={mainDisplayRole} />
{extraRoles.length > 0 && <MoreRolePill roles={extraRoles} />}
</div>
</TableCell>
);
};
type MoreRolePillProps = {
roles: readonly ScopedSlimRole[];
};
const MoreRolePill: React.FC<MoreRolePillProps> = ({ roles }) => {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Badge>+{roles.length} more</Badge>
</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) => (
<RoleBadge key={role.name} role={role} />
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
type RoleBadgeProps = {
role: ScopedSlimRole;
};
const RoleBadge: React.FC<RoleBadgeProps> = ({ role }) => {
const displayName = role.display_name || role.name;
const isOwnerRole =
role.name === "owner" || role.name === "organization-admin";
return (
<Badge
key={role.name}
variant={role.global ? "green" : isOwnerRole ? "purple" : "default"}
>
{role.global ? (
<Tooltip>
<TooltipTrigger asChild>
<span>{displayName}*</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
This user has this role for all organizations.
</TooltipContent>
</Tooltip>
) : (
displayName
)}
</Badge>
);
};
@@ -21,6 +21,7 @@ import {
SelectValue,
} from "#/components/Select/Select";
import { Spinner } from "#/components/Spinner/Spinner";
import { RoleSelector } from "#/modules/roles/RoleSelector";
import { cn } from "#/utils/cn";
import {
displayNameValidator,
@@ -28,7 +29,6 @@ import {
nameValidator,
onChangeTrimmed,
} from "#/utils/formUtils";
import { RoleSelector } from "./RoleSelector";
const loginTypeOptions = {
password: {
@@ -81,10 +81,10 @@ type CreateUserFormData = {
readonly login_type: TypesGen.LoginType;
readonly password: string;
readonly service_account: boolean;
readonly roles: string[];
readonly roles: Set<string>;
};
interface CreateUserFormProps {
type CreateUserFormProps = {
error?: unknown;
isLoading: boolean;
onSubmit: (user: CreateUserFormData) => void;
@@ -95,7 +95,7 @@ interface CreateUserFormProps {
availableRoles?: TypesGen.AssignableRoles[];
rolesLoading?: boolean;
rolesError?: unknown;
}
};
// Stable reference for empty org options to avoid re-render loops
// in the render-time state adjustment pattern.
@@ -133,7 +133,7 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
: "00000000-0000-0000-0000-000000000000",
login_type: defaultLoginType,
service_account: defaultLoginType === "none",
roles: [],
roles: new Set(),
},
validationSchema,
onSubmit,
@@ -241,6 +241,7 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
>
<SelectValue placeholder="Select a login type…" />
</SelectTrigger>
<SelectContent className="max-w-sm">
{availableLoginTypes.map((key) => {
const opt = loginTypeOptions[key];
@@ -345,30 +346,13 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
/>
)}
{rolesLoading ? (
<RoleSelector
loading
roles={[]}
selectedRoles={[]}
onChange={() => {}}
/>
) : rolesError ? (
<RoleSelector
error={rolesError}
roles={[]}
selectedRoles={[]}
onChange={() => {}}
/>
) : (
availableRoles &&
availableRoles.length > 0 && (
<RoleSelector
roles={availableRoles}
selectedRoles={form.values.roles}
onChange={(roles) => form.setFieldValue("roles", roles)}
/>
)
)}
<RoleSelector
loading={rolesLoading}
error={rolesError}
availableRoles={availableRoles}
selectedRoles={form.values.roles}
onChange={(roles) => form.setFieldValue("roles", roles)}
/>
</div>
<FormFooter className="mt-8">
@@ -38,7 +38,7 @@ const CreateUserPage: FC = () => {
password: user.password,
user_status: null,
service_account: user.service_account,
roles: user.roles,
roles: [...user.roles],
},
{
onSuccess: () => {
@@ -1,137 +0,0 @@
import { UserIcon } from "lucide-react";
import { type FC, useId } from "react";
import { getErrorMessage } from "#/api/errors";
import type { AssignableRoles } from "#/api/typesGenerated";
import { Alert, AlertTitle } from "#/components/Alert/Alert";
import { Checkbox } from "#/components/Checkbox/Checkbox";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { cn } from "#/utils/cn";
const roleDescriptions: Record<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 RoleSelectorProps {
roles: AssignableRoles[];
selectedRoles: string[];
onChange: (roles: string[]) => void;
loading?: boolean;
error?: unknown;
}
export const RoleSelector: FC<RoleSelectorProps> = ({
roles,
selectedRoles,
onChange,
loading,
error,
}) => {
const baseId = useId();
const selectableRoles = roles.filter((r) => r.name !== "member");
const handleToggle = (roleName: string) => {
if (selectedRoles.includes(roleName)) {
onChange(selectedRoles.filter((r) => r !== roleName));
} else {
onChange([...selectedRoles, roleName]);
}
};
if (loading) {
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Roles</span>
<div className="border border-border border-solid rounded-md">
<div className="p-3 flex flex-col gap-2">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex items-start gap-2">
<Skeleton className="mt-1 shrink-0 size-4 rounded" />
<div className="flex flex-col gap-1 flex-1">
<Skeleton variant="text" className="w-24" />
<Skeleton variant="text" className="w-48" />
</div>
</div>
))}
</div>
</div>
<div className="border-t border-border py-2 flex items-start gap-2 text-content-disabled">
<UserIcon className="size-4 mt-1 shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">Member</span>
<span className="text-sm">{roleDescriptions.member}</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Roles</span>
<Alert severity="error">
<AlertTitle>
{getErrorMessage(error, "Failed to load roles.")}
</AlertTitle>
</Alert>
</div>
);
}
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Roles</span>
{selectableRoles.length > 0 && (
<div className="border border-border border-solid rounded-md">
<div className="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.includes(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>
</div>
)}
<div className="border-t border-border py-2 flex items-start gap-2 text-content-disabled">
<UserIcon className="size-4 mt-1 shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">Member</span>
<span className="text-sm">{roleDescriptions.member}</span>
</div>
</div>
</div>
);
};
@@ -48,7 +48,7 @@ import {
} from "#/components/Table/Table";
import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery";
import { AISeatCell } from "#/modules/users/AISeatCell";
import { UserGroupsCell } from "#/pages/UsersPage/UsersTable/UserGroupsCell";
import { UserGroupsCell } from "#/modules/users/UserGroupsCell";
import { TableColumnHelpPopover } from "./UserTable/TableColumnHelpPopover";
import { UserRoleCell } from "./UserTable/UserRoleCell";
@@ -72,9 +72,6 @@ const meta: Meta<typeof UsersPage> = {
component: UsersPage,
parameters,
decorators: [withToaster, withAuthProvider, withDashboardProvider],
args: {
defaultNewPassword: "edWbqYiaVpEiEWwI",
},
};
export default meta;
@@ -377,8 +374,10 @@ export const UpdateUserRoleSuccess: Story = {
count: 60,
});
await user.click(within(userRow).getByLabelText("Edit user roles"));
await user.click(within(userRow).getByLabelText("Open menu"));
await user.click(screen.getByText("Edit roles"));
await user.click(screen.getByLabelText("Auditor", { exact: false }));
await user.click(screen.getByText("Confirm"));
await screen.findByText(/roles updated successfully/);
},
};
@@ -393,8 +392,10 @@ export const UpdateUserRoleError: Story = {
}
spyOn(API, "updateUserRoles").mockRejectedValue({});
await user.click(within(userRow).getByLabelText("Edit user roles"));
await user.click(within(userRow).getByLabelText("Open menu"));
await user.click(screen.getByText("Edit roles"));
await user.click(screen.getByLabelText("Auditor", { exact: false }));
await user.click(screen.getByText("Confirm"));
await screen.findByText(/Error updating user roles/);
},
};
+58 -59
View File
@@ -1,6 +1,6 @@
import { type FC, useState } from "react";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { deploymentConfig } from "#/api/queries/deployment";
@@ -8,7 +8,6 @@ import { groupsByUserId } from "#/api/queries/groups";
import { roles } from "#/api/queries/roles";
import {
activateUser,
authMethods,
deleteUser,
paginatedUsers,
suspendUser,
@@ -20,31 +19,23 @@ import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
import { useFilter } from "#/components/Filter/Filter";
import { useStatusFilterMenu } from "#/components/Filter/UsersFilter";
import { isNonInitialPage } from "#/components/PaginationWidget/utils";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { shouldShowAISeatColumn } from "#/modules/dashboard/entitlements";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { RoleSelectorDialog } from "#/modules/roles/RoleSelectorDialog";
import { pageTitle } from "#/utils/page";
import { generateRandomString } from "#/utils/random";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { UsersPageView } from "./UsersPageView";
type UserPageProps = {
// Used by Storybook to prevent generating a new password each time the story
// loads, avoiding Chromatic snapshot differences.
defaultNewPassword?: string;
};
const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
const UsersPage: React.FC = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { entitlements } = useDashboard();
const showAISeatColumn = shouldShowAISeatColumn(entitlements);
const groupsByUserIdQuery = useQuery(groupsByUserId());
const authMethodsQuery = useQuery(authMethods());
const { permissions, user: me } = useAuthenticated();
const {
@@ -74,22 +65,29 @@ const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
}),
});
const [userToSuspend, setUserToSuspend] = useState<User>();
const [userToSuspend, setUserToSuspend] = useState<User | undefined>(
undefined,
);
const suspendUserMutation = useMutation(suspendUser(queryClient));
const [userToActivate, setUserToActivate] = useState<User>();
const [userToActivate, setUserToActivate] = useState<User | undefined>(
undefined,
);
const activateUserMutation = useMutation(activateUser(queryClient));
const [userToDelete, setUserToDelete] = useState<User>();
const [userToDelete, setUserToDelete] = useState<User | undefined>(undefined);
const deleteUserMutation = useMutation(deleteUser(queryClient));
const [userToEditRoles, setUserToEditRoles] = useState<User | undefined>(
undefined,
);
const updateUserRolesMutation = useMutation(updateRoles(queryClient));
const [confirmResetPassword, setConfirmResetPassword] = useState<{
user: User;
newPassword: string;
}>();
const updatePasswordMutation = useMutation(updatePassword());
const updateRolesMutation = useMutation(updateRoles(queryClient));
// Indicates if oidc roles are synced from the oidc idp.
// Assign 'false' if unknown.
@@ -100,7 +98,6 @@ const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
const isLoading =
usersQuery.isLoading ||
rolesQuery.isLoading ||
authMethodsQuery.isLoading ||
groupsByUserIdQuery.isLoading;
return (
@@ -108,54 +105,56 @@ const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
<title>{pageTitle("Users")}</title>
<UsersPageView
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
roles={rolesQuery.data}
users={usersQuery.data?.users}
groupsByUserId={groupsByUserIdQuery.data}
authMethods={authMethodsQuery.data}
onListWorkspaces={(user) => {
navigate(
`/workspaces?filter=${encodeURIComponent(`owner:${user.username}`)}`,
);
}}
onViewActivity={(user) => {
navigate(
`/audit?filter=${encodeURIComponent(`username:${user.username}`)}`,
);
}}
onDeleteUser={setUserToDelete}
onSuspendUser={setUserToSuspend}
onActivateUser={setUserToActivate}
onResetUserPassword={(user) => {
setConfirmResetPassword({
user,
newPassword: defaultNewPassword ?? generateRandomString(12),
});
}}
onUpdateUserRoles={async (userId, roles) => {
try {
await updateRolesMutation.mutateAsync({ userId, roles });
toast.success("User roles updated successfully.");
} catch (e) {
toast.error(getErrorMessage(e, "Error updating user roles."), {
description: getErrorDetail(e),
});
}
}}
isUpdatingUserRoles={updateRolesMutation.isPending}
isLoading={isLoading}
canEditUsers={canEditUsers}
canViewActivity={entitlements.features.audit_log.enabled}
showAISeatColumn={showAISeatColumn}
isNonInitialPage={isNonInitialPage(searchParams)}
actorID={me.id}
filterProps={{
filter: useFilterResult,
error: usersQuery.error,
menus: { status: statusMenu },
}}
usersQuery={usersQuery}
groupsByUserId={groupsByUserIdQuery.data}
showAISeatColumn={showAISeatColumn}
onEditUserRoles={setUserToEditRoles}
isUpdatingUserRoles={updateUserRolesMutation.isPending}
onResetUserPassword={(user) => {
setConfirmResetPassword({
user,
newPassword:
process.env.STORYBOOK === "true"
? "hello-storybook"
: generateRandomString(12),
});
}}
onSuspendUser={setUserToSuspend}
onActivateUser={setUserToActivate}
onDeleteUser={setUserToDelete}
me={me.id}
canCreateUser={canCreateUser}
canEditUsers={canEditUsers}
canViewActivity={entitlements.features.audit_log.enabled}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
/>
<RoleSelectorDialog
key={userToEditRoles?.username}
user={userToEditRoles}
availableRoles={rolesQuery.data}
onCancel={() => setUserToEditRoles(undefined)}
onUpdateRoles={async (roles) => {
try {
await updateUserRolesMutation.mutateAsync({
userId: userToEditRoles!.id,
roles,
});
toast.success("User roles updated successfully.");
setUserToEditRoles(undefined);
} catch (e) {
toast.error(getErrorMessage(e, "Error updating user roles."), {
description: getErrorDetail(e),
});
}
}}
isUpdatingRoles={updateUserRolesMutation.isPending}
/>
<DeleteDialog
@@ -5,10 +5,7 @@ import {
MockMenu,
} from "#/components/Filter/storyHelpers";
import { mockSuccessResult } from "#/components/PaginationWidget/PaginationContainer.mocks";
import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery";
import {
MockAssignableSiteRoles,
MockAuthMethodsPasswordOnly,
MockUserMember,
MockUserOwner,
mockApiError,
@@ -31,19 +28,19 @@ const meta: Meta<typeof UsersPageView> = {
title: "pages/UsersPageView",
component: UsersPageView,
args: {
isNonInitialPage: false,
users: [
{ ...MockUserOwner, has_ai_seat: false },
{ ...MockUserMember, has_ai_seat: false },
],
roles: MockAssignableSiteRoles,
canEditUsers: true,
filterProps: defaultFilterProps,
authMethods: MockAuthMethodsPasswordOnly,
usersQuery: {
...mockSuccessResult,
totalRecords: 2,
} as UsePaginatedQueryResult,
data: {
count: 2,
users: [
{ ...MockUserOwner, has_ai_seat: false },
{ ...MockUserMember, has_ai_seat: false },
],
},
},
},
};
@@ -64,32 +61,40 @@ export const Member: Story = {
export const Empty: Story = {
args: {
users: [],
usersQuery: {
...mockSuccessResult,
totalRecords: 0,
} as UsePaginatedQueryResult,
data: {
count: 0,
users: [],
},
},
},
};
export const EmptyPage: Story = {
args: {
users: [],
isNonInitialPage: true,
usersQuery: {
...mockSuccessResult,
totalRecords: 0,
} as UsePaginatedQueryResult,
data: {
count: 0,
users: [],
},
},
},
};
export const WithError: Story = {
args: {
users: undefined,
usersQuery: {
...mockSuccessResult,
totalRecords: 0,
} as UsePaginatedQueryResult,
data: {
count: 0,
users: [],
},
},
filterProps: {
...defaultFilterProps,
error: mockApiError({
+9 -73
View File
@@ -1,7 +1,6 @@
import { UserPlusIcon } from "lucide-react";
import type { ComponentProps, FC } from "react";
import { Link as RouterLink } from "react-router";
import type { GroupsByUserId } from "#/api/queries/groups";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { UsersFilter } from "#/components/Filter/UsersFilter";
@@ -14,62 +13,19 @@ import {
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { UsersTable } from "./UsersTable/UsersTable";
import { UsersTable, type UsersTableProps } from "./UsersTable";
interface UsersPageViewProps {
users?: readonly TypesGen.User[];
roles?: TypesGen.AssignableRoles[];
isUpdatingUserRoles?: boolean;
canEditUsers: boolean;
oidcRoleSyncEnabled: boolean;
canViewActivity?: boolean;
showAISeatColumn?: boolean;
isLoading: boolean;
authMethods?: TypesGen.AuthMethods;
onSuspendUser: (user: TypesGen.User) => void;
onDeleteUser: (user: TypesGen.User) => void;
onListWorkspaces: (user: TypesGen.User) => void;
onViewActivity: (user: TypesGen.User) => void;
onActivateUser: (user: TypesGen.User) => void;
onResetUserPassword: (user: TypesGen.User) => void;
onUpdateUserRoles: (
userId: string,
roles: TypesGen.SlimRole["name"][],
) => void;
type UsersPageViewProps = Omit<UsersTableProps, "users"> & {
filterProps: ComponentProps<typeof UsersFilter>;
isNonInitialPage: boolean;
actorID: string;
groupsByUserId: GroupsByUserId | undefined;
usersQuery: PaginationResult;
// TODO: Refactor these out once we remove the multi-organization experiment.
canViewOrganizations?: boolean;
usersQuery: PaginationResult<TypesGen.GetUsersResponse>;
canCreateUser?: boolean;
}
};
export const UsersPageView: FC<UsersPageViewProps> = ({
users,
roles,
onSuspendUser,
onDeleteUser,
onListWorkspaces,
onViewActivity,
onActivateUser,
onResetUserPassword,
onUpdateUserRoles,
isUpdatingUserRoles,
canEditUsers,
oidcRoleSyncEnabled,
canViewActivity,
showAISeatColumn,
isLoading,
filterProps,
isNonInitialPage,
actorID,
authMethods,
groupsByUserId,
usersQuery,
canCreateUser,
...props
}) => {
return (
<>
@@ -77,10 +33,10 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
actions={
canCreateUser && (
<Button asChild>
<RouterLink to="create">
<Link to="create">
<UserPlusIcon />
Create user
</RouterLink>
</Link>
</Button>
)
}
@@ -94,27 +50,7 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
<UsersFilter {...filterProps} />
<PaginationContainer query={usersQuery} paginationUnitLabel="users">
<UsersTable
users={users}
roles={roles}
groupsByUserId={groupsByUserId}
onSuspendUser={onSuspendUser}
onDeleteUser={onDeleteUser}
onListWorkspaces={onListWorkspaces}
onViewActivity={onViewActivity}
onActivateUser={onActivateUser}
onResetUserPassword={onResetUserPassword}
onUpdateUserRoles={onUpdateUserRoles}
isUpdatingUserRoles={isUpdatingUserRoles}
canEditUsers={canEditUsers}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
canViewActivity={canViewActivity}
showAISeatColumn={showAISeatColumn}
isLoading={isLoading}
isNonInitialPage={isNonInitialPage}
actorID={actorID}
authMethods={authMethods}
/>
<UsersTable users={usersQuery.data?.users} {...props} />
</PaginationContainer>
</>
);
@@ -1,8 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
MockAssignableSiteRoles,
MockAuditorRole,
MockAuthMethodsPasswordOnly,
MockGroup,
MockMemberRole,
MockTemplateAdminRole,
@@ -20,10 +18,7 @@ const mockGroupsByUserId = new Map([
const meta: Meta<typeof UsersTable> = {
title: "pages/UsersPage/UsersTable",
component: UsersTable,
args: {
isNonInitialPage: false,
authMethods: MockAuthMethodsPasswordOnly,
},
args: {},
};
export default meta;
@@ -35,7 +30,6 @@ export const Example: Story = {
{ ...MockUserOwner, has_ai_seat: false },
{ ...MockUserMember, has_ai_seat: false },
],
roles: MockAssignableSiteRoles,
canEditUsers: false,
groupsByUserId: mockGroupsByUserId,
},
@@ -47,7 +41,6 @@ export const ExampleWithAISeatColumn: Story = {
{ ...MockUserOwner, has_ai_seat: true },
{ ...MockUserMember, has_ai_seat: false },
],
roles: MockAssignableSiteRoles,
canEditUsers: false,
groupsByUserId: mockGroupsByUserId,
showAISeatColumn: true,
@@ -90,7 +83,6 @@ export const Editable: Story = {
has_ai_seat: false,
},
],
roles: MockAssignableSiteRoles,
canEditUsers: true,
canViewActivity: true,
groupsByUserId: mockGroupsByUserId,
@@ -133,7 +125,6 @@ export const EditableWithAISeatColumn: Story = {
has_ai_seat: false,
},
],
roles: MockAssignableSiteRoles,
canEditUsers: true,
canViewActivity: true,
groupsByUserId: mockGroupsByUserId,
@@ -144,14 +135,12 @@ export const EditableWithAISeatColumn: Story = {
export const Empty: Story = {
args: {
users: [],
roles: MockAssignableSiteRoles,
},
};
export const Loading: Story = {
args: {
users: [],
roles: MockAssignableSiteRoles,
isLoading: true,
},
parameters: {
+313
View File
@@ -0,0 +1,313 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { EllipsisVerticalIcon, TrashIcon } from "lucide-react";
import { Link } from "react-router";
import type { GroupsByUserId } from "#/api/queries/groups";
import type * as TypesGen from "#/api/typesGenerated";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton";
import { PremiumBadge } from "#/components/Badges/Badges";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { LastSeen } from "#/components/LastSeen/LastSeen";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "#/components/TableLoader/TableLoader";
import { AISeatCell } from "#/modules/users/AISeatCell";
import { UserGroupsCell } from "#/modules/users/UserGroupsCell";
import {
AiAddonHelpPopover,
GroupsHelpPopover,
RolesHelpPopover,
} from "#/modules/users/UserHelpPopovers";
import { UserRoleCell } from "#/modules/users/UserRoleCell";
import { cn } from "#/utils/cn";
dayjs.extend(relativeTime);
export type UsersTableProps = {
// State
isLoading: boolean;
users: readonly TypesGen.User[] | undefined;
groupsByUserId: GroupsByUserId | undefined;
showAISeatColumn?: boolean;
// Actions
onEditUserRoles: (user: TypesGen.User) => void;
isUpdatingUserRoles?: boolean;
onResetUserPassword: (user: TypesGen.User) => void;
onSuspendUser: (user: TypesGen.User) => void;
onActivateUser: (user: TypesGen.User) => void;
onDeleteUser: (user: TypesGen.User) => void;
// Permissions
/**
* Used to disable the UI of actions that users cannot perform on themselves,
* like delete.
*/
me: string;
canEditUsers: boolean;
canViewActivity?: boolean;
/** User roles cannot be edited if OIDC Role Sync is enabled. */
oidcRoleSyncEnabled?: boolean;
};
export const UsersTable: React.FC<UsersTableProps> = (props) => {
const { showAISeatColumn } = props;
return (
<Table data-testid="users-table">
<TableHeader>
<TableRow>
<TableHead className="w-max">User</TableHead>
<TableHead className="w-1/6">
<div className="flex flex-row gap-2 items-center">
<span>Roles</span>
<RolesHelpPopover />
</div>
</TableHead>
<TableHead className="w-1/6">
<div className="flex flex-row gap-2 items-center">
<span>Groups</span>
<GroupsHelpPopover />
</div>
</TableHead>
{showAISeatColumn && (
<TableHead className="w-1/6">
<div className="flex flex-row gap-2 items-center">
<span>AI add-on</span>
<AiAddonHelpPopover />
</div>
</TableHead>
)}
<TableHead className="w-1/6">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<UsersTableBody {...props} />
</TableBody>
</Table>
);
};
const UsersTableBody: React.FC<UsersTableProps> = ({
isLoading,
users,
groupsByUserId,
showAISeatColumn,
onEditUserRoles,
isUpdatingUserRoles,
onResetUserPassword,
onSuspendUser,
onActivateUser,
onDeleteUser,
me,
canEditUsers,
canViewActivity,
oidcRoleSyncEnabled,
}) => {
if (isLoading) {
return (
<UsersTableSkeleton
showAISeatColumn={showAISeatColumn}
canEditUsers={canEditUsers}
/>
);
}
if (!users || users.length === 0) {
return (
<TableRow>
<TableCell colSpan={999}>
<div className="p-8">
<EmptyState message="No users found" />
</div>
</TableCell>
</TableRow>
);
}
return (
<>
{users?.map((user) => (
<TableRow key={user.id} data-testid={`user-${user.id}`}>
<TableCell>
<AvatarData
title={user.username}
subtitle={
user.is_service_account ? "Service Account" : user.email
}
src={user.avatar_url}
/>
</TableCell>
<UserRoleCell roles={user.roles} />
<UserGroupsCell userGroups={groupsByUserId?.get(user.id)} />
{showAISeatColumn && <AISeatCell hasAISeat={user.has_ai_seat} />}
<TableCell
className={cn(
"capitalize",
user.status === "suspended" && "text-content-secondary",
)}
>
<div>{user.status}</div>
{(user.status === "active" || user.status === "dormant") && (
<LastSeen at={user.last_seen_at} className="text-xs" />
)}
</TableCell>
{canEditUsers && (
<TableCell>
<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:${user.username}`)}`}
>
View workspaces
</Link>
</DropdownMenuItem>
{canViewActivity && (
<DropdownMenuItem asChild disabled={!canViewActivity}>
<Link
to={`/audit?filter=${encodeURIComponent(`username:${user.username}`)}`}
>
View activity {!canViewActivity && <PremiumBadge />}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to={user.username}>Edit</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={
isUpdatingUserRoles ||
(user.login_type === "oidc" && oidcRoleSyncEnabled)
}
onClick={() => onEditUserRoles(user)}
>
Edit roles
</DropdownMenuItem>
{user.status !== "suspended" && (
<DropdownMenuItem
disabled={user.login_type !== "password"}
onClick={() => onResetUserPassword(user)}
>
Reset password&hellip;
</DropdownMenuItem>
)}
{user.status === "active" || user.status === "dormant" ? (
<DropdownMenuItem
data-testid="suspend-button"
onClick={() => onSuspendUser(user)}
>
Suspend&hellip;
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onActivateUser(user)}>
Activate&hellip;
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onDeleteUser(user)}
disabled={user.id === me}
>
<TrashIcon className="size-icon-xs" />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
))}
</>
);
};
type UsersTableSkeletonProps = {
showAISeatColumn?: boolean;
canEditUsers: boolean;
};
const UsersTableSkeleton: React.FC<UsersTableSkeletonProps> = ({
showAISeatColumn,
canEditUsers,
}) => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<AvatarDataSkeleton />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
{showAISeatColumn && (
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
)}
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
{canEditUsers && (
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
)}
</TableRowSkeleton>
</TableLoaderSkeleton>
);
};
@@ -1,116 +0,0 @@
import type { FC } from "react";
import type { GroupsByUserId } from "#/api/queries/groups";
import type * as TypesGen from "#/api/typesGenerated";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { TableColumnHelpPopover } from "../../OrganizationSettingsPage/UserTable/TableColumnHelpPopover";
import { UsersTableBody } from "./UsersTableBody";
interface UsersTableProps {
users: readonly TypesGen.User[] | undefined;
roles: TypesGen.AssignableRoles[] | undefined;
groupsByUserId: GroupsByUserId | undefined;
isUpdatingUserRoles?: boolean;
canEditUsers: boolean;
canViewActivity?: boolean;
showAISeatColumn?: boolean;
isLoading: boolean;
onSuspendUser: (user: TypesGen.User) => void;
onActivateUser: (user: TypesGen.User) => void;
onDeleteUser: (user: TypesGen.User) => void;
onListWorkspaces: (user: TypesGen.User) => void;
onViewActivity: (user: TypesGen.User) => void;
onResetUserPassword: (user: TypesGen.User) => void;
onUpdateUserRoles: (
userId: string,
roles: TypesGen.SlimRole["name"][],
) => void;
isNonInitialPage: boolean;
actorID: string;
oidcRoleSyncEnabled: boolean;
authMethods?: TypesGen.AuthMethods;
}
export const UsersTable: FC<UsersTableProps> = ({
users,
roles,
onSuspendUser,
onDeleteUser,
onListWorkspaces,
onViewActivity,
onActivateUser,
onResetUserPassword,
onUpdateUserRoles,
isUpdatingUserRoles,
canEditUsers,
canViewActivity,
showAISeatColumn,
isLoading,
isNonInitialPage,
actorID,
oidcRoleSyncEnabled,
authMethods,
groupsByUserId,
}) => {
return (
<Table data-testid="users-table">
<TableHeader>
<TableRow>
<TableHead className="w-2/6">User</TableHead>
<TableHead className="w-2/6">
<div className="flex flex-row gap-2 items-center">
<span>Roles</span>
<TableColumnHelpPopover variant="roles" />
</div>
</TableHead>
<TableHead className="w-1/6">
<div className="flex flex-row gap-2 items-center">
<span>Groups</span>
<TableColumnHelpPopover variant="groups" />
</div>
</TableHead>
{showAISeatColumn && (
<TableHead className="w-1/6">
<div className="flex flex-row gap-2 items-center">
<span>AI add-on</span>
<TableColumnHelpPopover variant="ai_addon" />
</div>
</TableHead>
)}
<TableHead className="w-1/6">Login Type</TableHead>
<TableHead className="w-1/6">Status</TableHead>
{canEditUsers && <TableHead className="w-auto" />}
</TableRow>
</TableHeader>
<TableBody>
<UsersTableBody
users={users}
roles={roles}
groupsByUserId={groupsByUserId}
isLoading={isLoading}
canEditUsers={canEditUsers}
canViewActivity={canViewActivity}
showAISeatColumn={showAISeatColumn}
isUpdatingUserRoles={isUpdatingUserRoles}
onActivateUser={onActivateUser}
onDeleteUser={onDeleteUser}
onListWorkspaces={onListWorkspaces}
onViewActivity={onViewActivity}
onResetUserPassword={onResetUserPassword}
onSuspendUser={onSuspendUser}
onUpdateUserRoles={onUpdateUserRoles}
isNonInitialPage={isNonInitialPage}
actorID={actorID}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
authMethods={authMethods}
/>
</TableBody>
</Table>
);
};
@@ -1,317 +0,0 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import {
BanIcon,
EllipsisVerticalIcon,
KeyIcon,
ShieldIcon,
TrashIcon,
UserLockIcon,
} from "lucide-react";
import type { FC } from "react";
import { useNavigate } from "react-router";
import type { GroupsByUserId } from "#/api/queries/groups";
import type * as TypesGen from "#/api/typesGenerated";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton";
import { PremiumBadge } from "#/components/Badges/Badges";
import { Button } from "#/components/Button/Button";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { LastSeen } from "#/components/LastSeen/LastSeen";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { TableCell, TableRow } from "#/components/Table/Table";
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "#/components/TableLoader/TableLoader";
import { AISeatCell } from "#/modules/users/AISeatCell";
import { cn } from "#/utils/cn";
import { UserRoleCell } from "../../OrganizationSettingsPage/UserTable/UserRoleCell";
import { UserGroupsCell } from "./UserGroupsCell";
dayjs.extend(relativeTime);
interface UsersTableBodyProps {
users: readonly TypesGen.User[] | undefined;
groupsByUserId: GroupsByUserId | undefined;
authMethods?: TypesGen.AuthMethods;
roles?: TypesGen.AssignableRoles[];
isUpdatingUserRoles?: boolean;
canEditUsers: boolean;
isLoading: boolean;
canViewActivity?: boolean;
showAISeatColumn?: boolean;
onSuspendUser: (user: TypesGen.User) => void;
onDeleteUser: (user: TypesGen.User) => void;
onListWorkspaces: (user: TypesGen.User) => void;
onViewActivity: (user: TypesGen.User) => void;
onActivateUser: (user: TypesGen.User) => void;
onResetUserPassword: (user: TypesGen.User) => void;
onUpdateUserRoles: (
userId: string,
roles: TypesGen.SlimRole["name"][],
) => void;
isNonInitialPage: boolean;
actorID: string;
// oidcRoleSyncEnabled should be set to false if unknown.
// This is used to determine if the oidc roles are synced from the oidc idp and
// editing via the UI should be disabled.
oidcRoleSyncEnabled: boolean;
}
export const UsersTableBody: FC<UsersTableBodyProps> = ({
users,
authMethods,
roles,
onSuspendUser,
onDeleteUser,
onListWorkspaces,
onViewActivity,
onActivateUser,
onResetUserPassword,
onUpdateUserRoles,
isUpdatingUserRoles,
canEditUsers,
canViewActivity,
showAISeatColumn,
isLoading,
isNonInitialPage,
actorID,
oidcRoleSyncEnabled,
groupsByUserId,
}) => {
const navigate = useNavigate();
return (
<ChooseOne>
<Cond condition={Boolean(isLoading)}>
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<AvatarDataSkeleton />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
{showAISeatColumn && (
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
)}
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
{canEditUsers && (
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
)}
</TableRowSkeleton>
</TableLoaderSkeleton>
</Cond>
<Cond condition={!users || users.length === 0}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<TableRow>
<TableCell colSpan={999}>
<div className="p-8">
<EmptyState message="No users found on this page" />
</div>
</TableCell>
</TableRow>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
<div className="p-8">
<EmptyState message="No users found" />
</div>
</TableCell>
</TableRow>
</Cond>
</ChooseOne>
</Cond>
<Cond>
{users?.map((user) => (
<TableRow key={user.id} data-testid={`user-${user.id}`}>
<TableCell>
<AvatarData
title={user.username}
subtitle={
user.is_service_account ? "Service Account" : user.email
}
src={user.avatar_url}
/>
</TableCell>
<UserRoleCell
canEditUsers={canEditUsers}
allAvailableRoles={roles}
userLoginType={user.login_type}
roles={user.roles}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
isLoading={Boolean(isUpdatingUserRoles)}
onEditRoles={(roles) => onUpdateUserRoles(user.id, roles)}
/>
<UserGroupsCell userGroups={groupsByUserId?.get(user.id)} />
{showAISeatColumn && <AISeatCell hasAISeat={user.has_ai_seat} />}
<TableCell>
<LoginType authMethods={authMethods!} value={user.login_type} />
</TableCell>
<TableCell
className={cn(
"capitalize",
user.status === "suspended" && "text-content-secondary",
)}
>
<div>{user.status}</div>
{(user.status === "active" || user.status === "dormant") && (
<LastSeen at={user.last_seen_at} className="text-xs" />
)}
</TableCell>
{canEditUsers && (
<TableCell>
<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">
{user.status === "active" || user.status === "dormant" ? (
<DropdownMenuItem
data-testid="suspend-button"
onClick={() => onSuspendUser(user)}
>
Suspend&hellip;
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onActivateUser(user)}>
Activate&hellip;
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onListWorkspaces(user)}>
View workspaces
</DropdownMenuItem>
{canViewActivity && (
<DropdownMenuItem
onClick={() => onViewActivity(user)}
disabled={!canViewActivity}
>
View activity {!canViewActivity && <PremiumBadge />}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigate(user.username)}>
Edit
</DropdownMenuItem>
{user.login_type === "password" && (
<DropdownMenuItem
onClick={() => onResetUserPassword(user)}
disabled={user.login_type !== "password"}
>
Reset password&hellip;
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onDeleteUser(user)}
disabled={user.id === actorID}
>
<TrashIcon className="size-icon-xs" />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
))}
</Cond>
</ChooseOne>
);
};
interface LoginTypeProps {
authMethods: TypesGen.AuthMethods;
value: TypesGen.LoginType;
}
const LoginType: FC<LoginTypeProps> = ({ authMethods, value }) => {
let displayName: string = value;
let icon = <></>;
if (value === "password") {
displayName = "Password";
icon = <UserLockIcon className="size-icon-xs" />;
} else if (value === "none") {
displayName = "None";
icon = <BanIcon className="size-icon-xs" />;
} else if (value === "github") {
displayName = "GitHub";
icon = <ExternalImage src="/icon/github.svg" className="size-icon-xs" />;
} else if (value === "token") {
displayName = "Token";
icon = <KeyIcon className="size-icon-xs" />;
} else if (value === "oidc") {
displayName =
authMethods.oidc.signInText === "" ? "OIDC" : authMethods.oidc.signInText;
icon =
authMethods.oidc.iconUrl === "" ? (
<ShieldIcon className="size-icon-xs" />
) : (
<img
alt="Open ID Connect icon"
src={authMethods.oidc.iconUrl}
className="size-icon-xs"
/>
);
}
return (
<div className="flex items-center gap-2 text-sm">
{icon}
{displayName}
</div>
);
};
-5
View File
@@ -511,11 +511,6 @@ export const MockSiteRoles = [
MockAuditorRole,
MockWorkspaceCreationBanRole,
];
export const MockAssignableSiteRoles = [
assignableRole(MockUserAdminRole, true),
assignableRole(MockAuditorRole, true),
assignableRole(MockWorkspaceCreationBanRole, true),
];
export const MockUserOwner: TypesGen.User = {
id: "test-user",