Skip to content

Commit 787828d

Browse files
joeauyeungemrysalanikdhabaldevin-ai-integration[bot]
authored
feat: Toggle auto adding users to an org if they signup without an invite (calcom#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>
1 parent 8923047 commit 787828d

12 files changed

Lines changed: 154 additions & 37 deletions

File tree

apps/web/lib/pages/auth/verify-email.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
22
import { z } from "zod";
33

44
import dayjs from "@calcom/dayjs";
5-
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
65
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
6+
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
77
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
88
import { WEBAPP_URL } from "@calcom/lib/constants";
99
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";

apps/web/playwright/fixtures/users.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ const createTeamAndAddUser = async (
189189
orgAutoAcceptEmail: user.email.split("@")[1],
190190
isOrganizationVerified: !!isOrgVerified,
191191
isOrganizationConfigured: isDnsSetup,
192+
orgAutoJoinOnSignup: true,
192193
},
193194
};
194195
}

apps/web/playwright/organization/organization-invitation.e2e.ts

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ test.describe("Organization", () => {
9090

9191
// This test is already covered by booking.e2e.ts where existing user is invited and his booking links are tested.
9292
// We can re-test here when we want to test some more scenarios.
93-
93+
9494
test("existing user invited to an organization", () => {});
9595

9696
test("nonexisting user invited to a Team inside organization", async ({
@@ -222,15 +222,6 @@ test.describe("Organization", () => {
222222
"signup?token"
223223
);
224224

225-
await expectUserToBeAMemberOfOrganization({
226-
page,
227-
orgSlug: org.slug,
228-
username: usernameDerivedFromEmail,
229-
role: "member",
230-
isMemberShipAccepted: true,
231-
email: invitedUserEmail,
232-
});
233-
234225
assertInviteLink(inviteLink);
235226
await signupFromEmailInviteLink({
236227
browser,
@@ -272,7 +263,7 @@ test.describe("Organization", () => {
272263
});
273264

274265
// Such a user has user.username changed directly in addition to having the new username in the profile.username
275-
test("existing user migrated to an organization", async ({ users, page, emails }) => {
266+
test("existing user migrated to an organization", async ({ users, page, emails: _emails }) => {
276267
const orgOwner = await users.create(undefined, {
277268
hasTeam: true,
278269
isOrg: true,
@@ -313,7 +304,6 @@ test.describe("Organization", () => {
313304
await page.locator('[data-testid="continue-with-email-button"]').click();
314305
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
315306

316-
317307
await page.locator('input[name="username"]').fill(existingUser.username!);
318308
await page
319309
.locator('input[name="email"]')
@@ -346,23 +336,7 @@ test.describe("Organization", () => {
346336
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" });
347337
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
348338
await inviteAnEmail(page, invitedUserEmail, true);
349-
await expectUserToBeAMemberOfTeam({
350-
page,
351-
teamId: team.id,
352-
username: usernameDerivedFromEmail,
353-
role: "member",
354-
isMemberShipAccepted: true,
355-
email: invitedUserEmail,
356-
});
357339

358-
await expectUserToBeAMemberOfOrganization({
359-
page,
360-
orgSlug: org.slug,
361-
username: usernameDerivedFromEmail,
362-
role: "member",
363-
isMemberShipAccepted: true,
364-
email: invitedUserEmail,
365-
});
366340
const inviteLink = await expectInvitationEmailToBeReceived(
367341
page,
368342
emails,
@@ -594,7 +568,7 @@ async function expectUserToBeAMemberOfTeam({
594568
page,
595569
teamId,
596570
email,
597-
role,
571+
role: _role,
598572
username,
599573
isMemberShipAccepted,
600574
}: {

apps/web/public/static/locales/en/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2931,6 +2931,8 @@
29312931
"delete_org_eventtypes": "Delete individual event types",
29322932
"lock_org_users_eventtypes": "Lock individual event type creation",
29332933
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
2934+
"org_auto_join_title": "Automatically add new members to the organization if they sign up to Cal.com with the \"{{emailDomain}}\" email domain",
2935+
"org_auto_join_description": "New members are added after they verify their email.",
29342936
"add_to_event_type": "Add to event type",
29352937
"create_account_password": "Create account password",
29362938
"create_account_with_saml": "Create Account with SAML",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import { trpc } from "@calcom/trpc";
7+
import { SettingsToggle } from "@calcom/ui/components/form";
8+
import { showToast } from "@calcom/ui/components/toast";
9+
10+
interface IOrgAutoJoinSettingProps {
11+
orgId: number;
12+
orgAutoJoinEnabled: boolean;
13+
emailDomain: string;
14+
}
15+
16+
const OrgAutoJoinSetting = (props: IOrgAutoJoinSettingProps) => {
17+
const utils = trpc.useUtils();
18+
const [isEnabled, setIsEnabled] = useState(props.orgAutoJoinEnabled);
19+
const { t } = useLocale();
20+
21+
const mutation = trpc.viewer.organizations.update.useMutation({
22+
onSuccess: async () => {
23+
showToast(t("your_org_updated_successfully"), "success");
24+
},
25+
onError: () => {
26+
showToast(t("error_updating_settings"), "error");
27+
},
28+
onSettled: () => {
29+
utils.viewer.organizations.listCurrent.invalidate();
30+
},
31+
});
32+
33+
return (
34+
<SettingsToggle
35+
toggleSwitchAtTheEnd={true}
36+
checked={isEnabled}
37+
title={t("org_auto_join_title", { emailDomain: props.emailDomain })}
38+
labelClassName="text-sm"
39+
description={t("org_auto_join_description")}
40+
data-testid="make-team-private-check"
41+
onCheckedChange={(checked) => {
42+
mutation.mutate({
43+
orgAutoJoinOnSignup: checked,
44+
});
45+
setIsEnabled(checked);
46+
}}
47+
/>
48+
);
49+
};
50+
51+
export default OrgAutoJoinSetting;

packages/features/ee/organizations/pages/settings/privacy.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { trpc } from "@calcom/trpc/react";
1111

1212
import { BlocklistTable } from "~/settings/organizations/privacy/blocklist-table";
1313

14+
import OrgAutoJoinSetting from "../components/OrgAutoJoinSetting";
15+
1416
const PrivacyView = ({
1517
permissions,
1618
watchlistPermissions,
@@ -42,6 +44,14 @@ const PrivacyView = ({
4244
disabled={isDisabled}
4345
/>
4446

47+
{currentOrg.organizationSettings?.orgAutoAcceptEmail && (
48+
<OrgAutoJoinSetting
49+
orgId={currentOrg.id}
50+
orgAutoJoinEnabled={!!currentOrg.organizationSettings.orgAutoJoinOnSignup}
51+
emailDomain={currentOrg.organizationSettings.orgAutoAcceptEmail}
52+
/>
53+
)}
54+
4555
{watchlistPermissions?.canRead && (
4656
<div>
4757
<div>

packages/features/ee/organizations/repositories/OrganizationRepository.test.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,15 @@ describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () =
8181
expect(result).toBeNull();
8282
});
8383

84-
it("should throw an error if multiple organizations match the email domain", async () => {
84+
it("should return null if multiple organizations match the email domain", async () => {
8585
await createReviewedOrganization({ name: "Test Org 1", orgAutoAcceptEmail: "example.com" });
8686
await createReviewedOrganization({ name: "Test Org 2", orgAutoAcceptEmail: "example.com" });
8787

88-
await expect(
89-
organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com" })
90-
).rejects.toThrow("Multiple organizations found with the same auto accept email domain");
88+
const result = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({
89+
email: "test@example.com",
90+
});
91+
92+
expect(result).toBeNull();
9193
});
9294

9395
it("should return the parsed organization if a single match is found", async () => {
@@ -122,6 +124,65 @@ describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () =
122124

123125
expect(result).toEqual(null);
124126
});
127+
128+
it("should return null when orgAutoJoinOnSignup is false", async () => {
129+
await prismock.team.create({
130+
data: {
131+
name: "Test Org",
132+
isOrganization: true,
133+
organizationSettings: {
134+
create: {
135+
orgAutoAcceptEmail: "example.com",
136+
isOrganizationVerified: true,
137+
isAdminReviewed: true,
138+
orgAutoJoinOnSignup: false,
139+
},
140+
},
141+
},
142+
});
143+
144+
const result = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({
145+
email: "test@example.com",
146+
});
147+
148+
expect(result).toBeNull();
149+
});
150+
151+
it("should return organization when orgAutoJoinOnSignup is true", async () => {
152+
const organization = await prismock.team.create({
153+
data: {
154+
name: "Test Org",
155+
isOrganization: true,
156+
organizationSettings: {
157+
create: {
158+
orgAutoAcceptEmail: "example.com",
159+
isOrganizationVerified: true,
160+
isAdminReviewed: true,
161+
orgAutoJoinOnSignup: true,
162+
},
163+
},
164+
},
165+
});
166+
167+
const result = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({
168+
email: "test@example.com",
169+
});
170+
171+
expect(result).toEqual(organization);
172+
});
173+
174+
it("should return organization when orgAutoJoinOnSignup is not explicitly set (defaults to true)", async () => {
175+
const organization = await createReviewedOrganization({
176+
name: "Test Org",
177+
orgAutoAcceptEmail: "example.com",
178+
});
179+
180+
const result = await organizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({
181+
email: "test@example.com",
182+
});
183+
184+
expect(result).toEqual(organization);
185+
});
125186
});
126187

127188
describe("Organization.getVerifiedOrganizationByAutoAcceptEmailDomain", () => {

packages/features/ee/organizations/repositories/OrganizationRepository.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,16 +242,18 @@ export class OrganizationRepository {
242242
orgAutoAcceptEmail: emailDomain,
243243
isOrganizationVerified: true,
244244
isAdminReviewed: true,
245+
orgAutoJoinOnSignup: true,
245246
},
246247
},
247248
});
248249
if (orgs.length > 1) {
249250
logger.error(
250251
"Multiple organizations found with the same auto accept email domain",
251-
safeStringify({ orgs, emailDomain })
252+
safeStringify({ orgIds: orgs.map((org) => org.id), emailDomain })
252253
);
253-
// Detect and fail just in case this situation arises. We should really identify the problem in this case and fix the data.
254-
throw new Error("Multiple organizations found with the same auto accept email domain");
254+
255+
// If we cannot reliably confirm a unique org then return nothing
256+
return null;
255257
}
256258
const org = orgs[0];
257259
if (!org) {
@@ -285,6 +287,7 @@ export class OrganizationRepository {
285287
orgProfileRedirectsToVerifiedDomain: true,
286288
orgAutoAcceptEmail: true,
287289
disablePhoneOnlySMSNotifications: true,
290+
orgAutoJoinOnSignup: true,
288291
},
289292
});
290293

@@ -303,6 +306,7 @@ export class OrganizationRepository {
303306
orgProfileRedirectsToVerifiedDomain: organizationSettings?.orgProfileRedirectsToVerifiedDomain,
304307
orgAutoAcceptEmail: organizationSettings?.orgAutoAcceptEmail,
305308
disablePhoneOnlySMSNotifications: organizationSettings?.disablePhoneOnlySMSNotifications,
309+
orgAutoJoinOnSignup: organizationSettings?.orgAutoJoinOnSignup,
306310
},
307311
user: {
308312
role: membership?.role,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "public"."OrganizationSettings" ADD COLUMN "orgAutoJoinOnSignup" BOOLEAN NOT NULL DEFAULT true;

packages/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,7 @@ model OrganizationSettings {
686686
allowSEOIndexing Boolean @default(false)
687687
orgProfileRedirectsToVerifiedDomain Boolean @default(false)
688688
disablePhoneOnlySMSNotifications Boolean @default(false)
689+
orgAutoJoinOnSignup Boolean @default(true)
689690
}
690691

691692
enum MembershipRole {

0 commit comments

Comments
 (0)