Compare commits

...

84 Commits

Author SHA1 Message Date
supalarry 3e8470b8dd fix: password hint checks 2023-10-31 10:03:01 +01:00
Sean Brydon 3c40f4ea5b rename checkusername to be more fitting and provide more context 2023-10-26 11:12:34 +01:00
Sean Brydon 16d6cdc57e Transaction + comments 2023-10-26 11:11:28 +01:00
Sean Brydon ef4e4b2dcc i18n 2023-10-25 10:15:43 +01:00
Sean Brydon 93c662780b Update sm and md padding on form container 2023-10-25 10:01:14 +01:00
Sean Brydon fd030d3003 Fix lineheight 2023-10-25 09:54:08 +01:00
Sean Brydon 3230eaf7d5 add social proof for IS_CALCOM 2023-10-24 10:15:27 +01:00
sean-brydon 3d189c2852
Merge branch 'main' into feat/signup-refactor 2023-10-24 10:02:04 +01:00
Peer Richelsen 67e97eacb6
Merge branch 'main' into feat/signup-refactor 2023-10-23 18:44:00 +01:00
sean-brydon 5f5879cb47
Merge branch 'main' into feat/signup-refactor 2023-10-18 14:34:57 +01:00
Sean Brydon c6dc7dd715 Add toc 2023-10-18 14:33:09 +01:00
Sean Brydon e4a5bba18e fix hydration error 2023-10-18 14:28:18 +01:00
Sean Brydon 5d794efcae Fix i18n TOC and PP 2023-10-18 14:20:47 +01:00
Sean Brydon 366696e696 Fix imports 2023-10-18 14:20:19 +01:00
Sean Brydon b70838bed3 fix borked e2e 2023-10-16 14:49:01 +01:00
Sean Brydon de12a7a11a Merge remote-tracking branch 'origin/main' into feat/signup-refactor
# Conflicts:
#	apps/web/public/static/locales/en/common.json
2023-10-16 14:32:39 +01:00
Sean Brydon 69582a4da2 Fix yarn.lock 2023-10-16 14:12:27 +01:00
Sean Brydon 469133be5e use websiteURL to get location of signup 2023-10-16 14:11:07 +01:00
Sean Brydon b976fda08f Use css for svg border 2023-09-27 21:24:51 +01:00
Sean Brydon d6f18d79f8 Add prefill with query params 2023-09-27 18:25:32 +01:00
Sean Brydon a6bf5287bd Add prefill with query params 2023-09-27 18:15:02 +01:00
Sean Brydon 914161ab08 Fix transactional usage to use interactive handlers 2023-09-27 15:16:06 +01:00
Alex van Andel c30e867147 Use given locale input or user locale when translating updateProfile 2023-09-27 10:53:17 +01:00
sean-brydon b95c884ddc
Merge branch 'main' into feat/signup-refactor 2023-09-27 10:33:12 +01:00
Alex van Andel 1626d970c7 Refactor code to function primarily on CF 2023-09-27 02:19:16 +01:00
sean-brydon 50a25451f8
Merge branch 'main' into feat/signup-refactor 2023-09-26 11:55:26 +01:00
Sean Brydon a9d8cacbb5 Skip bool 2023-09-26 11:29:24 +01:00
Sean Brydon 6fb22146e4 Add an await 2023-09-26 11:20:39 +01:00
Sean Brydon 7c0f2cd71c Fix google icon 2023-09-26 11:05:16 +01:00
sean-brydon a30027583b
Merge branch 'main' into feat/signup-refactor 2023-09-26 10:55:33 +01:00
Sean Brydon c44cbe0781 Update copy and icons 2023-09-26 10:54:31 +01:00
Sean Brydon a45b5b3260 Use reddis to cache and fetch wordlist 2023-09-26 10:54:31 +01:00
sean-brydon 8b16e4de31
Merge branch 'main' into feat/signup-refactor 2023-09-25 09:30:02 +01:00
sean-brydon 0285e118e5
Merge branch 'main' into feat/signup-refactor 2023-09-22 08:58:50 +01:00
Sean Brydon 6005a1dcde Remove log 2023-09-22 08:56:57 +01:00
Sean Brydon c677ce03f6 Fix duplicate code and import from lib server 2023-09-22 08:56:57 +01:00
Sean Brydon ff8dc3cb0f Fix constants !! 2023-09-22 08:56:57 +01:00
Sean Brydon 8adce1b65f Remove premium self hosted comment 2023-09-22 08:56:57 +01:00
Alan a55f002490 avoid 500s on slugify 2023-09-21 22:55:37 -07:00
Alan 4e7b8965d1 add translation for saml 2023-09-21 22:24:36 -07:00
Alan aebdf20f36 Merge branch 'feat/signup-refactor' of github.com:calcom/cal.com into feat/signup-refactor 2023-09-21 22:06:49 -07:00
Alan 7b4f9ba537 Fix nit changes 2023-09-21 22:06:32 -07:00
alannnc 7509a36abb
Update apps/web/playwright/signup.e2e.ts 2023-09-21 21:25:35 -07:00
sean-brydon 885ea4b025
Update apps/web/pages/auth/login.tsx 2023-09-21 14:47:51 +01:00
Sean Brydon 981329e15d Icon spacing + 32px mt on features 2023-09-21 13:52:12 +01:00
Sean Brydon 1715af2056 Mt changes for features 2023-09-21 13:38:46 +01:00
Sean Brydon 0726594b31 Update login link 2023-09-21 11:26:52 +01:00
Sean Brydon 7e1b8b4b00 Unused 2023-09-21 11:04:19 +01:00
Sean Brydon eb9614ee97 Merge remote-tracking branch 'origin/main' into feat/signup-refactor
# Conflicts:
#	apps/web/pages/api/auth/signup.ts
#	apps/web/pages/signup.tsx
#	apps/web/public/static/locales/en/common.json
2023-09-21 11:04:08 +01:00
Sean Brydon 414d0d690c Features description 2023-09-21 10:57:10 +01:00
Sean Brydon 04f7bdb918 Fix signup jumping around 2023-09-21 10:38:49 +01:00
Sean Brydon 9d53103fe9 Premium username fixes 2023-09-20 17:02:24 +01:00
Sean Brydon f72ea54b52 Add features (WIP) 2023-09-20 10:40:32 +01:00
Sean Brydon 9e332063ad Mve to afterall 2023-09-19 16:56:07 +01:00
Sean Brydon 4392cb0a23 Fix token cleanup 2023-09-19 16:12:53 +01:00
Sean Brydon bb435ce114 Center Image 2023-09-19 15:46:19 +01:00
Sean Brydon 6f27fa8063 Signup with token test 2023-09-19 15:42:00 +01:00
Sean Brydon 35e4cc122e Normal constants 2023-09-19 15:25:27 +01:00
Sean Brydon 8e58538d4b Self hosted test - for premium username 2023-09-19 15:21:52 +01:00
Sean Brydon 44b60f9742 Tets + fixture changes 2023-09-19 14:34:28 +01:00
Sean Brydon 4dc3bb2ffb Revert "Change playwright to - playright/test"
This reverts commit fb14383021.
2023-09-19 09:51:00 +01:00
Sean Brydon fb14383021 Change playwright to - playright/test 2023-09-19 09:47:57 +01:00
Sean Brydon de2eb01924 Remove old page 2023-09-18 14:49:16 +01:00
Sean Brydon 6da98ecf95 Add inital username/email tests 2023-09-18 14:30:53 +01:00
Sean Brydon edbad6d89b Add signup api notice 2023-09-18 13:44:43 +01:00
Sean Brydon bd4ef38371 Fix positioning 2023-09-18 13:33:46 +01:00
Sean Brydon 2ffefcc97e Disable error on form state submitting 2023-09-18 12:07:04 +01:00
Sean Brydon 6a37445c51 Use product of the day - currentColor 2023-09-18 12:00:27 +01:00
Sean Brydon bc6af6ea3d Add premium username tag 2023-09-15 14:15:21 +01:00
Sean Brydon 260f0aaa20 Handle correct premium username if on IS_CALCOM 2023-09-15 11:06:20 +01:00
Sean Brydon eb04db9b26 Throw errors 2023-09-14 12:27:36 +01:00
Sean Brydon c1e63bcbda Throw error 2023-09-14 11:47:16 +01:00
Sean Brydon fc43e8544a Handle i18n for cal specific text 2023-09-14 11:04:14 +01:00
Sean Brydon dc277a0afa Add svgs + add in signup container 2023-09-14 09:54:13 +01:00
Sean Brydon f6a9e3e645 Signup inital work on UI 2023-09-07 10:30:56 +01:00
Sean Brydon de5df416df Move files about 2023-09-06 19:35:02 +01:00
Sean Brydon fa0d4dc084 Move files 2023-09-06 11:41:14 +01:00
Sean Brydon da7e599910 Sign up stripe redirect 2023-09-05 17:18:34 +01:00
Sean Brydon 72cc95a0d7 Skip if env has no price ID 2023-09-05 16:23:35 +01:00
Sean Brydon e58f945915 Return defaullt username status if not on calcom 2023-09-05 14:46:17 +01:00
Sean Brydon 699db2f649 self hosted instance working 2023-09-05 14:29:31 +01:00
Sean Brydon b5918c0317 Extract more utils 2023-09-05 14:10:08 +01:00
Sean Brydon 79e00728ee Move to new file 2023-09-05 11:56:17 +01:00
Sean Brydon 3bc48edea8 Merging api file [WIP] 2023-09-05 10:43:10 +01:00
24 changed files with 1627 additions and 335 deletions

