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:
co-authored by
Joe Au-Yeung
parent
4284e86e51
commit
19634073e9
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user