diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index 7dfe122ea8..f0ef6fd9d0 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -1,128 +1,44 @@ import type { NextApiResponse } from "next"; -import { - createUser, - findExistingUser, - ensurePostMethod, - handlePremiumUsernameFlow, - parseSignupData, - sendVerificationEmail, - syncServicesCreateUser, - throwIfSignupIsDisabled, - createStripeCustomer, -} from "@calcom/feature-auth/lib/signup/signupUtils"; -import { - checkIfTokenExistsAndValid, - acceptAllInvitesWithTeamId, - findTeam, - upsertUsersPasswordAndVerify, - joinOrgAndAcceptChildInvites, - cleanUpInviteToken, -} from "@calcom/feature-auth/lib/signup/teamInviteUtils"; -import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; +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 { 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"; -async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { +function ensureReqIsPost(req: RequestWithUsernameStatus) { + if (req.method !== "POST") { + throw new HttpError({ + statusCode: 405, + message: "Method not allowed", + }); + } +} + +function ensureSignupIsEnabled() { + if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") { + throw new HttpError({ + statusCode: 403, + message: "Signup is disabled", + }); + } +} + +export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { + // Use a try catch instead of returning res every time try { - ensurePostMethod(req); - throwIfSignupIsDisabled(); - const { email, password, language, token, username } = parseSignupData(req.body); - const hashedPassword = await hashPassword(password); + ensureReqIsPost(req); + ensureSignupIsEnabled(); - const customer = await createStripeCustomer({ - email, - username, - }); - - const premiumUsernameMetadata = await handlePremiumUsernameFlow({ - customer, - premiumUsernameStatusCode: req.usernameStatus.statusCode, - }); - - if (!token) { - await findExistingUser(username, email); - - // Create the user - const user = await createUser({ - username, - email, - hashedPassword, - metadata: premiumUsernameMetadata, - }); - await sendVerificationEmail({ - email, - language: language || (await getLocaleFromRequest(req)), - username: username || "", - }); - await syncServicesCreateUser(user); - } else { - const foundToken = await checkIfTokenExistsAndValid(token); - if (foundToken?.teamId) { - const teamUserValidation = await validateUsernameInTeam(username, email, foundToken?.teamId); - if (!teamUserValidation.isValid) { - return res.status(409).json({ message: "Username or email is already taken" }); - } - - const team = await findTeam(foundToken.teamId); - - if (team) { - const teamMetadata = team.metadata; - const user = await upsertUsersPasswordAndVerify(email, username, hashedPassword); - if (teamMetadata?.isOrganization) { - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - organizationId: team.id, - }, - }); - } - const membership = await acceptAllInvitesWithTeamId(user.id, team.id); - closeComUpsertTeamUser(team, user, membership.role); - if (team.parentId) { - await joinOrgAndAcceptChildInvites(user.id, team.parentId); - } - await cleanUpInviteToken(foundToken.id); - } - } + if (IS_CALCOM) { + return calcomSignupHandler(req, res); } - if (IS_CALCOM && premiumUsernameMetadata) { - if (premiumUsernameMetadata.checkoutSessionId) { - return res.status(402).json({ - message: "Created user but missing payment", - checkoutSessionId: premiumUsernameMetadata.checkoutSessionId, - }); - } - return res - .status(201) - .json({ message: "Created user", stripeCustomerId: premiumUsernameMetadata.stripeCustomerId }); - } - - return res.status(201).json({ message: "Created user" }); + return selfhostedSignupHandler(req, res); } catch (e) { - console.log({ - e, - }); if (e instanceof HttpError) { return res.status(e.statusCode).json({ message: e.message }); } - - return res.status(500).json({ message: "Internal server error" }); + throw e; } - - // if (IS_CALCOM) { - // // return await hostedHandler(req, res); - // } else { - // return await selfHostedHandler(req, res); - // } } - -export default usernameHandler(handler); diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts new file mode 100644 index 0000000000..c59020c8d3 --- /dev/null +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -0,0 +1,131 @@ +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 { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager"; +import prisma from "@calcom/prisma"; + +import { usernameHandler, type RequestWithUsernameStatus } from "../username"; + +async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { + 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 }); +} + +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..5292804b6b --- /dev/null +++ b/packages/features/auth/signup/handlers/selfHostedHandler.ts @@ -0,0 +1,201 @@ +import type { NextApiRequest, 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 { signupSchema } from "@calcom/prisma/zod-utils"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +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 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/signup/username.ts b/packages/features/auth/signup/username.ts new file mode 100644 index 0000000000..cd162a9ff9 --- /dev/null +++ b/packages/features/auth/signup/username.ts @@ -0,0 +1,126 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import notEmpty from "@calcom/lib/notEmpty"; +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; + +const wordlist = {}; + +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 const isPremiumUserName = (username: string): boolean => + username.length <= 4 || Object.prototype.hasOwnProperty.call(wordlist, username); + +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 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 (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 };