New product features | The latest in technology | The weekly debugging nightmares & more!
January 6, 2024
Take my knowledge and shove it up your brain...
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.
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}
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.
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.
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.
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});
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.
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
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)
How to add credential authentication using Auth.js & Next 14
Allow users to login using their email & password.
How to add email verification using Auth.js
Enhance your website security by allowing only verified users to log in.
How to add 2-factor authentication
Enhance your users' security using 2-factor authentication
How to use clerk to protect your route handlers
Clerk can also protect route handlers, Nice