928 lines
27 KiB
TypeScript
928 lines
27 KiB
TypeScript
import { randomBytes } from "crypto";
|
|
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 { 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";
|
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
import { isEmail } from "../util";
|
|
import type { TeamWithParent } from "./types";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["inviteMember.utils"] });
|
|
export type Invitee = Pick<
|
|
UserType,
|
|
"id" | "email" | "username" | "identityProvider" | "completedOnboarding"
|
|
>;
|
|
|
|
export type UserWithMembership = Invitee & {
|
|
teams?: Pick<Membership, "userId" | "teamId" | "accepted" | "role">[];
|
|
profiles: ProfileType[];
|
|
password: UserPassword | null;
|
|
};
|
|
|
|
export type Invitation = {
|
|
usernameOrEmail: string;
|
|
role: MembershipRole;
|
|
};
|
|
|
|
type InvitableExistingUser = UserWithMembership & {
|
|
newRole: MembershipRole;
|
|
};
|
|
|
|
type InvitableExistingUserWithProfile = InvitableExistingUser & {
|
|
profile: {
|
|
username: string;
|
|
} | null;
|
|
};
|
|
|
|
export async function ensureAtleastAdminPermissions({
|
|
userId,
|
|
teamId,
|
|
isOrg,
|
|
}: {
|
|
userId: number;
|
|
teamId: number;
|
|
isOrg?: boolean;
|
|
}) {
|
|
// Checks if the team they are inviting to IS the org. Not a child team
|
|
if (isOrg) {
|
|
if (!(await isOrganisationAdmin(userId, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
} else {
|
|
// TODO: do some logic here to check if the user is inviting a NEW user to a team that ISNT in the same org
|
|
if (!(await isTeamAdmin(userId, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
}
|
|
|
|
export function checkInputEmailIsValid(email: string) {
|
|
if (!isEmail(email))
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: `Invite failed because ${email} is not a valid email address`,
|
|
});
|
|
}
|
|
|
|
export async function getTeamOrThrow(teamId: number) {
|
|
const team = await prisma.team.findFirst({
|
|
where: {
|
|
id: teamId,
|
|
},
|
|
include: {
|
|
organizationSettings: true,
|
|
parent: {
|
|
include: {
|
|
organizationSettings: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!team)
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: `Team not found`,
|
|
});
|
|
|
|
return { ...team, metadata: teamMetadataSchema.parse(team.metadata) };
|
|
}
|
|
|
|
export async function getUniqueInvitationsOrThrowIfEmpty(invitations: Invitation[]) {
|
|
const usernamesOrEmailsSet = new Set<string>();
|
|
const uniqueInvitations: Invitation[] = [];
|
|
|
|
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 uniqueInvitations;
|
|
}
|
|
|
|
export const enum INVITE_STATUS {
|
|
USER_PENDING_MEMBER_OF_THE_ORG = "USER_PENDING_MEMBER_OF_THE_ORG",
|
|
USER_ALREADY_INVITED_OR_MEMBER = "USER_ALREADY_INVITED_OR_MEMBER",
|
|
USER_MEMBER_OF_OTHER_ORGANIZATION = "USER_MEMBER_OF_OTHER_ORGANIZATION",
|
|
CAN_BE_INVITED = "CAN_BE_INVITED",
|
|
}
|
|
|
|
export function canBeInvited(invitee: UserWithMembership, team: TeamWithParent) {
|
|
const myLog = log.getSubLogger({ prefix: ["canBeInvited"] });
|
|
myLog.debug("Checking if user can be invited", safeStringify({ invitee, team }));
|
|
const alreadyInvited = invitee.teams?.find(({ teamId: membershipTeamId }) => team.id === membershipTeamId);
|
|
if (alreadyInvited) {
|
|
return INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER;
|
|
}
|
|
|
|
const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId);
|
|
|
|
// An invitee here won't be a member of the team
|
|
// If he is invited to a sub-team and is already part of the organization.
|
|
if (
|
|
team.parentId &&
|
|
UserRepository.isAMemberOfOrganization({ user: invitee, organizationId: team.parentId })
|
|
) {
|
|
return INVITE_STATUS.CAN_BE_INVITED;
|
|
}
|
|
|
|
// user invited to join a team inside an org, but has not accepted invite to org yet
|
|
if (team.parentId && orgMembership && !orgMembership.accepted) {
|
|
return INVITE_STATUS.USER_PENDING_MEMBER_OF_THE_ORG;
|
|
}
|
|
|
|
if (
|
|
!ENABLE_PROFILE_SWITCHER &&
|
|
// Member of an organization is invited to join a team that is not a subteam of the organization
|
|
invitee.profiles.find((profile) => profile.organizationId != team.parentId)
|
|
) {
|
|
return INVITE_STATUS.USER_MEMBER_OF_OTHER_ORGANIZATION;
|
|
}
|
|
return INVITE_STATUS.CAN_BE_INVITED;
|
|
}
|
|
|
|
export async function findUsersWithInviteStatus({
|
|
invitations,
|
|
team,
|
|
}: {
|
|
invitations: Invitation[];
|
|
team: TeamWithParent;
|
|
}) {
|
|
const usernamesOrEmails = invitations.map((invitation) => invitation.usernameOrEmail);
|
|
const inviteesFromDb: UserWithMembership[] = await prisma.user.findMany({
|
|
where: {
|
|
OR: [
|
|
// Either it's a username in that organization
|
|
{
|
|
profiles: {
|
|
some: {
|
|
organizationId: team.id,
|
|
username: { in: usernamesOrEmails },
|
|
},
|
|
},
|
|
},
|
|
// Or it's an email
|
|
{ email: { in: usernamesOrEmails } },
|
|
],
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
username: true,
|
|
password: true,
|
|
completedOnboarding: true,
|
|
identityProvider: true,
|
|
profiles: true,
|
|
teams: true,
|
|
},
|
|
});
|
|
|
|
const userToRoleMap = buildUserToRoleMap();
|
|
const defaultMemberRole = MembershipRole.MEMBER;
|
|
// Check if the users found in the database can be invited to join the team/org
|
|
return inviteesFromDb.map((inviteeFromDb) => {
|
|
const newRole = getRoleForUser({ email: inviteeFromDb.email, username: inviteeFromDb.username });
|
|
|
|
return {
|
|
...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,
|
|
email,
|
|
team,
|
|
}: {
|
|
orgAutoAcceptDomain?: string | null;
|
|
orgVerified: boolean | null;
|
|
email: string;
|
|
team: Pick<TeamWithParent, "parentId" | "id">;
|
|
isOrg: boolean;
|
|
}) {
|
|
let orgId: number | undefined = undefined;
|
|
let autoAccept = false;
|
|
|
|
if (team.parentId || isOrg) {
|
|
orgId = team.parentId || team.id;
|
|
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;
|
|
} else {
|
|
orgId = undefined;
|
|
autoAccept = false;
|
|
}
|
|
}
|
|
|
|
return { orgId, autoAccept };
|
|
}
|
|
|
|
export async function createNewUsersConnectToOrgIfExists({
|
|
invitations,
|
|
isOrg,
|
|
teamId,
|
|
parentId,
|
|
autoAcceptEmailDomain,
|
|
orgConnectInfoByUsernameOrEmail,
|
|
isPlatformManaged,
|
|
timeFormat,
|
|
weekStart,
|
|
timeZone,
|
|
}: {
|
|
invitations: Invitation[];
|
|
isOrg: boolean;
|
|
teamId: number;
|
|
parentId?: number | null;
|
|
autoAcceptEmailDomain: string | null;
|
|
orgConnectInfoByUsernameOrEmail: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
|
|
isPlatformManaged?: boolean;
|
|
timeFormat?: number;
|
|
weekStart?: string;
|
|
timeZone?: string;
|
|
}) {
|
|
// fail if we have invalid emails
|
|
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 < invitations.length; index++) {
|
|
const invitation = invitations[index];
|
|
// Weird but orgId is defined only if the invited user email matches orgAutoAcceptEmail
|
|
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 =
|
|
emailDomain === autoAcceptEmailDomain
|
|
? slugify(emailUser)
|
|
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
|
|
|
|
// 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 || isOrg;
|
|
|
|
const createdUser = await tx.user.create({
|
|
data: {
|
|
username: isBecomingAnOrgMember ? orgMemberUsername : regularTeamMemberUsername,
|
|
email: invitation.usernameOrEmail,
|
|
verified: true,
|
|
invitedTo: teamId,
|
|
isPlatformManaged: !!isPlatformManaged,
|
|
timeFormat,
|
|
weekStart,
|
|
timeZone,
|
|
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
|
|
...(orgId
|
|
? {
|
|
profiles: {
|
|
createMany: {
|
|
data: [
|
|
{
|
|
uid: ProfileRepository.generateProfileUid(),
|
|
username: orgMemberUsername,
|
|
organizationId: orgId,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}
|
|
: null),
|
|
teams: {
|
|
create: {
|
|
teamId: teamId,
|
|
role: invitation.role,
|
|
accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// We also need to create the membership in the parent org if it exists
|
|
if (parentId) {
|
|
await tx.membership.create({
|
|
data: {
|
|
teamId: parentId,
|
|
userId: createdUser.id,
|
|
role: MembershipRole.MEMBER,
|
|
accepted: autoAccept,
|
|
},
|
|
});
|
|
}
|
|
createdUsers.push(createdUser);
|
|
}
|
|
return createdUsers;
|
|
},
|
|
{ timeout: 10000 }
|
|
);
|
|
return createdUsers;
|
|
}
|
|
|
|
export async function createMemberships({
|
|
teamId,
|
|
language,
|
|
invitees,
|
|
parentId,
|
|
accepted,
|
|
}: {
|
|
teamId: number;
|
|
language: string;
|
|
invitees: (InvitableExistingUser & {
|
|
needToCreateOrgMembership: boolean | null;
|
|
})[];
|
|
parentId: number | null;
|
|
accepted: boolean;
|
|
}) {
|
|
log.debug("Creating memberships for", safeStringify({ teamId, language, invitees, parentId, accepted }));
|
|
try {
|
|
await prisma.membership.createMany({
|
|
data: invitees.flatMap((invitee) => {
|
|
const organizationRole = invitee?.teams?.[0]?.role;
|
|
const data = [];
|
|
// membership for the team
|
|
data.push({
|
|
teamId,
|
|
userId: invitee.id,
|
|
accepted,
|
|
role:
|
|
organizationRole === MembershipRole.ADMIN || organizationRole === MembershipRole.OWNER
|
|
? organizationRole
|
|
: invitee.newRole,
|
|
});
|
|
|
|
// membership for the org
|
|
if (parentId && invitee.needToCreateOrgMembership) {
|
|
data.push({
|
|
accepted,
|
|
teamId: parentId,
|
|
userId: invitee.id,
|
|
role: MembershipRole.MEMBER,
|
|
});
|
|
}
|
|
return data;
|
|
}),
|
|
});
|
|
} catch (e) {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
|
logger.error("Failed to create memberships", teamId);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function sendSignupToOrganizationEmail({
|
|
usernameOrEmail,
|
|
team,
|
|
translation,
|
|
inviterName,
|
|
teamId,
|
|
isOrg,
|
|
}: {
|
|
usernameOrEmail: string;
|
|
team: { name: string; parent: { name: string } | null };
|
|
translation: TFunction;
|
|
inviterName: string;
|
|
teamId: number;
|
|
isOrg: boolean;
|
|
}) {
|
|
const token: string = randomBytes(32).toString("hex");
|
|
|
|
await prisma.verificationToken.create({
|
|
data: {
|
|
identifier: usernameOrEmail,
|
|
token,
|
|
expires: new Date(new Date().setHours(168)), // +1 week
|
|
team: {
|
|
connect: {
|
|
id: teamId,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await sendTeamInviteEmail({
|
|
language: translation,
|
|
from: inviterName || `${team.name}'s admin`,
|
|
to: usernameOrEmail,
|
|
teamName: team.name,
|
|
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`,
|
|
isCalcomMember: false,
|
|
isOrg: isOrg,
|
|
parentTeamName: team?.parent?.name,
|
|
isAutoJoin: false,
|
|
isExistingUserMovedToOrg: false,
|
|
// For a new user there is no prev and new links.
|
|
prevLink: null,
|
|
newLink: null,
|
|
});
|
|
}
|
|
|
|
type TeamAndOrganizationSettings = Team & {
|
|
organizationSettings?: OrganizationSettings | null;
|
|
};
|
|
|
|
export function getOrgState(
|
|
isOrg: boolean,
|
|
team: TeamAndOrganizationSettings & {
|
|
parent: TeamAndOrganizationSettings | null;
|
|
}
|
|
) {
|
|
const parentSettings = team.parent?.organizationSettings;
|
|
|
|
if (isOrg && team.organizationSettings?.orgAutoAcceptEmail) {
|
|
return {
|
|
isInOrgScope: true,
|
|
orgVerified: team.organizationSettings.isOrganizationVerified,
|
|
orgConfigured: team.organizationSettings.isOrganizationConfigured,
|
|
autoAcceptEmailDomain: team.organizationSettings.orgAutoAcceptEmail,
|
|
orgPublished: !!team.slug,
|
|
};
|
|
} else if (parentSettings?.orgAutoAcceptEmail) {
|
|
return {
|
|
isInOrgScope: true,
|
|
orgVerified: parentSettings.isOrganizationVerified,
|
|
orgConfigured: parentSettings.isOrganizationConfigured,
|
|
autoAcceptEmailDomain: parentSettings.orgAutoAcceptEmail,
|
|
orgPublished: !!team.parent?.slug,
|
|
};
|
|
}
|
|
|
|
return {
|
|
isInOrgScope: false,
|
|
orgVerified: null,
|
|
autoAcceptEmailDomain: null,
|
|
orgConfigured: null,
|
|
orgPublished: null,
|
|
};
|
|
}
|
|
|
|
export function getAutoJoinStatus({
|
|
team,
|
|
invitee,
|
|
connectionInfoMap,
|
|
}: {
|
|
team: TeamWithParent;
|
|
invitee: UserWithMembership;
|
|
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
|
|
}) {
|
|
const isRegularTeam = !team.isOrganization && !team.parentId;
|
|
|
|
if (isRegularTeam) {
|
|
// There are no-auto join in regular teams ever
|
|
return {
|
|
autoAccept: false,
|
|
// Following are not relevant for regular teams
|
|
needToCreateProfile: null,
|
|
needToCreateOrgMembership: null,
|
|
};
|
|
}
|
|
|
|
const isAutoAcceptEmail = connectionInfoMap[invitee.email].autoAccept;
|
|
const isUserMemberOfTheTeamsParentOrganization = team.parentId
|
|
? UserRepository.isAMemberOfOrganization({ user: invitee, organizationId: team.parentId })
|
|
: null;
|
|
|
|
if (isUserMemberOfTheTeamsParentOrganization) {
|
|
const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId);
|
|
|
|
const isAMemberOfOrg = orgMembership?.accepted;
|
|
return {
|
|
autoAccept: isAMemberOfOrg,
|
|
// User is a member of parent organization already - So, no need to create profile and membership with Org
|
|
needToCreateProfile: false,
|
|
needToCreateOrgMembership: false,
|
|
};
|
|
}
|
|
|
|
if (isAutoAcceptEmail) {
|
|
// User is not a member of parent organization but has autoAccept email
|
|
// We need to create profile as well as membership with the Org in this case
|
|
return {
|
|
autoAccept: true,
|
|
needToCreateProfile: true,
|
|
needToCreateOrgMembership: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
autoAccept: false,
|
|
needToCreateProfile: false,
|
|
needToCreateOrgMembership: true,
|
|
};
|
|
}
|
|
|
|
// split invited users between ones that can autojoin and the others who cannot autojoin
|
|
export const groupUsersByJoinability = ({
|
|
existingUsersWithMemberships,
|
|
team,
|
|
connectionInfoMap,
|
|
}: {
|
|
team: TeamWithParent;
|
|
existingUsersWithMemberships: InvitableExistingUserWithProfile[];
|
|
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
|
|
}) => {
|
|
const usersToAutoJoin = [];
|
|
const regularUsers = [];
|
|
|
|
for (let index = 0; index < existingUsersWithMemberships.length; index++) {
|
|
const existingUserWithMemberships = existingUsersWithMemberships[index];
|
|
const autoJoinStatus = getAutoJoinStatus({
|
|
invitee: existingUserWithMemberships,
|
|
team,
|
|
connectionInfoMap,
|
|
});
|
|
|
|
autoJoinStatus.autoAccept
|
|
? usersToAutoJoin.push({
|
|
...existingUserWithMemberships,
|
|
...autoJoinStatus,
|
|
})
|
|
: regularUsers.push({
|
|
...existingUserWithMemberships,
|
|
...autoJoinStatus,
|
|
});
|
|
}
|
|
|
|
return [usersToAutoJoin, regularUsers];
|
|
};
|
|
|
|
export const sendEmails = async (emailPromises: Promise<void>[]) => {
|
|
const sentEmails = await Promise.allSettled(emailPromises);
|
|
sentEmails.forEach((sentEmail) => {
|
|
if (sentEmail.status === "rejected") {
|
|
logger.error("Could not send email to user. Reason:", sentEmail.reason);
|
|
}
|
|
});
|
|
};
|
|
|
|
export const sendExistingUserTeamInviteEmails = async ({
|
|
existingUsersWithMemberships,
|
|
language,
|
|
currentUserTeamName,
|
|
currentUserName,
|
|
currentUserParentTeamName,
|
|
isOrg,
|
|
teamId,
|
|
isAutoJoin,
|
|
orgSlug,
|
|
}: {
|
|
language: TFunction;
|
|
isAutoJoin: boolean;
|
|
existingUsersWithMemberships: Omit<InvitableExistingUserWithProfile, "canBeInvited" | "newRole">[];
|
|
currentUserTeamName?: string;
|
|
currentUserParentTeamName: string | undefined;
|
|
currentUserName?: string | null;
|
|
isOrg: boolean;
|
|
teamId: number;
|
|
orgSlug: string | null;
|
|
}) => {
|
|
const sendEmailsPromises = existingUsersWithMemberships.map(async (user) => {
|
|
let sendTo = user.email;
|
|
if (!isEmail(user.email)) {
|
|
sendTo = user.email;
|
|
}
|
|
|
|
log.debug("Sending team invite email to", safeStringify({ user, currentUserName, currentUserTeamName }));
|
|
|
|
if (!currentUserTeamName) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "The team doesn't have a name",
|
|
});
|
|
}
|
|
|
|
// inform user of membership by email
|
|
if (currentUserTeamName) {
|
|
const inviteTeamOptions = {
|
|
joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`,
|
|
isCalcomMember: true,
|
|
};
|
|
/**
|
|
* Here we want to redirect to a different place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a different email template.
|
|
* This only changes if the user is a CAL user and has not completed onboarding and has no password
|
|
*/
|
|
if (!user.completedOnboarding && !user.password?.hash && user.identityProvider === "CAL") {
|
|
const token = randomBytes(32).toString("hex");
|
|
await prisma.verificationToken.create({
|
|
data: {
|
|
identifier: user.email,
|
|
token,
|
|
expires: new Date(new Date().setHours(168)), // +1 week
|
|
team: {
|
|
connect: {
|
|
id: teamId,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
|
|
inviteTeamOptions.isCalcomMember = false;
|
|
}
|
|
|
|
return sendTeamInviteEmail({
|
|
language,
|
|
isAutoJoin,
|
|
from: currentUserName ?? `${currentUserTeamName}'s admin`,
|
|
to: sendTo,
|
|
teamName: currentUserTeamName,
|
|
...inviteTeamOptions,
|
|
isOrg: isOrg,
|
|
parentTeamName: currentUserParentTeamName,
|
|
isExistingUserMovedToOrg: true,
|
|
prevLink: `${getOrgFullOrigin("")}/${user.username || ""}`,
|
|
newLink: user.profile ? `${getOrgFullOrigin(orgSlug ?? "")}/${user.profile.username}` : null,
|
|
});
|
|
}
|
|
});
|
|
|
|
await sendEmails(sendEmailsPromises);
|
|
};
|
|
|
|
type inviteMemberHandlerInput = {
|
|
teamId: number;
|
|
role?: "ADMIN" | "MEMBER" | "OWNER";
|
|
language: string;
|
|
};
|
|
|
|
export async function handleExistingUsersInvites({
|
|
invitableExistingUsers,
|
|
team,
|
|
orgConnectInfoByUsernameOrEmail,
|
|
teamId,
|
|
language,
|
|
inviter,
|
|
orgSlug,
|
|
isOrg,
|
|
}: {
|
|
invitableExistingUsers: InvitableExistingUser[];
|
|
team: TeamWithParent;
|
|
orgConnectInfoByUsernameOrEmail: Record<string, { orgId: number | undefined; autoAccept: boolean }>;
|
|
teamId: number;
|
|
language: string;
|
|
inviter: {
|
|
name: string | null;
|
|
};
|
|
isOrg: boolean;
|
|
orgSlug: string | null;
|
|
}) {
|
|
const translation = await getTranslation(language, "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({
|
|
teamId,
|
|
language,
|
|
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: 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({
|
|
teamId,
|
|
language,
|
|
invitees: regularUsers,
|
|
parentId: team.parentId,
|
|
accepted: false,
|
|
});
|
|
await sendExistingUserTeamInviteEmails({
|
|
currentUserName: inviter.name,
|
|
currentUserTeamName: team?.name,
|
|
existingUsersWithMemberships: regularUsers,
|
|
language: translation,
|
|
isOrg: 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,
|
|
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,
|
|
teamId: team.id,
|
|
isAutoJoin: false,
|
|
currentUserParentTeamName: team?.parent?.name,
|
|
orgSlug,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function handleNewUsersInvites({
|
|
invitationsForNewUsers,
|
|
team,
|
|
orgConnectInfoByUsernameOrEmail,
|
|
teamId,
|
|
language,
|
|
isOrg,
|
|
autoAcceptEmailDomain,
|
|
inviter,
|
|
}: {
|
|
invitationsForNewUsers: Invitation[];
|
|
teamId: number;
|
|
language: string;
|
|
orgConnectInfoByUsernameOrEmail: Record<string, { orgId: number | undefined; autoAccept: boolean }>;
|
|
autoAcceptEmailDomain: string | null;
|
|
team: TeamWithParent;
|
|
inviter: {
|
|
name: string | null;
|
|
};
|
|
isOrg: boolean;
|
|
}) {
|
|
const translation = await getTranslation(language, "common");
|
|
|
|
await createNewUsersConnectToOrgIfExists({
|
|
invitations: invitationsForNewUsers,
|
|
isOrg,
|
|
teamId: 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,
|
|
isOrg,
|
|
});
|
|
});
|
|
await sendEmails(sendVerifyEmailsPromises);
|
|
}
|