* fix: trigger lingo.dev by removing duplicate value * under progress * wow this worked * migrate schema * fix types * fix import for google login * Add onboarding tests for Azure (Microsoft sign up) * add comments back * fix failing test * fix: update Outlook login configuration and improve type safety in authentication adapter - Set OUTLOOK_LOGIN_ENABLED to false in .env.example - Refactor getServerSideProps to directly use samlTenantID and samlProductID - Update linkAccount method in next-auth-custom-adapter for better type handling - Remove redundant comment in next-auth-options related to Azure AD email verification * remove log * remove debug log from signin callback in next-auth options * fixup * chore: standardize naming * chore: add primary calendar for outlook, verify email and auto link org for outlook * chore: helper fns * chore: implement cubic feedback * cleanup * chore: implement cubic feedback again * WIP design# * feat: login design * fix: map identity provider names correctly * 32px of mt * fix: login UI * fix: type check * fix: fix type check again * chore: update OAuth login tests * fixup * fix: bad import * chore: update tests * fixup * fix: locales test * chore: implement PR feedback and fix minor issues * fix: revert token spreading change * fix: merge conflicts * chore: revert signup view changes * fixup: bring back reverted changes because of merge conflicts * fix: disable email input when microsoft sign in is in progress * chore: implement cubic feedback * cleanup: unused variables * fix: address Cubic AI review feedback (confidence >= 9/10) - Remove userId (PII) from log payloads in updateProfilePhotoMicrosoft.ts - Replace text selectors with data-testid in locale.e2e.ts and oauth-provider.e2e.ts - Restore callbackUrl redirect parameter in signup link in login-view.tsx - Add data-testid='login-subtitle' to login page subtitle element Co-Authored-By: unknown <> * fix: use empty alt for decorative icon images in login view MicrosoftIcon and GoogleIcon are decorative (adjacent to text labels), so they should have empty alt attributes per accessibility best practices. Co-Authored-By: unknown <> * chore: implement cubic feedback * cleanup * fixup * chore: implement PR feedback * chore: implement feedback * fix: address PR review feedback - type safety and centralize constants - Replace non-null assertions (!) with proper null checks for OUTLOOK_CLIENT_ID/SECRET - Replace `as any` casting with `Record<string, unknown>` for OAuth profile claims - Remove non-null assertion on account.access_token by adding conditional check - Centralize Outlook env constants in @calcom/lib/constants alongside MICROSOFT_CALENDAR_SCOPES - Add explanatory comment for getNextAuthProviderName usage in get.handler.ts Co-Authored-By: unknown <> * Revert "fix: address PR review feedback - type safety and centralize constants" This reverts commit 91ace141e6a28a23deea5897f7f9d6ad80319d84. * chore: implement feedback * chore: cleanup * chore: implement feedback * fix: merge conflicts * fix: revert formatting-only changes in packages/lib/constants.ts Co-Authored-By: unknown <> * fix: revert IdentityProvider enum location change in schema.prisma Co-Authored-By: unknown <> * chore: implement more PR feedback * fix: restore database-derived profileId from determineProfile in OAuth JWT The profileId regression was identified by Cubic AI (confidence 9/10). Previously, determineProfile's returned id was used to set profileId in the JWT via 'profileResult.id ?? token.profileId ?? null'. A recent refactor changed this to 'token.profileId ?? null', which drops the database-derived profile ID. On first OAuth login (or when profile switcher is disabled), token.profileId is likely null, so profileId would incorrectly be set to null even though determineProfile returned a valid profile with an id. This commit restores the correct priority chain: visitorProfileId ?? token.profileId ?? null Co-Authored-By: bot_apk <apk@cognition.ai> * refactor: revert pure formatting and import reordering changes Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: normalize determineProfile return type to use consistent 'id' field The determineProfile function returned a union type where one branch used 'id' and the other used 'profileId'. This caused TS2339 when destructuring 'id' from the result. Normalize the token.upId branch to also return 'id' (mapped from token.profileId) so the return type is consistent. Co-Authored-By: bot_apk <apk@cognition.ai> * chore: add tests * reveret: profileId changes should be in a separate PR * fix: avoid logging entire existingUser object in OAuth JWT callback Revert to logging only { userId, upId } instead of the full existingUser object, which contains PII (email, name, identity provider details). This restores the previous safe logging pattern. Co-Authored-By: bot_apk <apk@cognition.ai> * chore: remove profileId related tests --------- Co-authored-by: amrit <iamamrit27@gmail.com> Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Sean Brydon <sean@cal.com> Co-authored-by: bot_apk <apk@cognition.ai>
938 lines
40 KiB
TypeScript
938 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
|
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
|
import {
|
|
fetchSignup,
|
|
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,
|
|
CLOUDFLARE_SITE_ID,
|
|
IS_CALCOM,
|
|
URL_PROTOCOL_REGEX,
|
|
WEBAPP_URL,
|
|
WEBSITE_PRIVACY_POLICY_URL,
|
|
WEBSITE_TERMS_URL,
|
|
WEBSITE_URL,
|
|
} from "@calcom/lib/constants";
|
|
import { isENVDev } from "@calcom/lib/env";
|
|
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
|
import { pushGTMEvent } from "@calcom/lib/gtm";
|
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
|
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import { INVALID_CLOUDFLARE_TOKEN_ERROR } from "@calcom/lib/server/checkCfTurnstileToken";
|
|
import { IS_EUROPE } from "@calcom/lib/timezoneConstants";
|
|
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
|
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 { CheckboxField, Form, PasswordField, SelectField, TextField } from "@calcom/ui/components/form";
|
|
import { Icon } from "@calcom/ui/components/icon";
|
|
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
|
|
cfToken: z.string().optional(),
|
|
});
|
|
|
|
const TurnstileCaptcha = dynamic(() => import("@calcom/web/modules/auth/components/Turnstile"), {
|
|
ssr: false,
|
|
});
|
|
|
|
type FormValues = z.infer<typeof signupSchema>;
|
|
|
|
export type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
|
|
|
const FEATURES = [
|
|
{
|
|
title: "connect_all_calendars",
|
|
description: "connect_all_calendars_description",
|
|
i18nOptions: {
|
|
appName: APP_NAME,
|
|
},
|
|
icon: "calendar-heart" as const,
|
|
},
|
|
{
|
|
title: "set_availability",
|
|
description: "set_availbility_description",
|
|
icon: "users" as const,
|
|
},
|
|
{
|
|
title: "share_a_link_or_embed",
|
|
description: "share_a_link_or_embed_description",
|
|
icon: "link-2" as const,
|
|
i18nOptions: {
|
|
appName: APP_NAME,
|
|
},
|
|
},
|
|
];
|
|
|
|
function truncateDomain(domain: string) {
|
|
const maxLength = 25;
|
|
const cleanDomain = domain.replace(URL_PROTOCOL_REGEX, "");
|
|
|
|
if (cleanDomain.length <= maxLength) {
|
|
return cleanDomain;
|
|
}
|
|
|
|
return `${cleanDomain.substring(0, maxLength - 3)}.../`;
|
|
}
|
|
|
|
function UsernameField({
|
|
username,
|
|
setPremium,
|
|
premium,
|
|
setUsernameTaken,
|
|
orgSlug,
|
|
usernameTaken,
|
|
disabled,
|
|
...props
|
|
}: React.ComponentProps<typeof TextField> & {
|
|
username: string;
|
|
setPremium: (value: boolean) => void;
|
|
premium: boolean;
|
|
usernameTaken: boolean;
|
|
orgSlug?: string;
|
|
setUsernameTaken: (value: boolean) => void;
|
|
}) {
|
|
const { t } = useLocale();
|
|
const { register, formState } = useFormContext<FormValues>();
|
|
const debouncedUsername = useDebounce(username, 600);
|
|
|
|
useEffect(() => {
|
|
if (formState.isSubmitting || formState.isSubmitSuccessful) return;
|
|
|
|
async function checkUsername() {
|
|
// If the username can't be changed, there is no point in doing the username availability check
|
|
if (disabled) return;
|
|
if (!debouncedUsername) {
|
|
setPremium(false);
|
|
setUsernameTaken(false);
|
|
return;
|
|
}
|
|
fetchUsername(debouncedUsername, orgSlug ?? null).then(({ data }) => {
|
|
setPremium(data.premium);
|
|
setUsernameTaken(!data.available);
|
|
});
|
|
}
|
|
checkUsername();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [debouncedUsername, disabled, orgSlug, formState.isSubmitting, formState.isSubmitSuccessful]);
|
|
|
|
return (
|
|
<div>
|
|
<TextField
|
|
disabled={disabled}
|
|
{...props}
|
|
{...register("username")}
|
|
data-testid="signup-usernamefield"
|
|
/>
|
|
{(!formState.isSubmitting || !formState.isSubmitted) && (
|
|
<div className="text-gray text-default flex items-center text-sm">
|
|
<div className="text-sm ">
|
|
{usernameTaken ? (
|
|
<div className="text-error flex items-center">
|
|
<InfoIcon className="mr-1 inline-block h-4 w-4" />
|
|
<p>{t("already_in_use_error")}</p>
|
|
</div>
|
|
) : premium ? (
|
|
<div data-testid="premium-username-warning" className="flex items-center">
|
|
<StarIcon className="mr-1 inline-block h-4 w-4" />
|
|
<p>
|
|
{t("premium_username", {
|
|
price: getPremiumPlanPriceValue(),
|
|
interpolation: { escapeValue: false },
|
|
})}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function addOrUpdateQueryParam(url: string, key: string, value: string) {
|
|
const separator = url.includes("?") ? "&" : "?";
|
|
const param = `${key}=${encodeURIComponent(value)}`;
|
|
return `${url}${separator}${param}`;
|
|
}
|
|
|
|
export default function Signup({
|
|
prepopulateFormValues,
|
|
token,
|
|
orgSlug,
|
|
isGoogleLoginEnabled,
|
|
isOutlookLoginEnabled,
|
|
isSAMLLoginEnabled,
|
|
orgAutoAcceptEmail,
|
|
redirectUrl,
|
|
emailVerificationEnabled,
|
|
onboardingV3Enabled,
|
|
}: SignupProps) {
|
|
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
|
|
const [isSamlSignup, setIsSamlSignup] = useState(false);
|
|
const [premiumUsername, setPremiumUsername] = useState(false);
|
|
const [usernameTaken, setUsernameTaken] = useState(false);
|
|
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
|
const [isMicrosoftLoading, setIsMicrosoftLoading] = useState(false);
|
|
const [accountUnderReview, setAccountUnderReview] = useState(false);
|
|
const [displayEmailForm, setDisplayEmailForm] = useState(token);
|
|
const [turnstileKey, setTurnstileKey] = useState(0);
|
|
const searchParams = useCompatSearchParams();
|
|
const { t, i18n } = useLocale();
|
|
const router = useRouter();
|
|
const formMethods = useForm<FormValues>({
|
|
resolver: zodResolver(signupSchema),
|
|
defaultValues: prepopulateFormValues satisfies FormValues,
|
|
mode: "onTouched",
|
|
});
|
|
const {
|
|
register,
|
|
watch,
|
|
formState: { isSubmitting, errors, isSubmitSuccessful },
|
|
} = formMethods;
|
|
|
|
useEffect(() => {
|
|
if (redirectUrl) {
|
|
localStorage.setItem("onBoardingRedirect", redirectUrl);
|
|
}
|
|
}, [redirectUrl]);
|
|
|
|
const [userConsentToCookie, setUserConsentToCookie] = useState(false); // No need to be checked for user to proceed
|
|
|
|
function handleConsentChange(consent: boolean) {
|
|
setUserConsentToCookie(!consent);
|
|
}
|
|
|
|
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
|
|
const displayBackButton = token ? false : displayEmailForm;
|
|
|
|
const isPlatformUser = redirectUrl?.includes("platform") && redirectUrl?.includes("new");
|
|
|
|
const signUp: SubmitHandler<FormValues> = async (_data) => {
|
|
const { cfToken, ...data } = _data;
|
|
|
|
posthog.capture("signup_form_submitted", {
|
|
has_token: !!token,
|
|
is_org_invite: isOrgInviteByLink,
|
|
org_slug: orgSlug,
|
|
is_premium_username: premiumUsername,
|
|
username_taken: usernameTaken,
|
|
});
|
|
|
|
try {
|
|
const result = await fetchSignup(
|
|
{
|
|
...data,
|
|
language: i18n.language,
|
|
token,
|
|
},
|
|
cfToken
|
|
);
|
|
|
|
if (!result.ok) {
|
|
if (isUserAlreadyExistsError(result)) {
|
|
showToast(t("account_already_exists_please_login"), "warning");
|
|
const callbackUrl = token ? `/teams?token=${token}` : "/event-types";
|
|
setTimeout(() => {
|
|
router.push(`/auth/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
|
|
}, 3000);
|
|
return;
|
|
}
|
|
|
|
if (hasCheckoutSession(result)) {
|
|
const stripe = await getStripe();
|
|
if (stripe) {
|
|
const { error } = await stripe.redirectToCheckout({
|
|
sessionId: result.error.checkoutSessionId,
|
|
});
|
|
if (error) console.warn(error.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started";
|
|
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath;
|
|
const gettingStartedWithPlatform = "settings/platform/new";
|
|
|
|
const constructCallBackIfUrlPresent = () => {
|
|
if (isOrgInviteByLink) {
|
|
return `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`;
|
|
}
|
|
return addOrUpdateQueryParam(`${WEBAPP_URL}/${searchParams.get("callbackUrl")}`, "from", "signup");
|
|
};
|
|
|
|
const constructCallBackIfUrlNotPresent = () => {
|
|
if (isPlatformUser) {
|
|
return `${WEBAPP_URL}/${gettingStartedWithPlatform}?from=signup`;
|
|
}
|
|
return `${WEBAPP_URL}/${verifyOrGettingStarted}?from=signup`;
|
|
};
|
|
|
|
const constructCallBackUrl = () => {
|
|
const callbackUrlSearchParams = searchParams?.get("callbackUrl");
|
|
return callbackUrlSearchParams ? constructCallBackIfUrlPresent() : constructCallBackIfUrlNotPresent();
|
|
};
|
|
|
|
await signIn<"credentials">("credentials", {
|
|
...data,
|
|
callbackUrl: constructCallBackUrl(),
|
|
});
|
|
} catch (err) {
|
|
setTurnstileKey((k) => k + 1);
|
|
formMethods.setValue("cfToken", undefined);
|
|
|
|
const errorMessage = err instanceof Error ? err.message : t("unexpected_error_try_again");
|
|
|
|
if (errorMessage === INVALID_CLOUDFLARE_TOKEN_ERROR) {
|
|
return;
|
|
}
|
|
|
|
posthog.capture("signup_form_submit_error", {
|
|
has_token: !!token,
|
|
is_org_invite: isOrgInviteByLink,
|
|
org_slug: orgSlug,
|
|
is_premium_username: premiumUsername,
|
|
error_message: errorMessage,
|
|
});
|
|
formMethods.setError("apiError", { message: errorMessage });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{IS_CALCOM && (!IS_EUROPE || userConsentToCookie) ? (
|
|
<>
|
|
{process.env.NEXT_PUBLIC_GTM_ID && (
|
|
<>
|
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: GTM script injection */}
|
|
<Script
|
|
id="gtm-init-script"
|
|
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
|
|
// And we don't want it to error here anyways
|
|
|
|
dangerouslySetInnerHTML={{
|
|
__html: `(function (w, d, s, l, i) {
|
|
w[l] = w[l] || []; w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
|
var f = d.getElementsByTagName(s)[0], j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : '';
|
|
j.async = true; j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f);
|
|
})(window, document, 'script', 'dataLayer', '${process.env.NEXT_PUBLIC_GTM_ID}');`,
|
|
}}
|
|
/>
|
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: GTM noscript fallback */}
|
|
<noscript
|
|
dangerouslySetInnerHTML={{
|
|
__html: `<iframe src="https://www.googletagmanager.com/ns.html?id=${process.env.NEXT_PUBLIC_GTM_ID}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`,
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
<DubAnalytics
|
|
apiHost="/_proxy/dub"
|
|
cookieOptions={{
|
|
domain: isENVDev ? undefined : `.${new URL(WEBSITE_URL).hostname}`,
|
|
}}
|
|
domainsConfig={{
|
|
refer: "refer.cal.com",
|
|
}}
|
|
/>
|
|
</>
|
|
) : null}
|
|
<div
|
|
className={classNames(
|
|
"light bg-cal-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]",
|
|
"[--cal-brand-subtle:#9CA3AF]",
|
|
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]",
|
|
"[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1] "
|
|
)}>
|
|
<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">
|
|
{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>
|
|
) : (
|
|
<>
|
|
{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")}
|
|
</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 ? (
|
|
<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}
|
|
disabled={isMicrosoftLoading}
|
|
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 && prepopulateFormValues?.email) {
|
|
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>
|
|
)}
|
|
|
|
{isOutlookLoginEnabled && (
|
|
<div className="flex flex-col gap-2 md:flex-row">
|
|
<Button
|
|
color="secondary"
|
|
loading={isMicrosoftLoading}
|
|
disabled={isGoogleLoading}
|
|
CustomStartIcon={
|
|
<>
|
|
{/* eslint-disable @next/next/no-img-element */}
|
|
<img
|
|
className={classNames(
|
|
"text-subtle mr-2 h-4 w-4",
|
|
premiumUsername && "opacity-50"
|
|
)}
|
|
src="/microsoft-logo.svg"
|
|
alt="Continue with Microsoft Icon"
|
|
/>
|
|
</>
|
|
}
|
|
className={classNames("w-full justify-center rounded-md text-center")}
|
|
data-testid="continue-with-microsoft-button"
|
|
onClick={async () => {
|
|
posthog.capture("signup_microsoft_button_clicked", {
|
|
has_token: !!token,
|
|
is_org_invite: isOrgInviteByLink,
|
|
org_slug: orgSlug,
|
|
has_prepopulated_username: !!prepopulateFormValues?.username,
|
|
});
|
|
setIsSamlSignup(false);
|
|
setIsMicrosoftLoading(true);
|
|
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
|
const MICROSOFT_AUTH_URL = `${baseUrl}/auth/sso/microsoft`;
|
|
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 && prepopulateFormValues?.email) {
|
|
searchQueryParams.set("email", prepopulateFormValues.email);
|
|
}
|
|
const url = searchQueryParams.toString()
|
|
? `${MICROSOFT_AUTH_URL}?${searchQueryParams.toString()}`
|
|
: MICROSOFT_AUTH_URL;
|
|
|
|
router.push(url);
|
|
}}>
|
|
{t("continue_with_microsoft")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{(isGoogleLoginEnabled || isOutlookLoginEnabled) && (
|
|
<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 || isMicrosoftLoading}
|
|
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>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<>
|
|
<div className="-mt-4 mb-6 mr-12 grid w-full grid-cols-3 gap-5 pr-4 sm:gap-3 lg:grid-cols-4">
|
|
<div>
|
|
{/* eslint-disable @next/next/no-img-element */}
|
|
<img
|
|
src="/product-cards/product-of-the-day.svg"
|
|
className="h-[34px] w-full dark:invert"
|
|
alt="Cal.com was Product of the Day at ProductHunt"
|
|
/>
|
|
</div>
|
|
<div>
|
|
{/* eslint-disable @next/next/no-img-element */}
|
|
<img
|
|
src="/product-cards/product-of-the-week.svg"
|
|
className="h-[34px] w-full dark:invert"
|
|
alt="Cal.com was Product of the Week at ProductHunt"
|
|
/>
|
|
</div>
|
|
<div>
|
|
{/* eslint-disable @next/next/no-img-element */}
|
|
<img
|
|
src="/product-cards/product-of-the-month.svg"
|
|
className="h-[34px] w-full dark:invert"
|
|
alt="Cal.com was Product of the Month at ProductHunt"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mb-6 mr-12 grid w-full grid-cols-3 gap-5 pr-4 sm:gap-3 lg:grid-cols-4">
|
|
<div>
|
|
{/* eslint-disable @next/next/no-img-element */}
|
|
<img
|
|
src="/product-cards/producthunt.svg"
|
|
className="h-[54px] w-full"
|
|
alt="ProductHunt Rating of 5 Stars"
|
|
/>
|
|
</div>
|
|
<div>
|
|
{/* eslint-disable @next/next/no-img-element */}
|
|
<img
|
|
src="/product-cards/google-reviews.svg"
|
|
className="h-[54px] w-full"
|
|
alt="Google Reviews Rating of 4.7 Stars"
|
|
/>
|
|
</div>
|
|
<div>
|
|
{/* eslint-disable @next/next/no-img-element */}
|
|
<img
|
|
src="/product-cards/g2.svg"
|
|
className="h-[54px] w-full"
|
|
alt="G2 Rating of 4.7 Stars"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="border-default bg-black/3 hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
|
|
<img className="block dark:hidden" src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
|
|
{/* eslint-disable @next/next/no-img-element */}
|
|
<img
|
|
className="hidden dark:block"
|
|
src="/mock-event-type-list-dark.svg"
|
|
alt="Cal.com Booking Page"
|
|
/>
|
|
</div>
|
|
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
|
{FEATURES.map((feature, index) => (
|
|
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
|
<div className="text-emphasis items-center">
|
|
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
|
<span className="text-sm font-medium">{t(feature.title)}</span>
|
|
</div>
|
|
<div className="text-subtle text-sm">
|
|
<p>
|
|
{t(
|
|
feature.description,
|
|
feature.i18nOptions && {
|
|
...feature.i18nOptions,
|
|
}
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Toaster position="bottom-right" />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|