Skip to content

Commit

Permalink
🔒 Registration form hardening (#1160)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukevella authored Jun 19, 2024
1 parent db8655a commit c307963
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 99 deletions.
212 changes: 127 additions & 85 deletions apps/web/src/app/[locale]/(auth)/register/register-page.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,49 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@rallly/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import { TRPCClientError } from "@trpc/client";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import { signIn } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { usePostHog } from "posthog-js/react";
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { VerifyCode } from "@/components/auth/auth-forms";
import { AuthCard } from "@/components/auth/auth-layout";
import { Trans } from "@/components/trans";
import { useDayjs } from "@/utils/dayjs";
import { requiredString, validEmail } from "@/utils/form-validation";
import { trpc } from "@/utils/trpc/client";

type RegisterFormData = {
name: string;
email: string;
};
const registerFormSchema = z.object({
name: z.string().nonempty().max(100),
email: z.string().email(),
});

type RegisterFormData = z.infer<typeof registerFormSchema>;

export const RegisterForm = () => {
const { t } = useTranslation();
const { timeZone } = useDayjs();
const params = useParams<{ locale: string }>();
const searchParams = useSearchParams();
const { register, handleSubmit, getValues, setError, formState } =
useForm<RegisterFormData>({
defaultValues: { email: "" },
});
const form = useForm<RegisterFormData>({
defaultValues: { email: "", name: "" },
resolver: zodResolver(registerFormSchema),
});

const { handleSubmit, control, getValues, setError, formState } = form;
const queryClient = trpc.useUtils();
const requestRegistration = trpc.auth.requestRegistration.useMutation();
const authenticateRegistration =
Expand Down Expand Up @@ -80,87 +93,116 @@ export const RegisterForm = () => {
return (
<div>
<AuthCard>
<form
onSubmit={handleSubmit(async (data) => {
const res = await requestRegistration.mutateAsync({
email: data.email,
name: data.name,
});

if (!res.ok) {
switch (res.reason) {
case "userAlreadyExists":
setError("email", {
message: t("userAlreadyExists"),
});
break;
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
<Form {...form}>
<form
onSubmit={handleSubmit(async (data) => {
try {
await requestRegistration.mutateAsync(
{
email: data.email,
name: data.name,
},
{
onSuccess: (res) => {
if (!res.ok) {
switch (res.reason) {
case "userAlreadyExists":
setError("email", {
message: t("userAlreadyExists"),
});
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")}
</Button>
</form>
<div className="mb-1 text-2xl font-bold">
{t("createAnAccount")}
</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>
{!getValues("email") ? (
{!form.formState.isSubmitSuccessful ? (
<div className="mt-4 pt-4 text-center text-gray-500 sm:text-base">
<Trans
i18nKey="alreadyRegistered"
Expand Down
33 changes: 23 additions & 10 deletions apps/web/src/components/new-participant-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { VoteType } from "@rallly/database";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { FormMessage } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import { TRPCClientError } from "@trpc/client";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
Expand All @@ -16,13 +18,13 @@ import VoteIcon from "./poll/vote-icon";

const requiredEmailSchema = z.object({
requireEmail: z.literal(true),
name: z.string().trim().min(1),
name: z.string().nonempty().max(100),
email: z.string().email(),
});

const optionalEmailSchema = z.object({
requireEmail: z.literal(false),
name: z.string().trim().min(1),
name: z.string().nonempty().max(100),
email: z.string().email().or(z.literal("")),
});

Expand Down Expand Up @@ -87,7 +89,7 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {

const isEmailRequired = poll.requireParticipantEmail;

const { register, formState, setFocus, handleSubmit } =
const { register, setError, formState, setFocus, handleSubmit } =
useForm<NewParticipantFormData>({
resolver: zodResolver(schema),
defaultValues: {
Expand All @@ -102,13 +104,21 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
return (
<form
onSubmit={handleSubmit(async (data) => {
const newParticipant = await addParticipant.mutateAsync({
name: data.name,
votes: props.votes,
email: data.email,
pollId: poll.id,
});
props.onSubmit?.(newParticipant);
try {
const newParticipant = await addParticipant.mutateAsync({
name: data.name,
votes: props.votes,
email: data.email,
pollId: poll.id,
});
props.onSubmit?.(newParticipant);
} catch (error) {
if (error instanceof TRPCClientError) {
setError("root", {
message: error.shape.message,
});
}
}
})}
className="space-y-4"
>
Expand Down Expand Up @@ -152,6 +162,9 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
<label className="mb-1 text-gray-500">{t("response")}</label>
<VoteSummary votes={props.votes} />
</fieldset>
{formState.errors.root ? (
<FormMessage>{formState.errors.root.message}</FormMessage>
) : null}
<div className="flex gap-2">
<Button onClick={props.onCancel}>{t("cancel")}</Button>
<Button
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/trpc/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export const auth = router({
requestRegistration: publicProcedure
.input(
z.object({
name: z.string(),
email: z.string(),
name: z.string().nonempty().max(100),
email: z.string().email(),
}),
)
.mutation(
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/trpc/routers/polls/participants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const participants = router({
.input(
z.object({
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(),
votes: z
.object({
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const buttonVariants = cva(
size: {
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",
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: {
Expand Down

0 comments on commit c307963

Please sign in to comment.