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>
This commit is contained in:
parent
f23e4f2b9d
commit
dd9f801872
5 changed files with 125 additions and 38 deletions
25
ee/lib/core/checkPremiumUsername.ts
Normal file
25
ee/lib/core/checkPremiumUsername.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
23
lib/core/checkRegularUsername.ts
Normal file
23
lib/core/checkRegularUsername.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -5,22 +5,24 @@ import { RefObject, useEffect, useRef, useState } from "react";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import TimezoneSelect from "react-timezone-select";
|
import TimezoneSelect from "react-timezone-select";
|
||||||
|
|
||||||
|
import { asStringOrUndefined } from "@lib/asStringOrNull";
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { extractLocaleInfo, localeLabels, localeOptions, OptionType } from "@lib/core/i18n/i18n.utils";
|
import { extractLocaleInfo, localeLabels, localeOptions, OptionType } from "@lib/core/i18n/i18n.utils";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { trpc } from "@lib/trpc";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import ImageUploader from "@components/ImageUploader";
|
import ImageUploader from "@components/ImageUploader";
|
||||||
import Modal from "@components/Modal";
|
import Modal from "@components/Modal";
|
||||||
import SettingsShell from "@components/Settings";
|
import SettingsShell from "@components/Settings";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
|
import { Alert } from "@components/ui/Alert";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
import { UsernameInput } from "@components/ui/UsernameInput";
|
import { UsernameInput } from "@components/ui/UsernameInput";
|
||||||
import ErrorAlert from "@components/ui/alerts/Error";
|
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: "Light" },
|
||||||
|
@ -86,6 +88,7 @@ function HideBrandingInput(props: {
|
||||||
|
|
||||||
export default function Settings(props: Props) {
|
export default function Settings(props: Props) {
|
||||||
const { locale } = useLocale({ localeProp: props.localeProp });
|
const { locale } = useLocale({ localeProp: props.localeProp });
|
||||||
|
const mutation = trpc.useMutation("viewer.updateProfile");
|
||||||
|
|
||||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||||
const usernameRef = useRef<HTMLInputElement>(null);
|
const usernameRef = useRef<HTMLInputElement>(null);
|
||||||
|
@ -129,13 +132,6 @@ export default function Settings(props: Props) {
|
||||||
setImageSrc(newAvatar);
|
setImageSrc(newAvatar);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = async (resp) => {
|
|
||||||
if (!resp.ok) {
|
|
||||||
const error = await resp.json();
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function updateProfileHandler(event) {
|
async function updateProfileHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
@ -150,24 +146,18 @@ export default function Settings(props: Props) {
|
||||||
|
|
||||||
// TODO: Add validation
|
// TODO: Add validation
|
||||||
|
|
||||||
await fetch("/api/user/profile", {
|
await mutation
|
||||||
method: "PATCH",
|
.mutateAsync({
|
||||||
body: JSON.stringify({
|
|
||||||
username: enteredUsername,
|
username: enteredUsername,
|
||||||
name: enteredName,
|
name: enteredName,
|
||||||
description: enteredDescription,
|
bio: enteredDescription,
|
||||||
avatar: enteredAvatar,
|
avatar: enteredAvatar,
|
||||||
timeZone: enteredTimeZone,
|
timeZone: enteredTimeZone,
|
||||||
weekStart: enteredWeekStartDay,
|
weekStart: asStringOrUndefined(enteredWeekStartDay),
|
||||||
hideBranding: enteredHideBranding,
|
hideBranding: enteredHideBranding,
|
||||||
theme: selectedTheme ? selectedTheme.value : null,
|
theme: asStringOrUndefined(selectedTheme?.value),
|
||||||
locale: enteredLanguage,
|
locale: enteredLanguage,
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.then(handleError)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSuccessModalOpen(true);
|
setSuccessModalOpen(true);
|
||||||
setHasErrors(false); // dismiss any open errors
|
setHasErrors(false); // dismiss any open errors
|
||||||
|
@ -175,6 +165,7 @@ export default function Settings(props: Props) {
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setHasErrors(true);
|
setHasErrors(true);
|
||||||
setErrorMessage(err.message);
|
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.">
|
<Shell heading="Profile" subtitle="Edit your profile information, which shows on your scheduling link.">
|
||||||
<SettingsShell>
|
<SettingsShell>
|
||||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
<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="py-6 lg:pb-8">
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<div className="flex-grow space-y-6">
|
<div className="flex-grow space-y-6">
|
||||||
|
|
|
@ -8,3 +8,18 @@ import { Context } from "./createContext";
|
||||||
export function createRouter() {
|
export function createRouter() {
|
||||||
return trpc.router<Context>();
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -1,24 +1,19 @@
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { TRPCError } from "@trpc/server";
|
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
|
// routes only available to authenticated users
|
||||||
export const viewerRouter = createRouter()
|
export const viewerRouter = createProtectedRouter()
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.query("me", {
|
.query("me", {
|
||||||
resolve({ ctx }) {
|
resolve({ ctx }) {
|
||||||
return ctx.user;
|
return ctx.user;
|
||||||
|
@ -78,4 +73,42 @@ export const viewerRouter = createRouter()
|
||||||
|
|
||||||
return bookings;
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue