diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index f44c231fb3..7fd211dda6 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -1,210 +1,215 @@ -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 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 { IS_CALCOM } from "@calcom/lib/constants"; +import { WEBAPP_URL } 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 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 { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager"; import { signupSchema } from "@calcom/prisma/zod-utils"; -import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +export async function findExistingUser(username: string, email: string) { + const user = await prisma.user.findFirst({ + where: { + OR: [ + { + username, + }, + { + email, + }, + ], + }, + }); + if (user) { + throw new HttpError({ + statusCode: 442, + message: "A user exists with that email address", + }); + } +} + +function ensurePostMethod(req: RequestWithUsernameStatus, res: NextApiResponse) { if (req.method !== "POST") { - return res.status(405).end(); + throw new HttpError({ + statusCode: 405, + message: "Method not allowed", + }); } +} +function throwIfSignupIsDisabled() { if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") { - res.status(403).json({ message: "Signup is disabled" }); - return; + throw new HttpError({ + statusCode: 403, + message: "Signup is disabled", + }); } +} - 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; +function parseSignupData(data: unknown) { + const parsedSchema = signupSchema.safeParse(data); + if (!parsedSchema.success) { + throw new HttpError({ + statusCode: 422, + message: "Invalid input", + }); } + return { + ...parsedSchema.data, + email: parsedSchema.data.email.toLowerCase(), + username: slugify(parsedSchema.data.username), + }; +} - 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, - }, +async function handlePremiumUsernameFlow({ + email, + username, + premiumUsernameStatusCode, +}: { + email: string; + username: string; + premiumUsernameStatusCode: number; +}) { + if (!IS_CALCOM) return; + const metadata: { + stripeCustomerId?: string; + checkoutSessionId?: string; + } = {}; + + // 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}`; + + if (premiumUsernameStatusCode === 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, }); - 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" }); - } + /** We create a username-less user until he pays */ + metadata["stripeCustomerId"] = customer.id; + metadata["checkoutSessionId"] = checkoutSession.id; } - const hashedPassword = await hashPassword(password); + return metadata; +} - if (foundToken && foundToken?.teamId) { - const team = await prisma.team.findUnique({ - where: { - id: foundToken.teamId, - }, +function createUser({ + username, + email, + hashedPassword, + metadata, +}: { + username: string; + email: string; + hashedPassword: string; + metadata?: { + stripeCustomerId?: string; + checkoutSessionId?: string; + }; +}) { + return prisma.user.create({ + data: { + username, + email, + password: hashedPassword, + metadata, + }, + }); +} + +function syncServicesCreateUser(user: Awaited>) { + return IS_CALCOM && syncServicesCreateWebUser(user); +} + +function sendVerificationEmail({ + email, + language, + username, +}: { + email: string; + language: string; + username: string; +}) { + if (!IS_CALCOM) return; + return sendEmailVerification({ + email, + language, + username: username || "", + }); +} + +export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { + try { + ensurePostMethod(req, res); + throwIfSignupIsDisabled(); + const { email, password, language, token, username } = parseSignupData(req.body); + await findExistingUser(username, email); + const hashedPassword = await hashPassword(password); + const premiumUsernameMetadata = await handlePremiumUsernameFlow({ + email, + username, + premiumUsernameStatusCode: req.usernameStatus.statusCode, }); - 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, - }, + // Create the user + const user = await createUser({ + username, + email, + hashedPassword, + metadata: premiumUsernameMetadata, }); await sendEmailVerification({ - email: userEmail, - username, - language, + email, + language: await getLocaleFromRequest(req), + username: username || "", }); + await syncServicesCreateUser(user); + + 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 }); + } + } catch (e) { + if (e instanceof HttpError) { + return res.status(e.statusCode).json({ message: e.message }); + } + return res.status(500).json({ message: "Internal server error" }); } - res.status(201).json({ message: "Created user" }); + // if (IS_CALCOM) { + // // return await hostedHandler(req, res); + // } else { + // return await selfHostedHandler(req, res); + // } } diff --git a/packages/lib/server/username.ts b/packages/lib/server/username.ts new file mode 100644 index 0000000000..60c9d548e6 --- /dev/null +++ b/packages/lib/server/username.ts @@ -0,0 +1,126 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; + +import notEmpty from "../../../apps/website/lib/utils/notEmpty"; +import { wordlist } from "../../../apps/website/lib/utils/wordlist/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 }; diff --git a/yarn.lock b/yarn.lock index 3d6d9fa713..c8fef97305 100644 --- a/yarn.lock +++ b/yarn.lock @@ -197,25 +197,6 @@ __metadata: languageName: node linkType: hard -"@auth/core@npm:^0.1.4": - version: 0.1.4 - resolution: "@auth/core@npm:0.1.4" - dependencies: - "@panva/hkdf": 1.0.2 - cookie: 0.5.0 - jose: 4.11.1 - oauth4webapi: 2.0.5 - preact: 10.11.3 - preact-render-to-string: 5.2.3 - peerDependencies: - nodemailer: 6.8.0 - peerDependenciesMeta: - nodemailer: - optional: true - checksum: 64854404ea1883e0deb5535b34bed95cd43fc85094aeaf4f15a79e14045020eb944f844defe857edfc8528a0a024be89cbb2a3069dedef0e9217a74ca6c3eb79 - languageName: node - linkType: hard - "@aws-crypto/ie11-detection@npm:^3.0.0": version: 3.0.0 resolution: "@aws-crypto/ie11-detection@npm:3.0.0" @@ -3221,41 +3202,6 @@ __metadata: languageName: unknown linkType: soft -"@calcom/auth@workspace:apps/auth": - version: 0.0.0-use.local - resolution: "@calcom/auth@workspace:apps/auth" - dependencies: - "@auth/core": ^0.1.4 - "@calcom/app-store": "*" - "@calcom/app-store-cli": "*" - "@calcom/config": "*" - "@calcom/core": "*" - "@calcom/dayjs": "*" - "@calcom/embed-core": "workspace:*" - "@calcom/embed-react": "workspace:*" - "@calcom/embed-snippet": "workspace:*" - "@calcom/features": "*" - "@calcom/lib": "*" - "@calcom/prisma": "*" - "@calcom/trpc": "*" - "@calcom/tsconfig": "*" - "@calcom/types": "*" - "@calcom/ui": "*" - "@types/node": 16.9.1 - "@types/react": 18.0.26 - "@types/react-dom": ^18.0.9 - eslint: ^8.34.0 - eslint-config-next: ^13.2.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.1 - typescript: ^4.9.4 - languageName: unknown - linkType: soft - "@calcom/basecamp3@workspace:packages/app-store/basecamp3": version: 0.0.0-use.local resolution: "@calcom/basecamp3@workspace:packages/app-store/basecamp3" @@ -7653,13 +7599,6 @@ __metadata: languageName: node linkType: hard -"@panva/hkdf@npm:1.0.2": - version: 1.0.2 - resolution: "@panva/hkdf@npm:1.0.2" - checksum: 75183b4d5ea816ef516dcea70985c610683579a9e2ac540c2d59b9a3ed27eedaff830a43a1c43c1683556a457c92ac66e09109ee995ab173090e4042c4c4bb03 - languageName: node - linkType: hard - "@panva/hkdf@npm:^1.0.2": version: 1.0.4 resolution: "@panva/hkdf@npm:1.0.4" @@ -24677,13 +24616,6 @@ __metadata: languageName: node linkType: hard -"jose@npm:4.11.1": - version: 4.11.1 - resolution: "jose@npm:4.11.1" - checksum: cd15cba258d0fd20f6168631ce2e94fda8442df80e43c1033c523915cecdf390a1cc8efe0eab0c2d65935ca973d791c668aea80724d2aa9c2879d4e70f3081d7 - languageName: node - linkType: hard - "jose@npm:4.12.0": version: 4.12.0 resolution: "jose@npm:4.12.0" @@ -28758,13 +28690,6 @@ __metadata: languageName: node linkType: hard -"oauth4webapi@npm:2.0.5": - version: 2.0.5 - resolution: "oauth4webapi@npm:2.0.5" - checksum: 32d0cb7b1cca42d51dfb88075ca2d69fe33172a807e8ea50e317d17cab3bc80588ab8ebcb7eb4600c371a70af4674595b4b341daf6f3a655f1efa1ab715bb6c9 - languageName: node - linkType: hard - "oauth@npm:^0.9.15": version: 0.9.15 resolution: "oauth@npm:0.9.15" @@ -30535,17 +30460,6 @@ __metadata: languageName: node linkType: hard -"preact-render-to-string@npm:5.2.3": - version: 5.2.3 - resolution: "preact-render-to-string@npm:5.2.3" - dependencies: - pretty-format: ^3.8.0 - peerDependencies: - preact: ">=10" - checksum: 6e46288d8956adde35b9fe3a21aecd9dea29751b40f0f155dea62f3896f27cb8614d457b32f48d33909d2da81135afcca6c55077520feacd7d15164d1371fb44 - languageName: node - linkType: hard - "preact-render-to-string@npm:^5.1.19": version: 5.2.6 resolution: "preact-render-to-string@npm:5.2.6" @@ -30557,7 +30471,7 @@ __metadata: languageName: node linkType: hard -"preact@npm:10.11.3, preact@npm:^10.6.3": +"preact@npm:^10.6.3": version: 10.11.3 resolution: "preact@npm:10.11.3" checksum: 9387115aa0581e8226309e6456e9856f17dfc0e3d3e63f774de80f3d462a882ba7c60914c05942cb51d51e23e120dcfe904b8d392d46f29ad15802941fe7a367 @@ -39535,4 +39449,4 @@ __metadata: resolution: "zwitch@npm:2.0.4" checksum: f22ec5fc2d5f02c423c93d35cdfa83573a3a3bd98c66b927c368ea4d0e7252a500df2a90a6b45522be536a96a73404393c958e945fdba95e6832c200791702b6 languageName: node - linkType: hard \ No newline at end of file + linkType: hard