🔒 Registration form hardening (#1160)

This commit is contained in:
Luke Vella 2024-06-19 09:16:33 +01:00 committed by GitHub
parent db8655aab9
commit c307963a0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 154 additions and 99 deletions

View file

@ -1,6 +1,16 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@rallly/ui/form";
import { Input } from "@rallly/ui/input"; import { Input } from "@rallly/ui/input";
import { TRPCClientError } from "@trpc/client";
import Link from "next/link"; import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
@ -8,29 +18,32 @@ import { useTranslation } from "next-i18next";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import React from "react"; import React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { VerifyCode } from "@/components/auth/auth-forms"; import { VerifyCode } from "@/components/auth/auth-forms";
import { AuthCard } from "@/components/auth/auth-layout"; import { AuthCard } from "@/components/auth/auth-layout";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useDayjs } from "@/utils/dayjs"; import { useDayjs } from "@/utils/dayjs";
import { requiredString, validEmail } from "@/utils/form-validation";
import { trpc } from "@/utils/trpc/client"; import { trpc } from "@/utils/trpc/client";
type RegisterFormData = { const registerFormSchema = z.object({
name: string; name: z.string().nonempty().max(100),
email: string; email: z.string().email(),
}; });
type RegisterFormData = z.infer<typeof registerFormSchema>;
export const RegisterForm = () => { export const RegisterForm = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { timeZone } = useDayjs(); const { timeZone } = useDayjs();
const params = useParams<{ locale: string }>(); const params = useParams<{ locale: string }>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { register, handleSubmit, getValues, setError, formState } = const form = useForm<RegisterFormData>({
useForm<RegisterFormData>({ defaultValues: { email: "", name: "" },
defaultValues: { email: "" }, resolver: zodResolver(registerFormSchema),
}); });
const { handleSubmit, control, getValues, setError, formState } = form;
const queryClient = trpc.useUtils(); const queryClient = trpc.useUtils();
const requestRegistration = trpc.auth.requestRegistration.useMutation(); const requestRegistration = trpc.auth.requestRegistration.useMutation();
const authenticateRegistration = const authenticateRegistration =
@ -80,87 +93,116 @@ export const RegisterForm = () => {
return ( return (
<div> <div>
<AuthCard> <AuthCard>
<form <Form {...form}>
onSubmit={handleSubmit(async (data) => { <form
const res = await requestRegistration.mutateAsync({ onSubmit={handleSubmit(async (data) => {
email: data.email, try {
name: data.name, await requestRegistration.mutateAsync(
}); {
email: data.email,
if (!res.ok) { name: data.name,
switch (res.reason) { },
case "userAlreadyExists": {
setError("email", { onSuccess: (res) => {
message: t("userAlreadyExists"), if (!res.ok) {
}); switch (res.reason) {
break; case "userAlreadyExists":
case "emailNotAllowed": setError("email", {
setError("email", { message: t("userAlreadyExists"),
message: t("emailNotAllowed"), });
break;
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
});
break;
}
} else {
setToken(res.token);
}
},
},
);
} catch (error) {
if (error instanceof TRPCClientError) {
setError("root", {
message: error.shape.message,
}); });
}
} }
} else {
setToken(res.token);
}
})}
>
<div className="mb-1 text-2xl font-bold">{t("createAnAccount")}</div>
<p className="mb-4 text-gray-500">
{t("stepSummary", {
current: 1,
total: 2,
})} })}
</p>
<fieldset className="mb-4">
<label htmlFor="name" className="mb-1 text-gray-500">
{t("name")}
</label>
<Input
id="name"
className="w-full"
size="lg"
autoFocus={true}
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString })}
/>
{formState.errors.name?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.name.message}
</div>
) : null}
</fieldset>
<fieldset className="mb-4">
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")}
</label>
<Input
className="w-full"
id="email"
size="lg"
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", { validate: validEmail })}
/>
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<Button
loading={formState.isSubmitting}
type="submit"
variant="primary"
size="lg"
> >
{t("continue")} <div className="mb-1 text-2xl font-bold">
</Button> {t("createAnAccount")}
</form> </div>
<p className="mb-6 text-gray-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<div className="space-y-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="name">{t("name")}</FormLabel>
<FormControl>
<Input
{...field}
id="name"
size="lg"
autoFocus={true}
error={!!formState.errors.name}
placeholder={t("namePlaceholder")}
disabled={formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="email">{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
id="email"
size="lg"
error={!!formState.errors.email}
placeholder={t("emailPlaceholder")}
disabled={formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-6">
<Button
loading={formState.isSubmitting}
type="submit"
variant="primary"
size="lg"
>
{t("continue")}
</Button>
</div>
{formState.errors.root ? (
<FormMessage className="mt-6">
{formState.errors.root.message}
</FormMessage>
) : null}
</form>
</Form>
</AuthCard> </AuthCard>
{!getValues("email") ? ( {!form.formState.isSubmitSuccessful ? (
<div className="mt-4 pt-4 text-center text-gray-500 sm:text-base"> <div className="mt-4 pt-4 text-center text-gray-500 sm:text-base">
<Trans <Trans
i18nKey="alreadyRegistered" i18nKey="alreadyRegistered"

View file

@ -2,7 +2,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { VoteType } from "@rallly/database"; import { VoteType } from "@rallly/database";
import { Badge } from "@rallly/ui/badge"; import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { FormMessage } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input"; import { Input } from "@rallly/ui/input";
import { TRPCClientError } from "@trpc/client";
import clsx from "clsx"; import clsx from "clsx";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -16,13 +18,13 @@ import VoteIcon from "./poll/vote-icon";
const requiredEmailSchema = z.object({ const requiredEmailSchema = z.object({
requireEmail: z.literal(true), requireEmail: z.literal(true),
name: z.string().trim().min(1), name: z.string().nonempty().max(100),
email: z.string().email(), email: z.string().email(),
}); });
const optionalEmailSchema = z.object({ const optionalEmailSchema = z.object({
requireEmail: z.literal(false), requireEmail: z.literal(false),
name: z.string().trim().min(1), name: z.string().nonempty().max(100),
email: z.string().email().or(z.literal("")), email: z.string().email().or(z.literal("")),
}); });
@ -87,7 +89,7 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
const isEmailRequired = poll.requireParticipantEmail; const isEmailRequired = poll.requireParticipantEmail;
const { register, formState, setFocus, handleSubmit } = const { register, setError, formState, setFocus, handleSubmit } =
useForm<NewParticipantFormData>({ useForm<NewParticipantFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@ -102,13 +104,21 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
return ( return (
<form <form
onSubmit={handleSubmit(async (data) => { onSubmit={handleSubmit(async (data) => {
const newParticipant = await addParticipant.mutateAsync({ try {
name: data.name, const newParticipant = await addParticipant.mutateAsync({
votes: props.votes, name: data.name,
email: data.email, votes: props.votes,
pollId: poll.id, email: data.email,
}); pollId: poll.id,
props.onSubmit?.(newParticipant); });
props.onSubmit?.(newParticipant);
} catch (error) {
if (error instanceof TRPCClientError) {
setError("root", {
message: error.shape.message,
});
}
}
})} })}
className="space-y-4" className="space-y-4"
> >
@ -152,6 +162,9 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
<label className="mb-1 text-gray-500">{t("response")}</label> <label className="mb-1 text-gray-500">{t("response")}</label>
<VoteSummary votes={props.votes} /> <VoteSummary votes={props.votes} />
</fieldset> </fieldset>
{formState.errors.root ? (
<FormMessage>{formState.errors.root.message}</FormMessage>
) : null}
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={props.onCancel}>{t("cancel")}</Button> <Button onClick={props.onCancel}>{t("cancel")}</Button>
<Button <Button

View file

@ -12,8 +12,8 @@ export const auth = router({
requestRegistration: publicProcedure requestRegistration: publicProcedure
.input( .input(
z.object({ z.object({
name: z.string(), name: z.string().nonempty().max(100),
email: z.string(), email: z.string().email(),
}), }),
) )
.mutation( .mutation(

View file

@ -53,7 +53,7 @@ export const participants = router({
.input( .input(
z.object({ z.object({
pollId: z.string(), pollId: z.string(),
name: z.string().min(1, "Participant name is required"), name: z.string().min(1, "Participant name is required").max(100),
email: z.string().optional(), email: z.string().optional(),
votes: z votes: z
.object({ .object({

View file

@ -29,7 +29,7 @@ const buttonVariants = cva(
size: { size: {
default: "h-9 px-2.5 gap-x-2.5 text-sm", default: "h-9 px-2.5 gap-x-2.5 text-sm",
sm: "h-7 text-sm px-1.5 gap-x-1.5 rounded-md", sm: "h-7 text-sm px-1.5 gap-x-1.5 rounded-md",
lg: "h-11 text-sm gap-x-3 px-4 rounded-md", lg: "h-11 text-base gap-x-3 px-4 rounded-md",
}, },
}, },
defaultVariants: { defaultVariants: {