Fixed #1015 - Teams user registration is broken (#1090)

* Fixed #1015 - Teams user registration is broken

* Type fixes for avilability form in onboarding

* Re adds missing strings

* Updates user availability in one query

Tested and working correctly

* Fixes seeder and tests

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Alex van Andel 2021-11-11 05:44:53 +00:00 committed by GitHub
parent 16fba702fb
commit bf659c0b16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 267 additions and 217 deletions

View File

@ -1,11 +1,14 @@
import { useId } from "@radix-ui/react-id";
import { forwardRef, ReactNode } from "react";
import { FormProvider, SubmitHandler, UseFormReturn } from "react-hook-form";
import { forwardRef, ReactElement, ReactNode, Ref } from "react";
import { FieldValues, FormProvider, SubmitHandler, useFormContext, UseFormReturn } from "react-hook-form";
import classNames from "@lib/classNames";
import { getErrorFromUnknown } from "@lib/errors";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { Alert } from "@components/ui/Alert";
type InputProps = Omit<JSX.IntrinsicElements["input"], "name"> & { name: string };
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
return (
@ -28,78 +31,97 @@ export function Label(props: JSX.IntrinsicElements["label"]) {
);
}
export const TextField = forwardRef<
HTMLInputElement,
{
label: ReactNode;
} & React.ComponentProps<typeof Input> & {
labelProps?: React.ComponentProps<typeof Label>;
}
>(function TextField(props, ref) {
const id = useId();
const { label, ...passThroughToInput } = props;
type InputFieldProps = {
label?: ReactNode;
addOnLeading?: ReactNode;
} & React.ComponentProps<typeof Input> & {
labelProps?: React.ComponentProps<typeof Label>;
};
// TODO: use `useForm()` from RHF and get error state here too!
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(props, ref) {
const id = useId();
const { t } = useLocale();
const methods = useFormContext();
const {
label = t(props.name),
labelProps,
placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder"
? t(props.name + "_placeholder")
: "",
className,
addOnLeading,
...passThroughToInput
} = props;
return (
<div>
<Label htmlFor={id} {...props.labelProps}>
<Label htmlFor={id} {...labelProps}>
{label}
</Label>
<Input id={id} {...passThroughToInput} ref={ref} />
{addOnLeading ? (
<div className="flex mt-1 rounded-md shadow-sm">
{addOnLeading}
<Input
id={id}
placeholder={placeholder}
className={classNames(className, "mt-0")}
{...passThroughToInput}
ref={ref}
/>
</div>
) : (
<Input id={id} placeholder={placeholder} className={className} {...passThroughToInput} ref={ref} />
)}
{methods?.formState?.errors[props.name] && (
<Alert className="mt-1" severity="error" message={methods.formState.errors[props.name].message} />
)}
</div>
);
});
/**
* Form helper that creates a rect-hook-form Provider and helps with submission handling & default error handling
*/
export function Form<TFieldValues>(
props: {
/**
* Pass in the return from `react-hook-form`s `useForm()`
*/
form: UseFormReturn<TFieldValues>;
/**
* Submit handler - you'll get the typed form values back
*/
handleSubmit?: SubmitHandler<TFieldValues>;
/**
* Optional - Override the default error handling
* By default it shows a toast with the error
*/
handleError?: (err: ReturnType<typeof getErrorFromUnknown>) => void;
} & Omit<JSX.IntrinsicElements["form"], "ref">
export const TextField = forwardRef<HTMLInputElement, InputFieldProps>(function TextField(props, ref) {
return <InputField ref={ref} {...props} />;
});
export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(function PasswordField(
props,
ref
) {
const {
form,
handleSubmit,
handleError = (err) => {
showToast(err.message, "error");
},
...passThrough
} = props;
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
});
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
return <InputField type="email" inputMode="email" ref={ref} {...props} />;
});
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
JSX.IntrinsicElements["form"],
"onSubmit"
>;
const PlainForm = <T extends FieldValues>(props: FormProps<T>, ref: Ref<HTMLFormElement>) => {
const { form, handleSubmit, ...passThrough } = props;
return (
<FormProvider {...form}>
<form
onSubmit={
handleSubmit
? form.handleSubmit(async (...args) => {
try {
await handleSubmit(...args);
} catch (_err) {
const err = getErrorFromUnknown(_err);
handleError(err);
}
})
: undefined
}
ref={ref}
onSubmit={(event) => {
form
.handleSubmit(handleSubmit)(event)
.catch((err) => {
showToast(`${getErrorFromUnknown(err).message}`, "error");
});
}}
{...passThrough}>
{props.children}
</form>
</FormProvider>
);
}
};
export const Form = forwardRef(PlainForm) as <T extends FieldValues>(
p: FormProps<T> & { ref?: Ref<HTMLFormElement> }
) => ReactElement;
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
return (

View File

@ -4,6 +4,9 @@ interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> {
label?: string;
}
/**
* @deprecated Use <TextField addOnLeading={}> to achieve the same effect.
*/
const UsernameInput = React.forwardRef<HTMLInputElement, UsernameInputProps>((props, ref) => (
// todo, check if username is already taken here?
<div>

View File

@ -3,9 +3,10 @@ import dayjs, { Dayjs } from "dayjs";
import React, { useCallback, useState } from "react";
import { Controller, useFieldArray } from "react-hook-form";
import { defaultDayRange } from "@lib/availability";
import { weekdayNames } from "@lib/core/i18n/weekday";
import { useLocale } from "@lib/hooks/useLocale";
import { TimeRange, Schedule as ScheduleType } from "@lib/types/schedule";
import { TimeRange } from "@lib/types/schedule";
import Button from "@components/ui/Button";
import Select from "@components/ui/form/Select";
@ -30,22 +31,6 @@ const TIMES = (() => {
})();
/** End Time Increments For Select */
// sets the desired time in current date, needs to be current date for proper DST translation
const defaultDayRange: TimeRange = {
start: new Date(new Date().setHours(9, 0, 0, 0)),
end: new Date(new Date().setHours(17, 0, 0, 0)),
};
export const DEFAULT_SCHEDULE: ScheduleType = [
[],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[],
];
type Option = {
readonly label: string;
readonly value: number;
@ -139,7 +124,7 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
onChange={(e) => (e.target.checked ? replace([defaultDayRange]) : replace([]))}
className="inline-block border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
/>
<span className="text-sm inline-block capitalize">{weekday}</span>
<span className="inline-block text-sm capitalize">{weekday}</span>
</label>
</div>
<div className="flex-grow">
@ -157,7 +142,7 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
/>
</div>
))}
<span className="text-sm block text-gray-500">{!fields.length && t("no_availability")}</span>
<span className="block text-sm text-gray-500">{!fields.length && t("no_availability")}</span>
</div>
<div>
<Button

47
lib/availability.ts Normal file
View File

@ -0,0 +1,47 @@
import { Availability } from "@prisma/client";
import { Schedule, TimeRange } from "./types/schedule";
// sets the desired time in current date, needs to be current date for proper DST translation
export const defaultDayRange: TimeRange = {
start: new Date(new Date().setHours(9, 0, 0, 0)),
end: new Date(new Date().setHours(17, 0, 0, 0)),
};
export const DEFAULT_SCHEDULE: Schedule = [
[],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[defaultDayRange],
[],
];
export function getAvailabilityFromSchedule(schedule: Schedule): Availability[] {
return schedule.reduce((availability: Availability[], times: TimeRange[], day: number) => {
const addNewTime = (time: TimeRange) =>
({
days: [day],
startTime: time.start,
endTime: time.end,
} as Availability);
const filteredTimes = times.filter((time) => {
let idx;
if (
(idx = availability.findIndex(
(schedule) => schedule.startTime === time.start && schedule.endTime === time.end
)) !== -1
) {
availability[idx].days.push(day);
return false;
}
return true;
});
filteredTimes.forEach((time) => {
availability.push(addNewTime(time));
});
return availability;
}, [] as Availability[]);
}

View File

@ -39,6 +39,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
email: userEmail,
},
],
AND: [
{
emailVerified: {
not: null,
},
},
],
},
});

