refactor: improve team members page performance (#16155)

* fix: simplify workflow page and improve load time

* chore: use new endpoint

* chore: save progress

* refactor: code

* refactor: remove not requried code

* chore: remove schema

* chore: fix typ

* chore: improve

* chore: change name

* chore: remove unused

* chore: remove page

* refactor: teams page

* feat: add auto scroll

* chore: create validate unique invite

* fix: auth check

* fix: optimistic update

* chore

* fix: add loading

* fix: improvements

* chore: remove

* chore

* chore: fix teams page

* fix: team profile page

* fix: appearance page

* fix: sso view

* fix: type err

* feat: defer loading connected Apps

* fix: type err

* fix: type error

* fix: type err

* fix: connectedApps type

* chore: move

* chore: missing export

* feat: add search by name

* fix: display role change

* fix: use setInfiniteData

* chore: save progress

* test: add unit tests for loading members

* fix: test

* chore: update name

* fix: bugs and improvements

* chore: change variable name

* test: add tests for checkCanAccessMembers

* refactor: performance

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2024-08-29 13:32:54 +05:30
committed by GitHub
co-authored by Joe Au-Yeung
parent 4284e86e51
commit 19634073e9
26 changed files with 1313 additions and 195 deletions
@@ -116,6 +116,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
name: string;
slug: string;
parentId?: number;
isPrivate?: boolean;
};
}[];
schedules: {
@@ -19,7 +19,16 @@ const SAMLSSO = () => {
const teamId = Number(params.id);
const { data: team, isPending, error } = trpc.viewer.teams.get.useQuery({ teamId });
const {
data: team,
isPending,
error,
} = trpc.viewer.teams.getMinimal.useQuery(
{ teamId },
{
enabled: !!teamId,
}
);
useEffect(() => {
if (!HOSTED_CAL_FEATURES) {
@@ -40,7 +40,6 @@ const DisableTeamImpersonation = ({
setAllowImpersonation(_allowImpersonation);
mutation.mutate({ teamId, memberId, disableImpersonation: !_allowImpersonation });
}}
switchContainerClassName="mt-6"
/>
</>
);
@@ -26,6 +26,7 @@ export default function InviteLinkSettingsModal(props: InvitationLinkSettingsMod
showToast(t("invite_link_deleted"), "success");
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
trpcContext.viewer.teams.getMinimal.invalidate();
props.onExit();
},
onError: (e) => {
@@ -38,6 +39,7 @@ export default function InviteLinkSettingsModal(props: InvitationLinkSettingsMod
showToast(t("invite_link_updated"), "success");
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
trpcContext.viewer.teams.getMinimal.invalidate();
},
onError: (e) => {
showToast(e.message, "error");
@@ -25,6 +25,7 @@ const MakeTeamPrivateSwitch = ({
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.getMinimal.invalidate();
showToast(t(isOrg ? "your_org_updated_successfully" : "your_team_updated_successfully"), "success");
},
});
@@ -43,7 +44,7 @@ const MakeTeamPrivateSwitch = ({
setTeamPrivate(checked);
mutation.mutate({ id: teamId, isPrivate: checked });
}}
switchContainerClassName="mt-6"
switchContainerClassName="my-6"
data-testid="make-team-private-check"
/>
</>
@@ -11,6 +11,47 @@ type MembershipRoleOption = {
value: MembershipRole;
};
const updateRoleInCache = ({
utils,
teamId,
searchTerm,
role,
memberId,
}: {
utils: ReturnType<typeof trpc.useUtils>;
teamId: number;
searchTerm: string | undefined;
role: MembershipRole;
memberId: number;
}) => {
utils.viewer.teams.lazyLoadMembers.setInfiniteData(
{
limit: 10,
teamId,
searchTerm,
},
(data) => {
if (!data) {
return {
pages: [],
pageParams: [],
};
}
return {
...data,
pages: data.pages.map((page) => ({
...page,
members: page.members.map((member) => ({
...member,
role: member.id === memberId ? role : member.role,
})),
})),
};
}
);
};
export default function MemberChangeRoleModal(props: {
isOpen: boolean;
currentMember: MembershipRole;
@@ -18,6 +59,7 @@ export default function MemberChangeRoleModal(props: {
teamId: number;
initialRole: MembershipRole;
onExit: () => void;
searchTerm?: string;
}) {
const { t } = useLocale();
@@ -48,6 +90,20 @@ export default function MemberChangeRoleModal(props: {
const utils = trpc.useUtils();
const changeRoleMutation = trpc.viewer.teams.changeMemberRole.useMutation({
onMutate: async ({ teamId, memberId, role }) => {
await utils.viewer.teams.lazyLoadMembers.cancel();
const previousValue = utils.viewer.teams.lazyLoadMembers.getInfiniteData({
limit: 10,
teamId: teamId,
searchTerm: props.searchTerm,
});
if (previousValue) {
updateRoleInCache({ utils, teamId, memberId, role, searchTerm: props.searchTerm });
}
return { previousValue };
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
@@ -7,6 +7,7 @@ import { Controller, useForm } from "react-hook-form";
import TeamInviteFromOrg from "@calcom/ee/organizations/components/TeamInviteFromOrg";
import { classNames } from "@calcom/lib";
import { IS_TEAM_BILLING_ENABLED, MAX_NB_INVITES } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc";
@@ -42,6 +43,7 @@ type MemberInvitationModalProps = {
isPending?: boolean;
disableCopyLink?: boolean;
isOrg?: boolean;
checkMembershipMutation?: boolean;
};
type MembershipRoleOption = {
@@ -74,17 +76,16 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
enabled: !!session.data?.user?.org,
});
const checkIfMembershipExistsMutation = trpc.viewer.teams.checkIfMembershipExists.useMutation();
// Check current org role and not team role
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
const canSeeOrganization = !!(
props?.orgMembers &&
props.orgMembers?.length > 0 &&
currentOrg?.isPrivate &&
isOrgAdminOrOwner
);
const canSeeOrganization = currentOrg?.isPrivate
? isOrgAdminOrOwner
: !!(props?.orgMembers && props.orgMembers?.length > 0 && isOrgAdminOrOwner);
const [modalImportMode, setModalInputMode] = useState<ModalMode>(
canSeeOrganization ? "ORGANIZATION" : "INDIVIDUAL"
@@ -136,12 +137,19 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
const newMemberFormMethods = useForm<NewMemberForm>();
const validateUniqueInvite = (value: string) => {
if (!props?.members?.length) return true;
return !(
props?.members.some((member) => member?.username === value) ||
props?.members.some((member) => member?.email === value)
);
const checkIfMembershipExists = (value: string) => {
if (props.checkMembershipMutation) {
return checkIfMembershipExistsMutation.mutateAsync({
teamId: props.teamId,
value,
});
} else {
if (!props?.members?.length) return false;
return (
props?.members.some((member) => member?.username === value) ||
props?.members.some((member) => member?.email === value)
);
}
};
const handleFileUpload = (e: FileEvent<HTMLInputElement>) => {
@@ -207,7 +215,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
</span>
) : null
}>
<div>
<div className="max-h-9">
<Label className="sr-only" htmlFor="role">
{t("import_mode")}
</Label>
@@ -231,11 +239,13 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
control={newMemberFormMethods.control}
rules={{
required: t("enter_email"),
validate: (value) => {
validate: async (value) => {
// orgs can only invite members by email
if (typeof value === "string" && !isEmail(value)) return t("enter_email");
if (typeof value === "string")
return validateUniqueInvite(value) || t("member_already_invited");
if (typeof value === "string") {
const doesInviteExists = await checkIfMembershipExists(value);
return !doesInviteExists || t("member_already_invited");
}
},
}}
render={({ field: { onChange }, fieldState: { error } }) => (
@@ -437,7 +447,9 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
{t("cancel")}
</Button>
<Button
loading={props.isPending || createInviteMutation.isPending}
loading={
props.isPending || createInviteMutation.isPending || checkIfMembershipExistsMutation.isPending
}
type="submit"
color="primary"
className="me-2 ms-2"
@@ -450,3 +462,88 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
</Dialog>
);
}
export const MemberInvitationModalWithoutMembers = ({
hideInvitationModal,
showMemberInvitationModal,
teamId,
token,
onSettingsOpen,
}: {
hideInvitationModal: () => void;
showMemberInvitationModal: boolean;
teamId: number;
token?: string;
onSettingsOpen: () => void;
}) => {
const searchParams = useCompatSearchParams();
const { t, i18n } = useLocale();
const utils = trpc.useUtils();
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
const { data: orgMembersNotInThisTeam, isPending: isOrgListLoading } =
trpc.viewer.organizations.getMembers.useQuery(
{
teamIdToExclude: teamId,
distinctUser: true,
},
{
enabled: searchParams !== null && !!teamId && !!showMemberInvitationModal,
}
);
return (
<MemberInvitationModal
isPending={inviteMemberMutation.isPending || isOrgListLoading}
isOpen={showMemberInvitationModal}
orgMembers={orgMembersNotInThisTeam}
teamId={teamId}
token={token}
onExit={hideInvitationModal}
checkMembershipMutation={true}
onSubmit={(values, resetFields) => {
inviteMemberMutation.mutate(
{
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
},
{
onSuccess: async (data) => {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.lazyLoadMembers.invalidate();
await utils.viewer.organizations.getMembers.invalidate();
hideInvitationModal();
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.numUsersInvited,
}),
"success"
);
resetFields();
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
},
onError: (error) => {
showToast(error.message, "error");
},
}
);
}}
onSettingsOpen={() => {
hideInvitationModal();
onSettingsOpen();
}}
/>
);
};
@@ -26,14 +26,17 @@ import {
UserAvatar,
} from "@calcom/ui";
import type { ConnectedAppsType } from "../pages/team-members-view";
import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamAvailabilityModal from "./TeamAvailabilityModal";
import TeamPill, { TeamRole } from "./TeamPill";
interface Props {
team: RouterOutputs["viewer"]["teams"]["get"];
member: RouterOutputs["viewer"]["teams"]["get"]["members"][number];
team: NonNullable<RouterOutputs["viewer"]["teams"]["getMinimal"]>;
member: RouterOutputs["viewer"]["teams"]["lazyLoadMembers"]["members"][number];
isOrgAdminOrOwner: boolean | undefined;
searchTerm: string;
connectedApps: ConnectedAppsType[];
}
/** TODO: Migrate the one in apps/web to tRPC package */
@@ -47,6 +50,42 @@ const checkIsOrg = (team: Props["team"]) => {
return team.isOrganization;
};
const removeMemberFromCache = ({
utils,
memberId,
teamId,
searchTerm,
}: {
utils: ReturnType<typeof trpc.useUtils>;
memberId: number;
teamId: number;
searchTerm: string;
}) => {
utils.viewer.teams.lazyLoadMembers.setInfiniteData(
{
limit: 10,
teamId,
searchTerm,
},
(data) => {
if (!data) {
return {
pages: [],
pageParams: [],
};
}
return {
...data,
pages: data.pages.map((page) => ({
...page,
members: page.members.filter((member) => member.id !== memberId),
})),
};
}
);
};
export default function MemberListItem(props: Props) {
const { t, i18n } = useLocale();
@@ -57,11 +96,30 @@ export default function MemberListItem(props: Props) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
onMutate: async ({ teamIds }) => {
await utils.viewer.teams.lazyLoadMembers.cancel();
const previousValue = utils.viewer.teams.lazyLoadMembers.getInfiniteData({
limit: 10,
teamId: teamIds[0],
searchTerm: props.searchTerm,
});
if (previousValue) {
removeMemberFromCache({
utils,
memberId: props.member.id,
teamId: teamIds[0],
searchTerm: props.searchTerm,
});
}
return { previousValue };
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.eventTypes.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
await utils.viewer.organizations.getMembers.invalidate();
showToast(t("success"), "success");
},
async onError(err) {
@@ -78,11 +136,11 @@ export default function MemberListItem(props: Props) {
},
});
const ownersInTeam = () => {
const { members } = props.team;
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
return owners.length;
};
// const ownersInTeam = () => {
// const { members } = props.team;
// const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
// return owners.length;
// };
const currentUserId = useCurrentUserId();
@@ -103,7 +161,7 @@ export default function MemberListItem(props: Props) {
const editMode =
(props.team.membership?.role === MembershipRole.OWNER &&
(props.member.role !== MembershipRole.OWNER ||
ownersInTeam() > 1 ||
// ownersInTeam() > 1 ||
props.member.id !== currentUserId)) ||
(props.team.membership?.role === MembershipRole.ADMIN && props.member.role !== MembershipRole.OWNER) ||
props.isOrgAdminOrOwner;
@@ -118,7 +176,7 @@ export default function MemberListItem(props: Props) {
const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, "");
const bookingLink = !!props.member.username && `${bookerUrlWithoutProtocol}/${props.member.username}`;
const isAdmin = props.team && ["ADMIN", "OWNER"].includes(props.team.membership?.role);
const appList = props.member.connectedApps?.map(({ logo, name, externalId }) => {
const appList = props.connectedApps?.map(({ logo, name, externalId }) => {
return logo ? (
externalId ? (
<div className="ltr:mr-2 rtl:ml-2 ">
@@ -359,6 +417,7 @@ export default function MemberListItem(props: Props) {
currentMember={props.team.membership.role}
teamId={props.team?.id}
memberId={props.member.id}
searchTerm={props.searchTerm}
initialRole={props.member.role as MembershipRole}
onExit={() => setShowChangeMemberRoleModal(false)}
/>
@@ -12,8 +12,8 @@ import { Avatar, DatePicker, Label, Select, TimezoneSelect } from "@calcom/ui";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
interface Props {
team?: RouterOutputs["viewer"]["teams"]["get"];
member?: RouterOutputs["viewer"]["teams"]["get"]["members"][number];
team?: RouterOutputs["viewer"]["teams"]["getMinimal"];
member?: RouterOutputs["viewer"]["teams"]["lazyLoadMembers"]["members"][number];
}
export default function TeamAvailabilityModal(props: Props) {
@@ -86,7 +86,7 @@ export default function TeamAvailabilityModal(props: Props) {
</div>
<div className="col-span-1 max-h-[500px]">
{props.team && props.member && (
{props.team?.id && props.member && (
<TeamAvailabilityTimes
teamId={props.team.id}
memberId={props.member.id}
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import InviteLinkSettingsModal from "@calcom/ee/teams/components/InviteLinkSettingsModal";
import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal";
import { MemberInvitationModalWithoutMembers } from "@calcom/ee/teams/components/MemberInvitationModal";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getTeamUrlSync } from "@calcom/lib/getBookerUrl/client";
@@ -43,7 +43,7 @@ interface Props {
export default function TeamListItem(props: Props) {
const searchParams = useCompatSearchParams();
const { t, i18n } = useLocale();
const { t } = useLocale();
const utils = trpc.useUtils();
const user = trpc.viewer.me.useQuery().data;
const team = props.team;
@@ -52,9 +52,6 @@ export default function TeamListItem(props: Props) {
const [openMemberInvitationModal, setOpenMemberInvitationModal] = useState(showDialog);
const [openInviteLinkSettingsModal, setOpenInviteLinkSettingsModal] = useState(false);
const teamQuery = trpc.viewer.teams.get.useQuery({ teamId: team?.id });
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
const acceptOrLeaveMutation = trpc.viewer.teams.acceptOrLeave.useMutation({
onSuccess: (_data, variables) => {
showToast(t("success"), "success");
@@ -87,6 +84,10 @@ export default function TeamListItem(props: Props) {
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
const { hideDropdown, setHideDropdown } = props;
const hideInvitationModal = () => {
setOpenMemberInvitationModal(false);
};
if (!team) return <></>;
const teamUrl = team.isOrganization
? getTeamUrlSync({ orgSlug: team.slug, teamSlug: null })
@@ -109,56 +110,13 @@ export default function TeamListItem(props: Props) {
);
return (
<li className="">
<MemberInvitationModal
isOpen={openMemberInvitationModal}
<li>
<MemberInvitationModalWithoutMembers
hideInvitationModal={hideInvitationModal}
showMemberInvitationModal={openMemberInvitationModal}
teamId={team.id}
token={team.inviteToken?.token}
onExit={() => {
setOpenMemberInvitationModal(false);
}}
isPending={inviteMemberMutation.isPending}
onSubmit={(values, resetFields) => {
inviteMemberMutation.mutate(
{
teamId: team.id,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
},
{
onSuccess: async (data) => {
await utils.viewer.teams.get.invalidate();
setOpenMemberInvitationModal(false);
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.numUsersInvited,
}),
"success"
);
resetFields();
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
},
onError: (error) => {
showToast(error.message, "error");
},
}
);
}}
onSettingsOpen={() => {
setOpenMemberInvitationModal(false);
setOpenInviteLinkSettingsModal(true);
}}
members={teamQuery?.data?.members || []}
onSettingsOpen={() => setOpenInviteLinkSettingsModal(true)}
/>
{team.inviteToken && (
<InviteLinkSettingsModal
@@ -24,7 +24,7 @@ type BrandColorsFormValues = {
darkBrandColor: string;
};
type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["get"] };
type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["getMinimal"] };
const ProfileView = ({ team }: ProfileViewProps) => {
const { t } = useLocale();
@@ -188,7 +188,16 @@ const ProfileViewWrapper = () => {
const { t } = useLocale();
const { data: team, isPending, error } = trpc.viewer.teams.get.useQuery({ teamId: Number(params.id) });
const {
data: team,
isPending,
error,
} = trpc.viewer.teams.getMinimal.useQuery(
{ teamId: Number(params.id) },
{
enabled: !!Number(params.id),
}
);
useEffect(
function refactorMeWithoutEffect() {
@@ -1,90 +1,144 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { MembershipRole } from "@calcom/prisma/enums";
import type { AppCategories } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button, Meta, showToast, TextField } from "@calcom/ui";
import { Button, Meta, TextField } from "@calcom/ui";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
import InviteLinkSettingsModal from "../components/InviteLinkSettingsModal";
import MakeTeamPrivateSwitch from "../components/MakeTeamPrivateSwitch";
import MemberInvitationModal from "../components/MemberInvitationModal";
import { MemberInvitationModalWithoutMembers } from "../components/MemberInvitationModal";
import MemberListItem from "../components/MemberListItem";
import TeamInviteList from "../components/TeamInviteList";
type Team = RouterOutputs["viewer"]["teams"]["get"];
type Team = RouterOutputs["viewer"]["teams"]["getMinimal"];
interface MembersListProps {
team: Team | undefined;
team: Team;
isOrgAdminOrOwner: boolean | undefined;
}
const checkIfExist = (comp: string, query: string) =>
comp.toLowerCase().replace(/\s+/g, "").includes(query.toLowerCase().replace(/\s+/g, ""));
export type ConnectedAppsType = {
name: string | null;
logo: string | null;
externalId: string | null;
app: { slug: string; categories: AppCategories[] } | null;
};
function MembersList(props: MembersListProps) {
const { team, isOrgAdminOrOwner } = props;
const { t } = useLocale();
const [query, setQuery] = useState<string>("");
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const [connectedApps, setConnectedApps] = useState<Record<number, ConnectedAppsType[]>>({});
const [userIds, setUserIds] = useState<number[]>([]);
const members = team?.members;
const membersList = members
? members && query === ""
? members
: members.filter((member) => {
const email = member.email ? checkIfExist(member.email, query) : false;
const username = member.username ? checkIfExist(member.username, query) : false;
const name = member.name ? checkIfExist(member.name, query) : false;
const { data: getUserConnectedApps } = trpc.viewer.teams.getUserConnectedApps.useQuery(
{ userIds, teamId: team.id },
{ enabled: !!userIds.length }
);
const { data, isFetching, status, fetchNextPage, isFetchingNextPage, hasNextPage } =
trpc.viewer.teams.lazyLoadMembers.useInfiniteQuery(
{
limit: 10,
searchTerm: debouncedSearchTerm,
teamId: team.id,
},
{
enabled: !!team?.id,
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: keepPreviousData,
refetchOnWindowFocus: true,
refetchOnMount: true,
staleTime: 0,
}
);
// To defer fetching Connected Apps
useEffect(() => {
if (data?.pages) {
const userIds = data.pages[data.pages.length - 1].members.map((member) => member.id);
setUserIds(userIds);
}
}, [data]);
useEffect(() => {
if (getUserConnectedApps) {
setConnectedApps((prev) => ({ ...prev, ...getUserConnectedApps }));
}
}, [getUserConnectedApps]);
const buttonInView = useInViewObserver(() => {
if (!isFetching && hasNextPage && status === "success") {
fetchNextPage();
}
}, null);
return email || username || name;
})
: undefined;
return (
<div className="flex flex-col gap-y-3">
<TextField
type="search"
autoComplete="false"
onChange={(e) => setQuery(e.target.value)}
value={query}
onChange={(e) => setSearchTerm(e.target.value)}
value={searchTerm}
placeholder={`${t("search")}...`}
/>
{membersList?.length && team ? (
{data?.pages[0]?.members?.length && team ? (
<ul
className="divide-subtle border-subtle divide-y rounded-md border "
data-testId="team-member-list-container">
{membersList.map((member) => {
return (
<MemberListItem
key={member.id}
team={team}
member={member}
isOrgAdminOrOwner={isOrgAdminOrOwner}
/>
);
{data.pages?.map((page) => {
return page.members.map((member) => {
return (
<MemberListItem
key={member.id}
team={team}
member={member}
isOrgAdminOrOwner={isOrgAdminOrOwner}
searchTerm={debouncedSearchTerm}
connectedApps={connectedApps[member.id] ?? []}
/>
);
});
})}
</ul>
) : null}
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
);
}
const MembersView = () => {
const searchParams = useCompatSearchParams();
const { t, i18n } = useLocale();
const { t } = useLocale();
const router = useRouter();
const session = useSession();
const org = session?.data?.user.org;
const utils = trpc.useUtils();
const params = useParamsWithFallback();
const teamId = Number(params.id);
@@ -93,27 +147,17 @@ const MembersView = () => {
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
const { data: orgMembersNotInThisTeam, isPending: isOrgListLoading } =
trpc.viewer.organizations.getMembers.useQuery(
{
teamIdToExclude: teamId,
distinctUser: true,
},
{
enabled: searchParams !== null && !!teamId,
}
);
const {
data: team,
isPending: isTeamsLoading,
error: teamError,
} = trpc.viewer.teams.get.useQuery(
} = trpc.viewer.teams.getMinimal.useQuery(
{ teamId },
{
enabled: !!teamId,
}
);
useEffect(
function refactorMeWithoutEffect() {
if (teamError) {
@@ -123,9 +167,7 @@ const MembersView = () => {
[teamError]
);
const isPending = isOrgListLoading || isTeamsLoading;
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
const isPending = isTeamsLoading;
const isInviteOpen = !team?.membership.accepted;
@@ -134,6 +176,10 @@ const MembersView = () => {
const isOrgAdminOrOwner = org?.role === MembershipRole.OWNER || org?.role === MembershipRole.ADMIN;
const hideInvitationModal = () => {
setShowMemberInvitationModal(false);
};
return (
<>
<Meta
@@ -176,13 +222,7 @@ const MembersView = () => {
</>
)}
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && (
<>
<MembersList team={team} isOrgAdminOrOwner={isOrgAdminOrOwner} />
</>
)}
{team && session.data && (
{team && team.id && session.data && (
<DisableTeamImpersonation
teamId={team.id}
memberId={session.data.user.id}
@@ -190,68 +230,32 @@ const MembersView = () => {
/>
)}
{team && (isAdmin || isOrgAdminOrOwner) && (
{team && team.id && (isAdmin || isOrgAdminOrOwner) && (
<MakeTeamPrivateSwitch
isOrg={false}
teamId={team.id}
isPrivate={team.isPrivate}
isPrivate={team.isPrivate ?? false}
disabled={isInviteOpen}
/>
)}
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && (
<>
<MembersList team={team} isOrgAdminOrOwner={isOrgAdminOrOwner} />
</>
)}
</div>
{showMemberInvitationModal && team && (
<MemberInvitationModal
isPending={inviteMemberMutation.isPending}
isOpen={showMemberInvitationModal}
orgMembers={orgMembersNotInThisTeam}
members={team.members}
{showMemberInvitationModal && team && team.id && (
<MemberInvitationModalWithoutMembers
hideInvitationModal={hideInvitationModal}
showMemberInvitationModal={showMemberInvitationModal}
teamId={team.id}
token={team.inviteToken?.token}
onExit={() => setShowMemberInvitationModal(false)}
onSubmit={(values, resetFields) => {
inviteMemberMutation.mutate(
{
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
},
{
onSuccess: async (data) => {
await utils.viewer.teams.get.invalidate();
await utils.viewer.organizations.getMembers.invalidate();
setShowMemberInvitationModal(false);
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.numUsersInvited,
}),
"success"
);
resetFields();
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
},
onError: (error) => {
showToast(error.message, "error");
},
}
);
}}
onSettingsOpen={() => {
setShowMemberInvitationModal(false);
setInviteLinkSettingsModal(true);
}}
onSettingsOpen={() => setInviteLinkSettingsModal(true)}
/>
)}
{showInviteLinkSettingsModal && team?.inviteToken && (
{showInviteLinkSettingsModal && team?.inviteToken && team.id && (
<InviteLinkSettingsModal
isOpen={showInviteLinkSettingsModal}
teamId={team.id}
@@ -97,7 +97,7 @@ const ProfileView = () => {
data: team,
isPending,
error,
} = trpc.viewer.teams.get.useQuery(
} = trpc.viewer.teams.getMinimal.useQuery(
{ teamId },
{
enabled: !!teamId,
@@ -179,7 +179,7 @@ const ProfileView = () => {
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(team.bio)) }}
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(team.bio ?? null)) }}
/>
</>
)}
@@ -251,7 +251,7 @@ const ProfileView = () => {
);
};
export type TeamProfileFormProps = { team: RouterOutputs["viewer"]["teams"]["get"] };
export type TeamProfileFormProps = { team: RouterOutputs["viewer"]["teams"]["getMinimal"] };
const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
const utils = trpc.useUtils();
@@ -260,6 +260,104 @@ export async function getTeamWithMembers(args: {
};
}
export async function getMinimalTeam(args: {
id?: number;
slug?: string;
userId?: number;
orgSlug?: string | null;
/**
* If true, means that you are fetching an organization and not a team
*/
isOrgView?: boolean;
}) {
const { id, slug, userId, orgSlug, isOrgView } = args;
let lookupBy;
if (id) {
lookupBy = { id, havingMemberWithId: userId };
} else if (slug) {
lookupBy = { slug, havingMemberWithId: userId };
} else {
throw new Error("Must provide either id or slug");
}
const arg = {
lookupBy,
forOrgWithSlug: orgSlug ?? null,
isOrg: !!isOrgView,
teamSelect: {
id: true,
name: true,
slug: true,
isOrganization: true,
logoUrl: true,
bio: true,
hideBranding: true,
hideBookATeamMember: true,
isPrivate: true,
metadata: true,
parent: {
select: {
id: true,
slug: true,
name: true,
isPrivate: true,
isOrganization: true,
logoUrl: true,
metadata: true,
},
},
parentId: true,
children: {
select: {
name: true,
slug: true,
},
},
theme: true,
brandColor: true,
darkBrandColor: true,
inviteTokens: {
select: {
token: true,
expires: true,
expiresInDays: true,
identifier: true,
},
},
},
} as const;
const teamOrOrg = isOrgView ? await getOrg(arg) : await getTeam(arg);
if (!teamOrOrg) return null;
// Don't leak invite tokens to the frontend
const { inviteTokens, ...teamWithoutInviteTokens } = teamOrOrg;
// Don't leak stripe payment ids
const teamMetadata = teamOrOrg.metadata;
const {
paymentId: _,
subscriptionId: __,
subscriptionItemId: ___,
...restTeamMetadata
} = teamMetadata || {};
return {
...teamWithoutInviteTokens,
...(teamWithoutInviteTokens.logoUrl ? { logo: teamWithoutInviteTokens.logoUrl } : {}),
/** To prevent breaking we only return non-email attached token here, if we have one */
inviteToken: inviteTokens.find(
(token) =>
token.identifier === `invite-link-for-teamId-${teamOrOrg.id}` &&
token.expires > new Date(new Date().setHours(24))
),
metadata: restTeamMetadata,
};
}
// also returns team
export async function isTeamAdmin(userId: number, teamId: number) {
const team = await prisma.membership.findFirst({
@@ -222,4 +222,13 @@ export class MembershipRepository {
},
});
}
static async findFirstByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
return await prisma.membership.findFirst({
where: {
userId,
teamId,
},
});
}
}
@@ -2,6 +2,7 @@ import authedProcedure from "../../../procedures/authedProcedure";
import { importHandler, router } from "../../../trpc";
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
import { ZCheckIfMembershipExistsInputSchema } from "./checkIfMembershipExists.schema";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateInviteInputSchema } from "./createInvite.schema";
import { ZDeleteInputSchema } from "./delete.schema";
@@ -9,9 +10,12 @@ import { ZDeleteInviteInputSchema } from "./deleteInvite.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema";
import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema";
import { ZGetMinimalSchema } from "./getMinimal.schema";
import { ZGetUserConnectedAppsInputSchema } from "./getUserConnectedApps.schema";
import { ZHasEditPermissionForUserSchema } from "./hasEditPermissionForUser.schema";
import { ZInviteMemberInputSchema } from "./inviteMember/inviteMember.schema";
import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
import { ZLazyLoadMembersInputSchema } from "./lazyLoadMembers.schema";
import { ZGetListSchema } from "./list.schema";
import { ZListMembersInputSchema } from "./listMembers.schema";
import { hasTeamPlan } from "./procedures/hasTeamPlan";
@@ -32,6 +36,11 @@ export const viewerTeamsRouter = router({
const handler = await importHandler(namespaced("get"), () => import("./get.handler"));
return handler(opts);
}),
// Returns team
getMinimal: authedProcedure.input(ZGetMinimalSchema).query(async (opts) => {
const handler = await importHandler(namespaced("getMinimal"), () => import("./getMinimal.handler"));
return handler(opts);
}),
// Returns teams I a member of
list: authedProcedure.input(ZGetListSchema).query(async (opts) => {
const handler = await importHandler(namespaced("list"), () => import("./list.handler"));
@@ -114,6 +123,20 @@ export const viewerTeamsRouter = router({
const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler"));
return handler(opts);
}),
lazyLoadMembers: authedProcedure.input(ZLazyLoadMembersInputSchema).query(async (opts) => {
const handler = await importHandler(
namespaced("lazyLoadMembers"),
() => import("./lazyLoadMembers.handler")
);
return handler(opts);
}),
getUserConnectedApps: authedProcedure.input(ZGetUserConnectedAppsInputSchema).query(async (opts) => {
const handler = await importHandler(
namespaced("getUserConnectedApps"),
() => import("./getUserConnectedApps.handler")
);
return handler(opts);
}),
hasTeamPlan,
listInvites: authedProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("listInvites"), () => import("./listInvites.handler"));
@@ -162,4 +185,13 @@ export const viewerTeamsRouter = router({
);
return handler(opts);
}),
checkIfMembershipExists: authedProcedure
.input(ZCheckIfMembershipExistsInputSchema)
.mutation(async (opts) => {
const handler = await importHandler(
namespaced("checkIfMembershipExists"),
() => import("./checkIfMembershipExists.handler")
);
return handler(opts);
}),
});
@@ -0,0 +1,35 @@
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../trpc";
import type { TCheckIfMembershipExistsInputSchema } from "./checkIfMembershipExists.schema";
type CheckIfMembershipExistsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCheckIfMembershipExistsInputSchema;
};
const checkIfMembershipExistsHandler = async ({ ctx, input }: CheckIfMembershipExistsOptions) => {
const { teamId, value } = input;
const membership = await prisma.membership.findFirst({
where: {
teamId,
user: {
OR: [
{
email: value,
},
{
username: value,
},
],
},
},
});
return !!membership;
};
export default checkIfMembershipExistsHandler;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZCheckIfMembershipExistsInputSchema = z.object({
teamId: z.number(),
value: z.string(),
});
export type TCheckIfMembershipExistsInputSchema = z.infer<typeof ZCheckIfMembershipExistsInputSchema>;
@@ -0,0 +1,81 @@
import {
createBookingScenario,
TestData,
getOrganizer,
getScenarioData,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { describe, it, beforeEach, vi, expect } from "vitest";
import type { TrpcSessionUser } from "../../../trpc";
import getTeamWithMinimalData from "./getMinimal.handler";
describe("getTeamWithMinimalData", () => {
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
});
it("should return team", async () => {
const team = {
id: 1,
name: "Team 1",
slug: "team-1",
};
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
teams: [
{
membership: {
role: "ADMIN",
accepted: true,
},
team,
},
],
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
})
);
const ctx = {
user: {
id: organizer.id,
name: organizer.name,
} as NonNullable<TrpcSessionUser>,
};
const result = await getTeamWithMinimalData({
ctx,
input: {
teamId: team.id,
},
});
expect(result).toContain({
id: team.id,
name: team.name,
slug: team.slug,
isOrganization: false,
});
});
});
@@ -0,0 +1,49 @@
import { getMinimalTeam } from "@calcom/lib/server/queries/teams";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TGetMinimalInputSchema } from "./getMinimal.schema";
type GetMinimalDataOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetMinimalInputSchema;
};
export const getMinimal = async ({ ctx, input }: GetMinimalDataOptions) => {
const teamMembership = await MembershipRepository.findFirstByUserIdAndTeamId({
userId: ctx.user.id,
teamId: input.teamId,
});
if (!teamMembership) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not a member of this team.",
});
}
const team = await getMinimalTeam({
id: input.teamId,
userId: ctx.user.organization?.isOrgAdmin ? undefined : ctx.user.id,
isOrgView: input?.isOrg,
});
if (!team) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team not found",
});
}
const membership = {
role: teamMembership.role,
accepted: teamMembership.accepted,
};
return { ...team, membership };
};
export default getMinimal;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZGetMinimalSchema = z.object({
teamId: z.number(),
isOrg: z.boolean().optional(),
});
export type TGetMinimalInputSchema = z.infer<typeof ZGetMinimalSchema>;
@@ -0,0 +1,158 @@
import { Prisma } from "@prisma/client";
import { getAppFromSlug } from "@calcom/app-store/utils";
import { prisma } from "@calcom/prisma";
import type { AppCategories } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TGetUserConnectedAppsInputSchema } from "./getUserConnectedApps.schema";
type GetUserConnectedAppsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetUserConnectedAppsInputSchema;
};
const credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
userId: true,
app: {
select: {
slug: true,
categories: true,
},
},
destinationCalendars: {
select: {
externalId: true,
},
},
});
type Credential = Prisma.CredentialGetPayload<{ select: typeof credentialSelect }>;
type Apps = {
name: string | null;
logo: string | null;
externalId: string | null;
app: { slug: string; categories: AppCategories[] } | null;
};
// This should improve performance saving already app data found.
const appDataMap = new Map();
const checkCanUserAccessConnectedApps = async (
user: NonNullable<TrpcSessionUser>,
teamId: number,
userIds: number[]
) => {
// Check if the user is a member of the team or an admin/owner of the org
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
id: true,
parent: {
select: {
id: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
}
const isMember = await prisma.membership.findFirst({
where: {
userId: user.id,
teamId: teamId,
},
});
const isOrgAdminOrOwner =
team.parent &&
(await prisma.membership.findFirst({
where: {
userId: user.id,
teamId: team.parent.id,
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
},
}));
if (!isMember && !isOrgAdminOrOwner) {
throw new Error("User is not authorized to access this team's connected apps");
}
// Check if all userIds belong to the team
const teamMembers = await prisma.membership.findMany({
where: {
teamId,
userId: {
in: userIds,
},
},
select: {
userId: true,
},
});
if (teamMembers.length !== userIds.length) {
const teamMemberIds = teamMembers.map((member) => member.userId);
const invalidUserIds = userIds.filter((id) => !teamMemberIds.includes(id));
if (invalidUserIds.length > 0) {
throw new Error(`Some user IDs do not belong to the team: ${invalidUserIds.join(", ")}`);
}
}
};
export const getUserConnectedAppsHandler = async ({ ctx, input }: GetUserConnectedAppsOptions) => {
const { userIds, teamId } = input;
await checkCanUserAccessConnectedApps(ctx.user, teamId, userIds);
const credentialsPromises: Promise<Credential[]>[] = [];
const userConnectedAppsMap: Record<number, Apps[]> = {};
for (const userId of userIds) {
const cred = prisma.credential.findMany({
where: {
userId,
},
select: credentialSelect,
});
credentialsPromises.push(cred);
}
const credentialsList = await Promise.all(credentialsPromises);
for (const credentials of credentialsList) {
const userId = credentials[0]?.userId;
if (userId) {
userConnectedAppsMap[userId] = credentials?.map((cred) => {
const appSlug = cred.app?.slug;
let appData = appDataMap.get(appSlug);
if (!appData) {
appData = getAppFromSlug(appSlug);
appDataMap.set(appSlug, appData);
}
const isCalendar = cred?.app?.categories?.includes("calendar") ?? false;
const externalId = isCalendar ? cred.destinationCalendars?.[0]?.externalId : null;
return {
name: appData?.name ?? null,
logo: appData?.logo ?? null,
app: cred.app,
externalId: externalId ?? null,
};
});
}
}
return userConnectedAppsMap;
};
export default getUserConnectedAppsHandler;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZGetUserConnectedAppsInputSchema = z.object({
userIds: z.array(z.number()),
teamId: z.number(),
});
export type TGetUserConnectedAppsInputSchema = z.infer<typeof ZGetUserConnectedAppsInputSchema>;
@@ -0,0 +1,150 @@
import { Prisma } from "@prisma/client";
// import { getAppFromSlug } from "@calcom/app-store/utils";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TLazyLoadMembersInputSchema } from "./lazyLoadMembers.schema";
type LazyLoadMembersHandlerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TLazyLoadMembersInputSchema;
};
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
email: true,
name: true,
avatarUrl: true,
id: true,
bio: true,
disableImpersonation: true,
});
export const lazyLoadMembersHandler = async ({ ctx, input }: LazyLoadMembersHandlerOptions) => {
const { cursor, limit, teamId, searchTerm } = input;
const canAccessMembers = await checkCanAccessMembers(ctx, teamId);
if (!canAccessMembers) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to see members of the team",
});
}
const teamMembers = await prisma.membership.findMany({
where: {
teamId,
...(searchTerm && {
user: {
OR: [
{
email: {
contains: searchTerm,
mode: "insensitive",
},
},
{
username: {
contains: searchTerm,
mode: "insensitive",
},
},
{
name: {
contains: searchTerm,
mode: "insensitive",
},
},
],
},
}),
},
select: {
id: true,
role: true,
accepted: true,
user: {
select: userSelect,
},
},
cursor: cursor ? { id: cursor } : undefined,
take: limit + 1, // We take +1 as itll be used for the next cursor
orderBy: {
id: "asc",
},
});
let nextCursor: typeof cursor | undefined = undefined;
if (teamMembers && teamMembers.length > limit) {
const nextItem = teamMembers.pop();
nextCursor = nextItem?.id;
}
const membersWithApps = await Promise.all(
teamMembers.map(async (member) => {
const user = await UserRepository.enrichUserWithItsProfile({
user: member.user,
});
const { profile, ...restUser } = user;
return {
...restUser,
username: profile?.username ?? restUser.username,
role: member.role,
profile: profile,
organizationId: profile?.organizationId ?? null,
organization: profile?.organization,
accepted: member.accepted,
disableImpersonation: user.disableImpersonation,
bookerUrl: getBookerBaseUrlSync(profile?.organization?.slug || ""),
};
})
);
return { members: membersWithApps, nextCursor };
};
const checkCanAccessMembers = async (ctx: LazyLoadMembersHandlerOptions["ctx"], teamId: number) => {
const isOrgPrivate = ctx.user.profile?.organization?.isPrivate;
const isOrgAdminOrOwner = ctx.user.organization?.isOrgAdmin;
const orgId = ctx.user.organizationId;
const isTargetingOrg = teamId === ctx.user.organizationId;
if (isTargetingOrg) {
return isOrgPrivate && !isOrgAdminOrOwner;
}
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
});
if (isOrgAdminOrOwner && team?.parentId === orgId) {
return true;
}
const membership = await prisma.membership.findFirst({
where: {
teamId,
userId: ctx.user.id,
},
});
const isTeamAdminOrOwner =
membership?.role === MembershipRole.OWNER || membership?.role === MembershipRole.ADMIN;
if (team?.isPrivate && !isTeamAdminOrOwner) {
return false;
}
return true;
};
export default lazyLoadMembersHandler;
@@ -0,0 +1,10 @@
import { z } from "zod";
export const ZLazyLoadMembersInputSchema = z.object({
teamId: z.number(),
limit: z.number().default(10),
searchTerm: z.string().optional(),
cursor: z.number().optional().nullable(),
});
export type TLazyLoadMembersInputSchema = z.infer<typeof ZLazyLoadMembersInputSchema>;
@@ -0,0 +1,277 @@
import {
createBookingScenario,
TestData,
getOrganizer,
getScenarioData,
addUsers,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { describe, it, beforeEach, vi, expect } from "vitest";
import type { TrpcSessionUser } from "../../../trpc";
import lazyLoadMembers from "./lazyLoadMembers.handler";
const createTeamWithMembers = async ({ isPrivate = false }: { isPrivate?: boolean }) => {
const team = {
id: 1,
name: "Team 1",
slug: "team-1",
isPrivate,
};
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
teams: [
{
membership: {
role: "ADMIN",
accepted: true,
},
team,
},
],
});
const user2 = getOrganizer({
name: "User 2",
email: "user2@example.com",
id: 102,
schedules: [TestData.schedules.IstWorkHours],
teams: [
{
membership: {
role: "MEMBER",
accepted: true,
},
team,
},
],
});
const user3 = getOrganizer({
name: "User 3",
email: "user3@example.com",
id: 103,
schedules: [TestData.schedules.IstWorkHours],
teams: [
{
membership: {
role: "MEMBER",
accepted: true,
},
team,
},
],
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
usersApartFromOrganizer: [user2, user3],
})
);
return {
team,
organizer,
user2,
user3,
};
};
describe("lazyLoadMembers", () => {
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
});
it("should return team members for teams", async () => {
const { team, organizer, user2, user3 } = await createTeamWithMembers({ isPrivate: false });
const ctx = {
user: {
id: organizer.id,
name: organizer.name,
} as NonNullable<TrpcSessionUser>,
};
const result = await lazyLoadMembers({
ctx,
input: {
teamId: team.id,
limit: 10,
},
});
expect(result.members).toEqual([
expect.objectContaining({
id: organizer.id,
name: organizer.name,
username: organizer.username,
}),
expect.objectContaining({
id: user2.id,
name: user2.name,
username: user2.username,
}),
expect.objectContaining({
id: user3.id,
name: user3.name,
username: user3.username,
}),
]);
});
it("should return team members for private teams", async () => {
const { team, organizer, user2, user3 } = await createTeamWithMembers({ isPrivate: true });
//Logged in user is admin of the team
const ctx = {
user: {
id: organizer.id,
name: organizer.name,
} as NonNullable<TrpcSessionUser>,
};
const result = await lazyLoadMembers({
ctx,
input: {
teamId: team.id,
limit: 10,
},
});
expect(result.members).toEqual([
expect.objectContaining({
id: organizer.id,
name: organizer.name,
username: organizer.username,
}),
expect.objectContaining({
id: user2.id,
name: user2.name,
username: user2.username,
}),
expect.objectContaining({
id: user3.id,
name: user3.name,
username: user3.username,
}),
]);
});
it("should throw error if user is not admin/owner of the private team", async () => {
const { team, user2 } = await createTeamWithMembers({ isPrivate: true });
//Logged in user is not admin/owner of the team
const nonAdminUserCtx = {
user: {
id: user2.id,
name: user2.name,
} as NonNullable<TrpcSessionUser>,
};
await expect(
lazyLoadMembers({
ctx: nonAdminUserCtx,
input: {
teamId: team.id,
limit: 10,
},
})
).rejects.toThrowError("You are not authorized to see members of the team");
});
it("should throw error if user is not part of the private team", async () => {
const { team } = await createTeamWithMembers({ isPrivate: true });
const newUser = getOrganizer({
name: "New User",
email: "newuser@example.com",
id: 104,
schedules: [TestData.schedules.IstWorkHours],
});
addUsers([newUser]);
//Logged in user is not part of the team
const nonAdminUserCtx = {
user: {
id: newUser.id,
name: newUser.name,
} as NonNullable<TrpcSessionUser>,
};
await expect(
lazyLoadMembers({
ctx: nonAdminUserCtx,
input: {
teamId: team.id,
limit: 10,
},
})
).rejects.toThrowError("You are not authorized to see members of the team");
});
it("can search by name or email", async () => {
const { team, organizer } = await createTeamWithMembers({ isPrivate: false });
const ctx = {
user: {
id: organizer.id,
name: organizer.name,
} as NonNullable<TrpcSessionUser>,
};
// Search by email
const searchByEmail = await lazyLoadMembers({
ctx,
input: {
teamId: team.id,
limit: 10,
searchTerm: "organizer",
},
});
expect(searchByEmail.members).toEqual([
expect.objectContaining({
id: organizer.id,
name: organizer.name,
username: organizer.username,
}),
]);
// Search by name
const searchByName = await lazyLoadMembers({
ctx,
input: {
teamId: team.id,
limit: 10,
searchTerm: organizer.name,
},
});
expect(searchByName.members).toEqual([
expect.objectContaining({
id: organizer.id,
name: organizer.name,
username: organizer.username,
}),
]);
});
});