Nader's Daily Blog
Welcome to Every Developers favorite blog in the Devosphere

New product features | The latest in technology | The weekly debugging nightmares & more!

How to enable password reset using auth.js

How to enable password reset using auth.js

January 6, 2024

Nader Elmahdy

Nader Elmahdy

Take my knowledge and shove it up your brain...

Forgot your password? now worries, I got your bro.

Authentication

In the last 3 articles, we learned how to add credential authentication and enable email verification and 2-factor authentication.

If you're new here here are the the links to get started:

We will continue from where we left off and enable our users to reset their password if they forget it.

Update the schemas

We'll add a new schema for the password reset token

schema.prisma

1model PasswordResetToken {
2  id      String   @id @default(cuid())
3  email   String
4  token   String   @unique
5  expires DateTime
6
7  @@unique([email, token])
8}


Utils

In your utils folder create a password-reset-token.ts file.

utils/password-reset-token

1import { prisma } from "@/lib/prisma";
2export const getPasswordResetTokenByToken = async (token: string) => {
3  try {
4    const passwordResetToken = await prisma.passwordResetToken.findUnique({
5      where: { token },
6    });
7
8    return passwordResetToken;
9  } catch {
10    return null;
11  }
12};
13
14export const getPasswordResetTokenByEmail = async (email: string) => {
15  try {
16    const passwordResetToken = await prisma.passwordResetToken.findFirst({
17      where: { email },
18    });
19
20    return passwordResetToken;
21  } catch {
22    return null;
23  }
24};
25

These functions will help us get the token info using either the email or the token itself.

Generating the reset token

Go to the tokens.ts file in the lib folder and create the following function

lib/tokens.ts

1import { getPasswordResetTokenByEmail } from "@/utils/password-reset-token";
2
3export const generatePasswordResetToken = async (email: string) => {
4  const token = uuidv4();
5  const expires = new Date(new Date().getTime() + 3600 * 1000);
6
7  const existingToken = await getPasswordResetTokenByEmail(email);
8
9  if (existingToken) {
10    await prisma.passwordResetToken.delete({
11      where: { id: existingToken.id },
12    });
13  }
14
15  const passwordResetToken = await prisma.passwordResetToken.create({
16    data: {
17      email,
18      token,
19      expires,
20    },
21  });
22
23  return passwordResetToken;
24};

This function checks if there's an old token, deletes it, and generates a new one.

Sending the reset email

Go to mail.ts file inside the lib folder and create the following function

lib/mail.ts

1export const sendPasswordResetEmail = async (email: string, token: string) => {
2  const resetLink = `${domain}/new-password?token=${token}`;
3
4  await resend.emails.send({
5    from: process.env.FROM_EMAIL as string,
6    to: email,
7    subject: "Reset your password",
8    html: `<p>Click <a href="${resetLink}">here</a> to reset password.</p>`,
9  });
10};
11

This will send a link to the user, and when he clicks it, he will be redirected to a page where he can enter his new password.

Update zod schemas

We'll have 2 forms; one for the user to enter his email, and the other one to enter the password after clicking the link sent to his email.

Obviously, we'll need to validate both of these forms, so we'll need to create 2 new schemas for them.

In your schemas.ts file add the following:

schemas.ts

1export const NewPasswordSchema = z.object({
2  password: z.string().min(6, {
3    message: "Minimum of 6 characters required",
4  }),
5});
6
7export const ResetSchema = z.object({
8  email: z.string().email({
9    message: "Email is required",
10  }),
11});


Password reset actions

We'll need 2 server actions to handle each form.

Go to the actions folder and create a reset-password.ts file.

reset-password.ts

1"use server";
2
3import * as z from "zod";
4
5import { ResetSchema } from "@/schemas";
6import { getUserByEmail } from "@/utils/users";
7import { sendPasswordResetEmail } from "@/lib/mail";
8import { generatePasswordResetToken } from "@/lib/tokens";
9
10export const resetPassword = async (values: z.infer<typeof ResetSchema>) => {
11  const validatedFields = ResetSchema.safeParse(values);
12
13  if (!validatedFields.success) {
14    return { error: "Invalid emaiL!" };
15  }
16
17  const { email } = validatedFields.data;
18
19  const existingUser = await getUserByEmail(email);
20
21  if (!existingUser) {
22    return { error: "Email not found!" };
23  }
24
25  const passwordResetToken = await generatePasswordResetToken(email);
26  await sendPasswordResetEmail(
27    passwordResetToken.email,
28    passwordResetToken.token
29  );
30
31  return { success: "Reset email sent!" };
32};
33

The function receives the user email, makes sure it's valid and exists, and then sends a reset link to that email.

Create a new-passwrod.ts file in the same folder (actions)

new-password.ts

