mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: update UsersPage role editing to match new designs (#24857)
This commit is contained in:
@@ -42,6 +42,7 @@ site/.swc
|
||||
.gen-golden
|
||||
|
||||
# Build
|
||||
bin/
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
|
||||
+4
-3
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
+8
-8
@@ -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." }),
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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/);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
-12
@@ -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: {
|
||||
@@ -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…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{user.status === "active" || user.status === "dormant" ? (
|
||||
<DropdownMenuItem
|
||||
data-testid="suspend-button"
|
||||
onClick={() => onSuspendUser(user)}
|
||||
>
|
||||
Suspend…
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onActivateUser(user)}>
|
||||
Activate…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onDeleteUser(user)}
|
||||
disabled={user.id === me}
|
||||
>
|
||||
<TrashIcon className="size-icon-xs" />
|
||||
Delete…
|
||||
</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…
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onActivateUser(user)}>
|
||||
Activate…
|
||||
</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…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => onDeleteUser(user)}
|
||||
disabled={user.id === actorID}
|
||||
>
|
||||
<TrashIcon className="size-icon-xs" />
|
||||
Delete…
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user