From 9424649d6f04f636aaac7a7cb7a4898321a5ce9b Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:15:28 +0000 Subject: [PATCH] fix: add isCompanyEmail check to organization upgrade path (#27813) Users with personal emails (e.g. Gmail) could enter the org creation flow at /settings/organizations/new but only hit the company email error at checkout, with no way to fix it from the form. After changing their email in settings, the cached store email was still used. - Add server-side isCompanyEmail check in page.tsx to redirect early - Add client-side check in CreateANewOrganizationForm with clear messaging - Always use current session email for non-admin users (not cached store) - Add tests for isCompanyEmail utility Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../settings/organizations/new/page.tsx | 21 +++- .../components/CreateANewOrganizationForm.tsx | 47 ++++--- apps/web/public/static/locales/en/common.json | 2 + .../ee/organizations/lib/utils.test.ts | 117 ++++++++++++++++++ 4 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 packages/features/ee/organizations/lib/utils.test.ts diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/new/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/new/page.tsx index d913f69a7b..b72fed1e07 100644 --- a/apps/web/app/(use-page-wrapper)/settings/organizations/new/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/organizations/new/page.tsx @@ -1,7 +1,11 @@ +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; +import { UserPermissionRole } from "@calcom/prisma/enums"; +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { _generateMetadata } from "app/_utils"; - +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; import LicenseRequired from "~/ee/common/components/LicenseRequired"; - import LegacyPage, { LayoutWrapper } from "~/ee/organizations/new/create-new-view"; export const generateMetadata = async () => @@ -14,6 +18,19 @@ export const generateMetadata = async () => ); const ServerPage = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user?.id) { + return redirect("/auth/login"); + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + const userEmail = session.user.email || ""; + + if (!isAdmin && !isCompanyEmail(userEmail)) { + return redirect("/settings/my-account/profile"); + } + return ( diff --git a/apps/web/modules/ee/organizations/components/CreateANewOrganizationForm.tsx b/apps/web/modules/ee/organizations/components/CreateANewOrganizationForm.tsx index 2cc4aa756d..64db8628a9 100644 --- a/apps/web/modules/ee/organizations/components/CreateANewOrganizationForm.tsx +++ b/apps/web/modules/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -1,12 +1,7 @@ "use client"; -import type { SessionContextValue } from "next-auth/react"; -import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { Controller, useForm } from "react-hook-form"; - import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import slugify from "@calcom/lib/slugify"; @@ -16,13 +11,14 @@ import type { Ensure } from "@calcom/types/utils"; import classNames from "@calcom/ui/classNames"; import { Alert } from "@calcom/ui/components/alert"; import { Button } from "@calcom/ui/components/button"; -import { ToggleGroup } from "@calcom/ui/components/form"; -import { Form } from "@calcom/ui/components/form"; -import { Label } from "@calcom/ui/components/form"; -import { TextField } from "@calcom/ui/components/form"; +import { Form, Label, TextField, ToggleGroup } from "@calcom/ui/components/form"; import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio"; - import { useOnboarding } from "@calcom/web/modules/ee/organizations/lib/onboardingStore"; +import { useRouter } from "next/navigation"; +import type { SessionContextValue } from "next-auth/react"; +import { useSession } from "next-auth/react"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; function extractDomainFromEmail(email: string) { const match = email.match(/^(?:.*?:\/\/)?.*?([\w-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[/?#:]|$)/); @@ -52,6 +48,11 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure({ defaultValues: { billingPeriod: billingPeriod ?? BillingPeriod.MONTHLY, - slug: slug ?? (!isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined), - orgOwnerEmail: orgOwnerEmail || defaultOrgOwnerEmail, - name: name ?? (!isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined), + slug: slug ?? (!isAdmin ? deriveSlugFromEmail(effectiveOrgOwnerEmail) : undefined), + orgOwnerEmail: effectiveOrgOwnerEmail, + name: name ?? (!isAdmin ? deriveOrgNameFromEmail(effectiveOrgOwnerEmail) : undefined), seats: seats ?? null, pricePerSeat: pricePerSeat ?? null, }, @@ -111,6 +112,24 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure + + {t("update_email_address")} + + } + /> + + ); + } + return ( <>
{ + describe("isCompanyEmail", () => { + it("should return false for gmail.com email", () => { + expect(isCompanyEmail("user@gmail.com")).toBe(false); + }); + + it("should return false for yahoo.com email", () => { + expect(isCompanyEmail("user@yahoo.com")).toBe(false); + }); + + it("should return false for outlook.com email", () => { + expect(isCompanyEmail("user@outlook.com")).toBe(false); + }); + + it("should return false for hotmail.com email", () => { + expect(isCompanyEmail("user@hotmail.com")).toBe(false); + }); + + it("should return false for protonmail.com email", () => { + expect(isCompanyEmail("user@protonmail.com")).toBe(false); + }); + + it("should return false for icloud.com email", () => { + expect(isCompanyEmail("user@icloud.com")).toBe(false); + }); + + it("should return true for company email", () => { + expect(isCompanyEmail("user@acme.com")).toBe(true); + }); + + it("should return true for cal.com email", () => { + expect(isCompanyEmail("user@cal.com")).toBe(true); + }); + + it("should return false for email without @", () => { + expect(isCompanyEmail("invalidemail")).toBe(false); + }); + + it("should return false for empty email", () => { + expect(isCompanyEmail("")).toBe(false); + }); + + it("should return false for all popular personal email providers", () => { + const personalProviders = [ + "gmail.com", + "googlemail.com", + "yahoo.com", + "ymail.com", + "rocketmail.com", + "sbcglobal.net", + "att.net", + "outlook.com", + "hotmail.com", + "live.com", + "msn.com", + "outlook.co", + "hotmail.co.uk", + "aol.com", + "icloud.com", + "me.com", + "mac.com", + "mail.com", + "email.com", + "protonmail.com", + "proton.me", + "zoho.com", + "yandex.com", + "gmx.com", + "fastmail.com", + "tutanota.com", + "mail.ru", + "qq.com", + ]; + + for (const provider of personalProviders) { + expect(isCompanyEmail(`user@${provider}`)).toBe(false); + } + }); + + it("should return true for various company domains", () => { + const companyDomains = [ + "acme.com", + "techcorp.io", + "business.co", + "startup.ai", + "enterprise.net", + "cal.com", + ]; + + for (const domain of companyDomains) { + expect(isCompanyEmail(`user@${domain}`)).toBe(true); + } + }); + + it("should be case-insensitive for domain matching", () => { + expect(isCompanyEmail("user@GMAIL.COM")).toBe(false); + expect(isCompanyEmail("user@Gmail.Com")).toBe(false); + }); + }); + + describe("extractDomainFromEmail", () => { + it("should extract domain from a standard email", () => { + expect(extractDomainFromEmail("user@acme.com")).toBe("acme"); + }); + + it("should return empty string for invalid input", () => { + expect(extractDomainFromEmail("invalid")).toBe(""); + }); + + it("should return empty string for empty input", () => { + expect(extractDomainFromEmail("")).toBe(""); + }); + }); +});