Files
cal.diy/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts
T
3a0c766ed5 fix: Support adding a non-existent email as the owner of the organization (#14569)
* fix: Support adding an non-existent email as the owner of the organization

* Remove dead code

* fixes

* add test

* self review changes

* Update packages/trpc/server/routers/viewer/organizations/create.handler.ts

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

* Fix typo everywhere

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-05-06 07:09:22 +00:00

642 lines
19 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 logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { isTeamAdmin } from "@calcom/lib/server/queries";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
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 { 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 { InviteMemberOptions, 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 async function checkPermissions({
userId,
teamId,
isOrg,
}: {
userId: number;
teamId: number;
isOrg?: boolean;
}) {
// Checks if the team they are inviteing 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 getUsernameOrEmailsToInvite(usernameOrEmail: string | string[]) {
const emailsToInvite = Array.isArray(usernameOrEmail)
? Array.from(new Set(usernameOrEmail))
: [usernameOrEmail];
if (emailsToInvite.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You must provide at least one email address to invite.",
});
}
return emailsToInvite;
}
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 false;
}
const orgMembership = invitee.teams?.find((membersip) => membersip.teamId === team.parentId);
// invitee is invited to the org's team and is already part of the organization
if (
team.parentId &&
UserRepository.isAMemberOfOrganization({ user: invitee, organizationId: team.parentId })
) {
return true;
}
// user invited to join a team inside an org, but has not accepted invite to org yet
if (team.parentId && orgMembership && !orgMembership.accepted) {
return false;
}
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 false;
}
return true;
}
export async function getExistingUsersToInvite({
usernamesOrEmails,
team,
}: {
usernamesOrEmails: string[];
team: TeamWithParent;
}) {
const invitees: 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,
},
});
// Check if the users found in the database can be invited to join the team/org
return invitees.map((invitee) => {
return {
...invitee,
canBeInvited: canBeInvited(invitee, team),
};
});
}
export function getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified,
isOrg,
usersEmail,
team,
}: {
orgAutoAcceptDomain?: string | null;
orgVerified: boolean;
usersEmail: string;
team: TeamWithParent;
isOrg: boolean;
}) {
let orgId: number | undefined = undefined;
let autoAccept = false;
if (team.parentId || isOrg) {
orgId = team.parentId || team.id;
if (usersEmail.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({
usernamesOrEmails,
input,
parentId,
autoAcceptEmailDomain,
connectionInfoMap,
isPlatformManaged,
}: {
usernamesOrEmails: string[];
input: InviteMemberOptions["input"];
parentId?: number | null;
autoAcceptEmailDomain?: string;
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
isPlatformManaged?: boolean;
}) {
// fail if we have invalid emails
usernamesOrEmails.forEach((usernameOrEmail) => checkInputEmailIsValid(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];
// Weird but orgId is defined only if the invited user email matches orgAutoAcceptEmail
const { orgId, autoAccept } = connectionInfoMap[usernameOrEmail];
const [emailUser, emailDomain] = 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 || input.isOrg;
const createdUser = await tx.user.create({
data: {
username: isBecomingAnOrgMember ? orgMemberUsername : regularTeamMemberUsername,
email: usernameOrEmail,
verified: true,
invitedTo: input.teamId,
isPlatformManaged: !!isPlatformManaged,
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: input.teamId,
role: input.role as MembershipRole,
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({
input,
invitees,
parentId,
accepted,
}: {
input: InviteMemberOptions["input"];
invitees: (UserWithMembership & {
needToCreateOrgMembership: boolean | null;
})[];
parentId: number | null;
accepted: boolean;
}) {
log.debug("Creating memberships for", safeStringify({ input, 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: input.teamId,
userId: invitee.id,
accepted,
role:
organizationRole === MembershipRole.ADMIN || organizationRole === MembershipRole.OWNER
? organizationRole
: input.role,
});
// membership for the org
if (parentId && invitee.needToCreateOrgMembership) {
data.push({
accepted,
teamId: parentId,
userId: invitee.id,
role: MembershipRole.MEMBER,
});
}
return data;
}),
});
} 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;
}
}
export async function sendSignupToOrganizationEmail({
usernameOrEmail,
team,
translation,
inviterName,
teamId,
isOrg,
}: {
usernameOrEmail: string;
team: Awaited<ReturnType<typeof getTeamOrThrow>>;
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 getIsOrgVerified(
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,
} as {
isInOrgScope: false;
orgVerified: never;
autoAcceptEmailDomain: never;
orgConfigured: never;
orgPublished: never;
};
}
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: (UserWithMembership & {
profile: {
username: string;
} | null;
})[];
connectionInfoMap: Record<string, ReturnType<typeof getOrgConnectionInfo>>;
}) => {
const usersToAutoJoin = [];
const regularUsers = [];
for (let index = 0; index < existingUsersWithMemberships.length; index++) {
const existingUserWithMembersips = existingUsersWithMemberships[index];
const autoJoinStatus = getAutoJoinStatus({
invitee: existingUserWithMembersips,
team,
connectionInfoMap,
});
autoJoinStatus.autoAccept
? usersToAutoJoin.push({
...existingUserWithMembersips,
...autoJoinStatus,
})
: regularUsers.push({
...existingUserWithMembersips,
...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: (UserWithMembership & {
profile: {
username: string;
} | null;
})[];
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);
};