diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 82751db5e3..c9d3bad441 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -32,6 +32,7 @@ import logger from "@calcom/lib/logger"; import { randomString } from "@calcom/lib/random"; import { safeStringify } from "@calcom/lib/safeStringify"; import { CredentialRepository } from "@calcom/lib/server/repository/credential"; +import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { UserRepository } from "@calcom/lib/server/repository/user"; import slugify from "@calcom/lib/slugify"; @@ -57,20 +58,7 @@ const ORGANIZATIONS_AUTOLINK = const usernameSlug = (username: string) => `${slugify(username)}-${randomString(6).toLowerCase()}`; const getDomainFromEmail = (email: string): string => email.split("@")[1]; -const getVerifiedOrganizationByAutoAcceptEmailDomain = async (domain: string) => { - const existingOrg = await prisma.team.findFirst({ - where: { - organizationSettings: { - isOrganizationVerified: true, - orgAutoAcceptEmail: domain, - }, - }, - select: { - id: true, - }, - }); - return existingOrg?.id; -}; + const loginWithTotp = async (email: string) => `/auth/login?totp=${await (await import("./signJwt")).default({ email })}`; @@ -369,15 +357,17 @@ if (isSAMLLoginEnabled) { const hostedCal = Boolean(HOSTED_CAL_FEATURES); if (hostedCal && email) { const domain = getDomainFromEmail(email); - const organizationId = await getVerifiedOrganizationByAutoAcceptEmailDomain(domain); - if (organizationId) { + const org = await OrganizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain(domain); + if (org) { const createUsersAndConnectToOrgProps = { emailsToCreate: [email], - organizationId, identityProvider: IdentityProvider.SAML, identityProviderId: email, }; - await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + await createUsersAndConnectToOrg({ + createUsersAndConnectToOrgProps, + org, + }); user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email: email, }); diff --git a/packages/features/auth/signup/utils/getOrgUsernameFromEmail.test.ts b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.test.ts new file mode 100644 index 0000000000..62aa8aed79 --- /dev/null +++ b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { getOrgUsernameFromEmail, deriveNameFromOrgUsername } from "./getOrgUsernameFromEmail"; + +describe("getOrgUsernameFromEmail", () => { + it("should generate username with only email user part when domain matches autoAcceptEmailDomain", () => { + const email = "john.doe@example.com"; + const autoAcceptEmailDomain = "example.com"; + const result = getOrgUsernameFromEmail(email, autoAcceptEmailDomain); + expect(result).toBe("john.doe"); + }); + + it("should generate username with email user and domain when domain doesn't match autoAcceptEmailDomain", () => { + const email = "john.doe@example.com"; + const autoAcceptEmailDomain = "different.com"; + const result = getOrgUsernameFromEmail(email, autoAcceptEmailDomain); + expect(result).toBe("john.doe-example"); + }); +}); + +describe("deriveNameFromOrgUsername", () => { + it("should convert hyphenated username to capitalized words", () => { + const username = "john-doe-example"; + const result = deriveNameFromOrgUsername({ username }); + expect(result).toBe("John Doe Example"); + }); +}); diff --git a/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts index 0e6b4a8fff..ea629341b1 100644 --- a/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts +++ b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts @@ -9,3 +9,10 @@ export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: st return username; }; + +export const deriveNameFromOrgUsername = ({ username }: { username: string }) => { + return username + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +}; diff --git a/packages/features/ee/dsync/lib/handleGroupEvents.ts b/packages/features/ee/dsync/lib/handleGroupEvents.ts index 1dcd2981fa..ab8a46a242 100644 --- a/packages/features/ee/dsync/lib/handleGroupEvents.ts +++ b/packages/features/ee/dsync/lib/handleGroupEvents.ts @@ -100,11 +100,13 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb if (newUserEmails.length) { const createUsersAndConnectToOrgProps = { emailsToCreate: newUserEmails, - organizationId: org.id, identityProvider: IdentityProvider.CAL, identityProviderId: null, }; - const newUsers = await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + const newUsers = await createUsersAndConnectToOrg({ + createUsersAndConnectToOrgProps, + org, + }); await prisma.membership.createMany({ data: newUsers.map((user) => ({ userId: user.id, diff --git a/packages/features/ee/dsync/lib/handleUserEvents.ts b/packages/features/ee/dsync/lib/handleUserEvents.ts index 9db8791ff2..e4ee587718 100644 --- a/packages/features/ee/dsync/lib/handleUserEvents.ts +++ b/packages/features/ee/dsync/lib/handleUserEvents.ts @@ -121,11 +121,13 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe } else { const createUsersAndConnectToOrgProps = { emailsToCreate: [userEmail], - organizationId: org.id, identityProvider: IdentityProvider.CAL, identityProviderId: null, }; - await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + await createUsersAndConnectToOrg({ + createUsersAndConnectToOrgProps, + org, + }); await sendSignupToOrganizationEmail({ usernameOrEmail: userEmail, diff --git a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts index 37710c12bc..aee695057f 100644 --- a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts +++ b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts @@ -1,32 +1,39 @@ import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships"; -import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; import type { IdentityProvider } from "@calcom/prisma/enums"; import { CreationSource } from "@calcom/prisma/enums"; +import { + deriveNameFromOrgUsername, + getOrgUsernameFromEmail, +} from "../../../../auth/signup/utils/getOrgUsernameFromEmail"; import dSyncUserSelect from "./dSyncUserSelect"; type createUsersAndConnectToOrgPropsType = { emailsToCreate: string[]; - organizationId: number; identityProvider: IdentityProvider; identityProviderId: string | null; }; -const createUsersAndConnectToOrg = async ( - createUsersAndConnectToOrgProps: createUsersAndConnectToOrgPropsType -) => { - const { emailsToCreate, organizationId, identityProvider, identityProviderId } = - createUsersAndConnectToOrgProps; +export const createUsersAndConnectToOrg = async ({ + createUsersAndConnectToOrgProps, + org, +}: { + createUsersAndConnectToOrgProps: createUsersAndConnectToOrgPropsType; + org: { + id: number; + organizationSettings: { + orgAutoAcceptEmail: string | null; + } | null; + }; +}) => { + const { emailsToCreate, identityProvider, identityProviderId } = createUsersAndConnectToOrgProps; + // As of Mar 2024 Prisma createMany does not support nested creates and returning created records await prisma.user.createMany({ data: emailsToCreate.map((email) => { - const [emailUser, emailDomain] = email.split("@"); - const username = slugify(`${emailUser}-${emailDomain.split(".")[0]}`); - const name = username - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); + const username = getOrgUsernameFromEmail(email, org.organizationSettings?.orgAutoAcceptEmail ?? null); + const name = deriveNameFromOrgUsername({ username }); return { username, email, @@ -34,8 +41,8 @@ const createUsersAndConnectToOrg = async ( // Assume verified since coming from directory verified: true, emailVerified: new Date(), - invitedTo: organizationId, - organizationId, + invitedTo: org.id, + organizationId: org.id, identityProvider, identityProviderId, creationSource: CreationSource.WEBAPP, @@ -56,7 +63,7 @@ const createUsersAndConnectToOrg = async ( await createOrUpdateMemberships({ user, team: { - id: organizationId, + id: org.id, isOrganization: true, parentId: null, // orgs don't have a parentId }, diff --git a/packages/features/ee/sso/lib/sso.ts b/packages/features/ee/sso/lib/sso.ts index 18b8839b3c..5ad5d3d561 100644 --- a/packages/features/ee/sso/lib/sso.ts +++ b/packages/features/ee/sso/lib/sso.ts @@ -1,5 +1,6 @@ import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg"; import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; +import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; import type { PrismaClient } from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; import { TRPCError } from "@calcom/trpc/server"; @@ -21,26 +22,6 @@ const getAllAcceptedMemberships = async ({ prisma, email }: { prisma: PrismaClie }); }; -const getVerifiedOrganizationByAutoAcceptEmailDomain = async ({ - prisma, - domain, -}: { - prisma: PrismaClient; - domain: string; -}) => { - return await prisma.team.findFirst({ - where: { - organizationSettings: { - isOrganizationVerified: true, - orgAutoAcceptEmail: domain, - }, - }, - select: { - id: true, - }, - }); -}; - export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { const { connectionController } = await jackson(); @@ -54,7 +35,7 @@ export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { }); const domain = email.split("@")[1]; - const organization = await getVerifiedOrganizationByAutoAcceptEmailDomain({ prisma, domain }); + const organization = await OrganizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain(domain); if (!organization) throw new TRPCError({ @@ -62,15 +43,16 @@ export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { message: "no_account_exists", }); - const organizationId = organization.id; const createUsersAndConnectToOrgProps = { emailsToCreate: [email], - organizationId, identityProvider: IdentityProvider.SAML, identityProviderId: email, }; - await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + await createUsersAndConnectToOrg({ + createUsersAndConnectToOrgProps, + org: organization, + }); memberships = await getAllAcceptedMemberships({ prisma, email }); if (!memberships || memberships.length === 0) diff --git a/packages/lib/server/repository/organization.test.ts b/packages/lib/server/repository/organization.test.ts index 8f4378b9aa..e23ffda402 100644 --- a/packages/lib/server/repository/organization.test.ts +++ b/packages/lib/server/repository/organization.test.ts @@ -2,20 +2,76 @@ import prismock from "../../../../tests/libs/__mocks__/prisma"; import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { Prisma } from "@calcom/prisma/client"; + import { OrganizationRepository } from "./organization"; vi.mock("./teamUtils", () => ({ getParsedTeam: (org: any) => org, })); -describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () => { - beforeEach(async () => { - vi.resetAllMocks(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await prismock.reset(); +async function createOrganization( + data: Prisma.TeamCreateInput & { + organizationSettings: { + create: Prisma.OrganizationSettingsCreateWithoutOrganizationInput; + }; + } +) { + return await prismock.team.create({ + data: { + isOrganization: true, + ...data, + }, }); +} +async function createReviewedOrganization({ + name = "Test Org", + orgAutoAcceptEmail, +}: { + name: string; + orgAutoAcceptEmail: string; +}) { + return await createOrganization({ + name, + organizationSettings: { + create: { + orgAutoAcceptEmail, + isOrganizationVerified: true, + isAdminReviewed: true, + }, + }, + }); +} + +async function createTeam({ + name = "Test Team", + orgAutoAcceptEmail, +}: { + name: string; + orgAutoAcceptEmail: string; +}) { + return await prismock.team.create({ + data: { + name, + isOrganization: false, + organizationSettings: { + create: { + orgAutoAcceptEmail, + }, + }, + }, + }); +} + +beforeEach(async () => { + vi.resetAllMocks(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await prismock.reset(); +}); + +describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () => { it("should return null if no organization matches the email domain", async () => { const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com", @@ -67,44 +123,31 @@ describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () = }); }); -async function createReviewedOrganization({ - name = "Test Org", - orgAutoAcceptEmail, -}: { - name: string; - orgAutoAcceptEmail: string; -}) { - return await prismock.team.create({ - data: { - name, - isOrganization: true, - organizationSettings: { - create: { - orgAutoAcceptEmail, - isOrganizationVerified: true, - isAdminReviewed: true, - }, - }, - }, - }); -} +describe("Organization.getVerifiedOrganizationByAutoAcceptEmailDomain", () => { + it("should return organization when domain matches and organization is verified", async () => { + const verifiedOrganization = await createOrganization({ + name: "Test Org", + organizationSettings: { create: { orgAutoAcceptEmail: "cal.com", isOrganizationVerified: true } }, + }); -async function createTeam({ - name = "Test Team", - orgAutoAcceptEmail, -}: { - name: string; - orgAutoAcceptEmail: string; -}) { - return await prismock.team.create({ - data: { - name, - isOrganization: false, + const result = await OrganizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain("cal.com"); + + expect(result).toEqual({ + id: verifiedOrganization.id, organizationSettings: { - create: { - orgAutoAcceptEmail, - }, + orgAutoAcceptEmail: "cal.com", }, - }, + }); }); -} + + it("should not return organization when organization is not verified", async () => { + await createOrganization({ + name: "Test Org", + organizationSettings: { create: { orgAutoAcceptEmail: "cal.com", isOrganizationVerified: false } }, + }); + + const result = await OrganizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain("cal.com"); + + expect(result).toEqual(null); + }); +}); diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 43725c71a4..752e19b1f1 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -354,4 +354,23 @@ export class OrganizationRepository { return org?.calVideoLogo; } + + static async getVerifiedOrganizationByAutoAcceptEmailDomain(domain: string) { + return await prisma.team.findFirst({ + where: { + organizationSettings: { + isOrganizationVerified: true, + orgAutoAcceptEmail: domain, + }, + }, + select: { + id: true, + organizationSettings: { + select: { + orgAutoAcceptEmail: true, + }, + }, + }, + }); + } }