feat: Toggle auto adding users to an org if they signup without an invite (#25051)

* Remove auto adding users to an org

* Update tests

* Fix tests

* fix: Update organization invitation E2E tests to not expect auto-accept before signup

- Changed isMemberShipAccepted expectations from true to false before signup
- Users with emails matching orgAutoAcceptEmail are no longer auto-accepted
- They must explicitly accept the invitation after signup
- Fixed lint warnings for unused parameters

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Update E2E tests to expect pending membership after signup without auto-accept

Since auto-accept functionality was removed, users with emails matching
orgAutoAcceptEmail are no longer automatically accepted into organizations
after signup. They remain in pending state until explicitly accepted.

Updated assertions in:
- 'nonexisting user is invited to Org' test
- 'nonexisting user is invited to a team inside organization' test

Both tests now correctly expect isMemberShipAccepted: false after signup.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Restore `verify-email` and tests from `main`

* Add `orgAutoJoinOnSignup` to `organizationSettings`

* Update
`OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail`
to find orgs where `orgAutoJoinOnSignup` is true

* `organization.update` lint fix

* `organization.update` to handle `orgAutoJoinOnSignup`

* Create toggle for `orgAutoJoinOnSignup`

* test: Add comprehensive tests for orgAutoJoinOnSignup functionality

- Update existing test to expect null instead of error when multiple orgs match
- Add test for when orgAutoJoinOnSignup is false (should return null)
- Add test for when orgAutoJoinOnSignup is true (should return org)
- Add test for default behavior (orgAutoJoinOnSignup defaults to true)

These tests verify that the new orgAutoJoinOnSignup setting correctly controls
whether users are automatically added to organizations during email verification.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Type fix

* e2e: invited users should be accepted after signup (address cubic r2511916791)

Reverted post-signup isMemberShipAccepted assertions from false to true for
explicit invite scenarios. When users are explicitly invited to an org/team
and complete signup via invite link, their membership should be accepted.

This is distinct from auto-join by domain (controlled by orgAutoJoinOnSignup),
which only affects users who sign up without an invite but match the org's
email domain.

Backend sets membership.accepted = true on invite completion in:
packages/features/auth/signup/utils/createOrUpdateMemberships.ts:61,67,77,83

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix API V2 build

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2025-11-13 15:13:10 -03:00
committed by GitHub
co-authored by joe@cal.com <j.auyeung419@gmail.com> Alex van Andel Anik Dhabal Babu Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 89230474a5
commit 787828d0ca
12 changed files with 154 additions and 37 deletions
+1 -1
View File
@@ -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";
+1
View File
@@ -189,6 +189,7 @@ const createTeamAndAddUser = async (
orgAutoAcceptEmail: user.email.split("@")[1],
isOrganizationVerified: !!isOrgVerified,
isOrganizationConfigured: isDnsSetup,
orgAutoJoinOnSignup: true,
},
};
}
@@ -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,
}: {
@@ -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",
@@ -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 (
<SettingsToggle
toggleSwitchAtTheEnd={true}
checked={isEnabled}
title={t("org_auto_join_title", { emailDomain: props.emailDomain })}
labelClassName="text-sm"
description={t("org_auto_join_description")}
data-testid="make-team-private-check"
onCheckedChange={(checked) => {
mutation.mutate({
orgAutoJoinOnSignup: checked,
});
setIsEnabled(checked);
}}
/>
);
};
export default OrgAutoJoinSetting;
@@ -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 && (
<OrgAutoJoinSetting
orgId={currentOrg.id}
orgAutoJoinEnabled={!!currentOrg.organizationSettings.orgAutoJoinOnSignup}
emailDomain={currentOrg.organizationSettings.orgAutoAcceptEmail}
/>
)}
{watchlistPermissions?.canRead && (
<div>
<div>
@@ -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", () => {
@@ -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,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."OrganizationSettings" ADD COLUMN "orgAutoJoinOnSignup" BOOLEAN NOT NULL DEFAULT true;
+1
View File
@@ -686,6 +686,7 @@ model OrganizationSettings {
allowSEOIndexing Boolean @default(false)
orgProfileRedirectsToVerifiedDomain Boolean @default(false)
disablePhoneOnlySMSNotifications Boolean @default(false)
orgAutoJoinOnSignup Boolean @default(true)
}
enum MembershipRole {
@@ -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;
@@ -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<typeof ZUpdateInputSchema>;