first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}</>;
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user