1"use server";
2
3import * as z from "zod";
4import bcrypt from "bcryptjs";
5
6import { prisma } from "@/lib/prisma";
7import { NewPasswordSchema } from "@/schemas";
8import { getUserByEmail } from "@/utils/users";
9import { getPasswordResetTokenByToken } from "@/utils/password-reset-token";
10
11export const newPassword = async (
12  values: z.infer<typeof NewPasswordSchema>,
13  token?: string | null
14) => {
15  if (!token) {
16    return { error: "Missing token!" };
17  }
18
19  const validatedFields = NewPasswordSchema.safeParse(values);
20
21  if (!validatedFields.success) {
22    return { error: "Invalid fields!" };
23  }
24
25  const { password } = validatedFields.data;
26
27  const existingToken = await getPasswordResetTokenByToken(token);
28
29  if (!existingToken) {
30    return { error: "Invalid token!" };
31  }
32
33  const hasExpired = new Date(existingToken.expires) < new Date();
34
35  if (hasExpired) {
36    return { error: "Token has expired!" };
37  }
38
39  const existingUser = await getUserByEmail(existingToken.email);
40
41  if (!existingUser) {
42    return { error: "Email does not exist!" };
43  }
44
45  const hashedPassword = await bcrypt.hash(password, 10);
46
47  await prisma.user.update({
48    where: { id: existingUser.id },
49    data: { password: hashedPassword },
50  });
51
52  await prisma.passwordResetToken.delete({
53    where: { id: existingToken.id },
54  });
55
56  return { success: "Password updated!" };
57};
58

This function receives the new password the user has submitted, and the token.

It validates the password and the token, making sure the token isn't expired.

Then it updates the user password with the new one.


Reset forms

As I said, we'll have 2 forms, one for the email and one for the password.

In the components folder create PasswordResetForm.tsx

This form will have the user email input.

PasswordResetForm.tsx

1"use client";
2
3import * as z from "zod";
4import { useForm } from "react-hook-form";
5import { useState, useTransition } from "react";
6import { zodResolver } from "@hookform/resolvers/zod";
7import { ResetSchema } from "@/schemas";
8import { FormError } from "@/components/auth/FormError";
9import { FormSuccess } from "@/components/auth/FormSuccess";
10
11import {
12  Card,
13  CardHeader,
14  CardBody,
15  Divider,
16  Link as NUILink,
17  Input,
18  Button,
19} from "@nextui-org/react";
20import { ErrorMessage } from "@hookform/error-message";
21import { resetPassword } from "@/actions/reset-password";
22import Link from "next/link";
23
24type PasswordResetFormProps = {};
25
26export const PasswordResetForm = ({}: PasswordResetFormProps) => {
27  const [error, setError] = useState<string | undefined>("");
28  const [success, setSuccess] = useState<string | undefined>("");
29  const [isPending, startTransition] = useTransition();
30
31  const {
32    register,
33    handleSubmit,
34    formState: { errors },
35  } = useForm<z.infer<typeof ResetSchema>>({
36    resolver: zodResolver(ResetSchema),
37    defaultValues: {
38      email: "",
39    },
40  });
41
42  const onSubmit = (values: z.infer<typeof ResetSchema>) => {
43    setError("");
44    setSuccess("");
45
46    startTransition(() => {
47      resetPassword(values).then((data) => {
48        setError(data?.error);
49        setSuccess(data?.success);
50      });
51    });
52  };
53
54  return (
55    <Card className="w-[400px]">
56      <CardHeader className="flex justify-center">
57        <h1 className="text-xl font-bold">Password reset</h1>
58      </CardHeader>
59      <Divider />
60      <CardBody>
61        <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
62          <Input
63            variant="bordered"
64            {...register("email")}
65            id="email"
66            type="email"
67            label="Email"
68          />
69          <ErrorMessage
70            errors={errors}
71            name="email"
72            render={({ message }) => (
73              <p className="text-red-500 text-sm text-left w-full">{message}</p>
74            )}
75          />
76          <Button
77            color="primary"
78            fullWidth
79            type="submit"
80            isDisabled={isPending}
81            isLoading={isPending}
82          >
83            Reset Password
84          </Button>
85          <FormSuccess message={success} />
86          <FormError error={error} />
87        </form>
88        <NUILink as={Link} href="/login">
89          Back to login
90        </NUILink>
91      </CardBody>
92    </Card>
93  );
94};
95

Now create NewPasswordForm.tsx

This will have the new password input

NewPasswordForm.tsx

