feat: add signup watchlist review mode (#27912)

* feat: add signup watchlist review feature flag and handler logic

- Add 'signup-watchlist-review' global feature flag
- Add SIGNUP to WatchlistSource enum in Prisma schema
- When flag enabled, lock new signups and add email to watchlist
- Show 'account under review' message on signup page
- Add i18n strings for review UI
- Create seed migration for the feature flag

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* test: add isAccountUnderReview tests to fetchSignup test suite

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: address Cubic AI review feedback (confidence >= 9/10)

- Remove 'import process from node:process' in signup-view.tsx (P0 bug in 'use client' component)
- Move watchlist review check before checkoutSessionId early return in calcomSignupHandler (P1 premium bypass)
- Revert selfHostedHandler to original state (out of scope per user request)
- Add test mocks for FeaturesRepository and GlobalWatchlistRepository

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: remove node:process import from useFlags.ts (client-side file)

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: remove !token condition from watchlist review check

Token is present in normal email-verified signups, so the !token
condition was incorrectly skipping watchlist review for verified users.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* refactor: move user lock to UserRepository.lockByEmail

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* refactor: use cached getFeatureRepository() instead of deprecated FeaturesRepository

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* refactor: remove user locking, keep only watchlist addition on signup review

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* feat: lock user on signup review, remove watchlist entry on unlock

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Alex van Andel
2026-02-12 23:14:31 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot] Devin AI
parent 9dfcb0d482
commit d3bbed01f6
12 changed files with 569 additions and 415 deletions
@@ -1,17 +1,15 @@
import type { Mock } from "vitest";
import { vi } from "vitest";
import type { MockResponse } from "@calcom/features/auth/signup/handlers/__tests__/mocks/next.mocks";
import {
prismaMock,
resetPrismaMock,
} from "@calcom/features/auth/signup/handlers/__tests__/mocks/prisma.mocks";
import {
createMockTeam,
createMockFoundToken,
} from "@calcom/features/auth/signup/handlers/__tests__/mocks/signup.factories";
import type { SignupBody } from "@calcom/features/auth/signup/handlers/__tests__/mocks/signup.factories";
import {
createMockFoundToken,
createMockTeam,
} from "@calcom/features/auth/signup/handlers/__tests__/mocks/signup.factories";
import type { Mock } from "vitest";
import { vi } from "vitest";
const mockFindTokenByToken: Mock = vi.fn();
const mockValidateAndGetCorrectedUsernameForTeam: Mock = vi.fn();
@@ -77,6 +75,22 @@ vi.mock("@calcom/features/watchlist/lib/telemetry", () => ({ sentrySpan: {} }));
vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", () => ({
checkIfEmailIsBlockedInWatchlistController: vi.fn().mockResolvedValue(false),
}));
vi.mock("@calcom/features/di/containers/FeatureRepository", () => ({
getFeatureRepository: vi.fn().mockReturnValue({
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(false),
}),
}));
vi.mock("@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository", () => {
return {
GlobalWatchlistRepository: class {
findBlockedEmail = vi.fn().mockResolvedValue(null);
createEntry = vi.fn().mockResolvedValue({});
},
};
});
vi.mock("@calcom/features/watchlist/lib/utils/normalization", () => ({
normalizeEmail: vi.fn((e: string) => e.toLowerCase()),
}));
vi.mock("@calcom/web/lib/buildLegacyCtx", () => ({ buildLegacyRequest: vi.fn() }));
vi.mock("@calcom/features/auth/signup/utils/organization", () => ({ joinAnyChildTeamOnOrgInvite: vi.fn() }));
vi.mock("@calcom/features/auth/signup/utils/token", () => ({
@@ -1,14 +1,23 @@
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import process from "node:process";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { getLocaleFromRequest } from "@calcom/features/auth/lib/getLocaleFromRequest";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { SIGNUP_ERROR_CODES } from "@calcom/features/auth/signup/constants";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { joinAnyChildTeamOnOrgInvite } from "@calcom/features/auth/signup/utils/organization";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import {
findTokenByToken,
throwIfTokenExpired,
validateAndGetCorrectedUsernameForTeam,
} from "@calcom/features/auth/signup/utils/token";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { normalizeEmail } from "@calcom/features/watchlist/lib/utils/normalization";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -19,19 +28,17 @@ import type { CustomNextApiHandler } from "@calcom/lib/server/username";
import { usernameHandler } from "@calcom/lib/server/username";
import { getTrackingFromCookies } from "@calcom/lib/tracking";
import prisma from "@calcom/prisma";
import { CreationSource } from "@calcom/prisma/enums";
import { IdentityProvider } from "@calcom/prisma/enums";
import {
CreationSource,
IdentityProvider,
WatchlistAction,
WatchlistSource,
WatchlistType,
} from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { buildLegacyRequest } from "@calcom/web/lib/buildLegacyCtx";
import { joinAnyChildTeamOnOrgInvite } from "@calcom/features/auth/signup/utils/organization";
import { SIGNUP_ERROR_CODES } from "@calcom/features/auth/signup/constants";
import {
findTokenByToken,
throwIfTokenExpired,
validateAndGetCorrectedUsernameForTeam,
} from "@calcom/features/auth/signup/utils/token";
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
const log = logger.getSubLogger({ prefix: ["signupCalcomHandler"] });
@@ -291,6 +298,34 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => {
}
}
const featureRepository = getFeatureRepository();
const signupWatchlistReviewEnabled =
await featureRepository.checkIfFeatureIsEnabledGlobally("signup-watchlist-review");
if (signupWatchlistReviewEnabled && !token) {
const globalWatchlistRepo = new GlobalWatchlistRepository(prisma);
const normalizedEmail = normalizeEmail(email);
const existing = await globalWatchlistRepo.findBlockedEmail(normalizedEmail);
if (!existing) {
await globalWatchlistRepo.createEntry({
type: WatchlistType.EMAIL,
value: normalizedEmail,
action: WatchlistAction.BLOCK,
source: WatchlistSource.SIGNUP,
description: "Auto-added during signup review",
});
}
const userRepository = new UserRepository(prisma);
await userRepository.lockByEmail({ email });
return NextResponse.json(
{ message: "Created user", stripeCustomerId: customer.stripeCustomerId, accountUnderReview: true },
{ status: 201 }
);
}
if (checkoutSessionId) {
console.log("Created user but missing payment", checkoutSessionId);
return NextResponse.json(
@@ -37,6 +37,7 @@ const initialData: AppFlags = {
"hwm-seating": false,
"active-user-billing": false,
"sidebar-tips": false,
"signup-watchlist-review": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
+413 -387
View File
@@ -1,35 +1,22 @@
"use client";
import { Analytics as DubAnalytics } from "@dub/analytics/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Script from "next/script";
import posthog from "posthog-js";
import { useState, useEffect } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form";
import { Toaster } from "sonner";
import { z } from "zod";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import {
fetchSignup,
isUserAlreadyExistsError,
hasCheckoutSession,
isAccountUnderReview,
isUserAlreadyExistsError,
} from "@calcom/features/auth/signup/lib/fetchSignup";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import {
APP_NAME,
URL_PROTOCOL_REGEX,
IS_CALCOM,
WEBAPP_URL,
CLOUDFLARE_SITE_ID,
IS_CALCOM,
URL_PROTOCOL_REGEX,
WEBAPP_URL,
WEBSITE_PRIVACY_POLICY_URL,
WEBSITE_TERMS_URL,
WEBSITE_URL,
@@ -47,12 +34,24 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { PasswordField, CheckboxField, TextField, Form, SelectField } from "@calcom/ui/components/form";
import { CheckboxField, Form, PasswordField, SelectField, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { InfoIcon, ShieldCheckIcon, StarIcon } from "@coss/ui/icons";
import { showToast } from "@calcom/ui/components/toast";
import { InfoIcon, ShieldCheckIcon, StarIcon } from "@coss/ui/icons";
import { Analytics as DubAnalytics } from "@dub/analytics/react";
import { zodResolver } from "@hookform/resolvers/zod";
import type { getServerSideProps } from "@lib/signup/getServerSideProps";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Script from "next/script";
import { signIn } from "next-auth/react";
import posthog from "posthog-js";
import { useEffect, useState } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form";
import { Toaster } from "sonner";
import { z } from "zod";
const signupSchema = apiSignupSchema.extend({
apiError: z.string().optional(), // Needed to display API errors doesn't get passed to the API
@@ -199,6 +198,7 @@ export default function Signup({
const [premiumUsername, setPremiumUsername] = useState(false);
const [usernameTaken, setUsernameTaken] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [accountUnderReview, setAccountUnderReview] = useState(false);
const [displayEmailForm, setDisplayEmailForm] = useState(token);
const [turnstileKey, setTurnstileKey] = useState(0);
const searchParams = useCompatSearchParams();
@@ -277,6 +277,11 @@ export default function Signup({
throw new Error(result.error.message);
}
if (isAccountUnderReview(result)) {
setAccountUnderReview(true);
return;
}
if (process.env.NEXT_PUBLIC_GTM_ID) {
pushGTMEvent("create_account", { email: data.email, user: data.username, lang: data.language });
}
@@ -378,388 +383,409 @@ export default function Signup({
<div className="bg-cal-muted 2xl:border-subtle grid w-full max-w-[1440px] grid-cols-1 grid-rows-1 overflow-hidden lg:grid-cols-2 2xl:rounded-[20px] 2xl:border 2xl:py-6">
{/* Left side */}
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-24 2xl:px-28">
{displayBackButton && (
<div className="flex w-fit lg:-mt-12">
<Button
color="minimal"
className="hover:bg-subtle todesktop:mt-10 mb-6 flex h-6 max-h-6 w-full items-center rounded-md px-3 py-2"
StartIcon="arrow-left"
data-testid="signup-back-button"
onClick={() => {
setDisplayEmailForm(false);
setIsSamlSignup(false);
}}>
{t("back")}
{accountUnderReview ? (
<div
className="flex flex-col items-center gap-4 py-10 text-center"
data-testid="account-under-review">
<div className="bg-subtle flex h-12 w-12 items-center justify-center rounded-full">
<ShieldCheckIcon className="text-default h-6 w-6" />
</div>
<h1 className="font-cal text-[28px] leading-none">{t("account_under_review_title")}</h1>
<p className="text-subtle text-base font-medium leading-5">
{t("account_under_review_description")}
</p>
<Button href="/auth/login" className="mt-4">
{t("go_back_login")}
</Button>
</div>
)}
<div className="flex flex-col gap-2">
<h1 className="font-cal text-[28px] leading-none ">
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
</h1>
{IS_CALCOM ? (
<p className="text-subtle text-base font-medium leading-5">{t("cal_signup_description")}</p>
) : (
<p className="text-subtle text-base font-medium leading-5">
{t("calcom_explained", {
appName: APP_NAME,
})}
</p>
)}
{IS_CALCOM && (
<div className="mt-12">
<SelectField
label={t("data_region")}
value={{
label: t(
// Use WEBAPP_URL for SSR-safe region detection
WEBAPP_URL.includes("cal.eu") ||
(typeof window !== "undefined" &&
window.location.hostname === "localhost" &&
new URL(window.location.href).searchParams.get("region") === "eu")
? "european_union"
: "united_states"
),
value:
// Use WEBAPP_URL for SSR-safe region detection
WEBAPP_URL.includes("cal.eu") ||
(typeof window !== "undefined" &&
window.location.hostname === "localhost" &&
new URL(window.location.href).searchParams.get("region") === "eu")
? "eu"
: "us",
}}
options={[
{ label: t("united_states"), value: "us" },
{ label: t("european_union"), value: "eu" },
]}
onChange={(option) => {
if (option && "value" in option) {
const currentUrl = new URL(window.location.href);
// Handle localhost - add region as URL parameter
if (currentUrl.hostname === "localhost") {
currentUrl.searchParams.set("region", option.value);
window.location.href = currentUrl.toString();
return;
}
// Handle production domains - modify hostname only to preserve query params
if (option.value === "eu") {
currentUrl.hostname = currentUrl.hostname.replace("cal.com", "cal.eu");
} else {
currentUrl.hostname = currentUrl.hostname.replace("cal.eu", "cal.com");
}
window.location.href = currentUrl.toString();
}
}}
/>
</div>
)}
</div>
{/* Form Container */}
{displayEmailForm && (
<div className="mt-6">
<Form
className="flex flex-col gap-4"
form={formMethods}
handleSubmit={async (values) => {
let updatedValues = values;
if (!formMethods.getValues().username && isOrgInviteByLink) {
updatedValues = {
...values,
username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail),
};
}
await signUp(updatedValues);
}}>
{/* Username */}
{!isOrgInviteByLink ? (
<UsernameField
orgSlug={orgSlug}
label={t("username")}
username={watch("username") || ""}
premium={premiumUsername}
usernameTaken={usernameTaken}
disabled={!!orgSlug}
setUsernameTaken={(value) => setUsernameTaken(value)}
data-testid="signup-usernamefield"
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? truncateDomain(
`${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
URL_PROTOCOL_REGEX,
""
)}/`
)
: truncateDomain(
`${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
)
}
/>
) : null}
{/* Email */}
<TextField
id="signup-email"
{...register("email")}
label={t("email")}
placeholder="john@doe.com"
type="email"
autoComplete="email"
disabled={prepopulateFormValues?.email}
data-testid="signup-emailfield"
/>
{/* Password */}
{!isSamlSignup && (
<PasswordField
id="signup-password"
data-testid="signup-passwordfield"
autoComplete="new-password"
label={t("password")}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
/>
)}
{/* Cloudflare Turnstile Captcha */}
{CLOUDFLARE_SITE_ID ? (
<TurnstileCaptcha
key={turnstileKey}
appearance="interaction-only"
onVerify={(token) => {
formMethods.setValue("cfToken", token);
}}
onExpire={() => {
formMethods.setValue("cfToken", undefined);
}}
onError={() => {
formMethods.setValue("cfToken", undefined);
}}
/>
) : null}
<CheckboxField
data-testid="signup-cookie-content-checkbox"
onChange={() => handleConsentChange(userConsentToCookie)}
description={t("cookie_consent_checkbox")}
/>
{errors.apiError && (
<Alert
className="mb-3"
severity="error"
message={errors.apiError?.message}
data-testid="signup-error-message"
/>
)}
{isSamlSignup ? (
) : (
<>
{displayBackButton && (
<div className="flex w-fit lg:-mt-12">
<Button
data-testid="saml-submit-button"
color="primary"
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
!formMethods.getValues("email") ||
!formMethods.getValues("username") ||
premiumUsername ||
isSubmitting
}
color="minimal"
className="hover:bg-subtle todesktop:mt-10 mb-6 flex h-6 max-h-6 w-full items-center rounded-md px-3 py-2"
StartIcon="arrow-left"
data-testid="signup-back-button"
onClick={() => {
const username = formMethods.getValues("username");
if (!username) {
// should not be reached but needed to bypass type errors
showToast(t("username_required"), "error");
return;
}
posthog.capture("signup_saml_submit_button_clicked", {
has_token: !!token,
is_org_invite: isOrgInviteByLink,
org_slug: orgSlug,
});
localStorage.setItem("username", username);
const sp = new URLSearchParams();
// @NOTE: don't remove username query param as it's required right now for stripe payment page
sp.set("username", username);
sp.set("email", formMethods.getValues("email"));
router.push(
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
);
}}
className={classNames(
"my-2 w-full justify-center rounded-md text-center",
formMethods.formState.errors.username && formMethods.formState.errors.email
? "opacity-50"
: ""
)}>
<ShieldCheckIcon className="mr-2 h-5 w-5" />
{t("create_account_with_saml")}
</Button>
) : (
<Button
type="submit"
data-testid="signup-submit-button"
className="my-2 w-full justify-center"
loading={loadingSubmitState}
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
!formMethods.getValues("email") ||
!formMethods.getValues("password") ||
(CLOUDFLARE_SITE_ID && !process.env.NEXT_PUBLIC_IS_E2E && !watch("cfToken")) ||
isSubmitting ||
usernameTaken
}>
{premiumUsername && !usernameTaken
? `${t("get_started")} (${getPremiumPlanPriceValue()})`
: t("get_started")}
</Button>
)}
</Form>
</div>
)}
{!displayEmailForm && (
<div className="mt-8 flex flex-col gap-6">
{/* Upper Row */}
{isGoogleLoginEnabled && (
<div className="flex flex-col gap-2 md:flex-row">
<Button
color="primary"
loading={isGoogleLoading}
CustomStartIcon={
<>
{/* eslint-disable @next/next/no-img-element */}
<img
className={classNames(
"text-subtle mr-2 h-4 w-4",
premiumUsername && "opacity-50"
)}
src="/google-icon-colored.svg"
alt="Continue with Google Icon"
/>
</>
}
className={classNames("w-full justify-center rounded-md text-center")}
data-testid="continue-with-google-button"
onClick={async () => {
posthog.capture("signup_google_button_clicked", {
has_token: !!token,
is_org_invite: isOrgInviteByLink,
org_slug: orgSlug,
has_prepopulated_username: !!prepopulateFormValues?.username,
});
setDisplayEmailForm(false);
setIsSamlSignup(false);
setIsGoogleLoading(true);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
const searchQueryParams = new URLSearchParams();
if (prepopulateFormValues?.username) {
// If username is present we save it in query params to check for premium
searchQueryParams.set("username", prepopulateFormValues.username);
localStorage.setItem("username", prepopulateFormValues.username);
}
if (token) {
searchQueryParams.set("email", prepopulateFormValues?.email);
}
const url = searchQueryParams.toString()
? `${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`
: GOOGLE_AUTH_URL;
router.push(url);
}}>
{t("continue_with_google")}
{t("back")}
</Button>
</div>
)}
<div className="flex flex-col gap-2">
<h1 className="font-cal text-[28px] leading-none ">
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
</h1>
{IS_CALCOM ? (
<p className="text-subtle text-base font-medium leading-5">
{t("cal_signup_description")}
</p>
) : (
<p className="text-subtle text-base font-medium leading-5">
{t("calcom_explained", {
appName: APP_NAME,
})}
</p>
)}
{IS_CALCOM && (
<div className="mt-12">
<SelectField
label={t("data_region")}
value={{
label: t(
// Use WEBAPP_URL for SSR-safe region detection
WEBAPP_URL.includes("cal.eu") ||
(typeof window !== "undefined" &&
window.location.hostname === "localhost" &&
new URL(window.location.href).searchParams.get("region") === "eu")
? "european_union"
: "united_states"
),
value:
// Use WEBAPP_URL for SSR-safe region detection
WEBAPP_URL.includes("cal.eu") ||
(typeof window !== "undefined" &&
window.location.hostname === "localhost" &&
new URL(window.location.href).searchParams.get("region") === "eu")
? "eu"
: "us",
}}
options={[
{ label: t("united_states"), value: "us" },
{ label: t("european_union"), value: "eu" },
]}
onChange={(option) => {
if (option && "value" in option) {
const currentUrl = new URL(window.location.href);
{isGoogleLoginEnabled && (
<div>
<div className="relative flex items-center">
<div className="border-subtle grow border-t" />
<span className="text-subtle mx-2 shrink text-sm font-normal leading-none">
{t("or").toLocaleLowerCase()}
</span>
<div className="border-subtle grow border-t" />
// Handle localhost - add region as URL parameter
if (currentUrl.hostname === "localhost") {
currentUrl.searchParams.set("region", option.value);
window.location.href = currentUrl.toString();
return;
}
// Handle production domains - modify hostname only to preserve query params
if (option.value === "eu") {
currentUrl.hostname = currentUrl.hostname.replace("cal.com", "cal.eu");
} else {
currentUrl.hostname = currentUrl.hostname.replace("cal.eu", "cal.com");
}
window.location.href = currentUrl.toString();
}
}}
/>
</div>
)}
</div>
{/* Form Container */}
{displayEmailForm && (
<div className="mt-6">
<Form
className="flex flex-col gap-4"
form={formMethods}
handleSubmit={async (values) => {
let updatedValues = values;
if (!formMethods.getValues().username && isOrgInviteByLink) {
updatedValues = {
...values,
username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail),
};
}
await signUp(updatedValues);
}}>
{/* Username */}
{!isOrgInviteByLink ? (
<UsernameField
orgSlug={orgSlug}
label={t("username")}
username={watch("username") || ""}
premium={premiumUsername}
usernameTaken={usernameTaken}
disabled={!!orgSlug}
setUsernameTaken={(value) => setUsernameTaken(value)}
data-testid="signup-usernamefield"
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? truncateDomain(
`${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
URL_PROTOCOL_REGEX,
""
)}/`
)
: truncateDomain(
`${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
)
}
/>
) : null}
{/* Email */}
<TextField
id="signup-email"
{...register("email")}
label={t("email")}
placeholder="john@doe.com"
type="email"
autoComplete="email"
disabled={prepopulateFormValues?.email}
data-testid="signup-emailfield"
/>
{/* Password */}
{!isSamlSignup && (
<PasswordField
id="signup-password"
data-testid="signup-passwordfield"
autoComplete="new-password"
label={t("password")}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
/>
)}
{/* Cloudflare Turnstile Captcha */}
{CLOUDFLARE_SITE_ID ? (
<TurnstileCaptcha
key={turnstileKey}
appearance="interaction-only"
onVerify={(token) => {
formMethods.setValue("cfToken", token);
}}
onExpire={() => {
formMethods.setValue("cfToken", undefined);
}}
onError={() => {
formMethods.setValue("cfToken", undefined);
}}
/>
) : null}
<CheckboxField
data-testid="signup-cookie-content-checkbox"
onChange={() => handleConsentChange(userConsentToCookie)}
description={t("cookie_consent_checkbox")}
/>
{errors.apiError && (
<Alert
className="mb-3"
severity="error"
message={errors.apiError?.message}
data-testid="signup-error-message"
/>
)}
{isSamlSignup ? (
<Button
data-testid="saml-submit-button"
color="primary"
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
!formMethods.getValues("email") ||
!formMethods.getValues("username") ||
premiumUsername ||
isSubmitting
}
onClick={() => {
const username = formMethods.getValues("username");
if (!username) {
// should not be reached but needed to bypass type errors
showToast(t("username_required"), "error");
return;
}
posthog.capture("signup_saml_submit_button_clicked", {
has_token: !!token,
is_org_invite: isOrgInviteByLink,
org_slug: orgSlug,
});
localStorage.setItem("username", username);
const sp = new URLSearchParams();
// @NOTE: don't remove username query param as it's required right now for stripe payment page
sp.set("username", username);
sp.set("email", formMethods.getValues("email"));
router.push(
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
);
}}
className={classNames(
"my-2 w-full justify-center rounded-md text-center",
formMethods.formState.errors.username && formMethods.formState.errors.email
? "opacity-50"
: ""
)}>
<ShieldCheckIcon className="mr-2 h-5 w-5" />
{t("create_account_with_saml")}
</Button>
) : (
<Button
type="submit"
data-testid="signup-submit-button"
className="my-2 w-full justify-center"
loading={loadingSubmitState}
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
!formMethods.getValues("email") ||
!formMethods.getValues("password") ||
(CLOUDFLARE_SITE_ID && !process.env.NEXT_PUBLIC_IS_E2E && !watch("cfToken")) ||
isSubmitting ||
usernameTaken
}>
{premiumUsername && !usernameTaken
? `${t("get_started")} (${getPremiumPlanPriceValue()})`
: t("get_started")}
</Button>
)}
</Form>
</div>
)}
{!displayEmailForm && (
<div className="mt-8 flex flex-col gap-6">
{/* Upper Row */}
{isGoogleLoginEnabled && (
<div className="flex flex-col gap-2 md:flex-row">
<Button
color="primary"
loading={isGoogleLoading}
CustomStartIcon={
<>
{/* eslint-disable @next/next/no-img-element */}
<img
className={classNames(
"text-subtle mr-2 h-4 w-4",
premiumUsername && "opacity-50"
)}
src="/google-icon-colored.svg"
alt="Continue with Google Icon"
/>
</>
}
className={classNames("w-full justify-center rounded-md text-center")}
data-testid="continue-with-google-button"
onClick={async () => {
posthog.capture("signup_google_button_clicked", {
has_token: !!token,
is_org_invite: isOrgInviteByLink,
org_slug: orgSlug,
has_prepopulated_username: !!prepopulateFormValues?.username,
});
setIsSamlSignup(false);
setIsGoogleLoading(true);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
const searchQueryParams = new URLSearchParams();
if (prepopulateFormValues?.username) {
// If username is present we save it in query params to check for premium
searchQueryParams.set("username", prepopulateFormValues.username);
localStorage.setItem("username", prepopulateFormValues.username);
}
if (token) {
searchQueryParams.set("email", prepopulateFormValues?.email);
}
const url = searchQueryParams.toString()
? `${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`
: GOOGLE_AUTH_URL;
router.push(url);
}}>
{t("continue_with_google")}
</Button>
</div>
)}
{isGoogleLoginEnabled && (
<div>
<div className="relative flex items-center">
<div className="border-subtle grow border-t" />
<span className="text-subtle mx-2 shrink text-sm font-normal leading-none">
{t("or").toLocaleLowerCase()}
</span>
<div className="border-subtle grow border-t" />
</div>
</div>
)}
{/* Lower Row */}
<div className="flex flex-col gap-2">
<Button
color="secondary"
disabled={isGoogleLoading}
className={classNames("w-full justify-center rounded-md text-center")}
onClick={() => {
posthog.capture("signup_email_button_clicked", {
has_token: !!token,
is_org_invite: isOrgInviteByLink,
org_slug: orgSlug,
});
setDisplayEmailForm(true);
setIsSamlSignup(false);
}}
data-testid="continue-with-email-button">
{t("continue_with_email")}
</Button>
{isSAMLLoginEnabled && (
<Button
data-testid="continue-with-saml-button"
color="minimal"
disabled={isGoogleLoading}
className={classNames("w-full justify-center rounded-md text-center")}
onClick={() => {
posthog.capture("signup_saml_button_clicked", {
has_token: !!token,
is_org_invite: isOrgInviteByLink,
org_slug: orgSlug,
});
setDisplayEmailForm(true);
setIsSamlSignup(true);
}}>
{`${t("or").toLocaleLowerCase()} ${t("saml_sso")}`}
</Button>
)}
</div>
</div>
)}
{/* Lower Row */}
<div className="flex flex-col gap-2">
<Button
color="secondary"
disabled={isGoogleLoading}
className={classNames("w-full justify-center rounded-md text-center")}
onClick={() => {
posthog.capture("signup_email_button_clicked", {
has_token: !!token,
is_org_invite: isOrgInviteByLink,
org_slug: orgSlug,
});
setDisplayEmailForm(true);
setIsSamlSignup(false);
}}
data-testid="continue-with-email-button">
{t("continue_with_email")}
</Button>
{isSAMLLoginEnabled && (
<Button
data-testid="continue-with-saml-button"
color="minimal"
disabled={isGoogleLoading}
className={classNames("w-full justify-center rounded-md text-center")}
onClick={() => {
posthog.capture("signup_saml_button_clicked", {
has_token: !!token,
is_org_invite: isOrgInviteByLink,
org_slug: orgSlug,
});
setDisplayEmailForm(true);
setIsSamlSignup(true);
}}>
{`${t("or").toLocaleLowerCase()} ${t("saml_sso")}`}
</Button>
)}
{/* Already have an account & T&C */}
<div className="mt-10 flex h-full flex-col justify-end pb-6 text-xs">
<div className="flex flex-col text-sm">
<div className="flex gap-1">
<p className="text-subtle">{t("already_have_account")}</p>
<Link href="/auth/login" className="text-emphasis hover:underline">
{t("sign_in")}
</Link>
</div>
<div className="text-subtle">
<ServerTrans
t={t}
i18nKey="signing_up_terms"
values={{ appName: APP_NAME }}
components={[
<Link
className="text-emphasis hover:underline"
key="terms"
href={`${WEBSITE_TERMS_URL}`}
target="_blank">
Terms
</Link>,
<Link
className="text-emphasis hover:underline"
key="privacy"
href={`${WEBSITE_PRIVACY_POLICY_URL}`}
target="_blank">
Privacy Policy.
</Link>,
]}
/>
</div>
</div>
</div>
</div>
</>
)}
{/* Already have an account & T&C */}
<div className="mt-10 flex h-full flex-col justify-end pb-6 text-xs">
<div className="flex flex-col text-sm">
<div className="flex gap-1">
<p className="text-subtle">{t("already_have_account")}</p>
<Link href="/auth/login" className="text-emphasis hover:underline">
{t("sign_in")}
</Link>
</div>
<div className="text-subtle">
<ServerTrans
t={t}
i18nKey="signing_up_terms"
values={{ appName: APP_NAME }}
components={[
<Link
className="text-emphasis hover:underline"
key="terms"
href={`${WEBSITE_TERMS_URL}`}
target="_blank">
Terms
</Link>,
<Link
className="text-emphasis hover:underline"
key="privacy"
href={`${WEBSITE_PRIVACY_POLICY_URL}`}
target="_blank">
Privacy Policy.
</Link>,
]}
/>
</div>
</div>
</div>
</div>
<div className="border-subtle lg:bg-subtle mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl pl-4 dark:bg-none lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12">
{IS_CALCOM && (
@@ -2310,6 +2310,8 @@
"enable_apps": "Enable apps",
"enable_apps_description": "Enable apps that users can integrate with {{appName}}",
"purchase_license": "Purchase a license",
"account_under_review_title": "Your account is under review",
"account_under_review_description": "Your account has been created and is currently under review. You will be able to sign in once your account has been approved.",
"already_have_account": "Already have an account?",
"already_have_key": "I already have a key:",
"already_have_key_suggestion": "Please copy your existing CALCOM_LICENSE_KEY environment variable here.",
@@ -1,7 +1,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { SIGNUP_ERROR_CODES } from "../constants";
import { fetchSignup, isUserAlreadyExistsError, hasCheckoutSession } from "./fetchSignup";
import { fetchSignup, isUserAlreadyExistsError, hasCheckoutSession, isAccountUnderReview } from "./fetchSignup";
function createJsonResponse(json: unknown, status = 200) {
return new Response(JSON.stringify(json), {
@@ -101,3 +101,42 @@ describe("hasCheckoutSession", () => {
expect(hasCheckoutSession(result)).toBe(false);
});
});
describe("isAccountUnderReview", () => {
it("returns true when accountUnderReview is true in success response", () => {
const result = {
ok: true as const,
data: { message: "Created user", accountUnderReview: true },
};
expect(isAccountUnderReview(result)).toBe(true);
});
it("returns false when accountUnderReview is not set", () => {
const result = {
ok: true as const,
data: { message: "Created user" },
};
expect(isAccountUnderReview(result)).toBe(false);
});
it("returns false when accountUnderReview is false", () => {
const result = {
ok: true as const,
data: { message: "Created user", accountUnderReview: false },
};
expect(isAccountUnderReview(result)).toBe(false);
});
it("returns false for error responses", () => {
const result = {
ok: false as const,
status: 500,
error: { message: "Internal error" },
};
expect(isAccountUnderReview(result)).toBe(false);
});
});
@@ -11,6 +11,7 @@ type SignupData = {
type SignupSuccessResponse = {
message: string;
stripeCustomerId?: string;
accountUnderReview?: boolean;
};
type SignupErrorResponse = {
@@ -20,9 +21,9 @@ type SignupErrorResponse = {
type SignupResponse = SignupSuccessResponse | SignupErrorResponse;
export type SignupResult =
| { ok: true; data: SignupSuccessResponse }
| { ok: false; status: number; error: SignupErrorResponse };
export type SignupSuccessResult = { ok: true; data: SignupSuccessResponse };
export type SignupResult = SignupSuccessResult | { ok: false; status: number; error: SignupErrorResponse };
export async function fetchSignup(data: SignupData, cfToken?: string): Promise<SignupResult> {
const allParams = new URLSearchParams(window.location.search);
@@ -91,3 +92,7 @@ export function hasCheckoutSession(
): result is { ok: false; status: number; error: SignupErrorResponse & { checkoutSessionId: string } } {
return !result.ok && !!result.error.checkoutSessionId;
}
export function isAccountUnderReview(result: SignupResult): boolean {
return result.ok && result.data.accountUnderReview === true;
}
+1
View File
@@ -38,6 +38,7 @@ export type AppFlags = {
"hwm-seating": boolean;
"active-user-billing": boolean;
"sidebar-tips": boolean;
"signup-watchlist-review": boolean;
};
export type TeamFeatures = Record<keyof AppFlags, boolean>;
@@ -1429,4 +1429,11 @@ export class UserRepository {
},
});
}
async lockByEmail({ email }: { email: string }) {
await this.prismaClient.user.updateMany({
where: { email },
data: { locked: true },
});
}
}
@@ -0,0 +1,8 @@
-- Add SIGNUP to WatchlistSource enum
ALTER TYPE "WatchlistSource" ADD VALUE IF NOT EXISTS 'SIGNUP';
-- Seed the signup-watchlist-review feature flag
INSERT INTO "Feature" ("slug", "enabled", "type", "description", "createdAt", "updatedAt")
VALUES
('signup-watchlist-review', false, 'OPERATIONAL', 'When enabled, new signups are added to the watchlist for review', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING;
+3 -2
View File
@@ -2464,6 +2464,7 @@ enum WatchlistAction {
enum WatchlistSource {
MANUAL
FREE_DOMAIN_POLICY
SIGNUP
}
model Watchlist {
@@ -3044,7 +3045,7 @@ model TeamBilling {
subscriptionEnd DateTime?
billingPeriod BillingPeriod?
billingMode BillingMode @default(SEATS)
billingMode BillingMode @default(SEATS)
pricePerSeat Int?
paidSeats Int?
monthlyProrations MonthlyProration[]
@@ -3073,7 +3074,7 @@ model OrganizationBilling {
subscriptionEnd DateTime?
billingPeriod BillingPeriod?
billingMode BillingMode @default(SEATS)
billingMode BillingMode @default(SEATS)
pricePerSeat Int?
paidSeats Int?
monthlyProrations MonthlyProration[]
@@ -1,3 +1,5 @@
import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository";
import { normalizeEmail } from "@calcom/features/watchlist/lib/utils/normalization";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
@@ -20,12 +22,25 @@ const lockUserAccountHandler = async ({ input }: GetOptions) => {
data: {
locked,
},
select: {
id: true,
email: true,
},
});
if (!user) {
throw new Error("User not found");
}
if (!locked) {
const globalWatchlistRepo = new GlobalWatchlistRepository(prisma);
const normalizedEmail = normalizeEmail(user.email);
const watchlistEntry = await globalWatchlistRepo.findBlockedEmail(normalizedEmail);
if (watchlistEntry) {
await globalWatchlistRepo.deleteEntry(watchlistEntry.id);
}
}
return {
success: true,
userId,