From dd9f8018724c70f505a90edd7eba8631b2181590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Tue, 28 Sep 2021 02:57:30 -0600 Subject: [PATCH] cal 485 prevent users from changing their username to premium ones (#799) * Makes userRequired middleware * Prevent users from changing usernames to premium ones * refactor on zomars' branch (#801) * rename `profile` -> `mutation` * `createProtectedRouter()` helper * move profile mutation to `viewer.` * simplify checkUsername * Auto scrolls to error when there is one * Renames username helpers * Follows db convention Co-authored-by: Alex Johansson Co-authored-by: Bailey Pumfleet --- ee/lib/core/checkPremiumUsername.ts | 25 +++++++++++ lib/core/checkRegularUsername.ts | 23 ++++++++++ pages/settings/profile.tsx | 33 ++++++-------- server/createRouter.ts | 15 +++++++ server/routers/viewer.tsx | 67 +++++++++++++++++++++-------- 5 files changed, 125 insertions(+), 38 deletions(-) create mode 100644 ee/lib/core/checkPremiumUsername.ts create mode 100644 lib/core/checkRegularUsername.ts diff --git a/ee/lib/core/checkPremiumUsername.ts b/ee/lib/core/checkPremiumUsername.ts new file mode 100644 index 00000000..504f4a28 --- /dev/null +++ b/ee/lib/core/checkPremiumUsername.ts @@ -0,0 +1,25 @@ +import slugify from "@lib/slugify"; + +export async function checkPremiumUsername(_username: string) { + const username = slugify(_username); + const response = await fetch("https://cal.com/api/username", { + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username }), + method: "POST", + mode: "cors", + }); + + if (response.ok) { + return { + available: true as const, + }; + } + const json = await response.json(); + return { + available: false as const, + message: json.message as string, + }; +} diff --git a/lib/core/checkRegularUsername.ts b/lib/core/checkRegularUsername.ts new file mode 100644 index 00000000..0a574da5 --- /dev/null +++ b/lib/core/checkRegularUsername.ts @@ -0,0 +1,23 @@ +import prisma from "@lib/prisma"; +import slugify from "@lib/slugify"; + +export async function checkRegularUsername(_username: string) { + const username = slugify(_username); + + const user = await prisma.user.findUnique({ + where: { username }, + select: { + username: true, + }, + }); + + if (user) { + return { + available: false as const, + message: "A user exists with that username", + }; + } + return { + available: true as const, + }; +} diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index fd4c8e64..c7e9bc9f 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -5,22 +5,24 @@ import { RefObject, useEffect, useRef, useState } from "react"; import Select from "react-select"; import TimezoneSelect from "react-timezone-select"; +import { asStringOrUndefined } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { extractLocaleInfo, localeLabels, localeOptions, OptionType } from "@lib/core/i18n/i18n.utils"; import { useLocale } from "@lib/hooks/useLocale"; import { isBrandingHidden } from "@lib/isBrandingHidden"; import prisma from "@lib/prisma"; +import { trpc } from "@lib/trpc"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import ImageUploader from "@components/ImageUploader"; import Modal from "@components/Modal"; import SettingsShell from "@components/Settings"; import Shell from "@components/Shell"; +import { Alert } from "@components/ui/Alert"; import Avatar from "@components/ui/Avatar"; import Badge from "@components/ui/Badge"; import Button from "@components/ui/Button"; import { UsernameInput } from "@components/ui/UsernameInput"; -import ErrorAlert from "@components/ui/alerts/Error"; const themeOptions = [ { value: "light", label: "Light" }, @@ -86,6 +88,7 @@ function HideBrandingInput(props: { export default function Settings(props: Props) { const { locale } = useLocale({ localeProp: props.localeProp }); + const mutation = trpc.useMutation("viewer.updateProfile"); const [successModalOpen, setSuccessModalOpen] = useState(false); const usernameRef = useRef(null); @@ -129,13 +132,6 @@ export default function Settings(props: Props) { setImageSrc(newAvatar); }; - const handleError = async (resp) => { - if (!resp.ok) { - const error = await resp.json(); - throw new Error(error.message); - } - }; - async function updateProfileHandler(event) { event.preventDefault(); @@ -150,24 +146,18 @@ export default function Settings(props: Props) { // TODO: Add validation - await fetch("/api/user/profile", { - method: "PATCH", - body: JSON.stringify({ + await mutation + .mutateAsync({ username: enteredUsername, name: enteredName, - description: enteredDescription, + bio: enteredDescription, avatar: enteredAvatar, timeZone: enteredTimeZone, - weekStart: enteredWeekStartDay, + weekStart: asStringOrUndefined(enteredWeekStartDay), hideBranding: enteredHideBranding, - theme: selectedTheme ? selectedTheme.value : null, + theme: asStringOrUndefined(selectedTheme?.value), locale: enteredLanguage, - }), - headers: { - "Content-Type": "application/json", - }, - }) - .then(handleError) + }) .then(() => { setSuccessModalOpen(true); setHasErrors(false); // dismiss any open errors @@ -175,6 +165,7 @@ export default function Settings(props: Props) { .catch((err) => { setHasErrors(true); setErrorMessage(err.message); + document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); }); } @@ -182,7 +173,7 @@ export default function Settings(props: Props) {
- {hasErrors && } + {hasErrors && }
diff --git a/server/createRouter.ts b/server/createRouter.ts index 26fc7303..d274a506 100644 --- a/server/createRouter.ts +++ b/server/createRouter.ts @@ -8,3 +8,18 @@ import { Context } from "./createContext"; export function createRouter() { return trpc.router(); } + +export function createProtectedRouter() { + return createRouter().middleware(({ ctx, next }) => { + if (!ctx.user) { + throw new trpc.TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + ...ctx, + // infers that `user` is non-nullable to downstream procedures + user: ctx.user, + }, + }); + }); +} diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index d980ddb0..6b9a6ad5 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -1,24 +1,19 @@ +import { Prisma } from "@prisma/client"; import { TRPCError } from "@trpc/server"; +import { z } from "zod"; -import { createRouter } from "../createRouter"; +import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername"; + +import { checkRegularUsername } from "@lib/core/checkRegularUsername"; +import slugify from "@lib/slugify"; + +import { createProtectedRouter } from "../createRouter"; + +const checkUsername = + process.env.NEXT_PUBLIC_APP_URL === "https://cal.com" ? checkPremiumUsername : checkRegularUsername; // routes only available to authenticated users -export const viewerRouter = createRouter() - // check that user is authenticated - .middleware(({ ctx, next }) => { - const { user } = ctx; - if (!user) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return next({ - ctx: { - ...ctx, - // session value is known to be non-null now - user, - }, - }); - }) +export const viewerRouter = createProtectedRouter() .query("me", { resolve({ ctx }) { return ctx.user; @@ -78,4 +73,42 @@ export const viewerRouter = createRouter() return bookings; }, + }) + .mutation("updateProfile", { + input: z.object({ + username: z.string().optional(), + name: z.string().optional(), + bio: z.string().optional(), + avatar: z.string().optional(), + timeZone: z.string().optional(), + weekStart: z.string().optional(), + hideBranding: z.boolean().optional(), + theme: z.string().optional(), + completedOnboarding: z.boolean().optional(), + locale: z.string().optional(), + }), + async resolve({ input, ctx }) { + const { user, prisma } = ctx; + const data: Prisma.UserUpdateInput = { + ...input, + }; + if (input.username) { + const username = slugify(input.username); + // Only validate if we're changing usernames + if (username !== user.username) { + data.username = username; + const response = await checkUsername(username); + if (!response.available) { + throw new TRPCError({ code: "BAD_REQUEST", message: response.message }); + } + } + } + + await prisma.user.update({ + where: { + id: user.id, + }, + data, + }); + }, });