View File

@ -1,12 +1,11 @@
import { Availability } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import { getAvailabilityFromSchedule } from "@lib/availability";
import prisma from "@lib/prisma";
import { TimeRange } from "@lib/types/schedule";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
const session = await getSession({ req });
const userId = session?.user?.id;
if (!userId) {
res.status(401).json({ message: "Not authenticated" });
@ -17,58 +16,33 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Bad Request." });
}
const availability = req.body.schedule.reduce(
(availability: Availability[], times: TimeRange[], day: number) => {
const addNewTime = (time: TimeRange) =>
({
days: [day],
startTime: time.start,
endTime: time.end,
} as Availability);
const filteredTimes = times.filter((time) => {
let idx;
if (
(idx = availability.findIndex(
(schedule) => schedule.startTime === time.start && schedule.endTime === time.end
)) !== -1
) {
availability[idx].days.push(day);
return false;
}
return true;
});
filteredTimes.forEach((time) => {
availability.push(addNewTime(time));
});
return availability;
},
[] as Availability[]
);
const availability = getAvailabilityFromSchedule(req.body.schedule);
if (req.method === "POST") {
try {
await prisma.availability.deleteMany({
await prisma.user.update({
where: {
userId,
id: userId,
},
});
await Promise.all(
availability.map((schedule: Availability) =>
prisma.availability.create({
data: {
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
user: {
connect: {
id: userId,
},
data: {
availability: {
/* We delete user availabilty */
deleteMany: {
userId: {
equals: userId,
},
},
})
)
);
/* So we can replace it */
createMany: {
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
})),
},
},
},
});
return res.status(200).json({
message: "created",
});

View File

@ -1,43 +1,50 @@
import { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/client";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm, SubmitHandler, FormProvider } from "react-hook-form";
import { asStringOrNull } from "@lib/asStringOrNull";
import { useLocale } from "@lib/hooks/useLocale";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { EmailField, PasswordField, TextField } from "@components/form/fields";
import { HeadSeo } from "@components/seo/head-seo";
import { UsernameInput } from "@components/ui/UsernameInput";
import ErrorAlert from "@components/ui/alerts/Error";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
export default function Signup(props) {
type Props = inferSSRProps<typeof getServerSideProps>;
type FormValues = {
username: string;
email: string;
password: string;
passwordcheck: string;
apiError: string;
};
export default function Signup({ email }: Props) {
const { t } = useLocale();
const router = useRouter();
const methods = useForm<FormValues>();
const {
register,
formState: { errors, isSubmitting },
} = methods;
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
methods.setValue("email", email);
const handleErrors = async (resp) => {
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
};
const signUp = (e) => {
e.preventDefault();
if (e.target.password.value !== e.target.passwordcheck.value) {
throw new Error("Password mismatch");
}
const email: string = e.target.email.value;
const password: string = e.target.password.value;
fetch("/api/auth/signup", {
const signUp: SubmitHandler<FormValues> = async (data) => {
await fetch("/api/auth/signup", {
body: JSON.stringify({
username: e.target.username.value,
password,
email,
...data,
}),
headers: {
"Content-Type": "application/json",
@ -45,104 +52,96 @@ export default function Signup(props) {
method: "POST",
})
.then(handleErrors)
.then(() => signIn("Cal.com", { callbackUrl: (router.query.callbackUrl || "") as string }))
.then(async () => await signIn("Cal.com", { callbackUrl: (router.query.callbackUrl || "") as string }))
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
methods.setError("apiError", { message: err.message });
});
};
return (
<div
className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
className="flex flex-col justify-center min-h-screen py-12 bg-gray-50 sm:px-6 lg:px-8"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="font-cal text-center text-3xl font-extrabold text-gray-900">
<h2 className="text-3xl font-extrabold text-center text-gray-900 font-cal">
{t("create_your_account")}
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow mx-2 sm:rounded-lg sm:px-10">
<form method="POST" onSubmit={signUp} className="bg-white space-y-6">
{hasErrors && <ErrorAlert message={errorMessage} />}
<div>
<div className="mb-2">
<UsernameInput required />
</div>
<div className="mb-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
{t("email")}
</label>
<input
type="email"
name="email"
inputMode="email"
id="email"
placeholder="jdoe@example.com"
disabled={!!props.email}
readOnly={!!props.email}
value={props.email}
className="bg-gray-100 mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
<div className="mb-2">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
{t("password")}
</label>
<input
type="password"
name="password"
id="password"
<div className="px-4 py-8 mx-2 bg-white shadow sm:rounded-lg sm:px-10">
{/* TODO: Refactor as soon as /availability is live */}
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(signUp)} className="space-y-6 bg-white">
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
<div className="space-y-2">
<TextField
addOnLeading={
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{process.env.NEXT_PUBLIC_APP_URL}/
</span>
}
labelProps={{ className: "block text-sm font-medium text-gray-700" }}
className="flex-grow block w-full min-w-0 lowercase border-gray-300 rounded-none rounded-r-sm focus:ring-black focus:border-black sm:text-sm"
{...register("username")}
required
placeholder="•••••••••••••"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-brand sm:text-sm"
/>
<EmailField
{...register("email")}
className="block w-full px-3 py-2 mt-1 bg-gray-100 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
<PasswordField
labelProps={{
className: "block text-sm font-medium text-gray-700",
}}
{...register("password")}
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
<PasswordField
label={t("confirm_password")}
labelProps={{
className: "block text-sm font-medium text-gray-700",
}}
{...register("passwordcheck", {
validate: (value) =>
value === methods.watch("password") || (t("error_password_mismatch") as string),
})}
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
</div>
<div>
<label htmlFor="passwordcheck" className="block text-sm font-medium text-gray-700">
{t("confirm_password")}
</label>
<input
type="password"
name="passwordcheck"
id="passwordcheck"
required
placeholder="•••••••••••••"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-brand sm:text-sm"
/>
<div className="flex space-x-2">
<Button loading={isSubmitting} className="justify-center w-7/12">
{t("create_account")}
</Button>
<Button
color="secondary"
className="justify-center w-5/12"
onClick={() =>
signIn("Cal.com", { callbackUrl: (router.query.callbackUrl || "") as string })
}>
{t("login_instead")}
</Button>
</div>
</div>
<div className="mt-3 sm:mt-4 flex">
<input
type="submit"
value={t("create_account")}
className="btn btn-primary w-7/12 mr-2 inline-flex justify-center rounded-md border border-transparent cursor-pointer shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm"
/>
<a
onClick={() => signIn("Cal.com", { callbackUrl: (router.query.callbackUrl || "") as string })}
className="w-5/12 inline-flex justify-center text-sm text-gray-500 font-medium border px-4 py-2 rounded btn cursor-pointer">
{t("login_instead")}
</a>
</div>
</form>
</form>
</FormProvider>
</div>
</div>
</div>
);
}
export async function getServerSideProps(ctx) {
if (!ctx.query.token) {
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const token = asStringOrNull(ctx.query.token);
if (!token) {
return {
notFound: true,
};
}
const verificationRequest = await prisma.verificationRequest.findUnique({
where: {
token: ctx.query.token,
token,
},
});
@ -175,4 +174,4 @@ export async function getServerSideProps(ctx) {
}
return { props: { email: verificationRequest.identifier } };
}
};

View File

@ -2,6 +2,7 @@ import Link from "next/link";
import { useForm } from "react-hook-form";
import { QueryCell } from "@lib/QueryCell";
import { DEFAULT_SCHEDULE } from "@lib/availability";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { inferQueryOutput, trpc } from "@lib/trpc";
@ -10,7 +11,7 @@ import { Schedule as ScheduleType } from "@lib/types/schedule";
import Shell from "@components/Shell";
import { Form } from "@components/form/fields";
import Button from "@components/ui/Button";
import Schedule, { DEFAULT_SCHEDULE } from "@components/ui/form/Schedule";
import Schedule from "@components/ui/form/Schedule";
type FormValues = {
schedule: ScheduleType;

View File

@ -16,6 +16,7 @@ import { useForm } from "react-hook-form";
import TimezoneSelect from "react-timezone-select";
import { getSession } from "@lib/auth";
import { DEFAULT_SCHEDULE } from "@lib/availability";
import { useLocale } from "@lib/hooks/useLocale";
import getIntegrations from "@lib/integrations/getIntegrations";
import prisma from "@lib/prisma";
@ -29,7 +30,7 @@ import { CalendarListContainer } from "@components/integrations/CalendarListCont
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
import Schedule, { DEFAULT_SCHEDULE } from "@components/ui/form/Schedule";
import Schedule from "@components/ui/form/Schedule";
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
@ -313,7 +314,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
title: t("set_availability"),
description: t("set_availability_instructions"),
Component: (
<Form
<Form<ScheduleFormValues>
className="max-w-lg mx-auto text-black bg-white dark:bg-opacity-5 dark:text-white"
form={availabilityForm}
handleSubmit={async (values) => {

View File

@ -245,7 +245,7 @@
"troubleshoot_availability": "Troubleshoot your availability to explore why your times are showing as they are.",
"change_available_times": "Change available times",
"change_your_available_times": "Change your available times",
"change_start_end": "Set your weekly hours",
"change_start_end": "Change the start and end times of your day",
"change_start_end_buffer": "Set the start and end time of your day and a minimum buffer between your meetings.",
"current_start_date": "Currently, your day is set to start at",
"start_end_changed_successfully": "The start and end times for your day have been changed successfully.",
@ -254,7 +254,10 @@
"dark": "Dark",
"automatically_adjust_theme": "Automatically adjust theme based on invitee preferences",
"email": "Email",
"email_placeholder": "jdoe@example.com",
"full_name": "Full name",
"browse_api_documentation": "Browse our API documentation",
"leverage_our_api": "Leverage our API for full control and customizability.",
"create_webhook": "Create Webhook",
"booking_cancelled": "Booking Cancelled",
"booking_rescheduled": "Booking Rescheduled",
@ -463,7 +466,7 @@
"manage_your_billing_info": "Manage your billing information and cancel your subscription.",
"availability": "Availability",
"availability_updated_successfully": "Availability updated successfully",
"configure_availability": "Set times when you are available for bookings.",
"configure_availability": "Configure times when you are available for bookings.",
"change_weekly_schedule": "Change your weekly schedule",
"logo": "Logo",
"error": "Error",
@ -525,5 +528,7 @@
"connect_an_additional_calendar": "Connect an additional calendar",
"conferencing": "Conferencing",
"calendar": "Calendar",
"not_installed": "Not installed"
"not_installed": "Not installed",
"error_password_mismatch": "Passwords don't match.",
"error_required_field": "This field is required."
}

View File

@ -3,6 +3,7 @@ import dayjs from "dayjs";
import { uuid } from "short-uuid";
import { hashPassword } from "../lib/auth";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "../lib/availability";
const prisma = new PrismaClient();
@ -26,6 +27,11 @@ async function createUserAndEventType(opts: {
password: await hashPassword(opts.user.password),
emailVerified: new Date(),
completedOnboarding: opts.user.completedOnboarding ?? true,
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
},
},
};
const user = await prisma.user.upsert({
where: { email: opts.user.email },