diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index ad32497a2f..cba4a8f44a 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -24,10 +24,11 @@ import { IS_CALCOM } from "@calcom/lib/constants"; import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest"; import { HttpError } from "@calcom/lib/http-error"; import type { RequestWithUsernameStatus } from "@calcom/lib/server/username"; +import { usernameHandler } from "@calcom/lib/server/username"; import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import { validateUsernameInTeam } from "@calcom/lib/validateUsername"; -export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { +async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { try { ensurePostMethod(req); throwIfSignupIsDisabled(); @@ -120,3 +121,5 @@ export default async function handler(req: RequestWithUsernameStatus, res: NextA // return await selfHostedHandler(req, res); // } } + +export default usernameHandler(handler); diff --git a/packages/features/auth/lib/signup/calcomhandler.ts b/packages/features/auth/lib/signup/calcomhandler.ts new file mode 100644 index 0000000000..ae7079ac39 --- /dev/null +++ b/packages/features/auth/lib/signup/calcomhandler.ts @@ -0,0 +1,135 @@ +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 type { RequestWithUsernameStatus } from "@calcom/lib/server/username"; +import { usernameHandler } from "@calcom/lib/server/username"; +import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import prisma from "@calcom/prisma"; + +async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { + if (req.method === "POST") { + const { email: _email, password } = 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) { + res.status(422).json({ message: "Invalid username" }); + return; + } + + if (typeof _email !== "string" || !_email.includes("@")) { + res.status(422).json({ message: "Invalid email" }); + return; + } + + const email = _email.toLowerCase(); + + if (!password || password.trim().length < 7) { + res.status(422).json({ + message: "Invalid input - password should be at least 7 characters long.", + }); + return; + } + + const existingUser = await prisma.user.findFirst({ + where: { + OR: [ + { + username, + }, + { + email, + }, + ], + }, + }); + + if (existingUser) { + res.status(422).json({ message: "A user exists with that email address" }); + return; + } + + // 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); + + // 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) { + res.status(402).json({ + message: "Created user but missing payment", + checkoutSessionId, + }); + return; + } + + res.status(201).json({ message: "Created user", stripeCustomerId: customer.id }); + } else { + res.status(405).end(); + } +} + +export default usernameHandler(handler); diff --git a/packages/features/auth/lib/signup/selfhostedHandler.ts b/packages/features/auth/lib/signup/selfhostedHandler.ts new file mode 100644 index 0000000000..11affd1738 --- /dev/null +++ b/packages/features/auth/lib/signup/selfhostedHandler.ts @@ -0,0 +1,211 @@ +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 type { RequestWithUsernameStatus } from "@calcom/lib/server/username"; +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 { signupSchema } from "@calcom/prisma/zod-utils"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { + if (req.method !== "POST") { + return res.status(405).end(); + } + + 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", + }); + } + } + + 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, + }, + }); + + if (teamMetadata?.isOrganization) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + organizationId: team.id, + }, + }); + } + + const membership = await prisma.membership.update({ + where: { + userId_teamId: { userId: user.id, teamId: team.id }, + }, + data: { + accepted: true, + }, + }); + closeComUpsertTeamUser(team, user, membership.role); + + // Accept any child team invites for orgs. + if (team.parentId) { + // Join ORG + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + organizationId: team.parentId, + }, + }); + + /** 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, + }); + } + + res.status(201).json({ message: "Created user" }); +} diff --git a/packages/features/auth/lib/signup/signupUtils.ts b/packages/features/auth/lib/signup/signupUtils.ts index f0d38b9303..187dd93502 100644 --- a/packages/features/auth/lib/signup/signupUtils.ts +++ b/packages/features/auth/lib/signup/signupUtils.ts @@ -2,7 +2,7 @@ import type Stripe from "stripe"; import stripe from "@calcom/app-store/stripepayment/lib/server"; import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; -import { IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants"; +import { IS_CALCOM, IS_STRIPE_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import type { RequestWithUsernameStatus } from "@calcom/lib/server/username"; import slugify from "@calcom/lib/slugify"; @@ -66,6 +66,7 @@ export function parseSignupData(data: unknown) { } export async function createStripeCustomer({ email, username }: { email: string; username: string }) { // Create the customer in Stripe + if (!IS_STRIPE_ENABLED) return; const customer = await stripe.customers.create({ email, metadata: { @@ -85,6 +86,7 @@ export async function handlePremiumUsernameFlow({ customer?: Stripe.Customer; }) { if (!IS_CALCOM) return; + if (!IS_STRIPE_ENABLED) return; if (!customer) { throw new HttpError({