fix: Wrong username with -{DOMAIN} in case of an autoAcceptEmail when synced through SCIM (#18384)

* Okta fixes

* Fix username and name in case of sync from Okta

* Fix username and name in case of sync from Okta

* Add test

* cleanup code and add tests
This commit is contained in:
Hariom Balhara authored and GitHub committed 2025-01-29 12:05:27 -03:00
1 parent 33903f1a03
commit ac8b4a2861
9 files changed
+184 -105

No files matched your search

@@ -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,
});
@@ -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");
});
});
@@ -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(" ");
};
@@ -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,
@@ -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,
@@ -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
},
+6 -24
View File
@@ -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)
@@ -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);
});
});
@@ -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,
},
},
},
});
}
}