1"use client";
2
3import * as z from "zod";
4import { useForm } from "react-hook-form";
5import { useState, useTransition } from "react";
6import { zodResolver } from "@hookform/resolvers/zod";
7
8import { NewPasswordSchema } from "@/schemas";
9import { FormError } from "@/components/auth/FormError";
10import { FormSuccess } from "@/components/auth/FormSuccess";
11
12import {
13  Card,
14  CardHeader,
15  CardBody,
16  Divider,
17  Input,
18  Button,
19} from "@nextui-org/react";
20import { ErrorMessage } from "@hookform/error-message";
21import { newPassword } from "@/actions/new-password";
22import { useRouter, useSearchParams } from "next/navigation";
23
24type NewPasswordFormProps = {};
25
26export const NewPasswordForm = ({}: NewPasswordFormProps) => {
27  const searchParams = useSearchParams();
28  const token = searchParams.get("token");
29  const router = useRouter();
30  const [error, setError] = useState<string | undefined>("");
31  const [success, setSuccess] = useState<string | undefined>("");
32  const [isPending, startTransition] = useTransition();
33
34  const {
35    register,
36    handleSubmit,
37    formState: { errors },
38  } = useForm<z.infer<typeof NewPasswordSchema>>({
39    resolver: zodResolver(NewPasswordSchema),
40    defaultValues: {
41      password: "",
42    },
43  });
44
45  const onSubmit = (values: z.infer<typeof NewPasswordSchema>) => {
46    setError("");
47    setSuccess("");
48
49    startTransition(() => {
50      newPassword(values, token).then((data) => {
51        if (data.success) {
52          setSuccess(data?.success);
53          router.push("/login");
54        }
55        setError(data?.error);
56      });
57    });
58  };
59
60  return (
61    <Card className="w-[400px]">
62      <CardHeader className="flex justify-center">
63        <h1 className="text-xl font-bold">Password reset</h1>
64      </CardHeader>
65      <Divider />
66      <CardBody>
67        <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
68          <Input
69            variant="bordered"
70            {...register("password")}
71            id="password"
72            type="password"
73            label="new password"
74          />
75          <ErrorMessage
76            errors={errors}
77            name="password"
78            render={({ message }) => (
79              <p className="text-red-500 text-sm text-left w-full">{message}</p>
80            )}
81          />
82          <Button
83            color="primary"
84            fullWidth
85            type="submit"
86            isDisabled={isPending}
87            isLoading={isPending}
88          >
89            Reset Password
90          </Button>
91          <FormSuccess message={success} />
92          <FormError error={error} />
93        </form>
94      </CardBody>
95    </Card>
96  );
97};
98


Create new routes

The last step is to create new routes where we can use these forms.

Inside app/(auth) create reset-password/page.tsx

reset-password/page.tsx

1import { NextPage } from "next";
2
3import { PasswordResetForm } from "@/components/auth/PasswordResetForm";
4
5type ResetPasswordPageProps = {};
6
7const ResetPasswordPage: NextPage = async ({}: ResetPasswordPageProps) => {
8  return (
9    <>
10      <PasswordResetForm />
11    </>
12  );
13};
14
15export default ResetPasswordPage;
16

Create another route for the new password.

Inside app/(auth) create new-password/page.tsx

new-password/page.tsx

1import { NextPage } from "next";
2
3import { NewPasswordForm } from "@/components/auth/NewPasswordForm";
4
5type NewPasswordPageProps = {};
6
7const NewPasswordPage: NextPage = async ({}: NewPasswordPageProps) => {
8  return (
9    <>
10      <NewPasswordForm />
11    </>
12  );
13};
14
15export default NewPasswordPage;
16

Don't forget to add these 2 routes to the public routes array in routes.ts

routes.ts

1export const publicRoutes = [
2  "/",
3  "/verify-email",
4  "/new-password",
5  "/reset-password",
6];
7
8export const authRoutes = ["/login", "/signup"];
9
10// we'll make sure auth api route is always public.
11export const apiRoute = "/api/auth";
12
13// redirect users to this path after login
14export const DEFAULT_LOGIN_REDIRECT = "/users";
15

Last step, Go to the login form and add a link to the reset-password page.

LoginForm.tsx

1.
2.
3.
4<Input
5                variant="bordered"
6                label="Password"
7                type="password"
8                {...register("password")}
9              />
10              <ErrorMessage
11                errors={errors}
12                name="password"
13                render={({ message }) => (
14                  <p className="text-red-500 text-sm text-left w-full">
15                    {message}
16                  </p>
17                )}
18              />
19              <NUILink
20                as={Link}
21                color="primary"
22                href="/reset-password"
23                className="w-full text-left text-sm"
24              >
25                Forgot password?
26              </NUILink>
27            </>
28          )}
29          {error && (
30            <p className="text-red-500 text-sm text-left w-full">{error}</p>
31          )}
32          {success && (
33            <p className="text-green-500 text-sm text-left w-full">{success}</p>
34          )}
35.
36.
37.

Now let's try it.

And it works, you can confirm that by trying to submit the old password.

Here's the GitHub link for the full project which includes all the features (2-factor auth, email verification, and password reset)

You might also like: