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 <alexander@n1s.se>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
pull/803/head
Omar López 2021-09-28 02:57:30 -06:00 committed by GitHub
parent f23e4f2b9d
commit dd9f801872
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 125 additions and 38 deletions

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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<HTMLInputElement>(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) {
<Shell heading="Profile" subtitle="Edit your profile information, which shows on your scheduling link.">
<SettingsShell>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
{hasErrors && <ErrorAlert message={errorMessage} />}
{hasErrors && <Alert severity="error" title={errorMessage} />}
<div className="py-6 lg:pb-8">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">

View File

@ -8,3 +8,18 @@ import { Context } from "./createContext";
export function createRouter() {
return trpc.router<Context>();
}
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,
},
});
});
}

View File

@ -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,
});
},
});