diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index 24d9de40fe..4d90edf60e 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -1,218 +1,53 @@ -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 { 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"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") { - return res.status(405).end(); - } +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_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +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", - }); - } - } - - // 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; - - 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 { - 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, + throw new HttpError({ + statusCode: 403, + message: "Signup is disabled", }); } +} - res.status(201).json({ message: "Created user" }); +function ensureReqIsPost(req: RequestWithUsernameStatus) { + if (req.method !== "POST") { + throw new HttpError({ + statusCode: 405, + message: "Method not allowed", + }); + } +} + +export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { + // Use a try catch instead of returning res every time + try { + ensureReqIsPost(req); + ensureSignupIsEnabled(); + + /** + * 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. + * @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI. + */ + if (IS_PREMIUM_USERNAME_ENABLED) { + return await calcomSignupHandler(req, res); + } + + return await selfHostedSignupHandler(req, res); + } catch (e) { + if (e instanceof HttpError) { + return res.status(e.statusCode).json({ message: e.message }); + } + logger.error(e); + return res.status(500).json({ message: "Internal server error" }); + } } diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 327486bc8c..c6c32275d6 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -98,9 +98,9 @@ inferSSRProps & WithNonceProps<{}>) { callbackUrl = safeCallbackUrl || ""; const LoginFooter = ( - + {t("dont_have_an_account")} - + ); const TwoFactorFooter = ( diff --git a/apps/web/pages/auth/sso/[provider].tsx b/apps/web/pages/auth/sso/[provider].tsx index b2d8b418da..7337b4b311 100644 --- a/apps/web/pages/auth/sso/[provider].tsx +++ b/apps/web/pages/auth/sso/[provider].tsx @@ -9,6 +9,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain import stripe from "@calcom/features/ee/payments/server/stripe"; import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml"; import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso"; +import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { checkUsername } from "@calcom/lib/server/checkUsername"; import prisma from "@calcom/prisma"; @@ -72,7 +73,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => // Validating if username is Premium, while this is true an email its required for stripe user confirmation if (usernameParam && session.user.email) { const availability = await checkUsername(usernameParam, currentOrgDomain); - if (availability.available && availability.premium) { + if (availability.available && availability.premium && IS_PREMIUM_USERNAME_ENABLED) { const stripePremiumUrl = await getStripePremiumUsernameUrl({ userEmail: session.user.email, username: usernameParam, diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 041dcd943d..af52ed9894 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -1,25 +1,34 @@ 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 Link from "next/link"; +import { useRouter } 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 { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; import { getOrgFullOrigin } 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 { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; +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,7 +43,29 @@ type FormValues = z.infer; type SignupProps = inferSSRProps; -const checkValidEmail = (email: string) => z.string().email().safeParse(email).success; +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, + }, + }, +]; const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => { const [emailUser, emailDomain = ""] = email.split("@"); @@ -46,31 +77,124 @@ const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) = return username; }; +function UsernameField({ + username, + setPremium, + premium, + setUsernameTaken, + usernameTaken, + ...props +}: React.ComponentProps & { + username: string; + setPremium: (value: boolean) => void; + premium: boolean; + usernameTaken: boolean; + setUsernameTaken: (value: boolean) => void; +}) { + const { t } = useLocale(); + const { register, formState } = useFormContext(); + const debouncedUsername = useDebounce(username, 600); + + useEffect(() => { + if (formState.isSubmitting || formState.isSubmitSuccessful) return; + + async function checkUsername() { + if (!debouncedUsername) { + setPremium(false); + setUsernameTaken(false); + return; + } + fetchUsername(debouncedUsername).then(({ data }) => { + setPremium(data.premium); + setUsernameTaken(!data.available); + }); + } + checkUsername(); + }, [debouncedUsername, setPremium, setUsernameTaken, formState.isSubmitting, formState.isSubmitSuccessful]); + + return ( +
+ + {(!formState.isSubmitting || !formState.isSubmitted) && ( +
+

+ {usernameTaken ? ( +

+ + {t("already_in_use_error")} +
+ ) : premium ? ( +
+ + {t("premium_username", { + price: getPremiumPlanPriceValue(), + })} +
+ ) : null} +

+
+ )} +
+ ); +} + +const checkValidEmail = (email: string) => z.string().email().safeParse(email).success; + 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, + isGoogleLoginEnabled, + isSAMLLoginEnabled, + orgAutoAcceptEmail, +}: SignupProps) { + const [premiumUsername, setPremiumUsername] = useState(false); + const [usernameTaken, setUsernameTaken] = useState(false); + const searchParams = useCompatSearchParams(); const telemetry = useTelemetry(); const { t, i18n } = useLocale(); + const router = useRouter(); const flags = useFlagMap(); - const methods = useForm({ - mode: "onChange", + const formMethods = useForm({ resolver: zodResolver(signupSchema), - defaultValues: prepopulateFormValues, + defaultValues: prepopulateFormValues satisfies FormValues, + mode: "onChange", }); const { register, - formState: { errors, isSubmitting }, - } = methods; + watch, + formState: { isSubmitting, errors, isSubmitSuccessful }, + } = formMethods; - const handleErrors = async (resp: Response) => { + const loadingSubmitState = isSubmitSuccessful || isSubmitting; + + const handleErrorsAndStripe = async (resp: Response) => { if (!resp.ok) { const err = await resp.json(); - throw new Error(err.message); + 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); + } } }; @@ -88,7 +212,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,109 +230,262 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA }); }) .catch((err) => { - methods.setError("apiError", { message: err.message }); + formMethods.setError("apiError", { message: err.message }); }); }; return ( - <> -
+
+
-
-

- {t("create_your_account")} -

-
-
-
- -
{ - event.preventDefault(); - event.stopPropagation(); +
+ {/* Header */} + {errors.apiError && ( + + )} +
+

+ {IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")} +

+ {IS_CALCOM ? ( +

{t("cal_signup_description")}

+ ) : ( +

+ {t("calcom_explained", { + appName: APP_NAME, + })} +

+ )} +
+ {/* Form Container */} +
+ { + let updatedValues = values; + if (!formMethods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) { + updatedValues = { + ...values, + username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail), + }; + } + await signUp(updatedValues); + }}> + {/* Username */} + setUsernameTaken(value)} + data-testid="signup-usernamefield" + setPremium={(value) => setPremiumUsername(value)} + addOnLeading={ + orgSlug + ? `${getOrgFullOrigin(orgSlug, { protocol: true })}/` + : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/` + } + /> + {/* Email */} + - if (methods.formState?.errors?.apiError) { - methods.clearErrors("apiError"); - } - - if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) { - methods.setValue( - "username", - getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail) - ); - } - methods.handleSubmit(signUp)(event); - }} - className="bg-default space-y-6"> - {errors.apiError && } - {} -
- {!isOrgInviteByLink && ( - + + + {/* Continue with Social Logins */} + {token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : ( +
+
+
+ + {t("or_continue_with")} + +
+
+
+ )} + {/* Social Logins */} + {!token && ( +
+ {isGoogleLoginEnabled ? ( +
-
- - {!token && ( - - )} -
- - + ) : null} + {isSAMLLoginEnabled ? ( + + ) : null} +
+ )} +
+ {/* Already have an account & T&C */} +
+
+ + {t("already_have_account")} + +
+ By signing up, you agree to our{" "} + + Terms of Service{" "} + + and{" "} + + Privacy Policy. + +
+
+
+
+
+ {IS_CALCOM && ( +
+
+ # +
+
+ # +
+
+ # +
+
+ )} +
+ # +
+
+ {!IS_CALCOM && + FEATURES.map((feature) => ( + <> +
+
+ + {t(feature.title)} +
+
+

+ {t( + feature.description, + feature.i18nOptions && { + ...feature.i18nOptions, + } + )} +

+
+
+ + ))}
- +
); } +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 +499,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 +511,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, + }, + }) + ), }; } diff --git a/apps/web/playwright/fixtures/features.ts b/apps/web/playwright/fixtures/features.ts new file mode 100644 index 0000000000..a13e059284 --- /dev/null +++ b/apps/web/playwright/fixtures/features.ts @@ -0,0 +1,50 @@ +import type { Page } from "@playwright/test"; +import type { Feature } from "@prisma/client"; + +import type { AppFlags } from "@calcom/features/flags/config"; +import { prisma } from "@calcom/prisma"; + +type FeatureSlugs = keyof AppFlags; + +export const createFeatureFixture = (page: Page) => { + const store = { features: [], page } as { features: Feature[]; page: typeof page }; + let initalFeatures: Feature[] = []; + + // IIF to add all feautres to store on creation + return { + init: async () => { + const features = await prisma.feature.findMany(); + store.features = features; + initalFeatures = features; + return features; + }, + getAll: () => store.features, + get: (slug: FeatureSlugs) => store.features.find((b) => b.slug === slug), + deleteAll: async () => { + await prisma.feature.deleteMany({ + where: { slug: { in: store.features.map((feature) => feature.slug) } }, + }); + store.features = []; + }, + delete: async (slug: FeatureSlugs) => { + await prisma.feature.delete({ where: { slug } }); + store.features = store.features.filter((b) => b.slug !== slug); + }, + toggleFeature: async (slug: FeatureSlugs) => { + const feature = store.features.find((b) => b.slug === slug); + if (feature) { + const enabled = !feature.enabled; + await prisma.feature.update({ where: { slug }, data: { enabled } }); + store.features = store.features.map((b) => (b.slug === slug ? { ...b, enabled } : b)); + } + }, + set: async (slug: FeatureSlugs, enabled: boolean) => { + const feature = store.features.find((b) => b.slug === slug); + if (feature) { + store.features = store.features.map((b) => (b.slug === slug ? { ...b, enabled } : b)); + await prisma.feature.update({ where: { slug }, data: { enabled } }); + } + }, + reset: () => (store.features = initalFeatures), + }; +}; diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index f779f9a773..16b362c2fa 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -143,6 +143,17 @@ const createTeamAndAddUser = async ( export const createUsersFixture = (page: Page, emails: API | undefined, workerInfo: WorkerInfo) => { const store = { users: [], page } as { users: UserFixture[]; page: typeof page }; return { + buildForSignup: (opts?: Pick) => { + const uname = + opts?.useExactUsername && opts?.username + ? opts.username + : `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`; + return { + username: uname, + email: opts?.email ?? `${uname}@example.com`, + password: opts?.password ?? uname, + }; + }, create: async ( opts?: CustomUserOpts | null, scenario: { @@ -396,6 +407,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); + }, set: async (email: string) => { const user = await prisma.user.findUniqueOrThrow({ where: { email }, diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 21956d49ba..0d7f34879a 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -10,6 +10,7 @@ import prisma from "@calcom/prisma"; import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; import { createEmbedsFixture } from "../fixtures/embeds"; +import { createFeatureFixture } from "../fixtures/features"; import { createOrgsFixture } from "../fixtures/orgs"; import { createPaymentsFixture } from "../fixtures/payments"; import { createBookingPageFixture } from "../fixtures/regularBookings"; @@ -29,6 +30,7 @@ export interface Fixtures { emails?: API; routingForms: ReturnType; bookingPage: ReturnType; + features: ReturnType; } declare global { @@ -95,4 +97,9 @@ export const test = base.extend({ const bookingPage = createBookingPageFixture(page); await use(bookingPage); }, + features: async ({ page }, use) => { + const features = createFeatureFixture(page); + await features.init(); + await use(features); + }, }); diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts new file mode 100644 index 0000000000..60775a2143 --- /dev/null +++ b/apps/web/playwright/signup.e2e.ts @@ -0,0 +1,231 @@ +import { expect } from "@playwright/test"; +import { randomBytes } from "crypto"; + +import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; + +import { test } from "./lib/fixtures"; +import { getEmailsReceivedByUser } from "./lib/testUtils"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Signup Flow Test", async () => { + test.beforeEach(async ({ features }) => { + features.reset(); // This resets to the inital state not an empt yarray + }); + test.afterAll(async ({ users }) => { + await users.deleteAll(); + }); + 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 () => { + const user = 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(user.email); + 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, prisma }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(!IS_PREMIUM_USERNAME_ENABLED, "Only run on Cal.com"); + const userToCreate = users.buildForSignup({ + username: "rock", + password: "Password99!", + }); + // Ensure the premium username is available + await prisma.user.deleteMany({ where: { username: "rock" } }); + + // Signup with premium username name + await page.goto("/signup"); + + // Fill form + await page.locator('input[name="username"]').fill("rock"); + await page.locator('input[name="email"]').fill(userToCreate.email); + await page.locator('input[name="password"]').fill(userToCreate.password); + + await page.click('button[type="submit"]'); + + // Check that stripe checkout is present + const expectedUrl = "https://checkout.stripe.com"; + + await page.waitForURL((url) => url.href.startsWith(expectedUrl)); + const url = page.url(); + + // Check that the URL matches the expected URL + expect(url).toContain(expectedUrl); + // TODO: complete the stripe checkout flow + }); + test("Signup with valid (non premium) username", async ({ page, users, features }) => { + const userToCreate = users.buildForSignup({ + username: "rick-jones", + password: "Password99!", + }); + + await page.goto("/signup"); + + // Fill form + await page.locator('input[name="username"]').fill(userToCreate.username); + await page.locator('input[name="email"]').fill(userToCreate.email); + await page.locator('input[name="password"]').fill(userToCreate.password); + + await page.click('button[type="submit"]'); + await page.waitForLoadState("networkidle"); + // Find the newly created user and add it to the fixture store + const newUser = await users.set(userToCreate.email); + expect(newUser).not.toBeNull(); + + // 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 = page.locator('input[name="username"]'); + const emailInput = 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"); + const userToCreate = users.buildForSignup({ + username: "rick-team", + }); + + const createdtoken = await prisma.verificationToken.create({ + data: { + identifier: userToCreate.email, + token, + expires: new Date(new Date().setHours(168)), // +1 week + team: { + create: { + name: "Rick's Team", + slug: `${userToCreate.username}-team`, + }, + }, + }, + }); + + // create a user with the same email as the token + const rickTeamUser = await prisma.user.create({ + data: { + email: userToCreate.email, + username: userToCreate.username, + }, + }); + + // 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(userToCreate.username); + expect(await emailField.inputValue()).toBe(userToCreate.email); + + // Cleanup specific to this test + // Clean up the user and token + await prisma.user.deleteMany({ where: { email: userToCreate.email } }); + await prisma.verificationToken.deleteMany({ where: { identifier: createdtoken.identifier } }); + await prisma.team.deleteMany({ where: { id: createdtoken.teamId! } }); + }); + test("Email verification sent if enabled", async ({ page, prisma, emails, users, features }) => { + const EmailVerifyFlag = features.get("email-verification")?.enabled; + + // eslint-disable-next-line playwright/no-skipped-test + test.skip(!EmailVerifyFlag || !IS_MAILHOG_ENABLED, "Skipping check - Email verify disabled"); + // Ensure email verification before testing (TODO: this could break other tests but we can fix that later) + await prisma.feature.update({ + where: { slug: "email-verification" }, + data: { enabled: true }, + }); + const userToCreate = users.buildForSignup({ + username: "email-verify", + password: "Password99!", + }); + + await page.goto("/signup"); + + // Fill form + await page.locator('input[name="username"]').fill(userToCreate.username); + await page.locator('input[name="email"]').fill(userToCreate.email); + await page.locator('input[name="password"]').fill(userToCreate.password); + + await page.click('button[type="submit"]'); + + await page.waitForURL((url) => url.pathname.includes("/auth/verify-email")); + // Find the newly created user and add it to the fixture store + const newUser = await users.set(userToCreate.email); + expect(newUser).not.toBeNull(); + + const receivedEmails = await getEmailsReceivedByUser({ + emails, + userEmail: userToCreate.email, + }); + + // We need to wait for emails to be sent + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(5000); + + expect(receivedEmails?.total).toBe(1); + + const verifyEmail = receivedEmails?.items[0]; + expect(verifyEmail?.subject).toBe(`${APP_NAME}: Verify your account`); + }); +}); diff --git a/apps/web/public/google-icon.svg b/apps/web/public/google-icon.svg new file mode 100644 index 0000000000..f7fa5d3bb2 --- /dev/null +++ b/apps/web/public/google-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/mock-event-type-list.svg b/apps/web/public/mock-event-type-list.svg new file mode 100644 index 0000000000..65a3a6a452 --- /dev/null +++ b/apps/web/public/mock-event-type-list.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/product-cards/g2.svg b/apps/web/public/product-cards/g2.svg new file mode 100644 index 0000000000..5697032ef4 --- /dev/null +++ b/apps/web/public/product-cards/g2.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/product-cards/product-of-the-day.svg b/apps/web/public/product-cards/product-of-the-day.svg new file mode 100644 index 0000000000..f1bc20693b --- /dev/null +++ b/apps/web/public/product-cards/product-of-the-day.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/product-cards/product-of-the-month.svg b/apps/web/public/product-cards/product-of-the-month.svg new file mode 100644 index 0000000000..4a2ac6539b --- /dev/null +++ b/apps/web/public/product-cards/product-of-the-month.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/product-cards/product-of-the-week.svg b/apps/web/public/product-cards/product-of-the-week.svg new file mode 100644 index 0000000000..8b828e19d2 --- /dev/null +++ b/apps/web/public/product-cards/product-of-the-week.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/product-cards/producthunt.svg b/apps/web/public/product-cards/producthunt.svg new file mode 100644 index 0000000000..444f225dfe --- /dev/null +++ b/apps/web/public/product-cards/producthunt.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/public/product-cards/trustpilot.svg b/apps/web/public/product-cards/trustpilot.svg new file mode 100644 index 0000000000..4cb637bb1d --- /dev/null +++ b/apps/web/public/product-cards/trustpilot.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4a66dd48c1..9a0efd884b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -78,6 +78,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 and <3>Privacy Policy.", "open_env": "Open .env and agree to our License", "env_changed": "I've changed my .env", "accept_license": "Accept License", @@ -240,6 +241,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!", @@ -277,6 +279,9 @@ "nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who they’re 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}}", @@ -1049,6 +1054,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.", @@ -1164,6 +1170,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", @@ -1544,8 +1551,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", @@ -1599,6 +1608,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", @@ -2073,6 +2083,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", @@ -2096,6 +2112,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", diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts new file mode 100644 index 0000000000..ffa7baee73 --- /dev/null +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -0,0 +1,207 @@ +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, MembershipRole } 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); + await 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.upsert({ + where: { + userId_teamId: { userId: user.id, teamId: team.id }, + }, + update: { + accepted: true, + }, + create: { + userId: user.id, + teamId: team.id, + role: MembershipRole.MEMBER, + 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); diff --git a/packages/features/auth/signup/handlers/selfHostedHandler.ts b/packages/features/auth/signup/handlers/selfHostedHandler.ts new file mode 100644 index 0000000000..4b54669385 --- /dev/null +++ b/packages/features/auth/signup/handlers/selfHostedHandler.ts @@ -0,0 +1,147 @@ +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_PREMIUM_USERNAME_ENABLED } 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, MembershipRole } 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); + await 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.upsert({ + where: { + userId_teamId: { userId: user.id, teamId: team.id }, + }, + update: { + accepted: true, + }, + create: { + userId: user.id, + teamId: team.id, + role: MembershipRole.MEMBER, + 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_PREMIUM_USERNAME_ENABLED) { + 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" }); +} diff --git a/packages/features/auth/signup/username.ts b/packages/features/auth/signup/username.ts new file mode 100644 index 0000000000..642946dcd5 --- /dev/null +++ b/packages/features/auth/signup/username.ts @@ -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 = ( + req: RequestWithUsernameStatus, + res: NextApiResponse +) => void | Promise; + +const usernameHandler = + (handler: CustomNextApiHandler) => + async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise => { + 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 }; diff --git a/packages/features/auth/signup/utils/organization.ts b/packages/features/auth/signup/utils/organization.ts new file mode 100644 index 0000000000..c34474e324 --- /dev/null +++ b/packages/features/auth/signup/utils/organization.ts @@ -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, + }, + }), + ]); +} diff --git a/packages/features/auth/signup/utils/token.ts b/packages/features/auth/signup/utils/token.ts new file mode 100644 index 0000000000..da1b08967d --- /dev/null +++ b/packages/features/auth/signup/utils/token.ts @@ -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", + }); + } +} diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 1de85d4fcb..f9fb122f34 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -115,3 +115,7 @@ export const AB_TEST_BUCKET_PROBABILITY = defaultOnNaN( parseInt(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10", 10), 10 ); + +export const IS_PREMIUM_USERNAME_ENABLED = + (IS_CALCOM || (process.env.NEXT_PUBLIC_IS_E2E && IS_STRIPE_ENABLED)) && + process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY; diff --git a/packages/lib/server/checkUsername.ts b/packages/lib/server/checkUsername.ts index 9dcae03278..6c1c31f1c5 100644 --- a/packages/lib/server/checkUsername.ts +++ b/packages/lib/server/checkUsername.ts @@ -1,6 +1,6 @@ -import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; -import { IS_SELF_HOSTED } from "@calcom/lib/constants"; +import { IS_PREMIUM_USERNAME_ENABLED } 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_PREMIUM_USERNAME_ENABLED ? checkRegularUsername : checkPremiumUsername; diff --git a/packages/lib/server/username.ts b/packages/lib/server/username.ts new file mode 100644 index 0000000000..01d1ffd952 --- /dev/null +++ b/packages/lib/server/username.ts @@ -0,0 +1,156 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; + +import { IS_PREMIUM_USERNAME_ENABLED } from "../constants"; +import notEmpty from "../notEmpty"; + +const cachedData: Set = 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 = ( + req: RequestWithUsernameStatus, + res: NextApiResponse +) => void | Promise; + +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_PREMIUM_USERNAME_ENABLED + ? 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 => { + const username = slugify(req.body.username); + const check = await usernameCheck(username); + + let result: Parameters[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 }; diff --git a/packages/lib/slugify.ts b/packages/lib/slugify.ts index 71cd8a7ac8..63d1083681 100644 --- a/packages/lib/slugify.ts +++ b/packages/lib/slugify.ts @@ -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 diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index 49041244a6..c4f39f54de 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -59,6 +59,7 @@ const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: 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, // DO NOT OVERWRITE AVATAR. @@ -73,7 +74,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) }); } @@ -85,7 +86,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") }); } } } diff --git a/turbo.json b/turbo.json index 4344ae0f25..5fabef8bca 100644 --- a/turbo.json +++ b/turbo.json @@ -325,6 +325,7 @@ "TWILIO_VERIFY_SID", "UPSTASH_REDIS_REST_TOKEN", "UPSTASH_REDIS_REST_URL", + "USERNAME_BLACKLIST_URL", "VERCEL_ENV", "VERCEL_URL", "VITAL_API_KEY", diff --git a/yarn.lock b/yarn.lock index 79b331aa18..224f5a71d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3190,15 +3190,6 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.14.5, @babel/runtime@npm:^7.17.2, @babel/runtime@npm:^7.18.6": - version: 7.23.4 - resolution: "@babel/runtime@npm:7.23.4" - dependencies: - regenerator-runtime: ^0.14.0 - checksum: 8eb6a6b2367f7d60e7f7dd83f477cc2e2fdb169e5460694d7614ce5c730e83324bcf29251b70940068e757ad1ee56ff8073a372260d90cad55f18a825caf97cd - languageName: node - linkType: hard - "@babel/runtime@npm:^7.21.0": version: 7.23.1 resolution: "@babel/runtime@npm:7.23.1" @@ -3552,13 +3543,15 @@ __metadata: "@calcom/ui": "*" "@types/node": 16.9.1 "@types/react": 18.0.26 - "@types/react-dom": 18.0.9 + "@types/react-dom": ^18.0.9 eslint: ^8.34.0 eslint-config-next: ^13.2.1 - next: ^13.2.1 - next-auth: ^4.20.1 + next: ^13.4.6 + next-auth: ^4.22.1 + postcss: ^8.4.18 react: ^18.2.0 react-dom: ^18.2.0 + tailwindcss: ^3.3.3 typescript: ^4.9.4 languageName: unknown linkType: soft @@ -3652,7 +3645,7 @@ __metadata: "@calcom/ui": "*" "@headlessui/react": ^1.5.0 "@heroicons/react": ^1.0.6 - "@prisma/client": ^4.13.0 + "@prisma/client": ^5.4.2 "@tailwindcss/forms": ^0.5.2 "@types/node": 16.9.1 "@types/react": 18.0.26 @@ -3660,21 +3653,21 @@ __metadata: chart.js: ^3.7.1 client-only: ^0.0.1 eslint: ^8.34.0 - next: ^13.2.1 - next-auth: ^4.20.1 - next-i18next: ^11.3.0 + next: ^13.4.6 + next-auth: ^4.22.1 + next-i18next: ^13.2.2 postcss: ^8.4.18 - prisma: ^4.13.0 + prisma: ^5.4.2 prisma-field-encryption: ^1.4.0 react: ^18.2.0 react-chartjs-2: ^4.0.1 react-dom: ^18.2.0 react-hook-form: ^7.43.3 - react-live-chat-loader: ^2.7.3 + react-live-chat-loader: ^2.8.1 swr: ^1.2.2 - tailwindcss: ^3.2.1 + tailwindcss: ^3.3.3 typescript: ^4.9.4 - zod: ^3.20.2 + zod: ^3.22.2 languageName: unknown linkType: soft @@ -4762,7 +4755,7 @@ __metadata: remark: ^14.0.2 remark-html: ^14.0.1 remeda: ^1.24.1 - stripe: ^9.16.0 + stripe: ^14.5.0 tailwind-merge: ^1.13.2 tailwindcss: ^3.3.3 ts-node: ^10.9.1 @@ -8485,20 +8478,6 @@ __metadata: languageName: node linkType: hard -"@prisma/client@npm:^4.13.0": - version: 4.16.2 - resolution: "@prisma/client@npm:4.16.2" - dependencies: - "@prisma/engines-version": 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81 - peerDependencies: - prisma: "*" - peerDependenciesMeta: - prisma: - optional: true - checksum: 38e1356644a764946c69c8691ea4bbed0ba37739d833a435625bd5435912bed4b9bdd7c384125f3a4ab8128faf566027985c0f0840a42741c338d72e40b5d565 - languageName: node - linkType: hard - "@prisma/client@npm:^5.4.2": version: 5.4.2 resolution: "@prisma/client@npm:5.4.2" @@ -8557,13 +8536,6 @@ __metadata: languageName: node linkType: hard -"@prisma/engines-version@npm:4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81": - version: 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81 - resolution: "@prisma/engines-version@npm:4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81" - checksum: b42c6abe7c1928e546f15449e40ffa455701ef2ab1f62973628ecb4e19ff3652e34609a0d83196d1cbd0864adb44c55e082beec852b11929acf1c15fb57ca45a - languageName: node - linkType: hard - "@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574": version: 5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574 resolution: "@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574" @@ -8571,13 +8543,6 @@ __metadata: languageName: node linkType: hard -"@prisma/engines@npm:4.16.2": - version: 4.16.2 - resolution: "@prisma/engines@npm:4.16.2" - checksum: f423e6092c3e558cd089a68ae87459fba7fd390c433df087342b3269c3b04163965b50845150dfe47d01f811781bfff89d5ae81c95ca603c59359ab69ebd810f - languageName: node - linkType: hard - "@prisma/engines@npm:5.1.1": version: 5.1.1 resolution: "@prisma/engines@npm:5.1.1" @@ -24703,13 +24668,6 @@ __metadata: languageName: node linkType: hard -"i18next-fs-backend@npm:^1.1.4": - version: 1.2.0 - resolution: "i18next-fs-backend@npm:1.2.0" - checksum: da74d20f2b007f8e34eaf442fa91ad12aaff3b9891e066c6addd6d111b37e370c62370dfbc656730ab2f8afd988f2e7ea1c48301ebb19ccb716fb5965600eddf - languageName: node - linkType: hard - "i18next-fs-backend@npm:^2.1.1": version: 2.1.3 resolution: "i18next-fs-backend@npm:2.1.3" @@ -24717,15 +24675,6 @@ __metadata: languageName: node linkType: hard -"i18next@npm:^21.8.13": - version: 21.10.0 - resolution: "i18next@npm:21.10.0" - dependencies: - "@babel/runtime": ^7.17.2 - checksum: f997985e2d4d15a62a0936a82ff6420b97f3f971e776fe685bdd50b4de0cb4dc2198bc75efe6b152844794ebd5040d8060d6d152506a687affad534834836d81 - languageName: node - linkType: hard - "i18next@npm:^23.2.3": version: 23.2.3 resolution: "i18next@npm:23.2.3" @@ -26394,15 +26343,6 @@ __metadata: languageName: node linkType: hard -"jiti@npm:^1.19.1": - version: 1.21.0 - resolution: "jiti@npm:1.21.0" - bin: - jiti: bin/jiti.js - checksum: a7bd5d63921c170eaec91eecd686388181c7828e1fa0657ab374b9372bfc1f383cf4b039e6b272383d5cb25607509880af814a39abdff967322459cca41f2961 - languageName: node - linkType: hard - "joi@npm:^17.7.0": version: 17.10.2 resolution: "joi@npm:17.10.2" @@ -30129,31 +30069,6 @@ __metadata: languageName: node linkType: hard -"next-auth@npm:^4.20.1": - version: 4.24.5 - resolution: "next-auth@npm:4.24.5" - dependencies: - "@babel/runtime": ^7.20.13 - "@panva/hkdf": ^1.0.2 - cookie: ^0.5.0 - jose: ^4.11.4 - oauth: ^0.9.15 - openid-client: ^5.4.0 - preact: ^10.6.3 - preact-render-to-string: ^5.1.19 - uuid: ^8.3.2 - peerDependencies: - next: ^12.2.5 || ^13 || ^14 - nodemailer: ^6.6.5 - react: ^17.0.2 || ^18 - react-dom: ^17.0.2 || ^18 - peerDependenciesMeta: - nodemailer: - optional: true - checksum: 7cc49385123690ccb908f4552b75012717c4e45205a9fdc7cf48cd730dbcc7823a3e33e2a2073ecf1edae5c1980123f68678fd4af9198ea21ab0decb630cc71e - languageName: node - linkType: hard - "next-auth@npm:^4.22.1": version: 4.22.1 resolution: "next-auth@npm:4.22.1" @@ -30221,24 +30136,6 @@ __metadata: languageName: node linkType: hard -"next-i18next@npm:^11.3.0": - version: 11.3.0 - resolution: "next-i18next@npm:11.3.0" - dependencies: - "@babel/runtime": ^7.18.6 - "@types/hoist-non-react-statics": ^3.3.1 - core-js: ^3 - hoist-non-react-statics: ^3.3.2 - i18next: ^21.8.13 - i18next-fs-backend: ^1.1.4 - react-i18next: ^11.18.0 - peerDependencies: - next: ">= 10.0.0" - react: ">= 16.8.0" - checksum: fbce97a4fbf9ad846c08652471a833c7f173c3e7ddc7cafa1423625b4a684715bb85f76ae06fe9cbed3e70f12b8e78e2459e5bc1a3c3f5c517743f17648f8939 - languageName: node - linkType: hard - "next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::locator=calcom-monorepo%40workspace%3A.": version: 13.3.0 resolution: "next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::version=13.3.0&hash=bcbde7&locator=calcom-monorepo%40workspace%3A." @@ -30320,7 +30217,7 @@ __metadata: languageName: node linkType: hard -"next@npm:^13.2.1, next@npm:^13.4.6": +"next@npm:^13.4.6": version: 13.5.6 resolution: "next@npm:13.5.6" dependencies: @@ -32994,18 +32891,6 @@ __metadata: languageName: node linkType: hard -"prisma@npm:^4.13.0": - version: 4.16.2 - resolution: "prisma@npm:4.16.2" - dependencies: - "@prisma/engines": 4.16.2 - bin: - prisma: build/index.js - prisma2: build/index.js - checksum: 1d0ed616abd7f8de22441e333b976705f1cb05abcb206965df3fc6a7ea03911ef467dd484a4bc51fdc6cff72dd9857b9852be5f232967a444af0a98c49bfdb76 - languageName: node - linkType: hard - "prisma@npm:^5.4.2": version: 5.4.2 resolution: "prisma@npm:5.4.2" @@ -33380,6 +33265,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.11.0": + version: 6.11.2 + resolution: "qs@npm:6.11.2" + dependencies: + side-channel: ^1.0.4 + checksum: e812f3c590b2262548647d62f1637b6989cc56656dc960b893fe2098d96e1bd633f36576f4cd7564dfbff9db42e17775884db96d846bebe4f37420d073ecdc0b + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" @@ -33903,24 +33797,6 @@ __metadata: languageName: node linkType: hard -"react-i18next@npm:^11.18.0": - version: 11.18.6 - resolution: "react-i18next@npm:11.18.6" - dependencies: - "@babel/runtime": ^7.14.5 - html-parse-stringify: ^3.0.1 - peerDependencies: - i18next: ">= 19.0.0" - react: ">= 16.8.0" - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - checksum: 624c0a0313fac4e0d18560b83c99a8bd0a83abc02e5db8d01984e0643ac409d178668aa3a4720d01f7a0d9520d38598dcbff801d6f69a970bae67461de6cd852 - languageName: node - linkType: hard - "react-i18next@npm:^12.2.0": version: 12.3.1 resolution: "react-i18next@npm:12.3.1" @@ -34044,15 +33920,6 @@ __metadata: languageName: node linkType: hard -"react-live-chat-loader@npm:^2.7.3": - version: 2.8.2 - resolution: "react-live-chat-loader@npm:2.8.2" - peerDependencies: - react: ^16.14.0 || ^17.0.0 || ^18.0.0 - checksum: 30de0d27693f1c80641347f0efc9c846e0c8d52231eb3181b68d684ef580764d3bd8393d77ff61f3066af5cc65977fc1a108726965181ddbbd6a0feb0a9ebcb9 - languageName: node - linkType: hard - "react-live-chat-loader@npm:^2.8.1": version: 2.8.1 resolution: "react-live-chat-loader@npm:2.8.1" @@ -37458,6 +37325,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:^14.5.0": + version: 14.5.0 + resolution: "stripe@npm:14.5.0" + dependencies: + "@types/node": ">=8.1.0" + qs: ^6.11.0 + checksum: ad2f0205d1b0ce4691bf54dfd0953e197b2fb3c37c489e9f1799dfa75fbd21a49cb5ddb7bcf0a708242db565500ba494927e321aee91980f360c389a60048a0c + languageName: node + linkType: hard + "stripe@npm:^9.16.0": version: 9.16.0 resolution: "stripe@npm:9.16.0" @@ -37917,39 +37794,6 @@ __metadata: languageName: node linkType: hard -"tailwindcss@npm:^3.2.1": - version: 3.3.5 - resolution: "tailwindcss@npm:3.3.5" - dependencies: - "@alloc/quick-lru": ^5.2.0 - arg: ^5.0.2 - chokidar: ^3.5.3 - didyoumean: ^1.2.2 - dlv: ^1.1.3 - fast-glob: ^3.3.0 - glob-parent: ^6.0.2 - is-glob: ^4.0.3 - jiti: ^1.19.1 - lilconfig: ^2.1.0 - micromatch: ^4.0.5 - normalize-path: ^3.0.0 - object-hash: ^3.0.0 - picocolors: ^1.0.0 - postcss: ^8.4.23 - postcss-import: ^15.1.0 - postcss-js: ^4.0.1 - postcss-load-config: ^4.0.1 - postcss-nested: ^6.0.1 - postcss-selector-parser: ^6.0.11 - resolve: ^1.22.2 - sucrase: ^3.32.0 - bin: - tailwind: lib/cli.js - tailwindcss: lib/cli.js - checksum: e04bb3bb7f9f17e9b6db0c7ace755ef0d6d05bff36ebeb9e5006e13c018ed5566f09db30a1a34380e38fa93ebbb4ae0e28fe726879d5e9ddd8c5b52bffd26f14 - languageName: node - linkType: hard - "tailwindcss@npm:^3.3.3": version: 3.3.3 resolution: "tailwindcss@npm:3.3.3" @@ -42130,13 +41974,6 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.20.2": - version: 3.22.4 - resolution: "zod@npm:3.22.4" - checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f - languageName: node - linkType: hard - "zod@npm:^3.21.4, zod@npm:^3.22.2": version: 3.22.2 resolution: "zod@npm:3.22.2"