🔒 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,13 +93,17 @@ export const RegisterForm = () => {
return ( return (
<div> <div>
<AuthCard> <AuthCard>
<Form {...form}>
<form <form
onSubmit={handleSubmit(async (data) => { onSubmit={handleSubmit(async (data) => {
const res = await requestRegistration.mutateAsync({ try {
await requestRegistration.mutateAsync(
{
email: data.email, email: data.email,
name: data.name, name: data.name,
}); },
{
onSuccess: (res) => {
if (!res.ok) { if (!res.ok) {
switch (res.reason) { switch (res.reason) {
case "userAlreadyExists": case "userAlreadyExists":
@ -98,58 +115,76 @@ export const RegisterForm = () => {
setError("email", { setError("email", {
message: t("emailNotAllowed"), message: t("emailNotAllowed"),
}); });
break;
} }
} else { } else {
setToken(res.token); setToken(res.token);
} }
},
},
);
} catch (error) {
if (error instanceof TRPCClientError) {
setError("root", {
message: error.shape.message,
});
}
}
})} })}
> >
<div className="mb-1 text-2xl font-bold">{t("createAnAccount")}</div> <div className="mb-1 text-2xl font-bold">
<p className="mb-4 text-gray-500"> {t("createAnAccount")}
</div>
<p className="mb-6 text-gray-500">
{t("stepSummary", { {t("stepSummary", {
current: 1, current: 1,
total: 2, total: 2,
})} })}
</p> </p>
<fieldset className="mb-4"> <div className="space-y-4">
<label htmlFor="name" className="mb-1 text-gray-500"> <FormField
{t("name")} control={control}
</label> name="name"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="name">{t("name")}</FormLabel>
<FormControl>
<Input <Input
{...field}
id="name" id="name"
className="w-full"
size="lg" size="lg"
autoFocus={true} autoFocus={true}
error={!!formState.errors.name} error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")} placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString })} disabled={formState.isSubmitting}
/> />
{formState.errors.name?.message ? ( </FormControl>
<div className="mt-2 text-sm text-rose-500"> <FormMessage />
{formState.errors.name.message} </FormItem>
</div> )}
) : null} />
</fieldset> <FormField
<fieldset className="mb-4"> control={control}
<label htmlFor="email" className="mb-1 text-gray-500"> name="email"
{t("email")} render={({ field }) => (
</label> <FormItem>
<FormLabel htmlFor="email">{t("email")}</FormLabel>
<FormControl>
<Input <Input
className="w-full" {...field}
id="email" id="email"
size="lg" size="lg"
error={!!formState.errors.email} error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")} placeholder={t("emailPlaceholder")}
{...register("email", { validate: validEmail })} disabled={formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
{formState.errors.email.message}
</div> </div>
) : null} <div className="mt-6">
</fieldset>
<Button <Button
loading={formState.isSubmitting} loading={formState.isSubmitting}
type="submit" type="submit"
@ -158,9 +193,16 @@ export const RegisterForm = () => {
> >
{t("continue")} {t("continue")}
</Button> </Button>
</div>
{formState.errors.root ? (
<FormMessage className="mt-6">
{formState.errors.root.message}
</FormMessage>
) : null}
</form> </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,6 +104,7 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
return ( return (
<form <form
onSubmit={handleSubmit(async (data) => { onSubmit={handleSubmit(async (data) => {
try {
const newParticipant = await addParticipant.mutateAsync({ const newParticipant = await addParticipant.mutateAsync({
name: data.name, name: data.name,
votes: props.votes, votes: props.votes,
@ -109,6 +112,13 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
pollId: poll.id, 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: {