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:
co-authored by
cubic-dev-ai[bot]
Devin AI
parent
9dfcb0d482
commit
d3bbed01f6
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user