mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-06 20:51:48 +02:00
🔒 Registration form hardening (#1160)
This commit is contained in:
parent
db8655aab9
commit
c307963a0e
5 changed files with 154 additions and 99 deletions
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue