fix: Error in team members migration during org onboarding (#15349)

* fix: Error in team members migration during org onboarding

* Add invitationMemberHandler tests

* Add unit tests

* Improve tests and refactor

* Improve tests and refactor

* Fix type issue

* Fix createNewUsersConnectToOrgIfExists args
This commit is contained in:
Hariom Balhara
2024-06-27 00:53:55 +00:00
committed by GitHub
parent 750676f91a
commit 6670bbc1d7
15 changed files with 1212 additions and 453 deletions
@@ -36,17 +36,15 @@ export class OAuthClientUsersService {
const email = this.getOAuthUserEmail(oAuthClientId, body.email);
user = (
await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: [email],
input: {
teamId: organizationId,
role: "MEMBER",
usernameOrEmail: [email],
isOrg: true,
language: "en",
},
invitations: [{
usernameOrEmail: email,
role: "MEMBER"
}],
teamId: organizationId,
isOrg: true,
parentId: null,
autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users",
connectionInfoMap: {
orgConnectInfoByUsernameOrEmail: {
[email]: {
orgId: organizationId,
autoAccept: true,
@@ -234,7 +234,7 @@ export const TeamInviteEmail = (
</Trans>
) : (
<Trans i18nKey="email_team_invite|content|invited_to_subteam">
{invitedBy} has added you to the team <strong>{teamName}</strong> in their organization{" "}
{invitedBy} has invited you to the team <strong>{teamName}</strong> in their organization{" "}
<strong>{parentTeamName}</strong>.
</Trans>
)}{" "}
@@ -5,7 +5,6 @@ import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAn
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import {
getTeamOrThrow,
sendSignupToOrganizationEmail,
@@ -115,7 +114,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
newUserEmails.map((email) => {
return sendSignupToOrganizationEmail({
usernameOrEmail: email,
team: { ...group.team, metadata: teamMetadataSchema.parse(group.team.metadata) },
team: group.team,
translation,
inviterName: org.name,
teamId: group.teamId,
@@ -0,0 +1,21 @@
import { beforeEach, vi, expect } from "vitest";
import { mockReset, mockDeep } from "vitest-mock-extended";
import type * as payments from "@calcom/features/ee/teams/lib/payments";
vi.mock("@calcom/features/ee/teams/lib/payments", () => paymentsMock);
beforeEach(() => {
mockReset(paymentsMock);
});
const paymentsMock = mockDeep<typeof payments>();
export const paymentsScenarios = {};
export const paymentsExpects = {
expectQuantitySubscriptionToBeUpdatedForTeam: (teamId: number) => {
expect(paymentsMock.updateQuantitySubscriptionFromStripe).toHaveBeenCalledWith(teamId);
},
};
export default paymentsMock;
+28
View File
@@ -0,0 +1,28 @@
import { vi, beforeEach } from "vitest";
import type * as constants from "@calcom/lib/constants";
const mockedConstants = {
IS_PRODUCTION: false,
IS_TEAM_BILLING_ENABLED: false,
} as typeof constants;
vi.mock("@calcom/lib/constants", () => {
return mockedConstants;
});
beforeEach(() => {
Object.entries(mockedConstants).forEach(([key]) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete mockedConstants[key];
});
});
export const constantsScenarios = {
enableTeamBilling: () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockedConstants.IS_TEAM_BILLING_ENABLED = true;
},
};
@@ -219,22 +219,19 @@ async function moveTeam({
},
});
await Promise.all(
// TODO: Support different role for different members in usernameOrEmail list and then remove this map
team.members.map(async (membership) => {
// Invite team members to the new org. They are already members of the team.
await inviteMemberHandler({
ctx,
input: {
teamId: org.id,
language: "en",
role: membership.role,
usernameOrEmail: membership.user.email,
isOrg: true,
},
});
})
);
// Invite team members to the new org. They are already members of the team.
await inviteMemberHandler({
ctx,
input: {
teamId: org.id,
language: "en",
usernameOrEmail: team.members.map((m) => ({
email: m.user.email,
role: m.role,
})),
isOrg: true,
},
});
await addTeamRedirect({
oldTeamSlug: team.slug,
@@ -0,0 +1,122 @@
import { beforeEach, vi, expect } from "vitest";
import { mockReset, mockDeep } from "vitest-mock-extended";
import type * as inviteMemberUtils from "../utils";
vi.mock("../utils", async () => {
return inviteMemberUtilsMock;
});
beforeEach(() => {
mockReset(inviteMemberUtilsMock);
});
const inviteMemberUtilsMock = mockDeep<typeof inviteMemberUtils>();
export const inviteMemberutilsScenarios = {
checkPermissions: {
fakePassed: () =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
inviteMemberUtilsMock.checkPermissions.mockResolvedValue(undefined),
},
getTeamOrThrow: {
fakeReturnTeam: (team: { id: number } & Record<string, any>, forInput: { teamId: number }) => {
const fakedVal = {
organizationSettings: null,
parent: null,
parentId: null,
...team,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
inviteMemberUtilsMock.getTeamOrThrow.mockImplementation((teamId) => {
if (forInput.teamId === teamId) {
return fakedVal;
}
throw new Error("Mock Error: Unhandled input");
});
return fakedVal;
},
},
getOrgState: {
/**
* `getOrgState` completely generates the return value from input without using any outside variable like DB, etc.
* So, it makes sense to let it use the actual implementation instead of mocking the output based on input
*/
useActual: async function () {
const actualImport = await vi.importActual<typeof inviteMemberUtils>("../utils");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
return inviteMemberUtilsMock.getOrgState.mockImplementation(actualImport.getOrgState);
},
},
getUniqueInvitationsOrThrowIfEmpty: {
useActual: async function () {
const actualImport = await vi.importActual<typeof inviteMemberUtils>("../utils");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
return inviteMemberUtilsMock.getUniqueInvitationsOrThrowIfEmpty.mockImplementation(
actualImport.getUniqueInvitationsOrThrowIfEmpty
);
},
},
findUsersWithInviteStatus: {
useAdvancedMock: function (
returnVal: Awaited<ReturnType<typeof inviteMemberUtilsMock.findUsersWithInviteStatus>>,
forInput: {
team: any;
invitations: {
usernameOrEmail: string;
}[];
}
) {
inviteMemberUtilsMock.findUsersWithInviteStatus.mockImplementation(({ invitations, team }) => {
const allInvitationsExist = invitations.every((invitation) =>
forInput.invitations.find((i) => i.usernameOrEmail === invitation.usernameOrEmail)
);
if (forInput.team.id == team.id && allInvitationsExist) return returnVal;
});
return returnVal;
},
},
getOrgConnectionInfo: {
useActual: async function () {
const actualImport = await vi.importActual<typeof inviteMemberUtils>("../utils");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
return inviteMemberUtilsMock.getOrgConnectionInfo.mockImplementation(actualImport.getOrgConnectionInfo);
},
},
};
export const expects = {
expectSignupEmailsToBeSent: ({
emails,
team,
inviterName,
isOrg,
teamId,
}: {
emails: string[];
team;
inviterName: string;
teamId: number;
isOrg: boolean;
}) => {
emails.forEach((email, index) => {
expect(inviteMemberUtilsMock.sendSignupToOrganizationEmail.mock.calls[index][0]).toEqual(
expect.objectContaining({
usernameOrEmail: email,
team: team,
inviterName: inviterName,
teamId: teamId,
isOrg: isOrg,
})
);
});
},
};
export default inviteMemberUtilsMock;
@@ -0,0 +1,389 @@
import { scenarios as checkRateLimitAndThrowErrorScenarios } from "../../../../../../../tests/libs/__mocks__/checkRateLimitAndThrowError";
import { mock as getTranslationMock } from "../../../../../../../tests/libs/__mocks__/getTranslation";
import {
inviteMemberutilsScenarios as inviteMemberUtilsScenarios,
default as inviteMemberUtilsMock,
} from "./__mocks__/inviteMemberUtils";
import { default as paymentsMock } from "@calcom/features/ee/teams/lib/__mocks__/payments";
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { Profile } from "@calcom/prisma/client";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../../trpc";
import inviteMemberHandler from "./inviteMember.handler";
import { INVITE_STATUS } from "./utils";
vi.mock("@trpc/server", () => {
return {
TRPCError: class TRPCError {
code: string;
message: unknown;
constructor({ code, message }: { code: string; message: unknown }) {
this.code = code;
this.message = message;
}
},
};
});
function fakeNoUsersFoundMatchingInvitations(args: {
team: any;
invitations: {
role: MembershipRole;
usernameOrEmail: string;
}[];
}) {
inviteMemberUtilsScenarios.findUsersWithInviteStatus.useAdvancedMock([], args);
}
function getPersonalProfile({ username }: { username: string }) {
return {
id: null,
upId: "abc",
organization: null,
organizationId: null,
username,
startTime: 0,
endTime: 0,
avatarUrl: "",
name: "",
bufferTime: 0,
};
}
function getLoggedInUser() {
return {
id: 123,
name: "John Doe",
organization: {
id: 456,
isOrgAdmin: true,
metadata: null,
requestedSlug: null,
},
profile: getPersonalProfile({ username: "john_doe" }),
} as NonNullable<TrpcSessionUser>;
}
function buildExistingUser(user: { id: number; email: string; username: string }) {
return {
password: {
hash: "hash",
userId: 1,
},
identityProvider: IdentityProvider.CAL,
profiles: [] as Profile[],
completedOnboarding: false,
...user,
};
}
describe("inviteMemberHandler", () => {
beforeEach(async () => {
await inviteMemberUtilsScenarios.getOrgState.useActual();
await inviteMemberUtilsScenarios.getUniqueInvitationsOrThrowIfEmpty.useActual();
await inviteMemberUtilsScenarios.getOrgConnectionInfo.useActual();
checkRateLimitAndThrowErrorScenarios.fakeRateLimitPassed();
getTranslationMock.fakeIdentityFn();
inviteMemberUtilsScenarios.checkPermissions.fakePassed();
constantsScenarios.enableTeamBilling();
});
describe("Regular Team", () => {
describe("with 2 emails in input and when there are no users matching the emails", () => {
it("should call appropriate utilities to send email, add users and update in stripe. It should return `numUsersInvited`=2", async () => {
const usersToBeInvited = [
{
id: 1,
email: "user1@example.com",
},
{
id: 2,
email: "user2@example.com",
},
];
const loggedInUser = getLoggedInUser();
const input = {
teamId: 1,
role: MembershipRole.MEMBER,
isOrg: false,
language: "en",
usernameOrEmail: usersToBeInvited.map((u) => u.email),
};
const team = {
id: input.teamId,
name: "Team 1",
parent: null,
};
const retValueOfGetTeamOrThrowError = inviteMemberUtilsScenarios.getTeamOrThrow.fakeReturnTeam(team, {
teamId: input.teamId,
});
const allExpectedInvitations = [
{
role: input.role,
usernameOrEmail: usersToBeInvited[0].email,
},
{
role: input.role,
usernameOrEmail: usersToBeInvited[1].email,
},
];
fakeNoUsersFoundMatchingInvitations({
team,
invitations: allExpectedInvitations,
});
const ctx = {
user: loggedInUser,
};
// Call the inviteMemberHandler function
const result = await inviteMemberHandler({ ctx, input });
const expectedConnectionInfoMap = {
[usersToBeInvited[0].email]: {
orgId: undefined,
autoAccept: false,
},
[usersToBeInvited[1].email]: {
orgId: undefined,
autoAccept: false,
},
};
expect(inviteMemberUtilsMock.handleNewUsersInvites).toHaveBeenCalledWith({
invitationsForNewUsers: allExpectedInvitations,
team: retValueOfGetTeamOrThrowError,
orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap,
input,
inviter: loggedInUser,
autoAcceptEmailDomain: null,
});
expect(paymentsMock.updateQuantitySubscriptionFromStripe).toHaveBeenCalledWith(input.teamId);
expect(inviteMemberUtilsMock.handleExistingUsersInvites).not.toHaveBeenCalled();
expect(inviteMemberUtilsMock.getUniqueInvitationsOrThrowIfEmpty).toHaveBeenCalledWith([
{
role: input.role,
usernameOrEmail: usersToBeInvited[0].email,
},
{
role: input.role,
usernameOrEmail: usersToBeInvited[1].email,
},
]);
// Assert the result
expect(result).toEqual({
...input,
numUsersInvited: 2,
});
});
});
describe("with 2 emails in input and when there is one user matching the email", () => {
it("should call appropriate utilities to add users and update in stripe. It should return `numUsersInvited=2`", async () => {
const usersToBeInvited = [
buildExistingUser({
id: 1,
email: "user1@example.com",
username: "user1",
}),
{
id: null,
email: "user2@example.com",
},
] as const;
const loggedInUser = getLoggedInUser();
const input = {
teamId: 1,
role: MembershipRole.MEMBER,
isOrg: false,
language: "en",
usernameOrEmail: usersToBeInvited.map((u) => u.email),
};
const team = {
id: input.teamId,
name: "Team 1",
parent: null,
};
const retValueOfGetTeamOrThrowError = inviteMemberUtilsScenarios.getTeamOrThrow.fakeReturnTeam(team, {
teamId: input.teamId,
});
const allExpectedInvitations = [
{
role: input.role,
usernameOrEmail: usersToBeInvited[0].email,
},
{
role: input.role,
usernameOrEmail: usersToBeInvited[1].email,
},
];
const retValueOfFindUsersWithInviteStatus =
inviteMemberUtilsScenarios.findUsersWithInviteStatus.useAdvancedMock(
[
{
...usersToBeInvited[0],
canBeInvited: INVITE_STATUS.CAN_BE_INVITED,
newRole: input.role,
},
],
{
invitations: allExpectedInvitations,
team,
}
);
const ctx = {
user: loggedInUser,
};
const result = await inviteMemberHandler({ ctx, input });
const expectedConnectionInfoMap = {
[usersToBeInvited[0].email]: {
orgId: undefined,
autoAccept: false,
},
[usersToBeInvited[1].email]: {
orgId: undefined,
autoAccept: false,
},
};
expect(inviteMemberUtilsMock.handleNewUsersInvites).toHaveBeenCalledWith({
invitationsForNewUsers: allExpectedInvitations.slice(1),
team: retValueOfGetTeamOrThrowError,
orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap,
input,
inviter: loggedInUser,
autoAcceptEmailDomain: null,
});
expect(paymentsMock.updateQuantitySubscriptionFromStripe).toHaveBeenCalledWith(input.teamId);
expect(inviteMemberUtilsMock.handleExistingUsersInvites).toHaveBeenCalledWith({
invitableExistingUsers: retValueOfFindUsersWithInviteStatus,
input: input,
inviter: loggedInUser,
orgConnectInfoByUsernameOrEmail: expectedConnectionInfoMap,
orgSlug: null,
team: retValueOfGetTeamOrThrowError,
});
// Assert the result
expect(result).toEqual({
...input,
numUsersInvited: 2,
});
});
});
it("With one email in input and that email is already a member of the team, it should throw error", async () => {
const userToBeInvited = buildExistingUser({
id: 1,
email: "user1@example.com",
username: "user1",
});
const loggedInUser = getLoggedInUser();
const input = {
teamId: 1,
role: MembershipRole.MEMBER,
isOrg: false,
language: "en",
usernameOrEmail: userToBeInvited.email,
};
const team = {
id: input.teamId,
name: "Team 1",
parent: null,
};
inviteMemberUtilsScenarios.getTeamOrThrow.fakeReturnTeam(team, {
teamId: input.teamId,
});
inviteMemberUtilsScenarios.findUsersWithInviteStatus.useAdvancedMock(
[
{
...userToBeInvited,
canBeInvited: INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER,
newRole: input.role,
},
],
{
invitations: [
{
newRole: input.role,
usernameOrEmail: userToBeInvited.email,
},
],
team,
}
);
const ctx = {
user: loggedInUser,
};
try {
await inviteMemberHandler({ ctx, input });
throw new Error("Expected an error to be thrown");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
expect(e).toBeInstanceOf(TRPCError);
expect(e.code).toEqual("BAD_REQUEST");
expect(e.message).toBe(INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER);
}
});
});
it("When rate limit exceeded, it should throw error", async () => {
const userToBeInvited = buildExistingUser({
id: 1,
email: "user1@example.com",
username: "user1",
});
const errorMessageForRateLimit = checkRateLimitAndThrowErrorScenarios.fakeRateLimitFailed();
const loggedInUser = getLoggedInUser();
const input = {
teamId: 1,
role: MembershipRole.MEMBER,
isOrg: false,
language: "en",
usernameOrEmail: userToBeInvited.email,
};
const ctx = {
user: loggedInUser,
};
try {
await inviteMemberHandler({ ctx, input });
throw new Error("Expected an error to be thrown");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
expect(e).toBeInstanceOf(Error);
expect(e.message).toEqual(errorMessageForRateLimit);
}
});
});
@@ -1,14 +1,12 @@
import { type TFunction } from "i18next";
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { isOrganisationOwner } from "@calcom/lib/server/queries/organisations";
import { getParsedTeam } from "@calcom/lib/server/repository/teamUtils";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -16,20 +14,17 @@ import { TRPCError } from "@trpc/server";
import type { TInviteMemberInputSchema } from "./inviteMember.schema";
import type { TeamWithParent } from "./types";
import type { Invitation } from "./utils";
import {
checkPermissions,
createMemberships,
createNewUsersConnectToOrgIfExists,
getExistingUsersToInvite,
ensureAtleastAdminPermissions,
getTeamOrThrow,
getUniqueInvitationsOrThrowIfEmpty,
getOrgConnectionInfo,
getOrgState,
getTeamOrThrow,
getUniqueUsernameOrEmailsOrThrow,
groupUsersByJoinability,
findUsersWithInviteStatus,
INVITE_STATUS,
sendEmails,
sendExistingUserTeamInviteEmails,
sendSignupToOrganizationEmail,
handleExistingUsersInvites,
handleNewUsersInvites,
} from "./utils";
const log = logger.getSubLogger({ prefix: ["inviteMember.handler"] });
@@ -41,138 +36,202 @@ type InviteMemberOptions = {
input: TInviteMemberInputSchema;
};
export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => {
const myLog = log.getSubLogger({ prefix: ["inviteMemberHandler"] });
const translation = await getTranslation(input.language ?? "en", "common");
await checkRateLimitAndThrowError({
identifier: `invitedBy:${ctx.user.id}`,
});
const team = await getTeamOrThrow(input.teamId);
const isOrg = team.isOrganization;
// Only owners can award owner role in an organization.
if (isOrg && input.role === MembershipRole.OWNER && !(await isOrganisationOwner(ctx.user.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
await checkPermissions({
userId: ctx.user.id,
teamId:
ctx.user.organization.id && ctx.user.organization.isOrgAdmin ? ctx.user.organization.id : input.teamId,
isOrg,
});
const { autoAcceptEmailDomain, orgVerified } = getOrgState(isOrg, team);
const usernameOrEmailsToInvite = await getUniqueUsernameOrEmailsOrThrow(input.usernameOrEmail);
const isBulkInvite = usernameOrEmailsToInvite.length > 1;
const beSilentAboutErrors = isBulkInvite;
const orgConnectInfoByUsernameOrEmail = usernameOrEmailsToInvite.reduce((acc, usernameOrEmail) => {
function getOrgConnectionInfoGroupedByUsernameOrEmail({
uniqueInvitations,
orgState,
team,
isOrg,
}: {
uniqueInvitations: { usernameOrEmail: string; role: MembershipRole }[];
orgState: ReturnType<typeof getOrgState>;
team: Pick<TeamWithParent, "parentId" | "id">;
isOrg: boolean;
}) {
return uniqueInvitations.reduce((acc, invitation) => {
return {
...acc,
[usernameOrEmail]: getOrgConnectionInfo({
orgVerified,
orgAutoAcceptDomain: autoAcceptEmailDomain,
usersEmail: usernameOrEmail,
[invitation.usernameOrEmail]: getOrgConnectionInfo({
orgVerified: orgState.orgVerified,
orgAutoAcceptDomain: orgState.autoAcceptEmailDomain,
email: invitation.usernameOrEmail,
team,
isOrg: isOrg,
}),
};
}, {} as Record<string, ReturnType<typeof getOrgConnectionInfo>>);
const existingUsersWithMemberships = await getExistingUsersToInvite({
usernamesOrEmails: usernameOrEmailsToInvite,
team,
});
}
// Existing users have a criteria to be invited
const existingUsersWithMembershipsThatNeedToBeInvited = existingUsersWithMemberships.filter(
(invitee) => invitee.canBeInvited === INVITE_STATUS.CAN_BE_INVITED
);
// beSilentAboutErrors is false only when there is a single user being invited, so we just check the first item status here
// Bulk invites error are silently ignored and they should be logged differently when needed
const firstExistingUser = existingUsersWithMemberships[0];
if (
!beSilentAboutErrors &&
firstExistingUser &&
firstExistingUser.canBeInvited !== INVITE_STATUS.CAN_BE_INVITED
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: translation(existingUsersWithMemberships[0].canBeInvited),
});
}
const existingUsersEmailsAndUsernames = existingUsersWithMemberships.reduce(
function getInvitationsForNewUsers({
existingUsersToBeInvited,
uniqueInvitations,
}: {
existingUsersToBeInvited: Awaited<ReturnType<typeof findUsersWithInviteStatus>>;
uniqueInvitations: { usernameOrEmail: string; role: MembershipRole }[];
}) {
const existingUsersEmailsAndUsernames = existingUsersToBeInvited.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[] }
);
return uniqueInvitations.filter(
(invitation) =>
!existingUsersEmailsAndUsernames.emails.includes(invitation.usernameOrEmail) &&
!existingUsersEmailsAndUsernames.usernames.includes(invitation.usernameOrEmail)
);
}
// New Users can always be invited
const newUsersEmailsOrUsernames = usernameOrEmailsToInvite.filter(
(usernameOrEmail) =>
!existingUsersEmailsAndUsernames.emails.includes(usernameOrEmail) &&
!existingUsersEmailsAndUsernames.usernames.includes(usernameOrEmail)
function throwIfInvalidInvitationStatus({
firstExistingUser,
translation,
}: {
firstExistingUser: Awaited<ReturnType<typeof findUsersWithInviteStatus>>[number] | undefined;
translation: TFunction;
}) {
if (firstExistingUser && firstExistingUser.canBeInvited !== INVITE_STATUS.CAN_BE_INVITED) {
throw new TRPCError({
code: "BAD_REQUEST",
message: translation(firstExistingUser.canBeInvited),
});
}
}
function shouldBeSilentAboutErrors(invitations: Invitation[]) {
const isBulkInvite = invitations.length > 1;
return isBulkInvite;
}
function buildInvitationsFromInput({
usernameOrEmail,
roleForAllInvitees,
}: {
usernameOrEmail: TInviteMemberInputSchema["usernameOrEmail"];
roleForAllInvitees: MembershipRole | undefined;
}) {
const usernameOrEmailList = typeof usernameOrEmail === "string" ? [usernameOrEmail] : usernameOrEmail;
return usernameOrEmailList.map((usernameOrEmail) => {
if (typeof usernameOrEmail === "string")
return { usernameOrEmail: usernameOrEmail, role: roleForAllInvitees ?? MembershipRole.MEMBER };
return {
usernameOrEmail: usernameOrEmail.email,
role: usernameOrEmail.role,
};
});
}
export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => {
const myLog = log.getSubLogger({ prefix: ["inviteMemberHandler"] });
const translation = await getTranslation(input.language ?? "en", "common");
await checkRateLimitAndThrowError({
identifier: `invitedBy:${ctx.user.id}`,
});
const invitations = buildInvitationsFromInput({
usernameOrEmail: input.usernameOrEmail,
roleForAllInvitees: input.role,
});
const team = await getTeamOrThrow(input.teamId);
const isTeamAnOrg = team.isOrganization;
const isAddingNewOwner = !!invitations.find((invitation) => invitation.role === MembershipRole.OWNER);
const inviter = ctx.user;
const inviterOrg = inviter.organization;
if (isTeamAnOrg) {
await throwIfInviterCantAddOwnerToOrg();
}
await ensureAtleastAdminPermissions({
userId: ctx.user.id,
teamId: inviterOrg.id && inviterOrg.isOrgAdmin ? inviterOrg.id : input.teamId,
isOrg: isTeamAnOrg,
});
const uniqueInvitations = await getUniqueInvitationsOrThrowIfEmpty(invitations);
const beSilentAboutErrors = shouldBeSilentAboutErrors(uniqueInvitations);
const existingUsersToBeInvited = await findUsersWithInviteStatus({
invitations: uniqueInvitations,
team,
});
if (!beSilentAboutErrors) {
// beSilentAboutErrors is false only when there is a single user being invited, so we just check the first user status here
throwIfInvalidInvitationStatus({ firstExistingUser: existingUsersToBeInvited[0], translation });
}
const orgState = getOrgState(isTeamAnOrg, team);
const orgConnectInfoByUsernameOrEmail = getOrgConnectionInfoGroupedByUsernameOrEmail({
uniqueInvitations,
orgState,
team: {
parentId: team.parentId,
id: team.id,
},
isOrg: isTeamAnOrg,
});
const invitationsForNewUsers = getInvitationsForNewUsers({
existingUsersToBeInvited,
uniqueInvitations,
});
if (invitationsForNewUsers.length) {
await handleNewUsersInvites({
invitationsForNewUsers,
team,
orgConnectInfoByUsernameOrEmail,
input,
inviter: ctx.user,
autoAcceptEmailDomain: orgState.autoAcceptEmailDomain,
});
}
// Existing users have a criteria to be invited
const invitableExistingUsers = existingUsersToBeInvited.filter(
(invitee) => invitee.canBeInvited === INVITE_STATUS.CAN_BE_INVITED
);
myLog.debug(
"Notable variables:",
safeStringify({
usernameOrEmailsToInvite,
uniqueInvitations,
orgConnectInfoByUsernameOrEmail,
existingUsersWithMembershipsThatNeedToBeInvited: existingUsersWithMembershipsThatNeedToBeInvited,
existingUsersWithMemberships,
existingUsersEmailsAndUsernames,
newUsersEmailsOrUsernames,
invitableExistingUsers,
existingUsersToBeInvited,
invitationsForNewUsers,
})
);
// deal with users to create and invite to team/org
if (newUsersEmailsOrUsernames.length) {
await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: newUsersEmailsOrUsernames,
if (invitableExistingUsers.length) {
const organization = ctx.user.profile.organization;
const orgSlug = organization ? organization.slug || organization.requestedSlug : null;
await handleExistingUsersInvites({
invitableExistingUsers,
team,
orgConnectInfoByUsernameOrEmail,
input,
connectionInfoMap: orgConnectInfoByUsernameOrEmail,
autoAcceptEmailDomain,
parentId: team.parentId,
inviter: ctx.user,
orgSlug,
});
const sendVerifEmailsPromises = newUsersEmailsOrUsernames.map((usernameOrEmail) => {
return sendSignupToOrganizationEmail({
usernameOrEmail,
team,
translation,
inviterName: ctx.user.name ?? "",
teamId: input.teamId,
isOrg: input.isOrg,
});
});
await sendEmails(sendVerifEmailsPromises);
}
const organization = ctx.user.profile.organization;
const orgSlug = organization ? organization.slug || organization.requestedSlug : null;
// deal with existing users invited to join the team/org
await handleExistingUsersInvites({
existingUsersWithMemberships: existingUsersWithMembershipsThatNeedToBeInvited,
team,
orgConnectInfoByUsernameOrEmail,
input,
inviter: ctx.user,
orgSlug,
});
await handleSubscriptionUpdates(team.parentId || input.teamId);
if (IS_TEAM_BILLING_ENABLED) {
await updateQuantitySubscriptionFromStripe(team.parentId ?? input.teamId);
}
return {
...input,
numUsersInvited:
existingUsersWithMembershipsThatNeedToBeInvited.length + newUsersEmailsOrUsernames.length,
numUsersInvited: invitableExistingUsers.length + invitationsForNewUsers.length,
};
async function throwIfInviterCantAddOwnerToOrg() {
const isInviterOrgOwner = await isOrganisationOwner(ctx.user.id, input.teamId);
if (isAddingNewOwner && !isInviterOrgOwner) throw new TRPCError({ code: "UNAUTHORIZED" });
}
};
async function handleSubscriptionUpdates(teamId: number) {
@@ -181,195 +240,3 @@ async function handleSubscriptionUpdates(teamId: number) {
}
export default inviteMemberHandler;
async function handleExistingUsersInvites({
existingUsersWithMemberships,
team,
orgConnectInfoByUsernameOrEmail,
input,
inviter,
orgSlug,
}: {
existingUsersWithMemberships: Awaited<ReturnType<typeof getExistingUsersToInvite>>;
team: TeamWithParent;
orgConnectInfoByUsernameOrEmail: Record<string, { orgId: number | undefined; autoAccept: boolean }>;
input: {
teamId: number;
role: "ADMIN" | "MEMBER" | "OWNER";
isOrg: boolean;
usernameOrEmail: (string | string[]) & (string | string[] | undefined);
language: string;
};
inviter: {
name: string | null;
};
orgSlug: string | null;
}) {
if (!existingUsersWithMemberships.length) {
return;
}
const translation = await getTranslation(input.language ?? "en", "common");
if (!team.isOrganization) {
const [autoJoinUsers, regularUsers] = groupUsersByJoinability({
existingUsersWithMemberships: existingUsersWithMemberships.map((u) => {
return {
...u,
profile: null,
};
}),
team,
connectionInfoMap: orgConnectInfoByUsernameOrEmail,
});
log.debug(
"Inviting existing users to a team",
safeStringify({
autoJoinUsers,
regularUsers,
})
);
// invited users can autojoin, create their memberships in org
if (autoJoinUsers.length) {
await createMemberships({
input,
invitees: autoJoinUsers,
parentId: team.parentId,
accepted: true,
});
await Promise.all(
autoJoinUsers.map(async (userToAutoJoin) => {
await updateNewTeamMemberEventTypes(userToAutoJoin.id, team.id);
})
);
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: autoJoinUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
isAutoJoin: true,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
// invited users cannot autojoin, create provisional memberships and send email
if (regularUsers.length) {
await createMemberships({
input,
invitees: regularUsers,
parentId: team.parentId,
accepted: false,
});
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: regularUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
isAutoJoin: false,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
const parentOrganization = team.parent;
if (parentOrganization) {
const parsedOrg = getParsedTeam(parentOrganization);
// Create profiles if needed
await Promise.all([
autoJoinUsers
.concat(regularUsers)
.filter((u) => u.needToCreateProfile)
.map((user) =>
createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId: parsedOrg.id,
})
),
]);
}
} else {
const organization = team;
log.debug(
"Inviting existing users to an organization",
safeStringify({
existingUsersWithMemberships,
})
);
const existingUsersWithMembershipsNew = await Promise.all(
existingUsersWithMemberships.map(async (user) => {
const shouldAutoAccept = orgConnectInfoByUsernameOrEmail[user.email].autoAccept;
let profile = null;
if (shouldAutoAccept) {
profile = await createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId: organization.id,
});
}
await prisma.membership.create({
data: {
userId: user.id,
teamId: team.id,
accepted: shouldAutoAccept,
role: input.role,
},
});
return {
...user,
profile,
};
})
);
const autoJoinUsers = existingUsersWithMembershipsNew.filter(
(user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept
);
const regularUsers = existingUsersWithMembershipsNew.filter(
(user) => !orgConnectInfoByUsernameOrEmail[user.email].autoAccept
);
// Send emails to user who auto-joined
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: autoJoinUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
isAutoJoin: true,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
// Send emails to user who need to accept invite
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: regularUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
isAutoJoin: false,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
}
@@ -6,12 +6,32 @@ import { MembershipRole } from "@calcom/prisma/enums";
export const ZInviteMemberInputSchema = z.object({
teamId: z.number(),
usernameOrEmail: z
.union([z.string(), z.array(z.string())])
.union([
z.string(),
z
.union([
z.string(),
z.object({
email: z.string().email(),
role: z.nativeEnum(MembershipRole),
}),
])
.array(),
])
.transform((usernameOrEmail) => {
if (typeof usernameOrEmail === "string") {
return usernameOrEmail.trim().toLowerCase();
}
return usernameOrEmail.map((item) => item.trim().toLowerCase());
return usernameOrEmail.map((item) => {
if (typeof item === "string") {
return item.trim().toLowerCase();
}
return {
...item,
email: item.email.trim().toLowerCase(),
};
});
})
.refine(
(value) => {
@@ -33,7 +53,7 @@ export const ZInviteMemberInputSchema = z.object({
},
{ message: "Bulk invitations are restricted to email addresses only." }
),
role: z.nativeEnum(MembershipRole),
role: z.nativeEnum(MembershipRole).optional(),
language: z.string(),
isOrg: z.boolean().default(false),
});
@@ -10,8 +10,8 @@ import type { TeamWithParent } from "./types";
import type { UserWithMembership } from "./utils";
import { INVITE_STATUS } from "./utils";
import {
checkPermissions,
getUniqueUsernameOrEmailsOrThrow,
ensureAtleastAdminPermissions,
getUniqueInvitationsOrThrowIfEmpty,
getOrgState,
getOrgConnectionInfo,
canBeInvited,
@@ -28,6 +28,7 @@ vi.mock("@calcom/lib/server/queries", () => {
vi.mock("@calcom/lib/server/queries/organisations", () => {
return {
isOrganisationAdmin: vi.fn(),
isOrganisationOwner: vi.fn(),
};
});
@@ -123,37 +124,46 @@ const userInTeamNotAccepted: UserWithMembership = {
};
describe("Invite Member Utils", () => {
describe("checkPermissions", () => {
describe("ensureAtleastAdminPermissions", () => {
it("It should throw an error if the user is not an admin of the ORG", async () => {
vi.mocked(isOrganisationAdmin).mockResolvedValue(false);
await expect(checkPermissions({ userId: 1, teamId: 1, isOrg: true })).rejects.toThrow();
});
it("It should NOT throw an error if the user is an admin of the ORG", async () => {
vi.mocked(isOrganisationAdmin).mockResolvedValue(mockedReturnSuccessCheckPerms);
await expect(checkPermissions({ userId: 1, teamId: 1, isOrg: true })).resolves.not.toThrow();
});
it("It should throw an error if the user is not an admin of the team", async () => {
vi.mocked(isTeamAdmin).mockResolvedValue(false);
await expect(checkPermissions({ userId: 1, teamId: 1 })).rejects.toThrow();
});
it("It should NOT throw an error if the user is an admin of a team", async () => {
vi.mocked(isTeamAdmin).mockResolvedValue(mockedReturnSuccessCheckPerms);
await expect(checkPermissions({ userId: 1, teamId: 1 })).resolves.not.toThrow();
});
});
describe("getUniqueUsernameOrEmailsOrThrow", () => {
it("should throw a TRPCError with code BAD_REQUEST if no emails are provided", async () => {
await expect(getUniqueUsernameOrEmailsOrThrow([])).rejects.toThrow(TRPCError);
await expect(ensureAtleastAdminPermissions({ userId: 1, teamId: 1, isOrg: true })).rejects.toThrow(
"UNAUTHORIZED"
);
});
it("should return an array with one email if a string is provided", async () => {
const result = await getUniqueUsernameOrEmailsOrThrow("test@example.com");
expect(result).toEqual(["test@example.com"]);
it("It should NOT throw an error if the user is an admin of the ORG", async () => {
vi.mocked(isOrganisationAdmin).mockResolvedValue(mockedReturnSuccessCheckPerms);
await expect(
ensureAtleastAdminPermissions({ userId: 1, teamId: 1, isOrg: true })
).resolves.not.toThrow();
});
it("It should throw an error if the user is not an admin of the team", async () => {
vi.mocked(isTeamAdmin).mockResolvedValue(false);
await expect(ensureAtleastAdminPermissions({ userId: 1, teamId: 1 })).rejects.toThrow("UNAUTHORIZED");
});
it("It should NOT throw an error if the user is an admin of a team", async () => {
vi.mocked(isTeamAdmin).mockResolvedValue(mockedReturnSuccessCheckPerms);
await expect(ensureAtleastAdminPermissions({ userId: 1, teamId: 1 })).resolves.not.toThrow();
});
});
describe("getUniqueInvitationsOrThrowIfEmpty", () => {
it("should throw a TRPCError with code BAD_REQUEST if no emails are provided", async () => {
await expect(getUniqueInvitationsOrThrowIfEmpty([])).rejects.toThrow(TRPCError);
});
it("should return an array with multiple emails if an array is provided", async () => {
const result = await getUniqueUsernameOrEmailsOrThrow(["test1@example.com", "test2@example.com"]);
expect(result).toEqual(["test1@example.com", "test2@example.com"]);
const result = await getUniqueInvitationsOrThrowIfEmpty([
{ usernameOrEmail: "test1@example.com", role: MembershipRole.MEMBER },
{ usernameOrEmail: "test2@example.com", role: MembershipRole.MEMBER },
]);
expect(result).toEqual([
{ usernameOrEmail: "test1@example.com", role: MembershipRole.MEMBER },
{ usernameOrEmail: "test2@example.com", role: MembershipRole.MEMBER },
]);
});
});
describe("checkInputEmailIsValid", () => {
@@ -172,13 +182,13 @@ describe("Invite Member Utils", () => {
});
describe("getOrgConnectionInfo", () => {
const orgAutoAcceptDomain = "example.com";
const usersEmail = "user@example.com";
const email = "user@example.com";
it("should return autoAccept:false when orgVerified is false even if usersEmail domain matches orgAutoAcceptDomain", () => {
it("should return autoAccept:false when orgVerified is false even if email domain matches orgAutoAcceptDomain", () => {
const result = getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified: false,
usersEmail,
email,
team: {
...mockedRegularTeam,
parentId: 2,
@@ -188,11 +198,11 @@ describe("Invite Member Utils", () => {
expect(result).toEqual({ orgId: 2, autoAccept: false });
});
it("should return orgId and autoAccept as false if team has parent and usersEmail domain does not match orgAutoAcceptDomain", () => {
it("should return orgId and autoAccept as false if team has parent and email domain does not match orgAutoAcceptDomain", () => {
const result = getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified: true,
usersEmail: "user@other.com",
email: "user@other.com",
team: {
...mockedRegularTeam,
parentId: 2,
@@ -202,33 +212,33 @@ describe("Invite Member Utils", () => {
expect(result).toEqual({ orgId: undefined, autoAccept: false });
});
it("should return orgId and autoAccept as true if team has no parent and isOrg is true and usersEmail domain matches orgAutoAcceptDomain", () => {
it("should return orgId and autoAccept as true if team has no parent and isOrg is true and email domain matches orgAutoAcceptDomain", () => {
const result = getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified: true,
usersEmail,
email,
team: { ...mockedRegularTeam, parentId: null },
isOrg: true,
});
expect(result).toEqual({ orgId: 1, autoAccept: true });
});
it("should return orgId and autoAccept as false if team has no parent and isOrg is true and usersEmail domain does not match orgAutoAcceptDomain", () => {
it("should return orgId and autoAccept as false if team has no parent and isOrg is true and email domain does not match orgAutoAcceptDomain", () => {
const result = getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified: false,
usersEmail: "user@other.com",
email: "user@other.com",
team: { ...mockedRegularTeam, parentId: null },
isOrg: true,
});
expect(result).toEqual({ orgId: undefined, autoAccept: false });
});
it("should return orgId and autoAccept as false if team has no parent and isOrg is true and usersEmail domain matches orgAutoAcceptDomain but orgVerified is false", () => {
it("should return orgId and autoAccept as false if team has no parent and isOrg is true and email domain matches orgAutoAcceptDomain but orgVerified is false", () => {
const result = getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified: false,
usersEmail,
email,
team: { ...mockedRegularTeam, parentId: null },
isOrg: true,
});
@@ -290,6 +300,10 @@ describe("Invite Member Utils", () => {
const result = getOrgState(false, { ...mockedRegularTeam, ...team });
expect(result).toEqual({
isInOrgScope: false,
orgVerified: null,
orgConfigured: null,
orgPublished: null,
autoAcceptEmailDomain: null,
});
});
});
@@ -4,16 +4,20 @@ import type { TFunction } from "next-i18next";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { sendTeamInviteEmail } from "@calcom/emails";
import { ENABLE_PROFILE_SWITCHER, WEBAPP_URL } from "@calcom/lib/constants";
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { isTeamAdmin } from "@calcom/lib/server/queries";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { getParsedTeam } from "@calcom/lib/server/repository/teamUtils";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { Membership, OrganizationSettings, Team } from "@calcom/prisma/client";
import { Prisma, type User as UserType, type UserPassword } from "@calcom/prisma/client";
import { type User as UserType, type UserPassword, Prisma } from "@calcom/prisma/client";
import type { Profile as ProfileType } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -35,7 +39,19 @@ export type UserWithMembership = Invitee & {
password: UserPassword | null;
};
export async function checkPermissions({
export type Invitation = {
usernameOrEmail: string;
role: MembershipRole;
};
type ExistingUserWithInviteStatus = Awaited<ReturnType<typeof findUsersWithInviteStatus>>[number];
type ExistingUserWithInviteStatusAndProfile = ExistingUserWithInviteStatus & {
profile: {
username: string;
} | null;
};
export async function ensureAtleastAdminPermissions({
userId,
teamId,
isOrg,
@@ -85,19 +101,26 @@ export async function getTeamOrThrow(teamId: number) {
return { ...team, metadata: teamMetadataSchema.parse(team.metadata) };
}
export async function getUniqueUsernameOrEmailsOrThrow(usernameOrEmail: string | string[]) {
const emailsToInvite = Array.isArray(usernameOrEmail)
? Array.from(new Set(usernameOrEmail))
: [usernameOrEmail];
export async function getUniqueInvitationsOrThrowIfEmpty(invitations: Invitation[]) {
const usernamesOrEmailsSet = new Set<string>();
const uniqueInvitations: Invitation[] = [];
if (emailsToInvite.length === 0) {
invitations.forEach((usernameOrEmail) => {
if (usernamesOrEmailsSet.has(usernameOrEmail.usernameOrEmail)) {
return;
}
uniqueInvitations.push(usernameOrEmail);
usernamesOrEmailsSet.add(usernameOrEmail.usernameOrEmail);
});
if (uniqueInvitations.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You must provide at least one email address to invite.",
});
}
return emailsToInvite;
return uniqueInvitations;
}
export const enum INVITE_STATUS {
@@ -141,14 +164,15 @@ export function canBeInvited(invitee: UserWithMembership, team: TeamWithParent)
return INVITE_STATUS.CAN_BE_INVITED;
}
export async function getExistingUsersToInvite({
usernamesOrEmails,
export async function findUsersWithInviteStatus({
invitations,
team,
}: {
usernamesOrEmails: string[];
invitations: Invitation[];
team: TeamWithParent;
}) {
const invitees: UserWithMembership[] = await prisma.user.findMany({
const usernamesOrEmails = invitations.map((invitation) => invitation.usernameOrEmail);
const inviteesFromDb: UserWithMembership[] = await prisma.user.findMany({
where: {
OR: [
// Either it's a username in that organization
@@ -176,26 +200,43 @@ export async function getExistingUsersToInvite({
},
});
const userToRoleMap = buildUserToRoleMap();
const defaultMemberRole = MembershipRole.MEMBER;
// Check if the users found in the database can be invited to join the team/org
return invitees.map((invitee) => {
return inviteesFromDb.map((inviteeFromDb) => {
const newRole = getRoleForUser({ email: inviteeFromDb.email, username: inviteeFromDb.username });
return {
...invitee,
canBeInvited: canBeInvited(invitee, team),
...inviteeFromDb,
newRole: newRole ?? defaultMemberRole,
canBeInvited: canBeInvited(inviteeFromDb, team),
};
});
function buildUserToRoleMap() {
const userToRoleMap = new Map<string, MembershipRole>();
invitations.forEach((invitation) => {
userToRoleMap.set(invitation.usernameOrEmail, invitation.role);
});
return userToRoleMap;
}
function getRoleForUser({ email, username }: { email: string; username: string | null }) {
return userToRoleMap.get(email) || (username ? userToRoleMap.get(username) : defaultMemberRole);
}
}
export function getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified,
isOrg,
usersEmail,
email,
team,
}: {
orgAutoAcceptDomain?: string | null;
orgVerified: boolean;
usersEmail: string;
team: TeamWithParent;
orgVerified: boolean | null;
email: string;
team: Pick<TeamWithParent, "parentId" | "id">;
isOrg: boolean;
}) {
let orgId: number | undefined = undefined;
@@ -203,11 +244,11 @@ export function getOrgConnectionInfo({
if (team.parentId || isOrg) {
orgId = team.parentId || team.id;
if (usersEmail.split("@")[1] == orgAutoAcceptDomain) {
if (email.split("@")[1] == orgAutoAcceptDomain) {
// We discourage self-served organizations from being able to auto-accept feature by having a barrier of a fixed number of paying teams in the account for creating the organization
// We can't put restriction of a published organization here because when we move teams during the onboarding of the organization, it isn't published at the moment and we really need those members to be auto-added
// Further, sensitive operations like member editing and impersonating are disabled by default, unless reviewed by the ADMIN team
autoAccept = orgVerified;
autoAccept = !!orgVerified;
} else {
orgId = undefined;
autoAccept = false;
@@ -218,37 +259,39 @@ export function getOrgConnectionInfo({
}
export async function createNewUsersConnectToOrgIfExists({
usernamesOrEmails,
input,
invitations,
isOrg,
teamId,
parentId,
autoAcceptEmailDomain,
connectionInfoMap,
orgConnectInfoByUsernameOrEmail,
isPlatformManaged,
timeFormat,
weekStart,
timeZone,
}: {
usernamesOrEmails: string[];
input: InviteMemberOptions["input"];
invitations: Invitation[];
isOrg: boolean;
teamId: number;
parentId?: number | null;
autoAcceptEmailDomain?: string;
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
autoAcceptEmailDomain: string | null;
orgConnectInfoByUsernameOrEmail: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
isPlatformManaged?: boolean;
timeFormat?: number;
weekStart?: string;
timeZone?: string;
}) {
// fail if we have invalid emails
usernamesOrEmails.forEach((usernameOrEmail) => checkInputEmailIsValid(usernameOrEmail));
invitations.forEach((invitation) => checkInputEmailIsValid(invitation.usernameOrEmail));
// from this point we know usernamesOrEmails contains only emails
const createdUsers = await prisma.$transaction(
async (tx) => {
const createdUsers = [];
for (let index = 0; index < usernamesOrEmails.length; index++) {
const usernameOrEmail = usernamesOrEmails[index];
for (let index = 0; index < invitations.length; index++) {
const invitation = invitations[index];
// Weird but orgId is defined only if the invited user email matches orgAutoAcceptEmail
const { orgId, autoAccept } = connectionInfoMap[usernameOrEmail];
const [emailUser, emailDomain] = usernameOrEmail.split("@");
const { orgId, autoAccept } = orgConnectInfoByUsernameOrEmail[invitation.usernameOrEmail];
const [emailUser, emailDomain] = invitation.usernameOrEmail.split("@");
// An org member can't change username during signup, so we set the username
const orgMemberUsername =
@@ -259,14 +302,14 @@ export async function createNewUsersConnectToOrgIfExists({
// As a regular team member is allowed to change username during signup, we don't set any username for him
const regularTeamMemberUsername = null;
const isBecomingAnOrgMember = parentId || input.isOrg;
const isBecomingAnOrgMember = parentId || isOrg;
const createdUser = await tx.user.create({
data: {
username: isBecomingAnOrgMember ? orgMemberUsername : regularTeamMemberUsername,
email: usernameOrEmail,
email: invitation.usernameOrEmail,
verified: true,
invitedTo: input.teamId,
invitedTo: teamId,
isPlatformManaged: !!isPlatformManaged,
timeFormat,
weekStart,
@@ -289,8 +332,8 @@ export async function createNewUsersConnectToOrgIfExists({
: null),
teams: {
create: {
teamId: input.teamId,
role: input.role as MembershipRole,
teamId: teamId,
role: invitation.role,
accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted
},
},
@@ -323,8 +366,8 @@ export async function createMemberships({
parentId,
accepted,
}: {
input: InviteMemberOptions["input"];
invitees: (UserWithMembership & {
input: Omit<InviteMemberOptions["input"], "usernameOrEmail">;
invitees: (ExistingUserWithInviteStatus & {
needToCreateOrgMembership: boolean | null;
})[];
parentId: number | null;
@@ -344,7 +387,7 @@ export async function createMemberships({
role:
organizationRole === MembershipRole.ADMIN || organizationRole === MembershipRole.OWNER
? organizationRole
: input.role,
: invitee.newRole,
});
// membership for the org
@@ -360,22 +403,11 @@ export async function createMemberships({
}),
});
} catch (e) {
console.error(e);
if (e instanceof Prisma.PrismaClientKnownRequestError) {
// Don't throw an error if the user is already a member of the team when inviting multiple users
if (!Array.isArray(input.usernameOrEmail) && e.code === "P2002") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This user is a member of this team / has a pending invitation.",
});
} else if (Array.isArray(input.usernameOrEmail) && e.code === "P2002") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Trying to invite users already members of this team / have pending invitations",
});
}
logger.error("Failed to create memberships", input.teamId);
} else throw e;
} else {
throw e;
}
}
}
@@ -388,7 +420,7 @@ export async function sendSignupToOrganizationEmail({
isOrg,
}: {
usernameOrEmail: string;
team: Awaited<ReturnType<typeof getTeamOrThrow>>;
team: { name: string; parent: { name: string } | null };
translation: TFunction;
inviterName: string;
teamId: number;
@@ -457,12 +489,10 @@ export function getOrgState(
return {
isInOrgScope: false,
} as {
isInOrgScope: false;
orgVerified: never;
autoAcceptEmailDomain: never;
orgConfigured: never;
orgPublished: never;
orgVerified: null,
autoAcceptEmailDomain: null,
orgConfigured: null,
orgPublished: null,
};
}
@@ -528,32 +558,27 @@ export const groupUsersByJoinability = ({
connectionInfoMap,
}: {
team: TeamWithParent;
existingUsersWithMemberships: (UserWithMembership & {
profile: {
username: string;
} | null;
})[];
existingUsersWithMemberships: ExistingUserWithInviteStatusAndProfile[];
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
}) => {
const usersToAutoJoin = [];
const regularUsers = [];
for (let index = 0; index < existingUsersWithMemberships.length; index++) {
const existingUserWithMembersips = existingUsersWithMemberships[index];
const existingUserWithMemberships = existingUsersWithMemberships[index];
const autoJoinStatus = getAutoJoinStatus({
invitee: existingUserWithMembersips,
invitee: existingUserWithMemberships,
team,
connectionInfoMap,
});
autoJoinStatus.autoAccept
? usersToAutoJoin.push({
...existingUserWithMembersips,
...existingUserWithMemberships,
...autoJoinStatus,
})
: regularUsers.push({
...existingUserWithMembersips,
...existingUserWithMemberships,
...autoJoinStatus,
});
}
@@ -583,11 +608,7 @@ export const sendExistingUserTeamInviteEmails = async ({
}: {
language: TFunction;
isAutoJoin: boolean;
existingUsersWithMemberships: (UserWithMembership & {
profile: {
username: string;
} | null;
})[];
existingUsersWithMemberships: Omit<ExistingUserWithInviteStatusAndProfile, "canBeInvited" | "newRole">[];
currentUserTeamName?: string;
currentUserParentTeamName: string | undefined;
currentUserName?: string | null;
@@ -657,3 +678,236 @@ export const sendExistingUserTeamInviteEmails = async ({
await sendEmails(sendEmailsPromises);
};
type inviteMemberHandlerInput = {
teamId: number;
role?: "ADMIN" | "MEMBER" | "OWNER";
isOrg: boolean;
language: string;
};
export async function handleExistingUsersInvites({
invitableExistingUsers,
team,
orgConnectInfoByUsernameOrEmail,
input,
inviter,
orgSlug,
}: {
invitableExistingUsers: Awaited<ReturnType<typeof findUsersWithInviteStatus>>;
team: TeamWithParent;
orgConnectInfoByUsernameOrEmail: Record<string, { orgId: number | undefined; autoAccept: boolean }>;
input: inviteMemberHandlerInput;
inviter: {
name: string | null;
};
orgSlug: string | null;
}) {
const translation = await getTranslation(input.language ?? "en", "common");
if (!team.isOrganization) {
const [autoJoinUsers, regularUsers] = groupUsersByJoinability({
existingUsersWithMemberships: invitableExistingUsers.map((u) => {
return {
...u,
profile: null,
};
}),
team,
connectionInfoMap: orgConnectInfoByUsernameOrEmail,
});
log.debug(
"Inviting existing users to a team",
safeStringify({
autoJoinUsers,
regularUsers,
})
);
// invited users can autojoin, create their memberships in org
if (autoJoinUsers.length) {
await createMemberships({
input,
invitees: autoJoinUsers,
parentId: team.parentId,
accepted: true,
});
await Promise.all(
autoJoinUsers.map(async (userToAutoJoin) => {
await updateNewTeamMemberEventTypes(userToAutoJoin.id, team.id);
})
);
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: autoJoinUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
isAutoJoin: true,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
// invited users cannot autojoin, create provisional memberships and send email
if (regularUsers.length) {
await createMemberships({
input,
invitees: regularUsers,
parentId: team.parentId,
accepted: false,
});
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: regularUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
isAutoJoin: false,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
const parentOrganization = team.parent;
if (parentOrganization) {
const parsedOrg = getParsedTeam(parentOrganization);
// Create profiles if needed
await Promise.all([
autoJoinUsers
.concat(regularUsers)
.filter((u) => u.needToCreateProfile)
.map((user) =>
createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId: parsedOrg.id,
})
),
]);
}
} else {
const organization = team;
log.debug(
"Inviting existing users to an organization",
safeStringify({
invitableExistingUsers,
})
);
const existingUsersWithMembershipsNew = await Promise.all(
invitableExistingUsers.map(async (user) => {
const shouldAutoAccept = orgConnectInfoByUsernameOrEmail[user.email].autoAccept;
let profile = null;
if (shouldAutoAccept) {
profile = await createAProfileForAnExistingUser({
user: {
id: user.id,
email: user.email,
currentUsername: user.username,
},
organizationId: organization.id,
});
}
await prisma.membership.create({
data: {
userId: user.id,
teamId: team.id,
accepted: shouldAutoAccept,
role: user.newRole,
},
});
return {
...user,
profile,
};
})
);
const autoJoinUsers = existingUsersWithMembershipsNew.filter(
(user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept
);
const regularUsers = existingUsersWithMembershipsNew.filter(
(user) => !orgConnectInfoByUsernameOrEmail[user.email].autoAccept
);
// Send emails to user who auto-joined
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: autoJoinUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
isAutoJoin: true,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
// Send emails to user who need to accept invite
await sendExistingUserTeamInviteEmails({
currentUserName: inviter.name,
currentUserTeamName: team?.name,
existingUsersWithMemberships: regularUsers,
language: translation,
isOrg: input.isOrg,
teamId: team.id,
isAutoJoin: false,
currentUserParentTeamName: team?.parent?.name,
orgSlug,
});
}
}
export async function handleNewUsersInvites({
invitationsForNewUsers,
team,
orgConnectInfoByUsernameOrEmail,
input,
autoAcceptEmailDomain,
inviter,
}: {
invitationsForNewUsers: Invitation[];
input: inviteMemberHandlerInput;
orgConnectInfoByUsernameOrEmail: Record<string, { orgId: number | undefined; autoAccept: boolean }>;
autoAcceptEmailDomain: string | null;
team: TeamWithParent;
inviter: {
name: string | null;
};
}) {
const translation = await getTranslation(input.language ?? "en", "common");
await createNewUsersConnectToOrgIfExists({
invitations: invitationsForNewUsers,
isOrg: input.isOrg,
teamId: input.teamId,
orgConnectInfoByUsernameOrEmail,
autoAcceptEmailDomain: autoAcceptEmailDomain,
parentId: team.parentId,
});
const sendVerifyEmailsPromises = invitationsForNewUsers.map((invitation) => {
return sendSignupToOrganizationEmail({
usernameOrEmail: invitation.usernameOrEmail,
team: {
name: team.name,
parent: team.parent,
},
translation,
inviterName: inviter.name ?? "",
teamId: input.teamId,
isOrg: input.isOrg,
});
});
await sendEmails(sendVerifyEmailsPromises);
}
@@ -4,7 +4,7 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { checkPermissions, getTeamOrThrow } from "./inviteMember/utils";
import { ensureAtleastAdminPermissions, getTeamOrThrow } from "./inviteMember/utils";
import type { TResendInvitationInputSchema } from "./resendInvitation.schema";
type InviteMemberOptions = {
@@ -17,7 +17,7 @@ type InviteMemberOptions = {
export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOptions) => {
const team = await getTeamOrThrow(input.teamId);
await checkPermissions({
await ensureAtleastAdminPermissions({
userId: ctx.user.id,
teamId:
ctx.user.organization.id && ctx.user.organization.isOrgAdmin ? ctx.user.organization.id : input.teamId,
@@ -0,0 +1,29 @@
import { beforeEach, vi } from "vitest";
import { mockReset, mockDeep } from "vitest-mock-extended";
import type * as checkRateLimitAndThrowError from "@calcom/lib/checkRateLimitAndThrowError";
vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => checkRateLimitAndThrowErrorMock);
beforeEach(() => {
mockReset(checkRateLimitAndThrowErrorMock);
});
const checkRateLimitAndThrowErrorMock = mockDeep<typeof checkRateLimitAndThrowError>();
export const scenarios = {
fakeRateLimitPassed: () => {
// It doesn't matter what the implementation is, as long as it resolves without error
checkRateLimitAndThrowErrorMock.checkRateLimitAndThrowError.mockResolvedValue(undefined);
},
fakeRateLimitFailed: () => {
const error = new Error("FAKE_RATE_LIMIT_ERROR");
// It doesn't matter what the implementation is, as long as it resolves without error
checkRateLimitAndThrowErrorMock.checkRateLimitAndThrowError.mockImplementation(() => {
throw error;
});
return error.message;
},
};
export default checkRateLimitAndThrowErrorMock;
+21
View File
@@ -0,0 +1,21 @@
import { beforeEach, vi } from "vitest";
import { mockReset, mockDeep } from "vitest-mock-extended";
import type * as getTranslation from "@calcom/lib/server/i18n";
vi.mock("@calcom/lib/server/i18n", () => getTranslationMock);
beforeEach(() => {
mockReset(getTranslationMock);
});
const getTranslationMock = mockDeep<typeof getTranslation>();
export const mock = {
fakeIdentityFn: () =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
getTranslationMock.getTranslation.mockImplementation(async () => (key: string) => key),
};
export default getTranslationMock;