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:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
+122
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user