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
parent
f23e4f2b9d
commit
dd9f801872
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 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">
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue