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>
This commit is contained in:
sean-brydon
2026-02-10 06:15:28 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent b03c00aded
commit 9424649d6f
4 changed files with 171 additions and 16 deletions
@@ -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 (
<LayoutWrapper>
<LicenseRequired>
@@ -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<SessionC
const { slug, name, orgOwnerEmail, billingPeriod, pricePerSeat, seats, onboardingId, reset } =
useOnboardingStore();
// For non-admin users, always use the current session email to prevent stale cached email issues.
// This handles the case where a user changes their email in settings and returns to org creation.
const effectiveOrgOwnerEmail = !isAdmin ? defaultOrgOwnerEmail : orgOwnerEmail || defaultOrgOwnerEmail;
const userHasCompanyEmail = isAdmin || isCompanyEmail(effectiveOrgOwnerEmail);
const newOrganizationFormMethods = useForm<{
name: string;
seats: number | null;
@@ -62,9 +63,9 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
}>({
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<SessionC
});
const needToCreateOnboarding = !onboardingId;
if (!userHasCompanyEmail) {
return (
<div className="stack-y-5">
<Alert
severity="warning"
title={t("use_company_email_to_create_an_organization")}
message={t("update_email_organization_description")}
actions={
<Button href="/settings/my-account/profile" color="secondary" className="mt-2">
{t("update_email_address")}
</Button>
}
/>
</div>
);
}
return (
<>
<Form
@@ -3637,6 +3637,8 @@
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "You cannot create an organization as you are already a part of an organization",
"you_need_to_complete_user_onboarding_before_creating_an_organization": "You need to complete user onboarding before creating an organization",
"use_company_email_to_create_an_organization": "Use company email to create an organization",
"update_email_organization_description": "To create an organization, you need a company email address. Please update your email in your profile settings.",
"update_email_address": "Update email address",
"you_cannot_create_a_platform_organization_as_you_are_already_part_of_a_team": "You cannot create a platform organization as you are already a part of a team",
"you_need_to_have_minimum_published_teams": "You need to have minimum published teams",
"you_are_part_of_this_organization_already": "You are part of this organization already",
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { extractDomainFromEmail, isCompanyEmail } from "./utils";
describe("utils", () => {
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("");
});
});
});