View File

@ -1,218 +1,50 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
import { IS_CALCOM } from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { HttpError } from "@calcom/lib/http-error";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
function ensureReqIsPost(req: RequestWithUsernameStatus) {
if (req.method !== "POST") {
return res.status(405).end();
throw new HttpError({
statusCode: 405,
message: "Method not allowed",
});
}
}
function ensureSignupIsEnabled() {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
res.status(403).json({ message: "Signup is disabled" });
return;
}
const data = req.body;
const { email, password, language, token } = signupSchema.parse(data);
const username = slugify(data.username);
const userEmail = email.toLowerCase();
if (!username) {
res.status(422).json({ message: "Invalid username" });
return;
}
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
if (token) {
foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
select: {
id: true,
expires: true,
teamId: true,
},
});
if (!foundToken) {
return res.status(401).json({ message: "Invalid Token" });
}
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
return res.status(401).json({ message: "Token expired" });
}
if (foundToken?.teamId) {
const teamUserValidation = await validateUsernameInTeam(username, userEmail, foundToken?.teamId);
if (!teamUserValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
} else {
const userValidation = await validateUsername(username, userEmail);
if (!userValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
const hashedPassword = await hashPassword(password);
if (foundToken && foundToken?.teamId) {
const team = await prisma.team.findUnique({
where: {
id: foundToken.teamId,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
if (IS_CALCOM && (!teamMetadata?.isOrganization || !!team.parentId)) {
const checkUsername = await checkPremiumUsername(username);
if (checkUsername.premium) {
// This signup page is ONLY meant for team invites and local setup. Not for every day users.
// In singup redesign/refactor coming up @sean will tackle this to make them the same API/page instead of two.
return res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
throw new HttpError({
statusCode: 403,
message: "Signup is disabled",
});
}
}
// Identify the org id in an org context signup, either the invited team is an org
// or has a parentId, otherwise parentId will be null, making orgId null
const orgId = teamMetadata?.isOrganization ? team.id : team.parentId;
export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
// Use a try catch instead of returning res every time
try {
ensureReqIsPost(req);
ensureSignupIsEnabled();
const user = await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
organizationId: orgId,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
organizationId: orgId,
},
});
const membership = await prisma.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.id,
accepted: true,
role: MembershipRole.MEMBER,
},
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs and create a membership for the org itself
if (team.parentId) {
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
await prisma.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.parentId },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.parentId,
accepted: true,
role: MembershipRole.MEMBER,
},
});
// We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG
await prisma.membership.updateMany({
where: {
userId: user.id,
team: {
id: team.parentId,
},
accepted: false,
},
data: {
accepted: true,
},
});
// Join any other invites
await prisma.membership.updateMany({
where: {
userId: user.id,
team: {
parentId: team.parentId,
},
accepted: false,
},
data: {
accepted: true,
},
});
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
} else {
/**
* Im not sure its worth merging these two handlers. They are different enough to be separate.
* Calcom handles things like creating a stripe customer - which we don't need to do for self hosted.
* It also handles things like premium username.
* TODO: (SEAN) - Extract a lot of the logic from calcomHandler into a separate file and import it into both handlers.
*/
if (IS_CALCOM) {
const checkUsername = await checkPremiumUsername(username);
if (checkUsername.premium) {
res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
});
return;
}
}
await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
},
});
await sendEmailVerification({
email: userEmail,
username,
language,
});
return await calcomSignupHandler(req, res);
}
res.status(201).json({ message: "Created user" });
return await selfHostedSignupHandler(req, res);
} catch (e) {
if (e instanceof HttpError) {
return res.status(e.statusCode).json({ message: e.message });
}
return res.status(500).json({ message: "Internal server error" });
}
}

View File

