2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, ConfirmationDialogContent, Dialog, DialogTrigger, showToast } from "@calcom/ui";
import type { User } from "../UserListTable";
interface Props {
users: User[];
onRemove: () => void;
}
export function DeleteBulkUsers({ users, onRemove }: Props) {
const { t } = useLocale();
const selectedRows = users; // Get selected rows from table
const utils = trpc.useUtils();
const deleteMutation = trpc.viewer.organizations.bulkDeleteUsers.useMutation({
onSuccess: () => {
utils.viewer.organizations.listMembers.invalidate();
showToast("Deleted Users", "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button StartIcon="ban">{t("Delete")}</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_users_from_org")}
confirmBtnText={t("remove")}
isPending={deleteMutation.isPending}
onConfirm={() => {
deleteMutation.mutateAsync({
userIds: selectedRows.map((user) => user.id),
});
onRemove();
}}>
<p className="mt-5">
{t("remove_users_from_org_confirm", {
userCount: selectedRows.length,
})}
</p>
</ConfirmationDialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,262 @@
import type { Table } from "@tanstack/react-table";
import { Check } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
showToast,
PopoverContent,
PopoverTrigger,
} from "@calcom/ui";
import type { User } from "../UserListTable";
interface Props {
table: Table<User>;
orgTeams: RouterOutputs["viewer"]["organizations"]["getTeams"] | undefined;
}
export function EventTypesList({ table, orgTeams }: Props) {
const { t } = useLocale();
const utils = trpc.useUtils();
const teamIds = orgTeams?.map((team) => team.id);
const { data } = trpc.viewer.eventTypes.getByViewer.useQuery({
filters: { teamIds, schedulingTypes: [SchedulingType.ROUND_ROBIN] },
});
const addMutation = trpc.viewer.organizations.addMembersToEventTypes.useMutation({
onError: (error) => {
showToast(error.message, "error");
},
onSuccess: () => {
showToast(
`${selectedUsers.length} users added to ${Array.from(selectedEvents).length} events`,
"success"
);
utils.viewer.organizations.listMembers.invalidate();
utils.viewer.eventTypes.invalidate();
// Clear the selected values
setSelectedEvents(new Set());
setSelectedTeams(new Set());
table.toggleAllRowsSelected(false);
},
});
const removeHostsMutation = trpc.viewer.organizations.removeHostsFromEventTypes.useMutation({
onError: (error) => {
showToast(error.message, "error");
},
onSuccess: () => {
showToast(
`${selectedUsers.length} users were removed from ${Array.from(removeHostFromEvents).length} events`,
"success"
);
utils.viewer.organizations.listMembers.invalidate();
utils.viewer.eventTypes.invalidate();
// Clear the selected values
setRemoveHostFromEvents(new Set());
table.toggleAllRowsSelected(false);
},
});
const [selectedEvents, setSelectedEvents] = useState<Set<number>>(new Set());
const [selectedTeams, setSelectedTeams] = useState<Set<number>>(new Set());
const [removeHostFromEvents, setRemoveHostFromEvents] = useState<Set<number>>(new Set());
const teams = data?.eventTypeGroups;
const selectedUsers = table.getSelectedRowModel().flatRows.map((row) => row.original);
// Add value array to the set
const addValue = (set: Set<number>, setSet: Dispatch<SetStateAction<Set<number>>>, value: number[]) => {
const updatedSet = new Set(set);
value.forEach((v) => updatedSet.add(v));
setSet(updatedSet);
};
// Remove value array from the set
const removeValue = (set: Set<number>, setSet: Dispatch<SetStateAction<Set<number>>>, value: number[]) => {
const updatedSet = new Set(set);
value.forEach((v) => updatedSet.delete(v));
setSet(updatedSet);
};
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button StartIcon="link">{t("add_to_event_type")}</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0 shadow-md" align="start" sideOffset={12}>
<Command>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{teams &&
teams.map((team) => {
const events = team.eventTypes;
const teamId = team.teamId;
if (events.length === 0 || !teamId) return null;
const ids = events.map((event) => event.id);
const areAllUsersHostForTeam = selectedUsers.every((user) =>
events.every((event) => event.hosts.some((host) => host.userId === user.id))
);
const isSelected = ids.every(
(id) =>
selectedEvents.has(id) || (areAllUsersHostForTeam && !removeHostFromEvents.has(id))
);
return (
<>
<ListItem
isTeam
onSelect={() => {
if (!isSelected) {
// Add current team and its event
addValue(selectedTeams, setSelectedTeams, [teamId]);
addValue(selectedEvents, setSelectedEvents, ids);
setRemoveHostFromEvents(new Set());
} else {
const eventIdsWhereAllUsersAreHosts = events
.filter((event) =>
selectedUsers.every((user) =>
event.hosts.some((host) => host.userId === user.id)
)
)
.map((event) => event.id);
addValue(
removeHostFromEvents,
setRemoveHostFromEvents,
eventIdsWhereAllUsersAreHosts
);
// Remove selected team and its event
removeValue(selectedEvents, setSelectedEvents, ids);
removeValue(selectedTeams, setSelectedTeams, [teamId]);
}
}}
isSelected={isSelected}
text={team.profile.name || ""}
key={team.profile.name}
/>
{events.map((event) => {
const hosts = event.hosts;
const areAllUsersHostForEventType = selectedUsers.every((user) =>
hosts.some((host) => host.userId === user.id)
);
const isSelected =
(selectedEvents.has(event.id) || areAllUsersHostForEventType) &&
!removeHostFromEvents.has(event.id);
return (
<ListItem
isTeam={false}
onSelect={() => {
if (!isSelected) {
if (areAllUsersHostForEventType) {
removeValue(removeHostFromEvents, setRemoveHostFromEvents, [event.id]);
} else {
// Add current event and its team
addValue(selectedEvents, setSelectedEvents, [event.id]);
addValue(selectedTeams, setSelectedTeams, [teamId]);
}
} else {
if (areAllUsersHostForEventType) {
// remove selected users as hosts
addValue(removeHostFromEvents, setRemoveHostFromEvents, [event.id]);
} else {
// remove current event and its team
removeValue(selectedEvents, setSelectedEvents, [event.id]);
// if no event from current team is selected, remove the team
setSelectedEvents((selectedEvents) => {
if (!ids.some((id) => selectedEvents.has(id))) {
setSelectedTeams((selectedTeams) => {
const updatedTeams = new Set(selectedTeams);
updatedTeams.delete(teamId);
return updatedTeams;
});
}
return selectedEvents;
});
}
}
}}
key={event.id}
text={event.title}
isSelected={isSelected}
/>
);
})}
</>
);
})}
</CommandGroup>
</CommandList>
</Command>
<div className="my-1.5 flex w-full">
<Button
className="ml-auto mr-1.5 rounded-md"
size="sm"
onClick={() => {
const userIds = selectedUsers.map((user) => user.id);
if (selectedEvents.size > 0) {
addMutation.mutateAsync({
userIds: userIds,
teamIds: Array.from(selectedTeams),
eventTypeIds: Array.from(selectedEvents),
});
}
if (removeHostFromEvents.size > 0) {
removeHostsMutation.mutateAsync({
userIds,
eventTypeIds: Array.from(removeHostFromEvents),
});
}
}}>
{t("apply")}
</Button>
</div>
</PopoverContent>
</Popover>
</>
);
}
interface ListItemProps {
text: string;
isSelected: boolean;
onSelect: () => void;
isTeam: boolean;
}
const ListItem = ({ onSelect, text, isSelected, isTeam }: ListItemProps) => {
return (
<CommandItem
key={text}
onSelect={onSelect}
className={classNames(isTeam && "text-subtle text-xs font-normal")}>
{text}
<div
className={classNames(
"border-subtle ml-auto flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Check className={classNames("h-4 w-4")} />
</div>
</CommandItem>
);
};

View File

@@ -0,0 +1,166 @@
import type { Table } from "@tanstack/react-table";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Icon,
Popover,
PopoverContent,
PopoverTrigger,
showToast,
} from "@calcom/ui";
import type { User } from "../UserListTable";
interface Props {
table: Table<User>;
}
export function TeamListBulkAction({ table }: Props) {
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
const [selectedValues, setSelectedValues] = useState<Set<number>>(new Set());
const [removeFromTeams, setRemoveFromTeams] = useState<Set<number>>(new Set());
const utils = trpc.useUtils();
const mutation = trpc.viewer.organizations.addMembersToTeams.useMutation({
onError: (error) => {
showToast(error.message, "error");
},
onSuccess: (res) => {
showToast(
`${res.invitedTotalUsers} Users invited to ${Array.from(selectedValues).length} teams`,
"success"
);
// Optimistically update the data from query trpc cache listMembers
// We may need to set this data instread of invalidating. Will see how performance handles it
utils.viewer.organizations.listMembers.invalidate();
// Clear the selected values
setSelectedValues(new Set());
table.toggleAllRowsSelected(false);
},
});
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
onError: (error) => {
showToast(error.message, "error");
},
onSuccess: () => {
showToast(`${selectedUsers.length} Users removed from ${removeFromTeams.size} teams`, "success");
utils.viewer.organizations.listMembers.invalidate();
// Clear the selected values
setRemoveFromTeams(new Set());
table.toggleAllRowsSelected(false);
},
});
const { t } = useLocale();
const selectedUsers = table.getSelectedRowModel().flatRows.map((row) => row.original);
// Add a value to the set
const addValue = (set: Set<number>, setSet: Dispatch<SetStateAction<Set<number>>>, value: number) => {
const updatedSet = new Set(set);
updatedSet.add(value);
setSet(updatedSet);
};
// Remove value from the set
const removeValue = (set: Set<number>, setSet: Dispatch<SetStateAction<Set<number>>>, value: number) => {
const updatedSet = new Set(set);
updatedSet.delete(value);
setSet(updatedSet);
};
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button StartIcon="users">{t("add_to_team")}</Button>
</PopoverTrigger>
{/* We dont really use shadows much - but its needed here */}
<PopoverContent className="w-[200px] p-0 shadow-md" align="start" sideOffset={12}>
<Command>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{teams &&
teams.map((option) => {
const areAllUsersInTeam = selectedUsers.every((user) =>
user.teams.some((team) => team.id === option.id)
);
const isSelected =
(selectedValues.has(option.id) || areAllUsersInTeam) && !removeFromTeams.has(option.id);
return (
<CommandItem
key={option.id}
onSelect={() => {
if (!isSelected) {
if (areAllUsersInTeam) {
removeValue(removeFromTeams, setRemoveFromTeams, option.id);
} else {
addValue(selectedValues, setSelectedValues, option.id);
}
} else {
if (areAllUsersInTeam) {
addValue(removeFromTeams, setRemoveFromTeams, option.id);
} else {
removeValue(selectedValues, setSelectedValues, option.id);
}
}
}}>
<span>{option.name}</span>
<div
className={classNames(
"border-subtle ml-auto flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
<div className="my-1.5 flex w-full">
<Button
loading={mutation.isPending}
className="ml-auto mr-1.5 rounded-md"
size="sm"
onClick={async () => {
if (selectedValues.size > 0) {
mutation.mutateAsync({
userIds: selectedUsers.map((user) => user.id),
teamIds: Array.from(selectedValues),
});
}
if (removeFromTeams.size > 0) {
removeMemberMutation.mutateAsync({
memberIds: selectedUsers.map((user) => user.id),
teamIds: Array.from(removeFromTeams),
isOrg: true,
});
}
}}>
{t("apply")}
</Button>
</div>
</PopoverContent>
</Popover>
</>
);
}

View File

@@ -0,0 +1,27 @@
import { useSession } from "next-auth/react";
import type { Dispatch } from "react";
import MemberChangeRoleModal from "@calcom/features/ee/teams/components/MemberChangeRoleModal";
import type { Action, State } from "./UserListTable";
export function ChangeUserRoleModal(props: { state: State; dispatch: Dispatch<Action> }) {
const { data: session } = useSession();
const orgId = session?.user.org?.id;
if (!orgId || !props.state.changeMemberRole.user) return null;
return (
<MemberChangeRoleModal
isOpen={true}
currentMember={props.state.changeMemberRole.user?.role}
teamId={orgId}
memberId={props.state.changeMemberRole.user?.id}
initialRole={props.state.changeMemberRole.user?.role}
onExit={() =>
props.dispatch({
type: "CLOSE_MODAL",
})
}
/>
);
}

View File

@@ -0,0 +1,55 @@
import { useSession } from "next-auth/react";
import type { Dispatch } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Dialog, ConfirmationDialogContent, showToast } from "@calcom/ui";
import type { State, Action } from "./UserListTable";
export function DeleteMemberModal({ state, dispatch }: { state: State; dispatch: Dispatch<Action> }) {
const { t } = useLocale();
const { data: session } = useSession();
const utils = trpc.useUtils();
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
onSuccess() {
// We don't need to wait for invalidate to finish
Promise.all([
utils.viewer.teams.get.invalidate(),
utils.viewer.eventTypes.invalidate(),
utils.viewer.organizations.listMembers.invalidate(),
]);
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
return (
<Dialog
open={state.deleteMember.showModal}
onOpenChange={(open) =>
!open &&
dispatch({
type: "CLOSE_MODAL",
})
}>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={() => {
// Shouldnt ever happen just for type safety
if (!session?.user.org?.id || !state?.deleteMember?.user?.id) return;
removeMemberMutation.mutate({
teamIds: [session?.user.org.id],
memberIds: [state?.deleteMember?.user.id],
isOrg: true,
});
}}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,69 @@
import { classNames } from "@calcom/lib";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import type { BadgeProps } from "@calcom/ui";
import { Badge, Button, Label } from "@calcom/ui";
type DisplayInfoType<T extends boolean> = {
label: string;
value: T extends true ? string[] : string;
asBadge?: boolean;
isArray?: T;
displayCopy?: boolean;
badgeColor?: BadgeProps["variant"];
} & (T extends false
? { displayCopy?: boolean; displayCount?: never }
: { displayCopy?: never; displayCount?: number }); // Only show displayCopy if its not an array is false
export function DisplayInfo<T extends boolean>({
label,
value,
asBadge,
isArray,
displayCopy,
displayCount,
badgeColor,
}: DisplayInfoType<T>) {
const { copyToClipboard, isCopied } = useCopy();
const values = (isArray ? value : [value]) as string[];
return (
<div className="flex flex-col">
<Label className="text-subtle mb-1 text-xs font-semibold uppercase leading-none">
{label} {displayCount && `(${displayCount})`}
</Label>
<div className={classNames(asBadge ? "mt-0.5 flex space-x-2" : "flex flex-col")}>
<>
{values.map((v) => {
const content = (
<span
className={classNames(
"text-emphasis inline-flex items-center gap-1 font-normal leading-5",
asBadge ? "text-xs" : "text-sm"
)}>
{v}
{displayCopy && (
<Button
size="sm"
variant="icon"
onClick={() => copyToClipboard(v)}
color="minimal"
className="text-subtle rounded-md"
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
/>
)}
</span>
);
return asBadge ? (
<Badge variant={badgeColor} size="sm">
{content}
</Badge>
) : (
content
);
})}
</>
</div>
</div>
);
}

View File

@@ -0,0 +1,203 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useSession } from "next-auth/react";
import type { Dispatch } from "react";
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { shallow } from "zustand/shallow";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc/react";
import {
Form,
TextField,
ToggleGroup,
TextAreaField,
TimezoneSelect,
Label,
showToast,
Avatar,
ImageUploader,
SheetHeader,
SheetBody,
SheetFooter,
} from "@calcom/ui";
import type { Action } from "../UserListTable";
import { SheetFooterControls } from "./SheetFooterControls";
import { useEditMode } from "./store";
type MembershipOption = {
value: MembershipRole;
label: string;
};
const editSchema = z.object({
name: z.string(),
username: z.string(),
email: z.string().email(),
avatar: z.string(),
bio: z.string(),
role: z.enum([MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER]),
timeZone: z.string(),
// schedules: z.array(z.string()),
// teams: z.array(z.string()),
});
type EditSchema = z.infer<typeof editSchema>;
export function EditForm({
selectedUser,
avatarUrl,
domainUrl,
dispatch,
}: {
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
avatarUrl: string;
domainUrl: string;
dispatch: Dispatch<Action>;
}) {
const [setMutationLoading] = useEditMode((state) => [state.setMutationloading], shallow);
const { t } = useLocale();
const session = useSession();
const org = session?.data?.user?.org;
const utils = trpc.useUtils();
const form = useForm({
resolver: zodResolver(editSchema),
defaultValues: {
name: selectedUser?.name ?? "",
username: selectedUser?.username ?? "",
email: selectedUser?.email ?? "",
avatar: avatarUrl,
bio: selectedUser?.bio ?? "",
role: selectedUser?.role ?? "",
timeZone: selectedUser?.timeZone ?? "",
},
});
const isOwner = org?.role === MembershipRole.OWNER;
const membershipOptions = useMemo<MembershipOption[]>(() => {
const options: MembershipOption[] = [
{
value: MembershipRole.MEMBER,
label: t("member"),
},
{
value: MembershipRole.ADMIN,
label: t("admin"),
},
];
if (isOwner) {
options.push({
value: MembershipRole.OWNER,
label: t("owner"),
});
}
return options;
}, [t, isOwner]);
const mutation = trpc.viewer.organizations.updateUser.useMutation({
onSuccess: () => {
dispatch({ type: "CLOSE_MODAL" });
utils.viewer.organizations.listMembers.invalidate();
showToast(t("profile_updated_successfully"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
onSettled: () => {
/**
* /We need to do this as the submit button lives out side
* the form for some complicated reason so we can't relay on mutationState
*/
setMutationLoading(false);
},
});
const watchTimezone = form.watch("timeZone");
return (
<>
<Form
form={form}
id="edit-user-form"
className="flex h-full flex-col"
handleSubmit={(values) => {
setMutationLoading(true);
mutation.mutate({
userId: selectedUser?.id ?? "",
role: values.role,
username: values.username,
name: values.name,
email: values.email,
avatar: values.avatar,
bio: values.bio,
timeZone: values.timeZone,
});
}}>
<SheetHeader>
<div className="flex flex-col gap-2">
<Controller
control={form.control}
name="avatar"
render={({ field: { value } }) => (
<div className="flex items-center">
<Avatar alt={`${selectedUser?.name} avatar`} imageSrc={value} size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
form.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
/>
</div>
</div>
)}
/>
<div className="space-between flex flex-col leading-none">
<span className="text-emphasis text-lg font-semibold">
{selectedUser?.name ?? "Nameless User"}
</span>
<p className="subtle text-sm font-normal">
{domainUrl}/{selectedUser?.username}
</p>
</div>
</div>
</SheetHeader>
<SheetBody className="mt-6 flex h-full flex-col space-y-3">
<TextField label={t("username")} {...form.register("username")} />
<TextField label={t("name")} {...form.register("name")} />
<TextField label={t("email")} {...form.register("email")} />
<TextAreaField label={t("bio")} {...form.register("bio")} className="min-h-52" />
<div>
<Label>{t("role")}</Label>
<ToggleGroup
isFullWidth
defaultValue={selectedUser?.role ?? "MEMBER"}
value={form.watch("role")}
options={membershipOptions}
onValueChange={(value: EditSchema["role"]) => {
form.setValue("role", value);
}}
/>
</div>
<div>
<Label>{t("timezone")}</Label>
<TimezoneSelect value={watchTimezone ?? "America/Los_Angeles"} />
</div>
</SheetBody>
<SheetFooter>
<SheetFooterControls />
</SheetFooter>
</Form>
</>
);
}

View File

@@ -0,0 +1,133 @@
import type { Dispatch } from "react";
import { shallow } from "zustand/shallow";
import { useOrgBranding } from "@calcom/ee/organizations/context/provider";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Avatar,
Label,
Loader,
Sheet,
SheetContent,
SheetBody,
Skeleton,
SheetHeader,
SheetDescription,
SheetTitle,
SheetFooter,
} from "@calcom/ui";
import type { Action, State } from "../UserListTable";
import { DisplayInfo } from "./DisplayInfo";
import { EditForm } from "./EditUserForm";
import { SheetFooterControls } from "./SheetFooterControls";
import { useEditMode } from "./store";
export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dispatch<Action> }) {
const { t } = useLocale();
const { user: selectedUser } = state.editSheet;
const orgBranding = useOrgBranding();
const [editMode, setEditMode] = useEditMode((state) => [state.editMode, state.setEditMode], shallow);
const { data: loadedUser, isPending } = trpc.viewer.organizations.getUser.useQuery({
userId: selectedUser?.id,
});
const avatarURL = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${loadedUser?.username}/avatar.png`;
const schedulesNames = loadedUser?.schedules && loadedUser?.schedules.map((s) => s.name);
const teamNames =
loadedUser?.teams && loadedUser?.teams.map((t) => `${t.name} ${!t.accepted ? "(pending)" : ""}`);
return (
<Sheet
open={true}
onOpenChange={() => {
setEditMode(false);
dispatch({ type: "CLOSE_MODAL" });
}}>
<SheetContent>
{!isPending && loadedUser ? (
<>
{!editMode ? (
<>
<SheetHeader>
<Avatar
asChild
className="h-[36px] w-[36px]"
alt={`${loadedUser?.name} avatar`}
imageSrc={loadedUser.avatarUrl}
/>
<SheetTitle>
<Skeleton loading={isPending} as="p" waitForTranslation={false}>
<span className="text-emphasis text-lg font-semibold">
{loadedUser?.name ?? "Nameless User"}
</span>
</Skeleton>
</SheetTitle>
<SheetDescription>
<Skeleton loading={isPending} as="p" waitForTranslation={false}>
<p className="subtle text-sm font-normal">
{orgBranding?.fullDomain ?? WEBAPP_URL}/{loadedUser?.username}
</p>
</Skeleton>
</SheetDescription>
</SheetHeader>
<SheetBody className="flex flex-col space-y-5">
<DisplayInfo label={t("email")} value={loadedUser?.email ?? ""} displayCopy />
<DisplayInfo
label={t("bio")}
badgeColor="gray"
value={loadedUser?.bio ? loadedUser?.bio : t("user_has_no_bio")}
/>
<DisplayInfo label={t("role")} value={loadedUser?.role ?? ""} asBadge badgeColor="blue" />
<DisplayInfo label={t("timezone")} value={loadedUser?.timeZone ?? ""} />
<div className="flex flex-col">
<Label className="text-subtle mb-1 text-xs font-semibold uppercase leading-none">
{t("availability_schedules")}
</Label>
<div className="flex flex-col">
{schedulesNames
? schedulesNames.map((scheduleName) => (
<span
key={scheduleName}
className="text-emphasis inline-flex items-center gap-1 text-sm font-normal leading-5">
{scheduleName}
</span>
))
: t("user_has_no_schedules")}
</div>
</div>
<DisplayInfo
label={t("teams")}
displayCount={teamNames?.length ?? 0}
value={
teamNames && teamNames?.length === 0 ? [t("user_isnt_in_any_teams")] : teamNames ?? "" // TS wtf
}
asBadge={teamNames && teamNames?.length > 0}
/>
</SheetBody>
<SheetFooter>
<SheetFooterControls />
</SheetFooter>
</>
) : (
<>
<EditForm
selectedUser={loadedUser}
avatarUrl={loadedUser.avatarUrl ?? avatarURL}
domainUrl={orgBranding?.fullDomain ?? WEBAPP_URL}
dispatch={dispatch}
/>
</>
)}
</>
) : (
<Loader />
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,59 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SheetClose, Button } from "@calcom/ui";
import { useEditMode } from "./store";
function EditModeFooter() {
const { t } = useLocale();
const setEditMode = useEditMode((state) => state.setEditMode);
const isPending = useEditMode((state) => state.mutationLoading);
return (
<>
<Button
color="secondary"
type="button"
className="justify-center md:w-1/5"
onClick={() => {
setEditMode(false);
}}>
{t("cancel")}
</Button>
<Button type="submit" className="w-full justify-center" form="edit-user-form" loading={isPending}>
{t("update")}
</Button>
</>
);
}
function MoreInfoFooter() {
const { t } = useLocale();
const setEditMode = useEditMode((state) => state.setEditMode);
return (
<>
<SheetClose asChild>
<Button color="secondary" type="button" className="w-full justify-center lg:w-1/5">
{t("close")}
</Button>
</SheetClose>
<Button
type="button"
onClick={() => {
setEditMode(true);
}}
className="w-full justify-center gap-2"
variant="icon"
key="EDIT_BUTTON"
StartIcon="pencil">
{t("edit")}
</Button>
</>
);
}
export function SheetFooterControls() {
const editMode = useEditMode((state) => state.editMode);
return <>{editMode ? <EditModeFooter /> : <MoreInfoFooter />}</>;
}

View File

@@ -0,0 +1,15 @@
import { create } from "zustand";
interface EditModeState {
editMode: boolean;
setEditMode: (editMode: boolean) => void;
mutationLoading: boolean;
setMutationloading: (loading: boolean) => void;
}
export const useEditMode = create<EditModeState>((set) => ({
editMode: false,
setEditMode: (editMode) => set({ editMode }),
mutationLoading: false,
setMutationloading: (loading) => set({ mutationLoading: loading }),
}));

View File

@@ -0,0 +1,47 @@
import { signIn, useSession } from "next-auth/react";
import type { Dispatch } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui";
import type { Action, State } from "./UserListTable";
export function ImpersonationMemberModal(props: { state: State; dispatch: Dispatch<Action> }) {
const { t } = useLocale();
const { data: session } = useSession();
const teamId = session?.user.org?.id;
const user = props.state.impersonateMember.user;
if (!user || !teamId) return null;
return (
<Dialog
open={true}
onOpenChange={() =>
props.dispatch({
type: "CLOSE_MODAL",
})
}>
<DialogContent type="creation" title={t("impersonate")} description={t("impersonation_user_tip")}>
<form
onSubmit={async (e) => {
e.preventDefault();
await signIn("impersonation-auth", {
username: user.email,
teamId: teamId,
});
props.dispatch({
type: "CLOSE_MODAL",
});
}}>
<DialogFooter showDivider className="mt-8">
<DialogClose color="secondary">{t("cancel")}</DialogClose>
<Button color="primary" type="submit">
{t("impersonate")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,74 @@
import { useSession } from "next-auth/react";
import type { Dispatch } from "react";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { showToast } from "@calcom/ui";
import type { Action } from "./UserListTable";
interface Props {
dispatch: Dispatch<Action>;
}
export function InviteMemberModal(props: Props) {
const { data: session } = useSession();
const utils = trpc.useUtils();
const { t, i18n } = useLocale();
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
props.dispatch({ type: "CLOSE_MODAL" });
// Need to figure out if invalidating here is the right approach - we could have already
// loaded a bunch of data and idk how pagination works with invalidation. We may need to use
// Optimistic updates here instead.
await utils.viewer.organizations.listMembers.invalidate();
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.numUsersInvited,
}),
"success"
);
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
},
onError: (error) => {
showToast(error.message, "error");
},
});
if (!session?.user.org?.id) return null;
const orgId = session.user.org.id;
return (
<MemberInvitationModal
members={[]}
isOpen={true}
onExit={() => {
props.dispatch({
type: "CLOSE_MODAL",
});
}}
teamId={orgId}
isOrg={true}
isPending={inviteMemberMutation.isPending}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId: orgId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
});
}}
/>
);
}

View File

@@ -0,0 +1,452 @@
import { keepPreviousData } from "@tanstack/react-query";
import type { ColumnDef, Table } from "@tanstack/react-table";
import { m } from "framer-motion";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui";
import { useOrgBranding } from "../../../ee/organizations/context/provider";
import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers";
import { EventTypesList } from "./BulkActions/EventTypesList";
import { TeamListBulkAction } from "./BulkActions/TeamList";
import { ChangeUserRoleModal } from "./ChangeUserRoleModal";
import { DeleteMemberModal } from "./DeleteMemberModal";
import { EditUserSheet } from "./EditSheet/EditUserSheet";
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
import { InviteMemberModal } from "./InviteMemberModal";
import { TableActions } from "./UserTableActions";
export interface User {
id: number;
username: string | null;
email: string;
timeZone: string;
role: MembershipRole;
avatarUrl: string | null;
accepted: boolean;
disableImpersonation: boolean;
completedOnboarding: boolean;
teams: {
id: number;
name: string;
slug: string | null;
}[];
}
type Payload = {
showModal: boolean;
user?: User;
};
export type State = {
changeMemberRole: Payload;
deleteMember: Payload;
impersonateMember: Payload;
inviteMember: Payload;
editSheet: Payload;
};
export type Action =
| {
type:
| "SET_CHANGE_MEMBER_ROLE_ID"
| "SET_DELETE_ID"
| "SET_IMPERSONATE_ID"
| "INVITE_MEMBER"
| "EDIT_USER_SHEET";
payload: Payload;
}
| {
type: "CLOSE_MODAL";
};
const initialState: State = {
changeMemberRole: {
showModal: false,
},
deleteMember: {
showModal: false,
},
impersonateMember: {
showModal: false,
},
inviteMember: {
showModal: false,
},
editSheet: {
showModal: false,
},
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CHANGE_MEMBER_ROLE_ID":
return { ...state, changeMemberRole: action.payload };
case "SET_DELETE_ID":
return { ...state, deleteMember: action.payload };
case "SET_IMPERSONATE_ID":
return { ...state, impersonateMember: action.payload };
case "INVITE_MEMBER":
return { ...state, inviteMember: action.payload };
case "EDIT_USER_SHEET":
return { ...state, editSheet: action.payload };
case "CLOSE_MODAL":
return {
...state,
changeMemberRole: { showModal: false },
deleteMember: { showModal: false },
impersonateMember: { showModal: false },
inviteMember: { showModal: false },
editSheet: { showModal: false },
};
default:
return state;
}
}
export function UserListTable() {
const { data: session } = useSession();
const { copyToClipboard, isCopied } = useCopy();
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
const tableContainerRef = useRef<HTMLDivElement>(null);
const [state, dispatch] = useReducer(reducer, initialState);
const { t } = useLocale();
const orgBranding = useOrgBranding();
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false);
const { data, isPending, fetchNextPage, isFetching } =
trpc.viewer.organizations.listMembers.useInfiniteQuery(
{
limit: 10,
searchTerm: debouncedSearchTerm,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: keepPreviousData,
}
);
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
const adminOrOwner = org?.user.role === "ADMIN" || org?.user.role === "OWNER";
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
const memorisedColumns = useMemo(() => {
const permissions = {
canEdit: adminOrOwner,
canRemove: adminOrOwner,
canResendInvitation: adminOrOwner,
canImpersonate: false,
};
const cols: ColumnDef<User>[] = [
// Disabling select for this PR: Will work on actions etc in a follow up
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
},
{
id: "member",
accessorFn: (data) => data.email,
header: `Member (${totalDBRowCount})`,
cell: ({ row }) => {
const { username, email, avatarUrl } = row.original;
return (
<div className="flex items-center gap-2">
<Avatar
size="sm"
alt={username || email}
imageSrc={getUserAvatarUrl({
avatarUrl,
})}
/>
<div className="">
<div
data-testid={`member-${username}-username`}
className="text-emphasis text-sm font-medium leading-none">
{username || "No username"}
</div>
<div
data-testid={`member-${username}-email`}
className="text-subtle mt-1 text-sm leading-none">
{email}
</div>
</div>
</div>
);
},
filterFn: (rows, id, filterValue) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Weird typing issue
return rows.getValue(id).includes(filterValue);
},
},
{
id: "role",
accessorFn: (data) => data.role,
header: "Role",
cell: ({ row, table }) => {
const { role, username } = row.original;
return (
<Badge
data-testid={`member-${username}-role`}
variant={role === "MEMBER" ? "gray" : "blue"}
onClick={() => {
table.getColumn("role")?.setFilterValue([role]);
}}>
{role}
</Badge>
);
},
filterFn: (rows, id, filterValue) => {
if (filterValue.includes("PENDING")) {
if (filterValue.length === 1) return !rows.original.accepted;
else return !rows.original.accepted || filterValue.includes(rows.getValue(id));
}
// Show only the selected roles
return filterValue.includes(rows.getValue(id));
},
},
{
id: "teams",
accessorFn: (data) => data.teams.map((team) => team.name),
header: "Teams",
cell: ({ row, table }) => {
const { teams, accepted, email, username } = row.original;
// TODO: Implement click to filter
return (
<div className="flex h-full flex-wrap items-center gap-2">
{accepted ? null : (
<Badge
data-testid2={`member-${username}-pending`}
variant="red"
className="text-xs"
data-testid={`email-${email.replace("@", "")}-pending`}
onClick={() => {
table.getColumn("role")?.setFilterValue(["PENDING"]);
}}>
Pending
</Badge>
)}
{teams.map((team) => (
<Badge
key={team.id}
variant="gray"
onClick={() => {
table.getColumn("teams")?.setFilterValue([team.name]);
}}>
{team.name}
</Badge>
))}
</div>
);
},
filterFn: (rows, _, filterValue: string[]) => {
const teamNames = rows.original.teams.map((team) => team.name);
return filterValue.some((value: string) => teamNames.includes(value));
},
},
{
id: "actions",
cell: ({ row }) => {
const user = row.original;
const permissionsRaw = permissions;
const isSelf = user.id === session?.user.id;
const permissionsForUser = {
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
canRemove: permissionsRaw.canRemove && !isSelf,
canImpersonate:
user.accepted && !user.disableImpersonation && !isSelf && !!org?.canAdminImpersonate,
canLeave: user.accepted && isSelf,
canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted,
};
return (
<TableActions
user={user}
permissionsForUser={permissionsForUser}
dispatch={dispatch}
domain={domain}
/>
);
},
},
];
return cols;
}, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount]);
//we must flatten the array of arrays from the useInfiniteQuery hook
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[];
const totalFetched = flatData.length;
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
const fetchMoreOnBottomReached = useCallback(
(containerRefElement?: HTMLDivElement | null) => {
if (containerRefElement) {
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
fetchNextPage();
}
}
},
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
);
useEffect(() => {
fetchMoreOnBottomReached(tableContainerRef.current);
}, [fetchMoreOnBottomReached]);
return (
<>
<DataTable
data-testId="user-list-data-table"
onSearch={(value) => setDebouncedSearchTerm(value)}
selectionOptions={[
{
type: "render",
render: (table) => <TeamListBulkAction table={table} />,
},
{
type: "action",
icon: "handshake",
label: "Group Meeting",
needsXSelected: 2,
onClick: () => {
setDynamicLinkVisible((old) => !old);
},
},
{
type: "render",
render: (table) => <EventTypesList table={table} orgTeams={teams} />,
},
{
type: "render",
render: (table) => (
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
),
},
]}
renderAboveSelection={(table: Table<User>) => {
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
const users = table
.getSelectedRowModel()
.flatRows.map((row) => row.original.username)
.filter((u) => u !== null);
const usersNameAsString = users.join("+");
const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`;
const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, "");
return (
<>
{isVisible ? (
<m.div
layout
className="bg-brand-default text-inverted item-center animate-fade-in-bottom hidden w-full gap-1 rounded-lg p-2 text-sm font-medium leading-none md:flex">
<div className="w-[300px] items-center truncate p-2">
<p>{domainWithoutHttps}</p>
</div>
<div className="ml-auto flex items-center">
<Button
StartIcon="copy"
size="sm"
onClick={() => copyToClipboard(dynamicLinkOfSelectedUsers)}>
{!isCopied ? t("copy") : t("copied")}
</Button>
<Button
EndIcon="external-link"
size="sm"
href={dynamicLinkOfSelectedUsers}
target="_blank"
rel="noopener noreferrer">
Open
</Button>
</div>
</m.div>
) : null}
</>
);
}}
tableContainerRef={tableContainerRef}
tableCTA={
adminOrOwner && (
<Button
type="button"
color="primary"
StartIcon="plus"
size="sm"
className="rounded-md"
onClick={() =>
dispatch({
type: "INVITE_MEMBER",
payload: {
showModal: true,
},
})
}
data-testid="new-organization-member-button">
{t("add")}
</Button>
)
}
columns={memorisedColumns}
data={flatData}
isPending={isPending}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
filterableItems={[
{
tableAccessor: "role",
title: "Role",
options: [
{ label: "Owner", value: "OWNER" },
{ label: "Admin", value: "ADMIN" },
{ label: "Member", value: "MEMBER" },
{ label: "Pending", value: "PENDING" },
],
},
{
tableAccessor: "teams",
title: "Teams",
options: teams ? teams.map((team) => ({ label: team.name, value: team.name })) : [],
},
]}
/>
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
{state.editSheet.showModal && <EditUserSheet dispatch={dispatch} state={state} />}
</>
);
}

View File

@@ -0,0 +1,213 @@
import { useSession } from "next-auth/react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
ButtonGroup,
Tooltip,
Button,
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuSeparator,
showToast,
} from "@calcom/ui";
import type { Action } from "./UserListTable";
import type { User } from "./UserListTable";
export function TableActions({
user,
permissionsForUser,
dispatch,
domain,
}: {
user: User;
dispatch: React.Dispatch<Action>;
domain: string;
permissionsForUser: {
canEdit: boolean;
canRemove: boolean;
canImpersonate: boolean;
canResendInvitation: boolean;
};
}) {
const { t, i18n } = useLocale();
const { data: session } = useSession();
const resendInvitationMutation = trpc.viewer.teams.resendInvitation.useMutation({
onSuccess: () => {
showToast(t("invitation_resent"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const usersProfileUrl = `${domain}/${user.username}`;
if (!session?.user.org?.id) return null;
const orgId = session?.user?.org?.id;
return (
<>
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
<Tooltip content={t("view_public_page")}>
<Button
target="_blank"
href={usersProfileUrl}
color="secondary"
className={classNames(!permissionsForUser.canEdit ? "rounded-r-md" : "")}
variant="icon"
StartIcon="external-link"
/>
</Tooltip>
{(permissionsForUser.canEdit || permissionsForUser.canRemove) && (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
className="radix-state-open:rounded-r-md"
color="secondary"
variant="icon"
StartIcon="ellipsis"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
{permissionsForUser.canEdit && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canImpersonate && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon="lock">
{t("impersonate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
payload: {
user,
showModal: true,
},
})
}
color="destructive"
StartIcon="user-x">
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canResendInvitation && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
resendInvitationMutation.mutate({
teamId: orgId,
language: i18n.language,
email: user.email,
isOrg: true,
});
}}
StartIcon="send">
{t("resend_invitation")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
)}
</ButtonGroup>
<div className="flex md:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="minimal" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" StartIcon="external-link">
{t("view_public_page")}
</DropdownItem>
</DropdownMenuItem>
{permissionsForUser.canEdit && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon="user-x">
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
</>
);
}