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:
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
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."OrganizationSettings" ADD COLUMN "orgAutoJoinOnSignup" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user