@ -97,9 +97,9 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
callbackUrl = safeCallbackUrl || "";
const LoginFooter = (
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("dont_have_an_account")}
</a>
</Link>
);
const TwoFactorFooter = (

View File

@ -1,25 +1,33 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CalendarHeart, Info, Link2, ShieldCheckIcon, StarIcon, Users } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form";
import { z } from "zod";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import { getOrgFullDomain } from "@calcom/ee/organizations/lib/orgDomains";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { classNames } from "@calcom/lib";
import { APP_NAME, IS_CALCOM, IS_SELF_HOSTED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
import { Button, HeadSeo, PasswordField, TextField, Form, Alert } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@ -34,44 +42,138 @@ type FormValues = z.infer<typeof signupSchema>;
type SignupProps = inferSSRProps<typeof getServerSideProps>;
const FEATURES = [
{
title: "connect_all_calendars",
description: "connect_all_calendars_description",
i18nOptions: {
appName: APP_NAME,
},
icon: CalendarHeart,
},
{
title: "set_availability",
description: "set_availbility_description",
icon: Users,
},
{
title: "share_a_link_or_embed",
description: "share_a_link_or_embed_description",
icon: Link2,
i18nOptions: {
appName: APP_NAME,
},
},
];
function UsernameField({
username,
setPremium,
premium,
setUsernameTaken,
usernameTaken,
...props
}: React.ComponentProps<typeof TextField> & {
username: string;
setPremium: (value: boolean) => void;
premium: boolean;
usernameTaken: boolean;
setUsernameTaken: (value: boolean) => void;
}) {
const { t } = useLocale();
const { register, formState } = useFormContext<FormValues>();
const debouncedUsername = useDebounce(username, 600);
useEffect(() => {
async function checkUsername() {
if (!debouncedUsername) {
setPremium(false);
setUsernameTaken(false);
return;
}
fetchUsername(debouncedUsername).then(({ data }) => {
setPremium(data.premium);
setUsernameTaken(!data.available);
});
}
checkUsername();
}, [debouncedUsername, setPremium, setUsernameTaken]);
return (
<div>
<TextField
{...props}
{...register("username")}
data-testid="signup-usernamefield"
addOnFilled={false}
/>
{!formState.isSubmitting && (
<div className="text-gray text-default flex items-center text-sm">
<p className="flex items-center text-sm ">
{usernameTaken ? (
<div className="text-error">
<Info className="mr-1 inline-block h-4 w-4" />
{t("already_in_use_error")}
</div>
) : premium ? (
<div data-testid="premium-username-warning">
<StarIcon className="mr-1 inline-block h-4 w-4" />
{t("premium_username", {
price: getPremiumPlanPriceValue(),
})}
</div>
) : null}
</p>
</div>
)}
</div>
);
}
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
return username;
};
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, orgAutoAcceptEmail }: SignupProps) {
export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) {
const [premiumUsername, setPremiumUsername] = useState(false);
const [usernameTaken, setUsernameTaken] = useState(false);
const searchParams = useSearchParams();
const telemetry = useTelemetry();
const { t, i18n } = useLocale();
const router = useRouter();
const flags = useFlagMap();
const methods = useForm<FormValues>({
mode: "onChange",
const formMethods = useForm<FormValues>({
resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues,
defaultValues: prepopulateFormValues satisfies FormValues,
mode: "onChange",
});
const {
register,
formState: { errors, isSubmitting },
} = methods;
watch,
formState: { isSubmitting, errors },
} = formMethods;
const handleErrors = async (resp: Response) => {
const handleErrorsAndStripe = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
if (err.checkoutSessionId) {
const stripe = await getStripe();
if (stripe) {
console.log("Redirecting to stripe checkout");
const { error } = await stripe.redirectToCheckout({
sessionId: err.checkoutSessionId,
});
console.warn(error.message);
}
} else {
throw new Error(err.message);
}
}
};
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
@ -88,7 +190,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
},
method: "POST",
})
.then(handleErrors)
.then(handleErrorsAndStripe)
.then(async () => {
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
@ -106,14 +208,13 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
});
})
.catch((err) => {
methods.setError("apiError", { message: err.message });
formMethods.setError("apiError", { message: err.message });
});
};
return (
<>
<div
className="bg-muted flex min-h-screen flex-col justify-center "
className="light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center"
style={
{
"--cal-brand": "#111827",
@ -121,94 +222,226 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
}>
<div className="bg-muted 2xl:border-subtle grid max-h-[800px] w-full max-w-[1440px] grid-cols-1 grid-rows-1 lg:grid-cols-2 2xl:rounded-lg 2xl:border ">
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="font-cal text-emphasis text-center text-3xl font-extrabold">
{t("create_your_account")}
</h2>
<div className="flex w-full flex-col px-4 py-6 sm:px-16 md:px-24 2xl:px-28">
{/* Header */}
{errors.apiError && (
<Alert severity="error" message={errors.apiError?.message} data-testid="signup-error-message" />
)}
<div className="flex flex-col gap-1">
<h1 className="font-cal text-[28px] ">
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
</h1>
{IS_CALCOM ? (
<p className="text-subtle text-base font-medium leading-6">{t("cal_signup_description")}</p>
) : (
<p className="text-subtle text-base font-medium leading-6">
{t("calcom_explained", {
appName: APP_NAME,
})}
</p>
)}
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-default mx-2 p-6 shadow sm:rounded-lg lg:p-8">
<FormProvider {...methods}>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
if (methods.formState?.errors?.apiError) {
methods.clearErrors("apiError");
}
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
methods.setValue(
"username",
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
);
}
methods.handleSubmit(signUp)(event);
}}
className="bg-default space-y-6">
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
{}
<div className="space-y-4">
{!isOrgInviteByLink && (
<TextField
{/* Form Container */}
<div className="mt-10">
<Form
className="flex flex-col gap-4"
form={formMethods}
handleSubmit={async (values) => {
await signUp(values);
}}>
{/* Username */}
<UsernameField
label={t("username")}
username={watch("username")}
premium={premiumUsername}
usernameTaken={usernameTaken}
setUsernameTaken={(value) => setUsernameTaken(value)}
data-testid="signup-usernamefield"
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
? getOrgFullDomain(orgSlug, { protocol: true })
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}
disabled={!!orgSlug}
required
/>
)}
<EmailField
{/* Email */}
<TextField
{...register("email")}
disabled={prepopulateFormValues?.email}
className="disabled:bg-emphasis disabled:hover:cursor-not-allowed"
label={t("email")}
type="email"
data-testid="signup-emailfield"
/>
{/* Password */}
<PasswordField
labelProps={{
className: "block text-sm font-medium text-default",
}}
data-testid="signup-passwordfield"
label={t("password")}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
className="border-default mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button type="submit" loading={isSubmitting} className="w-full justify-center">
{t("create_account")}
<Button
type="submit"
className="my-2 w-full justify-center"
loading={isSubmitting}
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
usernameTaken
}>
{premiumUsername && !usernameTaken
? `Create Account for ${getPremiumPlanPriceValue()}`
: t("create_account")}
</Button>
</Form>
{/* Continue with Social Logins */}
<div className="mt-6">
<div className="relative flex items-center">
<div className="border-subtle flex-grow border-t" />
<span className="text-subtle leadning-none mx-2 flex-shrink text-sm font-normal ">
{t("or_continue_with")}
</span>
<div className="border-subtle flex-grow border-t" />
</div>
</div>
{/* Social Logins */}
{!token && (
<div className="mt-6 grid gap-2 md:grid-cols-2">
<Button
color="secondary"
className="w-full justify-center"
onClick={() =>
signIn("Cal.com", {
callbackUrl: searchParams?.get("callbackUrl")
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
: `${WEBAPP_URL}/getting-started`,
})
}>
{t("login_instead")}
disabled={!!formMethods.formState.errors.username}
className={classNames(
"w-full justify-center rounded-md text-center",
formMethods.formState.errors.username ? "opacity-50" : ""
)}
onClick={async () => {
if (!formMethods.getValues("username")) {
formMethods.trigger("username");
return;
}
const username = formMethods.getValues("username");
const searchQueryParams = new URLSearchParams();
searchQueryParams.set("username", formMethods.getValues("username"));
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
localStorage.setItem("username", username);
// @NOTE: don't remove username query param as it's required right now for stripe payment page
const googleAuthUrl = `${baseUrl}/auth/sso/google?${searchQueryParams.toString()}`;
router.push(googleAuthUrl);
}}>
<img className="text-emphasis mr-2 h-5 w-5" src="/google-icon.svg" alt="" />
Google
</Button>
<Button
color="secondary"
disabled={!!formMethods.formState.errors.username || !!formMethods.formState.errors.email}
className={classNames(
"w-full justify-center rounded-md text-center",
formMethods.formState.errors.username && formMethods.formState.errors.email
? "opacity-50"
: ""
)}
onClick={() => {
if (!formMethods.getValues("username")) {
formMethods.trigger("username");
}
if (!formMethods.getValues("email")) {
formMethods.trigger("email");
return;
}
const username = formMethods.getValues("username");
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", formMethods.getValues("username"));
sp.set("email", formMethods.getValues("email"));
router.push(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`);
}}>
<ShieldCheckIcon className="mr-2 h-5 w-5" />
{t("saml_sso")}
</Button>
</div>
)}
</div>
</form>
</FormProvider>
{/* Already have an account & T&C */}
<div className="mt-6">
<div className="flex flex-col text-sm">
<Link href="/auth/login" className="text-emphasis hover:underline">
{t("already_have_account")}
</Link>
<div className="text-subtle">
By signing up, you agree to our{" "}
<Link className="text-emphasis hover:underline" href={`${WEBSITE_URL}/terms`}>
Terms of Service{" "}
</Link>
<span>and</span>{" "}
<Link className="text-emphasis hover:underline" href={`${WEBSITE_URL}/privacy`}>
Privacy Policy.
</Link>
</div>
</div>
</div>
</div>
<div className="bg-subtle border-subtle hidden w-full flex-col justify-between rounded-l-2xl py-12 pl-12 lg:flex">
{IS_CALCOM && (
<div className="mb-12 mr-12 grid h-full w-full grid-cols-4 gap-4 ">
<div className="">
<img src="/product-cards/trustpilot.svg" className="h-[54px] w-full" alt="#" />
</div>
<div>
<img src="/product-cards/g2.svg" className="h-[54px] w-full" alt="#" />
</div>
<div>
<img src="/product-cards/producthunt.svg" className="h-[54px] w-full" alt="#" />
</div>
</div>
)}
<div
className="rounded-2xl border-y border-l border-dashed border-[#D1D5DB5A] py-[6px] pl-[6px]"
style={{
backgroundColor: "rgba(236,237,239,0.9)",
}}>
<img src="/mock-event-type-list.svg" alt="#" className="" />
</div>
<div className="mr-12 mt-8 grid h-full w-full grid-cols-3 gap-4 overflow-hidden">
{!IS_CALCOM &&
FEATURES.map((feature) => (
<>
<div className="flex flex-col leading-none">
<div className="text-emphasis items-center">
<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>
</div>
);
}
const querySchema = z.object({
username: z
.string()
.optional()
.transform((val) => val || ""),
email: z.string().email().optional(),
});
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma);
@ -222,6 +455,9 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
prepopulateFormValues: undefined,
};
// username + email prepopulated from query params
const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query);
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || flags["disable-signup"]) {
return {
notFound: true,
@ -231,7 +467,15 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
// no token given, treat as a normal signup without verification token
if (!token) {
return {
props: JSON.parse(JSON.stringify(props)),
props: JSON.parse(
JSON.stringify({
...props,
prepopulateFormValues: {
username: preFillusername || null,
email: prefilEmail || null,
},
})
),
};
}

View File

@ -370,6 +370,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
await prisma.user.delete({ where: { id } });
store.users = store.users.filter((b) => b.id !== id);
},
deleteByEmail: async (email: string) => {
// Use deleteMany instead of delete to avoid the findUniqueOrThrow error that happens before the delete
await prisma.user.deleteMany({
where: {
email,
},
});
store.users = store.users.filter((b) => b.email !== email);
},
};
};

View File

@ -0,0 +1,207 @@
import { expect } from "@playwright/test";
import { randomBytes } from "crypto";
import { IS_CALCOM } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
const SKIP_PREMIUM_USERNAME = !!IS_CALCOM;
test.describe("Signup Flow Test", async () => {
test.afterAll(async ({ users }) => {
await users.deleteAll();
/**
* Delete specifically created users for this test
* as they are not deleted by the above fixture
* (As they are not created by the fixture)
*/
await users.deleteByEmail("rick@example.com");
await users.deleteByEmail("rick-jones@example.com");
// Clean up the user and token
await prisma.user.deleteMany({
where: {
email: "rick-team@example.com",
},
});
await prisma.verificationToken.deleteMany({
where: {
identifier: "rick-team@example.com",
},
});
await prisma.team.deleteMany({
where: {
name: "Rick's Team",
},
});
});
test("Username is taken", async ({ page, users }) => {
// log in trail user
await test.step("Sign up", async () => {
await users.create({
username: "pro",
});
await page.goto("/signup");
const alertMessage = "Username or email is already taken";
// Fill form
await page.locator('input[name="username"]').fill("pro");
await page.locator('input[name="email"]').fill("pro@example.com");
await page.locator('input[name="password"]').fill("Password99!");
// Submit form
await page.click('button[type="submit"]');
const alert = await page.waitForSelector('[data-testid="alert"]');
const alertMessageInner = await alert.innerText();
expect(alertMessage).toBeDefined();
expect(alertMessageInner).toContain(alertMessageInner);
});
});
test("Email is taken", async ({ page, users }) => {
// log in trail user
await test.step("Sign up", async () => {
await users.create({
username: "pro",
});
await page.goto("/signup");
const alertMessage = "Username or email is already taken";
// Fill form
await page.locator('input[name="username"]').fill("randomuserwhodoesntexist");
await page.locator('input[name="email"]').fill("pro@example.com");
await page.locator('input[name="password"]').fill("Password99!");
// Submit form
await page.click('button[type="submit"]');
const alert = await page.waitForSelector('[data-testid="alert"]');
const alertMessageInner = await alert.innerText();
expect(alertMessage).toBeDefined();
expect(alertMessageInner).toContain(alertMessageInner);
});
});
test("Premium Username Flow - creates stripe checkout", async ({ page, users }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!SKIP_PREMIUM_USERNAME, "Only run on Cal.com");
// Signup with premium username name
await page.goto("/signup");
// Fill form
await page.locator('input[name="username"]').fill("rick");
await page.locator('input[name="email"]').fill("rick@example.com");
await page.locator('input[name="password"]').fill("Password99!");
await page.click('button[type="submit"]');
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com"; // Adjust the expected URL
await page.waitForURL((url) => url.pathname.includes(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
// TODO: complete the stripe checkout flow
});
test("Premium Username Flow - SelfHosted", async ({ page, users }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(SKIP_PREMIUM_USERNAME, "Only run on Selfhosted Instances");
// Signup with premium username name
await page.goto("/signup");
// Fill form
await page.locator('input[name="username"]').fill("rick");
await page.locator('input[name="email"]').fill("rick@example.com");
await page.locator('input[name="password"]').fill("Password99!");
await page.click('button[type="submit"]');
await page.waitForURL((url) => url.pathname.includes("/auth/verify-email"));
expect(page.url()).toContain("/auth/verify-email");
});
test("Signup with valid (non premium) username", async ({ page, users }) => {
await page.goto("/signup");
// Fill form
await page.locator('input[name="username"]').fill("rick-jones");
await page.locator('input[name="email"]').fill("rick-jones@example.com");
await page.locator('input[name="password"]').fill("Password99!");
await page.click('button[type="submit"]');
await page.waitForURL((url) => url.pathname.includes("/auth/verify-email"));
// Check that the URL matches the expected URL
expect(page.url()).toContain("/auth/verify-email");
});
test("Signup fields prefilled with query params", async ({ page, users }) => {
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";
await page.goto(signupUrlWithParams);
// Fill form
const usernameInput = await page.locator('input[name="username"]');
const emailInput = await page.locator('input[name="email"]');
expect(await usernameInput.inputValue()).toBe("rick-jones");
expect(await emailInput.inputValue()).toBe("rick-jones@example.com");
});
test("Signup with token prefils correct fields", async ({ page, users, prisma }) => {
//Create a user and create a token
const token = randomBytes(32).toString("hex");
// @shivram we can probably create a fixture for this logic.
const createdtoken = await prisma.verificationToken.create({
data: {
identifier: "rick-team@example.com",
token,
expires: new Date(new Date().setHours(168)), // +1 week
team: {
create: {
name: "Rick's Team",
slug: "ricks-team",
},
},
},
});
// create a user with the same email as the token
const rickTeamUser = await prisma.user.create({
data: {
email: "rick-team@example.com",
username: "rick-team",
},
});
// Create provitional membership
await prisma.membership.create({
data: {
teamId: createdtoken.teamId ?? -1,
userId: rickTeamUser.id,
role: "ADMIN",
accepted: false,
},
});
const signupUrlWithToken = `/signup?token=${token}`;
await page.goto(signupUrlWithToken);
const usernameField = page.locator('input[name="username"]');
const emailField = page.locator('input[name="email"]');
expect(await usernameField.inputValue()).toBe("rick-team");
expect(await emailField.inputValue()).toBe("rick-team@example.com");
});
});

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31877 15.36C4.26002 15.36 0.95752 12.0588 0.95752 8.00001C0.95752 3.94126 4.26002 0.640015 8.31877 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.25252 4.07376 8.31877 4.07376C6.15377 4.07376 4.39127 5.83501 4.39127 8.00001C4.39127 10.165 6.15377 11.9263 8.31877 11.9263C9.88002 11.9263 11.1138 11.1288 11.695 9.77001H7.99877V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31877 15.36Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 347 KiB

View File

@ -0,0 +1,17 @@
<svg width="115" height="54" viewBox="0 0 115 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12158_115893)">
<path d="M8.95263 15.0632L3.41053 17.0526L3.55263 11.1553L0 6.53684L5.61316 4.83158L8.95263 0L12.2921 4.83158L17.9053 6.53684L14.3526 11.1553L14.4947 17.0526L8.95263 15.0632Z" fill="#FF492C"/>
<path d="M32.9684 15.0632L27.4263 17.0526L27.6394 11.1553L24.0157 6.53684L29.6289 4.83158L32.9684 0L36.3079 4.83158L41.921 6.53684L38.3684 11.1553L38.5105 17.0526L32.9684 15.0632Z" fill="#FF492C"/>
<path d="M56.9842 15.0632L51.4421 17.0526L51.6553 11.1553L48.0316 6.53684L53.7158 4.83158L56.9842 0L60.3237 4.83158L65.9369 6.53684L62.3843 11.1553L62.5264 17.0526L56.9842 15.0632Z" fill="#FF492C"/>
<path d="M81 15.0632L75.4579 17.0526L75.671 11.1553L72.0474 6.53684L77.7316 4.83158L81 0L84.3395 4.83158L90.0237 6.53684L86.4 11.1553L86.5421 17.0526L81 15.0632Z" fill="#FF492C"/>
<path d="M105.016 0L101.747 4.83158L96.0631 6.53684L99.6868 11.1553L99.4736 17.0526L105.016 15.0632V0Z" fill="#FF492C"/>
<path d="M110.416 11.1553L114.039 6.53684L108.355 4.83158L105.016 0V15.0632L110.558 17.0526L110.416 11.1553Z" fill="white"/>
<path d="M28.4682 40.2593C28.4682 47.4831 22.6148 53.3365 15.391 53.3365C8.1672 53.3365 2.31384 47.4831 2.31384 40.2593C2.31384 33.0355 8.1672 27.1821 15.391 27.1821C22.6148 27.1821 28.4682 33.0407 28.4682 40.2593Z" fill="#FF492C"/>
<path d="M21.0507 38.1252H17.6716V37.9683C17.6716 37.3929 17.7866 36.9168 18.0168 36.5454C18.247 36.1689 18.6445 35.8393 19.2199 35.5464L19.4815 35.4156C19.947 35.1802 20.0673 34.9762 20.0673 34.7356C20.0673 34.4479 19.8163 34.2386 19.4135 34.2386C18.9322 34.2386 18.5713 34.4897 18.3202 34.9971L17.6716 34.3485C17.8128 34.0451 18.043 33.8045 18.3464 33.611C18.655 33.4174 18.995 33.3233 19.3664 33.3233C19.832 33.3233 20.2347 33.4436 20.5642 33.6947C20.9043 33.9458 21.0716 34.2909 21.0716 34.7251C21.0716 35.4208 20.6793 35.8445 19.947 36.2212L19.5338 36.4304C19.0944 36.6501 18.8799 36.8488 18.8171 37.1993H21.0507V38.1252ZM20.7526 39.1923H17.0544L15.2079 42.3936H18.9061L20.7578 45.5948L22.6043 42.3936L20.7526 39.1923ZM15.527 44.533C13.173 44.533 11.2585 42.6185 11.2585 40.2646C11.2585 37.9107 13.173 35.9962 15.527 35.9962L16.9916 32.9362C16.5156 32.842 16.0291 32.7949 15.527 32.7949C11.3998 32.7949 8.052 36.1427 8.052 40.2646C8.052 44.3918 11.3945 47.7396 15.527 47.7396C17.1694 47.7396 18.6916 47.206 19.9261 46.3063L18.3045 43.5026C17.5618 44.1407 16.5888 44.533 15.527 44.533Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_12158_115893">
<rect width="114.395" height="54" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -0,0 +1,9 @@
<svg width="161" height="75" viewBox="0 0 161 75" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6 21.2L4.8 24L5 15.7L0 9.2L7.9 6.8L12.6 0L17.3 6.8L25.2 9.2L20.2 15.7L20.4 24L12.6 21.2Z" fill="#E9A944"/>
<path d="M46.4 21.2L38.6 24L38.9 15.7L33.8 9.2L41.7 6.8L46.4 0L51.1 6.8L59 9.2L54 15.7L54.2 24L46.4 21.2Z" fill="#E9A944"/>
<path d="M80.2 21.2L72.4 24L72.7 15.7L67.6 9.2L75.6 6.8L80.2 0L84.9 6.8L92.8 9.2L87.8 15.7L88 24L80.2 21.2Z" fill="#E9A944"/>
<path d="M114 21.2L106.2 24L106.5 15.7L101.4 9.2L109.4 6.8L114 0L118.7 6.8L126.7 9.2L121.6 15.7L121.8 24L114 21.2Z" fill="#E9A944"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 56.5C40 66.7175 31.7175 75 21.5 75C11.2825 75 3 66.7175 3 56.5C3 46.2825 11.2825 38 21.5 38C31.7175 38 40 46.2825 40 56.5Z" fill="#FF6154"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.967 56.5H18.725V50.95H23.967C24.703 50.95 25.4088 51.2424 25.9292 51.7628C26.4496 52.2832 26.742 52.989 26.742 53.725C26.742 54.461 26.4496 55.1668 25.9292 55.6872C25.4088 56.2076 24.703 56.5 23.967 56.5ZM23.967 47.25H15.025V65.75H18.725V60.2H23.967C25.6843 60.2 27.3312 59.5178 28.5455 58.3035C29.7598 57.0892 30.442 55.4423 30.442 53.725C30.442 52.0077 29.7598 50.3608 28.5455 49.1465C27.3312 47.9322 25.6843 47.25 23.967 47.25Z" fill="white"/>
<path d="M147.6 21.2L139.8 24L140.1 15.7L135 9.2L143 6.8L147.6 0L152.3 6.8L160.3 9.2L155.2 15.7L155.4 24L147.6 21.2Z" fill="#E9A944"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@ -67,6 +67,7 @@
"cannot_repackage_codebase": "You can not repackage or sell the codebase",
"acquire_license": "Acquire a commercial license to remove these terms by emailing",
"terms_summary": "Summary of terms",
"signing_up_terms":"By signing up, you agree to our <2>Terms of Service</2> and <3>Privacy Policy</3>.",
"open_env": "Open .env and agree to our License",
"env_changed": "I've changed my .env",
"accept_license": "Accept License",
@ -229,6 +230,7 @@
"reset_your_password": "Set your new password with the instructions sent to your email address.",
"email_change": "Log back in with your new email address and password.",
"create_your_account": "Create your account",
"create_your_calcom_account": "Create your Cal.com account",
"sign_up": "Sign up",
"youve_been_logged_out": "You've been logged out",
"hope_to_see_you_soon": "We hope to see you again soon!",
@ -266,6 +268,9 @@
"nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who theyre booking with.",
"set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.",
"set_availability": "Set your availability",
"set_availbility_description":"Set schedules for the times you want to be booked.",
"share_a_link_or_embed":"Share a link or embed",
"share_a_link_or_embed_description":"Share your {{appName}} link or embed on your site.",
"availability_settings": "Availability Settings",
"continue_without_calendar": "Continue without calendar",
"continue_with": "Continue with {{appName}}",
@ -1038,6 +1043,7 @@
"user_impersonation_heading": "User Impersonation",
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
"team_impersonation_description": "Allows your team Owners/Admins to temporarily sign in as you.",
"cal_signup_description":"Free for individuals. Team plans for collaborative features.",
"make_team_private": "Make team private",
"make_team_private_description": "Your team members won't be able to see other team members when this is turned on.",
"you_cannot_see_team_members": "You cannot see all the team members of a private team.",
@ -1151,6 +1157,7 @@
"active_on": "Active on",
"workflow_updated_successfully": "{{workflowName}} workflow updated successfully",
"premium_to_standard_username_description": "This is a standard username and updating will take you to billing to downgrade.",
"premium_username": "This is a premium username, get yours for {{price}}",
"current": "Current",
"premium": "premium",
"standard": "standard",
@ -1530,8 +1537,10 @@
"your_org_disbanded_successfully": "Your organization has been disbanded successfully",
"error_creating_team": "Error creating team",
"you": "You",
"or_continue_with": "Or continue with",
"resend_email": "Resend email",
"member_already_invited": "Member has already been invited",
"already_in_use_error": "Username already in use",
"enter_email_or_username": "Enter an email or username",
"team_name_taken": "This name is already taken",
"must_enter_team_name": "Must enter a team name",
@ -1585,6 +1594,7 @@
"enable_apps": "Enable Apps",
"enable_apps_description": "Enable apps that users can integrate with {{appName}}",
"purchase_license": "Purchase a License",
"already_have_account":"I 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.",
"app_is_enabled": "{{appName}} is enabled",
@ -2062,6 +2072,12 @@
"include_calendar_event": "Include calendar event",
"oAuth": "OAuth",
"recently_added":"Recently added",
"connect_all_calendars":"Connect all your calendars",
"connect_all_calendars_description":"{{appName}} reads availability from all your existing calendars.",
"workflow_automation":"Workflow automation",
"workflow_automation_description":"Personalise your scheduling experience with workflows",
"scheduling_for_your_team":"Workflow automation",
"scheduling_for_your_team_description":"Schedule for your team with collective and round-robin scheduling",
"no_members_found": "No members found",
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
"availability_schedules":"Availability Schedules",
@ -2085,6 +2101,7 @@
"edit_users_availability":"Edit user's availability: {{username}}",
"resend_invitation": "Resend invitation",
"invitation_resent": "The invitation was resent.",
"saml_sso": "SAML",
"add_client": "Add client",
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
"add_new_client": "Add new Client",

View File

@ -0,0 +1,202 @@
import type { NextApiResponse } from "next";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { HttpError } from "@calcom/lib/http-error";
import { usernameHandler, type RequestWithUsernameStatus } from "@calcom/lib/server/username";
import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsername } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token";
async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
const {
email: _email,
password,
token,
} = signupSchema
.pick({
email: true,
password: true,
token: true,
})
.parse(req.body);
let username: string | null = req.usernameStatus.requestedUserName;
let checkoutSessionId: string | null = null;
// Check for premium username
if (req.usernameStatus.statusCode === 418) {
return res.status(req.usernameStatus.statusCode).json(req.usernameStatus.json);
}
// Validate the user
if (!username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
const email = _email.toLowerCase();
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
validateUsernameForTeam({ username, email, teamId: foundToken?.teamId ?? null });
} else {
const usernameAndEmailValidation = await validateUsername(username, email);
if (!usernameAndEmailValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
}
// Create the customer in Stripe
const customer = await stripe.customers.create({
email,
metadata: {
email /* Stripe customer email can be changed, so we add this to keep track of which email was used to signup */,
username,
},
});
const returnUrl = `${WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=/auth/verify?sessionId={CHECKOUT_SESSION_ID}`;
// Pro username, must be purchased
if (req.usernameStatus.statusCode === 402) {
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer: customer.id,
line_items: [
{
price: getPremiumMonthlyPlanPriceId(),
quantity: 1,
},
],
success_url: returnUrl,
cancel_url: returnUrl,
allow_promotion_codes: true,
});
/** We create a username-less user until he pays */
checkoutSessionId = checkoutSession.id;
username = null;
}
// Hash the password
const hashedPassword = await hashPassword(password);
if (foundToken && foundToken?.teamId) {
const team = await prisma.team.findUnique({
where: {
id: foundToken.teamId,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
const user = await prisma.user.upsert({
where: { email },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
email,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
},
});
// Wrapping in a transaction as if one fails we want to rollback the whole thing to preventa any data inconsistencies
const membership = await prisma.$transaction(async (tx) => {
if (teamMetadata?.isOrganization) {
await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await tx.membership.update({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
data: {
accepted: true,
},
});
return membership;
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parentId) {
await joinAnyChildTeamOnOrgInvite({
userId: user.id,
orgId: team.parentId,
});
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
} else {
// Create the user
const user = await prisma.user.create({
data: {
username,
email,
password: hashedPassword,
metadata: {
stripeCustomerId: customer.id,
checkoutSessionId,
},
},
});
sendEmailVerification({
email,
language: await getLocaleFromRequest(req),
username: username || "",
});
// Sync Services
await syncServicesCreateWebUser(user);
}
if (checkoutSessionId) {
console.log("Created user but missing payment", checkoutSessionId);
return res.status(402).json({
message: "Created user but missing payment",
checkoutSessionId,
});
}
return res.status(201).json({ message: "Created user", stripeCustomerId: customer.id });
}
export default usernameHandler(handler);

View File

@ -0,0 +1,142 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { IS_CALCOM } from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsername } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const data = req.body;
const { email, password, language, token } = signupSchema.parse(data);
const username = slugify(data.username);
const userEmail = email.toLowerCase();
if (!username) {
res.status(422).json({ message: "Invalid username" });
return;
}
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
validateUsernameForTeam({ username, email: userEmail, teamId: foundToken?.teamId });
} else {
const userValidation = await validateUsername(username, userEmail);
if (!userValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
const hashedPassword = await hashPassword(password);
if (foundToken && foundToken?.teamId) {
const team = await prisma.team.findUnique({
where: {
id: foundToken.teamId,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
const user = await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
},
});
const membership = await prisma.$transaction(async (tx) => {
if (teamMetadata?.isOrganization) {
await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await tx.membership.update({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
data: {
accepted: true,
},
});
return membership;
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parentId) {
await joinAnyChildTeamOnOrgInvite({
userId: user.id,
orgId: team.parentId,
});
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
} else {
if (IS_CALCOM) {
const checkUsername = await checkPremiumUsername(username);
if (checkUsername.premium) {
res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
});
return;
}
}
await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
},
});
await sendEmailVerification({
email: userEmail,
username,
language,
});
}
res.status(201).json({ message: "Created user" });
}

View File

@ -0,0 +1,124 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import notEmpty from "@calcom/lib/notEmpty";
import { isPremiumUserName, generateUsernameSuggestion } from "@calcom/lib/server/username";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
export type RequestWithUsernameStatus = NextApiRequest & {
usernameStatus: {
/**
* ```text
* 200: Username is available
* 402: Pro username, must be purchased
* 418: A user exists with that username
* ```
*/
statusCode: 200 | 402 | 418;
requestedUserName: string;
json: {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
};
};
};
export const usernameStatusSchema = z.object({
statusCode: z.union([z.literal(200), z.literal(402), z.literal(418)]),
requestedUserName: z.string(),
json: z.object({
available: z.boolean(),
premium: z.boolean(),
message: z.string().optional(),
suggestion: z.string().optional(),
}),
});
type CustomNextApiHandler<T = unknown> = (
req: RequestWithUsernameStatus,
res: NextApiResponse<T>
) => void | Promise<void>;
const usernameHandler =
(handler: CustomNextApiHandler) =>
async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise<void> => {
const username = slugify(req.body.username);
const check = await usernameCheck(username);
req.usernameStatus = {
statusCode: 200,
requestedUserName: username,
json: {
available: true,
premium: false,
message: "Username is available",
},
};
if (check.premium) {
req.usernameStatus.statusCode = 402;
req.usernameStatus.json.premium = true;
req.usernameStatus.json.message = "This is a premium username.";
}
if (!check.available) {
req.usernameStatus.statusCode = 418;
req.usernameStatus.json.available = false;
req.usernameStatus.json.message = "A user exists with that username";
}
req.usernameStatus.json.suggestion = check.suggestedUsername;
return handler(req, res);
};
const usernameCheck = async (usernameRaw: string) => {
const response = {
available: true,
premium: false,
suggestedUsername: "",
};
const username = slugify(usernameRaw);
const user = await prisma.user.findFirst({
where: { username, organizationId: null },
select: {
username: true,
},
});
if (user) {
response.available = false;
}
if (await isPremiumUserName(username)) {
response.premium = true;
}
// get list of similar usernames in the db
const users = await prisma.user.findMany({
where: {
username: {
contains: username,
},
},
select: {
username: true,
},
});
// We only need suggestedUsername if the username is not available
if (!response.available) {
response.suggestedUsername = await generateUsernameSuggestion(
users.map((user) => user.username).filter(notEmpty),
username
);
}
return response;
};
export { usernameHandler, usernameCheck };

View File

@ -0,0 +1,55 @@
import prisma from "@calcom/prisma";
export async function joinOrganization({
organizationId,
userId,
}: {
userId: number;
organizationId: number;
}) {
return await prisma.user.update({
where: {
id: userId,
},
data: {
organizationId: organizationId,
},
});
}
export async function joinAnyChildTeamOnOrgInvite({ userId, orgId }: { userId: number; orgId: number }) {
await prisma.$transaction([
prisma.user.update({
where: {
id: userId,
},
data: {
organizationId: orgId,
},
}),
prisma.membership.updateMany({
where: {
userId,
team: {
id: orgId,
},
accepted: false,
},
data: {
accepted: true,
},
}),
prisma.membership.updateMany({
where: {
userId,
team: {
parentId: orgId,
},
accepted: false,
},
data: {
accepted: true,
},
}),
]);
}

View File

@ -0,0 +1,55 @@
import dayjs from "@calcom/dayjs";
import { HttpError } from "@calcom/lib/http-error";
import { validateUsernameInTeam } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
export async function findTokenByToken({ token }: { token: string }) {
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
select: {
id: true,
expires: true,
teamId: true,
},
});
if (!foundToken) {
throw new HttpError({
statusCode: 401,
message: "Invalid Token",
});
}
return foundToken;
}
export function throwIfTokenExpired(expires?: Date) {
if (!expires) return;
if (dayjs(expires).isBefore(dayjs())) {
throw new HttpError({
statusCode: 401,
message: "Token expired",
});
}
}
export async function validateUsernameForTeam({
username,
email,
teamId,
}: {
username: string;
email: string;
teamId: number | null;
}) {
if (!teamId) return;
const teamUserValidation = await validateUsernameInTeam(username, email, teamId);
if (!teamUserValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
}

View File

@ -1,6 +1,6 @@
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { IS_CALCOM } from "@calcom/lib/constants";
import { checkRegularUsername } from "./checkRegularUsername";
import { usernameCheck as checkPremiumUsername } from "./username";
export const checkUsername = IS_SELF_HOSTED ? checkRegularUsername : checkPremiumUsername;
export const checkUsername = !IS_CALCOM ? checkRegularUsername : checkPremiumUsername;

View File

@ -0,0 +1,156 @@
import type { NextApiRequest, NextApiResponse } from "next";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { IS_CALCOM } from "../constants";
import notEmpty from "../notEmpty";
const cachedData: Set<string> = new Set();
export type RequestWithUsernameStatus = NextApiRequest & {
usernameStatus: {
/**
* ```text
* 200: Username is available
* 402: Pro username, must be purchased
* 418: A user exists with that username
* ```
*/
statusCode: 200 | 402 | 418;
requestedUserName: string;
json: {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
};
};
};
type CustomNextApiHandler<T = unknown> = (
req: RequestWithUsernameStatus,
res: NextApiResponse<T>
) => void | Promise<void>;
export async function isBlacklisted(username: string) {
// NodeJS forEach is very, very fast (these days) so even though we only have to construct the Set
// once every few iterations, it doesn't add much overhead.
if (!cachedData.size && process.env.USERNAME_BLACKLIST_URL) {
await fetch(process.env.USERNAME_BLACKLIST_URL).then(async (resp) =>
(await resp.text()).split("\n").forEach(cachedData.add, cachedData)
);
}
return cachedData.has(username);
}
export const isPremiumUserName = IS_CALCOM
? async (username: string) => {
return username.length <= 4 || isBlacklisted(username);
}
: // outside of cal.com the concept of premium username needs not exist.
() => Promise.resolve(false);
export const generateUsernameSuggestion = async (users: string[], username: string) => {
const limit = username.length < 2 ? 9999 : 999;
let rand = 1;
while (users.includes(username + String(rand).padStart(4 - rand.toString().length, "0"))) {
rand = Math.ceil(1 + Math.random() * (limit - 1));
}
return username + String(rand).padStart(4 - rand.toString().length, "0");
};
const processResult = (
result: "ok" | "username_exists" | "is_premium"
): // explicitly assign return value to ensure statusCode is typehinted
{ statusCode: RequestWithUsernameStatus["usernameStatus"]["statusCode"]; message: string } => {
// using a switch statement instead of multiple ifs to make sure typescript knows
// there is only limited options
switch (result) {
case "ok":
return {
statusCode: 200,
message: "Username is available",
};
case "username_exists":
return {
statusCode: 418,
message: "A user exists with that username",
};
case "is_premium":
return { statusCode: 402, message: "This is a premium username." };
}
};
const usernameHandler =
(handler: CustomNextApiHandler) =>
async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise<void> => {
const username = slugify(req.body.username);
const check = await usernameCheck(username);
let result: Parameters<typeof processResult>[0] = "ok";
if (check.premium) result = "is_premium";
if (!check.available) result = "username_exists";
const { statusCode, message } = processResult(result);
req.usernameStatus = {
statusCode,
requestedUserName: username,
json: {
available: result !== "username_exists",
premium: result === "is_premium",
message,
suggestion: check.suggestedUsername,
},
};
return handler(req, res);
};
const usernameCheck = async (usernameRaw: string) => {
const response = {
available: true,
premium: false,
suggestedUsername: "",
};
const username = slugify(usernameRaw);
const user = await prisma.user.findFirst({
where: { username, organizationId: null },
select: {
username: true,
},
});
if (user) {
response.available = false;
}
if (await isPremiumUserName(username)) {
response.premium = true;
}
// get list of similar usernames in the db
const users = await prisma.user.findMany({
where: {
username: {
contains: username,
},
},
select: {
username: true,
},
});
// We only need suggestedUsername if the username is not available
if (!response.available) {
response.suggestedUsername = await generateUsernameSuggestion(
users.map((user) => user.username).filter(notEmpty),
username
);
}
return response;
};
export { usernameHandler, usernameCheck };

View File

@ -2,6 +2,9 @@
// For eg:- "test-slug" is the slug user wants to set but while typing "test-" would get replace to "test" becauser of replace(/-+$/, "")
export const slugify = (str: string, forDisplayingInput?: boolean) => {
if (!str) {
return "";
}
const s = str
.toLowerCase() // Convert to lowercase
.trim() // Remove whitespace from both sides

View File

@ -33,6 +33,7 @@ type UpdateProfileOptions = {
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale;
const data: Prisma.UserUpdateInput = {
...input,
metadata: userMetadata,
@ -45,7 +46,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const layoutError = validateBookerLayouts(input?.metadata?.defaultBookerLayouts || null);
if (layoutError) {
const t = await getTranslation("en", "common");
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t(layoutError) });
}
@ -57,7 +58,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const response = await checkUsername(username);
isPremiumUsername = response.premium;
if (!response.available) {
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t("username_already_taken") });
}
}
}

View File

@ -324,6 +324,7 @@
"ZOHOCRM_CLIENT_ID",
"ZOHOCRM_CLIENT_SECRET",
"ZOOM_CLIENT_ID",
"ZOOM_CLIENT_SECRET"
"ZOOM_CLIENT_SECRET",
"USERNAME_BLACKLIST_URL"
]
}