diff --git a/apps/web/lib/pages/auth/verify-email.ts b/apps/web/lib/pages/auth/verify-email.ts index 0b11c692f0..c6f8bcd012 100644 --- a/apps/web/lib/pages/auth/verify-email.ts +++ b/apps/web/lib/pages/auth/verify-email.ts @@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; +import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { IS_STRIPE_ENABLED } from "@calcom/lib/constants"; diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index c173994c49..3dd7172177 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -189,6 +189,7 @@ const createTeamAndAddUser = async ( orgAutoAcceptEmail: user.email.split("@")[1], isOrganizationVerified: !!isOrgVerified, isOrganizationConfigured: isDnsSetup, + orgAutoJoinOnSignup: true, }, }; } diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 1183634168..df4baa9123 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -90,7 +90,7 @@ test.describe("Organization", () => { // This test is already covered by booking.e2e.ts where existing user is invited and his booking links are tested. // We can re-test here when we want to test some more scenarios. - + test("existing user invited to an organization", () => {}); test("nonexisting user invited to a Team inside organization", async ({ @@ -222,15 +222,6 @@ test.describe("Organization", () => { "signup?token" ); - await expectUserToBeAMemberOfOrganization({ - page, - orgSlug: org.slug, - username: usernameDerivedFromEmail, - role: "member", - isMemberShipAccepted: true, - email: invitedUserEmail, - }); - assertInviteLink(inviteLink); await signupFromEmailInviteLink({ browser, @@ -272,7 +263,7 @@ test.describe("Organization", () => { }); // Such a user has user.username changed directly in addition to having the new username in the profile.username - test("existing user migrated to an organization", async ({ users, page, emails }) => { + test("existing user migrated to an organization", async ({ users, page, emails: _emails }) => { const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, @@ -313,7 +304,6 @@ test.describe("Organization", () => { await page.locator('[data-testid="continue-with-email-button"]').click(); await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible(); - await page.locator('input[name="username"]').fill(existingUser.username!); await page .locator('input[name="email"]') @@ -346,23 +336,7 @@ test.describe("Organization", () => { const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" }); const usernameDerivedFromEmail = invitedUserEmail.split("@")[0]; await inviteAnEmail(page, invitedUserEmail, true); - await expectUserToBeAMemberOfTeam({ - page, - teamId: team.id, - username: usernameDerivedFromEmail, - role: "member", - isMemberShipAccepted: true, - email: invitedUserEmail, - }); - await expectUserToBeAMemberOfOrganization({ - page, - orgSlug: org.slug, - username: usernameDerivedFromEmail, - role: "member", - isMemberShipAccepted: true, - email: invitedUserEmail, - }); const inviteLink = await expectInvitationEmailToBeReceived( page, emails, @@ -594,7 +568,7 @@ async function expectUserToBeAMemberOfTeam({ page, teamId, email, - role, + role: _role, username, isMemberShipAccepted, }: { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 323d1796f5..d22d8ab729 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2931,6 +2931,8 @@ "delete_org_eventtypes": "Delete individual event types", "lock_org_users_eventtypes": "Lock individual event type creation", "lock_org_users_eventtypes_description": "Prevent members from creating their own event types.", + "org_auto_join_title": "Automatically add new members to the organization if they sign up to Cal.com with the \"{{emailDomain}}\" email domain", + "org_auto_join_description": "New members are added after they verify their email.", "add_to_event_type": "Add to event type", "create_account_password": "Create account password", "create_account_with_saml": "Create Account with SAML", diff --git a/packages/features/ee/organizations/pages/components/OrgAutoJoinSetting.tsx b/packages/features/ee/organizations/pages/components/OrgAutoJoinSetting.tsx new file mode 100644 index 0000000000..c6b677293c --- /dev/null +++ b/packages/features/ee/organizations/pages/components/OrgAutoJoinSetting.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import { SettingsToggle } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +interface IOrgAutoJoinSettingProps { + orgId: number; + orgAutoJoinEnabled: boolean; + emailDomain: string; +} + +const OrgAutoJoinSetting = (props: IOrgAutoJoinSettingProps) => { + const utils = trpc.useUtils(); + const [isEnabled, setIsEnabled] = useState(props.orgAutoJoinEnabled); + const { t } = useLocale(); + + const mutation = trpc.viewer.organizations.update.useMutation({ + onSuccess: async () => { + showToast(t("your_org_updated_successfully"), "success"); + }, + onError: () => { + showToast(t("error_updating_settings"), "error"); + }, + onSettled: () => { + utils.viewer.organizations.listCurrent.invalidate(); + }, + }); + + return ( + { + mutation.mutate({ + orgAutoJoinOnSignup: checked, + }); + setIsEnabled(checked); + }} + /> + ); +}; + +export default OrgAutoJoinSetting; diff --git a/packages/features/ee/organizations/pages/settings/privacy.tsx b/packages/features/ee/organizations/pages/settings/privacy.tsx index 7cb858621a..447d4ac8e3 100644 --- a/packages/features/ee/organizations/pages/settings/privacy.tsx +++ b/packages/features/ee/organizations/pages/settings/privacy.tsx @@ -11,6 +11,8 @@ import { trpc } from "@calcom/trpc/react"; import { BlocklistTable } from "~/settings/organizations/privacy/blocklist-table"; +import OrgAutoJoinSetting from "../components/OrgAutoJoinSetting"; + const PrivacyView = ({ permissions, watchlistPermissions, @@ -42,6 +44,14 @@ const PrivacyView = ({ disabled={isDisabled} /> + {currentOrg.organizationSettings?.orgAutoAcceptEmail && ( + + )} + {watchlistPermissions?.canRead && (
diff --git a/packages/features/ee/organizations/repositories/OrganizationRepository.test.ts b/packages/features/ee/organizations/repositories/OrganizationRepository.test.ts index 1df9ec9a42..87ca79279f 100644 --- a/packages/features/ee/organizations/repositories/OrganizationRepository.test.ts +++ b/packages/features/ee/organizations/repositories/OrganizationRepository.test.ts @@ -81,13 +81,15 @@ describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () = expect(result).toBeNull(); }); - it("should throw an error if multiple organizations match the email domain", async () => { + it("should return null if multiple organizations match the email domain", async () => { await createReviewedOrganization({ name: "Test Org 1", orgAutoAcceptEmail: "example.com" }); await createReviewedOrganization({ name: "Test Org 2", orgAutoAcceptEmail: "example.com" }); - await expect( - organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com" }) - ).rejects.toThrow("Multiple organizations found with the same auto accept email domain"); + const result = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ + email: "test@example.com", + }); + + expect(result).toBeNull(); }); it("should return the parsed organization if a single match is found", async () => { @@ -122,6 +124,65 @@ describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () = expect(result).toEqual(null); }); + + it("should return null when orgAutoJoinOnSignup is false", async () => { + await prismock.team.create({ + data: { + name: "Test Org", + isOrganization: true, + organizationSettings: { + create: { + orgAutoAcceptEmail: "example.com", + isOrganizationVerified: true, + isAdminReviewed: true, + orgAutoJoinOnSignup: false, + }, + }, + }, + }); + + const result = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ + email: "test@example.com", + }); + + expect(result).toBeNull(); + }); + + it("should return organization when orgAutoJoinOnSignup is true", async () => { + const organization = await prismock.team.create({ + data: { + name: "Test Org", + isOrganization: true, + organizationSettings: { + create: { + orgAutoAcceptEmail: "example.com", + isOrganizationVerified: true, + isAdminReviewed: true, + orgAutoJoinOnSignup: true, + }, + }, + }, + }); + + const result = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ + email: "test@example.com", + }); + + expect(result).toEqual(organization); + }); + + it("should return organization when orgAutoJoinOnSignup is not explicitly set (defaults to true)", async () => { + const organization = await createReviewedOrganization({ + name: "Test Org", + orgAutoAcceptEmail: "example.com", + }); + + const result = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ + email: "test@example.com", + }); + + expect(result).toEqual(organization); + }); }); describe("Organization.getVerifiedOrganizationByAutoAcceptEmailDomain", () => { diff --git a/packages/features/ee/organizations/repositories/OrganizationRepository.ts b/packages/features/ee/organizations/repositories/OrganizationRepository.ts index 7ab25073d0..8059331f0b 100644 --- a/packages/features/ee/organizations/repositories/OrganizationRepository.ts +++ b/packages/features/ee/organizations/repositories/OrganizationRepository.ts @@ -242,16 +242,18 @@ export class OrganizationRepository { orgAutoAcceptEmail: emailDomain, isOrganizationVerified: true, isAdminReviewed: true, + orgAutoJoinOnSignup: true, }, }, }); if (orgs.length > 1) { logger.error( "Multiple organizations found with the same auto accept email domain", - safeStringify({ orgs, emailDomain }) + safeStringify({ orgIds: orgs.map((org) => org.id), emailDomain }) ); - // Detect and fail just in case this situation arises. We should really identify the problem in this case and fix the data. - throw new Error("Multiple organizations found with the same auto accept email domain"); + + // If we cannot reliably confirm a unique org then return nothing + return null; } const org = orgs[0]; if (!org) { @@ -285,6 +287,7 @@ export class OrganizationRepository { orgProfileRedirectsToVerifiedDomain: true, orgAutoAcceptEmail: true, disablePhoneOnlySMSNotifications: true, + orgAutoJoinOnSignup: true, }, }); @@ -303,6 +306,7 @@ export class OrganizationRepository { orgProfileRedirectsToVerifiedDomain: organizationSettings?.orgProfileRedirectsToVerifiedDomain, orgAutoAcceptEmail: organizationSettings?.orgAutoAcceptEmail, disablePhoneOnlySMSNotifications: organizationSettings?.disablePhoneOnlySMSNotifications, + orgAutoJoinOnSignup: organizationSettings?.orgAutoJoinOnSignup, }, user: { role: membership?.role, diff --git a/packages/prisma/migrations/20251112171210_add_org_auto_join_on_signup_to_organization_settings/migration.sql b/packages/prisma/migrations/20251112171210_add_org_auto_join_on_signup_to_organization_settings/migration.sql new file mode 100644 index 0000000000..294eaef151 --- /dev/null +++ b/packages/prisma/migrations/20251112171210_add_org_auto_join_on_signup_to_organization_settings/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."OrganizationSettings" ADD COLUMN "orgAutoJoinOnSignup" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9ff244c329..5c0561b45a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -686,6 +686,7 @@ model OrganizationSettings { allowSEOIndexing Boolean @default(false) orgProfileRedirectsToVerifiedDomain Boolean @default(false) disablePhoneOnlySMSNotifications Boolean @default(false) + orgAutoJoinOnSignup Boolean @default(true) } enum MembershipRole { diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index 087d6fc813..5de0590a92 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -65,26 +65,36 @@ const updateOrganizationSettings = async ({ }) => { const data: Prisma.OrganizationSettingsUpdateInput = {}; + // eslint-disable-next-line no-prototype-builtins if (input.hasOwnProperty("lockEventTypeCreation")) { data.lockEventTypeCreationForUsers = input.lockEventTypeCreation; } + // eslint-disable-next-line no-prototype-builtins if (input.hasOwnProperty("adminGetsNoSlotsNotification")) { data.adminGetsNoSlotsNotification = input.adminGetsNoSlotsNotification; } + // eslint-disable-next-line no-prototype-builtins if (input.hasOwnProperty("allowSEOIndexing")) { data.allowSEOIndexing = input.allowSEOIndexing; } + // eslint-disable-next-line no-prototype-builtins if (input.hasOwnProperty("orgProfileRedirectsToVerifiedDomain")) { data.orgProfileRedirectsToVerifiedDomain = input.orgProfileRedirectsToVerifiedDomain; } + // eslint-disable-next-line no-prototype-builtins if (input.hasOwnProperty("disablePhoneOnlySMSNotifications")) { data.disablePhoneOnlySMSNotifications = input.disablePhoneOnlySMSNotifications; } + // eslint-disable-next-line no-prototype-builtins + if (input.hasOwnProperty("orgAutoJoinOnSignup")) { + data.orgAutoJoinOnSignup = input.orgAutoJoinOnSignup; + } + // If no settings values have changed lets skip this update if (Object.keys(data).length === 0) return; diff --git a/packages/trpc/server/routers/viewer/organizations/update.schema.ts b/packages/trpc/server/routers/viewer/organizations/update.schema.ts index 0dcb8df399..13c42f97ee 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.schema.ts @@ -35,6 +35,7 @@ export const ZUpdateInputSchema = z.object({ allowSEOIndexing: z.boolean().optional(), orgProfileRedirectsToVerifiedDomain: z.boolean().optional(), disablePhoneOnlySMSNotifications: z.boolean().optional(), + orgAutoJoinOnSignup: z.boolean().optional(), }); export type TUpdateInputSchema = z.infer;