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:
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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user