From ea8437b4f7855e2e644b560ae4b9a104362222da Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 29 Nov 2023 19:57:51 +0200 Subject: [PATCH] fix: invite member by username (#12591) * fix: invite member by username * fixup! fix: invite member by username --- .../inviteMember/inviteMember.handler.ts | 37 +++++++++++------ .../teams/inviteMember/inviteMember.schema.ts | 41 +++++++++---------- .../inviteMember/inviteMemberUtils.test.ts | 24 ++++++++--- .../viewer/teams/inviteMember/utils.ts | 20 +++++++-- 4 files changed, 78 insertions(+), 44 deletions(-) diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 41b081433b..deb8a55019 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -9,7 +9,7 @@ import type { TInviteMemberInputSchema } from "./inviteMember.schema"; import { checkPermissions, getTeamOrThrow, - getEmailsToInvite, + getUsernameOrEmailsToInvite, getOrgConnectionInfo, getIsOrgVerified, sendVerificationEmail, @@ -42,43 +42,54 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = const team = await getTeamOrThrow(input.teamId, input.isOrg); const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team); - const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail); - const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => { + const usernameOrEmailsToInvite = await getUsernameOrEmailsToInvite(input.usernameOrEmail); + const orgConnectInfoByUsernameOrEmail = usernameOrEmailsToInvite.reduce((acc, usernameOrEmail) => { return { ...acc, - [email]: getOrgConnectionInfo({ + [usernameOrEmail]: getOrgConnectionInfo({ orgVerified, orgAutoAcceptDomain: autoAcceptEmailDomain, - usersEmail: email, + usersEmail: usernameOrEmail, team, isOrg: input.isOrg, }), }; }, {} as Record>); const existingUsersWithMembersips = await getUsersToInvite({ - usernameOrEmail: emailsToInvite, + usernamesOrEmails: usernameOrEmailsToInvite, isInvitedToOrg: input.isOrg, team, }); - const existingUsersEmails = existingUsersWithMembersips.map((user) => user.email); - const newUsersEmails = emailsToInvite.filter((email) => !existingUsersEmails.includes(email)); + const existingUsersEmailsAndUsernames = existingUsersWithMembersips.reduce( + (acc, user) => ({ + emails: user.email ? [...acc.emails, user.email] : acc.emails, + usernames: user.username ? [...acc.usernames, user.username] : acc.usernames, + }), + { emails: [], usernames: [] } as { emails: string[]; usernames: string[] } + ); + const newUsersEmailsOrUsernames = usernameOrEmailsToInvite.filter( + (usernameOrEmail) => + !existingUsersEmailsAndUsernames.emails.includes(usernameOrEmail) && + !existingUsersEmailsAndUsernames.usernames.includes(usernameOrEmail) + ); + // deal with users to create and invite to team/org - if (newUsersEmails.length) { + if (newUsersEmailsOrUsernames.length) { await createNewUsersConnectToOrgIfExists({ - usernamesOrEmails: newUsersEmails, + usernamesOrEmails: newUsersEmailsOrUsernames, input, - connectionInfoMap: orgConnectInfoByEmail, + connectionInfoMap: orgConnectInfoByUsernameOrEmail, autoAcceptEmailDomain, parentId: team.parentId, }); - const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => { + const sendVerifEmailsPromises = newUsersEmailsOrUsernames.map((usernameOrEmail) => { return sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, - connectionInfo: orgConnectInfoByEmail[usernameOrEmail], + connectionInfo: orgConnectInfoByUsernameOrEmail[usernameOrEmail], }); }); sendEmails(sendVerifEmailsPromises); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts index 35c59a3523..19f354a59e 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts @@ -2,8 +2,6 @@ import { z } from "zod"; import { MembershipRole } from "@calcom/prisma/enums"; -import { TRPCError } from "@trpc/server"; - export const ZInviteMemberInputSchema = z.object({ teamId: z.number(), usernameOrEmail: z @@ -14,27 +12,26 @@ export const ZInviteMemberInputSchema = z.object({ } return usernameOrEmail.map((item) => item.trim().toLowerCase()); }) - .refine((value) => { - let invalidEmail; - if (Array.isArray(value)) { - if (value.length > 100) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `You are limited to inviting a maximum of 100 users at once.`, - }); + .refine( + (value) => { + if (Array.isArray(value)) { + if (value.length > 100) { + return false; + } } - invalidEmail = value.find((email) => !z.string().email().safeParse(email).success); - } else { - invalidEmail = !z.string().email().safeParse(value).success ? value : null; - } - if (invalidEmail) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invite failed because '${invalidEmail}' is not a valid email address`, - }); - } - return true; - }), + return true; + }, + { message: "You are limited to inviting a maximum of 100 users at once." } + ) + .refine( + (value) => { + if (Array.isArray(value)) { + return !value.some((email) => !z.string().email().safeParse(email).success); + } + return true; + }, + { message: "Bulk invitations are restricted to email addresses only." } + ), role: z.nativeEnum(MembershipRole), language: z.string(), isOrg: z.boolean().default(false), diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index 6dc4ea15df..d7207b8505 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -10,11 +10,12 @@ import type { TeamWithParent } from "./types"; import type { Invitee, UserWithMembership } from "./utils"; import { checkPermissions, - getEmailsToInvite, + getUsernameOrEmailsToInvite, getIsOrgVerified, getOrgConnectionInfo, validateInviteeEligibility, shouldAutoJoinIfInOrg, + checkInputEmailIsValid, } from "./utils"; vi.mock("@calcom/lib/server/queries", () => { @@ -101,22 +102,35 @@ describe("Invite Member Utils", () => { await expect(checkPermissions({ userId: 1, teamId: 1 })).resolves.not.toThrow(); }); }); - describe("getEmailsToInvite", () => { + describe("getUsernameOrEmailsToInvite", () => { it("should throw a TRPCError with code BAD_REQUEST if no emails are provided", async () => { - await expect(getEmailsToInvite([])).rejects.toThrow(TRPCError); + await expect(getUsernameOrEmailsToInvite([])).rejects.toThrow(TRPCError); }); it("should return an array with one email if a string is provided", async () => { - const result = await getEmailsToInvite("test@example.com"); + const result = await getUsernameOrEmailsToInvite("test@example.com"); expect(result).toEqual(["test@example.com"]); }); it("should return an array with multiple emails if an array is provided", async () => { - const result = await getEmailsToInvite(["test1@example.com", "test2@example.com"]); + const result = await getUsernameOrEmailsToInvite(["test1@example.com", "test2@example.com"]); expect(result).toEqual(["test1@example.com", "test2@example.com"]); }); }); + describe("checkInputEmailIsValid", () => { + it("should throw a TRPCError with code BAD_REQUEST if the email is invalid", () => { + const invalidEmail = "invalid-email"; + expect(() => checkInputEmailIsValid(invalidEmail)).toThrow(TRPCError); + expect(() => checkInputEmailIsValid(invalidEmail)).toThrowError( + "Invite failed because invalid-email is not a valid email address" + ); + }); + it("should not throw an error if the email is valid", () => { + const validEmail = "valid-email@example.com"; + expect(() => checkInputEmailIsValid(validEmail)).not.toThrow(); + }); + }); describe("getOrgConnectionInfo", () => { const orgAutoAcceptDomain = "example.com"; const usersEmail = "user@example.com"; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 2ea1ed579c..1279996cb5 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -46,6 +46,14 @@ export async function checkPermissions({ } } +export function checkInputEmailIsValid(email: string) { + if (!isEmail(email)) + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invite failed because ${email} is not a valid email address`, + }); +} + export async function getTeamOrThrow(teamId: number, isOrg?: boolean) { const team = await prisma.team.findFirst({ where: { @@ -62,7 +70,7 @@ export async function getTeamOrThrow(teamId: number, isOrg?: boolean) { return team; } -export async function getEmailsToInvite(usernameOrEmail: string | string[]) { +export async function getUsernameOrEmailsToInvite(usernameOrEmail: string | string[]) { const emailsToInvite = Array.isArray(usernameOrEmail) ? Array.from(new Set(usernameOrEmail)) : [usernameOrEmail]; @@ -128,11 +136,11 @@ export function validateInviteeEligibility( } export async function getUsersToInvite({ - usernameOrEmail, + usernamesOrEmails, isInvitedToOrg, team, }: { - usernameOrEmail: string[]; + usernamesOrEmails: string[]; isInvitedToOrg: boolean; team: TeamWithParent; }): Promise { @@ -149,7 +157,7 @@ export async function getUsersToInvite({ const invitees: UserWithMembership[] = await prisma.user.findMany({ where: { - OR: [{ username: { in: usernameOrEmail }, ...orgWhere }, { email: { in: usernameOrEmail } }], + OR: [{ username: { in: usernamesOrEmails }, ...orgWhere }, { email: { in: usernamesOrEmails } }], }, select: { id: true, @@ -217,6 +225,10 @@ export async function createNewUsersConnectToOrgIfExists({ autoAcceptEmailDomain?: string; connectionInfoMap: Record>; }) { + // fail if we have invalid emails + usernamesOrEmails.forEach((usernameOrEmail) => checkInputEmailIsValid(usernameOrEmail)); + + // from this point we know usernamesOrEmails contains only emails await prisma.$transaction( async (tx) => { for (let index = 0; index < usernamesOrEmails.length; index++) {