14
Next.js and MongoDB full-fledged app Part 3: Email Verification, Password Reset/Change
(this post is actually rewritten from this old post to include new changes to the project)
This is a follow-up to Part 2. Make sure you read it before this post.
This time I am adding the following features: Email Verification, Password Reset, and Password Change.
Again, Below are the Github repository and a demo for this project to follow along.
nextjs-mongodb-app is a Full-fledged serverless app made with Next.JS and MongoDB
Different from many other Next.js tutorials, this:
- Does not use the enormously big Express.js, supports
serverless
- Minimal, no fancy stuff like Redux or GraphQL for simplicity in learning
- Using Next.js latest features like API Routes or getServerSideProps
For more information, visit the Github repo.
We are working on several features, which all involve email transactions.
Email Verification allows you to verify the emails users used to sign up by sending them a verification link.
Password Reset allows the users to reset their passwords from a reset link sent to their emails.
Password Change allows the users to change their passwords simply by inputting their old and new passwords.
This is the easiest feature to implement. All we have to do is match the user's old password against the database and save their new one.
I added the section right below the Profile Settings page.
import { useCurrentUser } from "@/lib/user";
import { useRouter } from "next/router";
import { useEffect, useCallback } from "react";
import { fetcher } from "@/lib/fetch";
const AboutYou = ({ user, mutate }) => {
/* ... */
};
const Auth = () => {
const oldPasswordRef = useRef();
const newPasswordRef = useRef();
const onSubmit = useCallback(async (e) => {
e.preventDefault();
try {
await fetcher("/api/user/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
oldPassword: oldPasswordRef.current.value,
newPassword: newPasswordRef.current.value,
}),
});
} catch (e) {
console.error(e.message);
} finally {
oldPasswordRef.current.value = "";
newPasswordRef.current.value = "";
}
}, []);
return (
<section>
<h4>Password</h4>
<form onSubmit={onSubmit}>
<input
type="password"
autoComplete="current-password"
ref={oldPasswordRef}
placeholder="Old Password"
/>
<input
type="password"
autoComplete="new-password"
ref={newPasswordRef}
placeholder="New Password"
/>
<button type="submit">Save</button>
</form>
</section>
);
};
const SettingsPage = () => {
const { data, error, mutate } = useCurrentUser();
const router = useRouter();
useEffect(() => {
if (!data && !error) return; // useCurrentUser might still be loading
if (!data.user) {
router.replace("/login");
}
}, [router, data, error]);
if (!data?.user) return null;
return (
<>
<AboutYou user={data.user} mutate={mutate} />
<Auth />
</>
);
};
export default SettingsPage;
When the user submits, we make a PUT call to /api/user/password
with the old and new password. After the request, we clear the new and old password fields.
We now go ahead and create our API at /api/user/password
.
Create /pages/api/user/password/index.js
.
import { auths, database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";
const handler = nc();
handler.use(database, ...auths);
handler.put(
validateBody({
type: "object",
properties: {
oldPassword: { type: "string", minLength: 8 },
newPassword: { type: "string", minLength: 8 },
},
required: ["oldPassword", "newPassword"],
additionalProperties: false,
}),
async (req, res) => {
if (!req.user) {
res.json(401).end();
return;
}
const { oldPassword, newPassword } = req.body;
// We could not req.user because that object does not have the `password` field
const currentUser = await db
.collection("users")
.findOne({ _id: req.user._id });
const matched = await bcrypt.compare(oldPassword, currentUser.password);
if (!matched) {
res.status(401).json({
error: { message: "The old password you entered is incorrect." },
});
return;
}
const password = await bcrypt.hash(newPassword, 10);
await req.db
.collection("users")
.updateOne({ _id: currentUser._id }, { $set: { password } });
res.status(204).end();
}
);
export default handler;
We first validate the body using our validateBody middleware. Then, we check if the user is logged in by checking req.user
. If not, it will send a 401 response.
Then, we fetch the user object, containing hashed password field. (In the previous version of this project, I directly compare using req.user.password
. However, the new version uses projection
to omit that field when authenticating users for security reasons.)
We then go ahead and retrieve oldPassword
and newPassword
from the request body. The oldPassword
is compared against the hashed current password (bcrypt.compare(oldPassword, currentUser.password)
). If it does not match, we reject the request. If it does we hash the new password (bcrypt.hash(newPassword, 10)
) and save it in our database.
await req.db
.collection("users")
.updateOne({ _id: req.user._id }, { $set: { password } });
And the feature is ready to roll~
(Note: The GIF is from an old, barebone version :))
Now that the user can change their current password to a new one. Yet that is only when they know their current password. Let's implement the password reset feature.
We will have two routes for this API.
-
POST /pages/api/user/password/reset
: Handle requests to create a password reset token and send email -
PUT /pages/api/user/password/reset
: Reset password using a token.
Create /pages/api/user/password/reset/index.js
:
import { sendMail } from "@/api-lib/mail";
import { database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";
import normalizeEmail from "validator/lib/normalizeEmail";
import { nanoid } from "nanoid";
const handler = nc();
handler.use(database);
handler.post(
validateBody({
type: "object",
properties: {
email: { type: "string", minLength: 1 },
},
required: ["email"],
additionalProperties: false,
}),
async (req, res) => {
const email = normalizeEmail(req.body.email);
const user = await req.db.collection("users").findOne({ email });
if (!user) {
res.status(400).json({
error: { message: "We couldn’t find that email. Please try again." },
});
return;
}
const securedTokenId = nanoid(32); // create a secure reset password token
await db.collection("tokens").insertOne({
_id: securedTokenId,
creatorId: user._id,
type: "passwordReset",
expireAt: new Date(Date.now() + 20 * 60 * 1000), // let's make it expire after 20 min
});
await sendMail({
to: user.email,
from: "[email protected]",
subject: "[nextjs-mongodb-app] Reset your password.",
html: `
<div>
<p>Hello, ${user.name}</p>
<p>Please follow <a href="${process.env.WEB_URI}/forget-password/${securedTokenId}">this link</a> to reset your password.</p>
</div>
`,
});
res.status(204).end();
}
);
export default handler;
We only need our database middleware in this API because we don't need any authentication info.
We first verify if there is a user with such email in the database req.db.collection('users').findOne({ email: req.body.email })
.
If the user exists, we create a secure passwordReset
token. We insert a document to our tokens
collection with the created token along with the intended user's _id in creatorId
. The secure token is set as the document _id
(since _id
is indexed by MongoDB, it allows faster lookup)
To set the token to expire after the expireAt
property for security reasons, we can create an index on that collection like below (usually called when the server starts):
db.collection("tokens").createIndex("expireAt", { expireAfterSeconds: 0 });
We then send an email to the user with a password reset link (website_url/forget-password/{token}
).
Note: You have to implement the sendEmail function.
We now create a forget password page at /pages/forget-password/index.jsx
import { fetcher } from "@/lib/fetch";
import { useCallback, useRef } from "react";
const ForgetPasswordPage = () => {
const emailRef = useRef();
const onSubmit = useCallback(async (e) => {
e.preventDefault();
try {
await fetcher("/api/user/password/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: emailRef.current.value,
}),
});
} catch (e) {
console.error(e.message);
}
}, []);
return (
<>
<Head>
<title>Forget password</title>
</Head>
<h1>Forget password</h1>
<p>
Enter the email address associated with your account, and we'll
send you a link to reset your password.
</p>
<form onSubmit={onSubmit}>
<input
ref={emailRef}
type="email"
autoComplete="email"
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
</>
);
};
export default ForgetPasswordPage;
We simply ask the user for their email, which we then send to our just-created API above at '/api/user/password/reset'.
We need an API to resolve the reset token.
Let's add a PUT request handler to /pages/api/user/password/reset.js
:
import nc from "next-connect";
import bcrypt from "bcryptjs";
import database from "@/api-lib/middlewares";
const handler = nc();
handler.use(database);
handler.post(/* ... */);
handler.put(
validateBody({
type: "object",
properties: {
password: { type: "string", minLength: 8 },
token: { type: "string", minLength: 0 },
},
required: ["password", "token"],
additionalProperties: false,
}),
async (req, res) => {
const deletedToken = await db
.collection("tokens")
.findOneAndDelete({ _id: id, type });
if (!deletedToken) {
res.status(403).end();
return;
}
const password = await bcrypt.hash(newPassword, 10);
await db
.collection("users")
.updateOne(
{ _id: deletedToken.creatorId },
{ $set: { password } }
);
res.status(204).end();
}
);
export default handler;
The handler will try to find and delete the token we inserted earlier. If deleteToken
is not null, we also know that the token is deleted from the database (we don't want the same token to be used twice).
If the token is null, we simply reject the request.
We then hash the password using bcrypt
and update the user whose _id
can be found at token.creatorId
.
This page represents the link that is sent to the user's email (website_url/forget-password/{token}
). Create /pages/forget-password/[token].jsx
:
import { database } from "@/api-lib/middlewares";
import nc from "next-connect";
import Head from "next/head";
const ResetPasswordTokenPage = ({ valid, token }) => {
const passwordRef = useRef();
const onSubmit = useCallback(
async (event) => {
event.preventDefault();
setStatus("loading");
try {
await fetcher("/api/user/password/reset", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
password: passwordRef.current.value,
}),
});
} catch (e) {
console.error(e.message);
}
},
[token]
);
if (!valid)
return (
<>
<h1>Invalid Link</h1>
<p>
It looks like you may have clicked on an invalid link. Please close
this window and try again.
</p>
</>
);
return (
<>
<Head>
<title>Forget password</title>
</Head>
<h1>Forget password</h1>
<p>Enter a new password for your account</p>
<form onSubmit={onSubmit}>
<input
ref={passwordRef}
type="password"
autoComplete="new-password"
placeholder="New Password"
/>
<button type="submit">Reset password</button>
</form>
</>
);
};
export async function getServerSideProps(context) {
await nc().use(database).run(context.req, context.res);
const tokenDoc = await db.collection("tokens").findOne({
_id: context.params.token,
type: "passwordReset",
});
return { props: { token: context.params.token, valid: !!tokenDoc } };
}
export default ResetPasswordTokenPage;
For this page, we use getServerSideProps to check the token validity (This function is run server-side only). The token
can be found in context.params
since our page is a dynamic route (with dynamic token
parameter: /pages/forget-password/[token].jsx
).
Our database
middleware is used to load the database into req.db
. We use it to check if the token can be found in the database by querying its _id
(we set the secure token as the document _id
earlier).
The page component will know if the token is valid based on the valid
prop, which is true
if the token document can be found and false
otherwise.
As we can see, if valid
is false
, we show a "Invalid Link" message.
Otherwise, we render an input for a new password.
After the user submit the new password, we simply make a request to the just created API along with the token received from props.
await fetcher("/api/user/password/reset", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
password: passwordRef.current.value,
}),
});
Let's finish up with email verification. This feature is similar to password reset as we similarly send a link with the token to the user.
Get started by creating /pages/api/user/email/verify.js
. This will handle users' requests to receive a confirmation email.
import { sendMail } from "@/api-lib/mail";
import { auths, database } from "@/api-lib/middlewares";
import nc from "next-connect";
const handler = nc();
handler.post(async (req, res) => {
if (!req.user) {
res.json(401).end();
return;
}
const securedTokenId = nanoid(32);
const token = await {
_id: securedTokenId,
creatorId: req.user._id,
type: "emailVerify",
expireAt: new Date(Date.now() + 1000 * 60 * 60 * 24), // expires in 24h
};
await sendMail({
to: req.user.email,
from: "[email protected]",
subject: `Verification Email for ${process.env.WEB_URI}`,
html: `
<div>
<p>Hello, ${req.user.name}</p>
<p>Please follow <a href="${process.env.WEB_URI}/verify-email/${token._id}">this link</a> to confirm your email.</p>
</div>
`,
});
res.status(204).end();
});
export default handler;
In this handler, we are checking if the user is logged in and create a token that associates the user ID req.user._id
. This token will expire in 24 hours.
The token creation and email sending are similar to those of Reset Password.
Similar to the password reset page, we create a dynamic route page at /pages/verify-email/[token].jsx
.
import { database } from "@/api-lib/middlewares";
import nc from "next-connect";
import Head from "next/head";
export default function EmailVerifyPage({ valid }) {
return (
<>
<Head>
<title>Email verification</title>
</Head>
<p>
{valid
? "Thank you for verifying your email address. You may close this page."
: "It looks like you may have clicked on an invalid link. Please close this window and try again."}
</p>
</>
);
}
export async function getServerSideProps(context) {
const handler = nc(ncOpts);
handler.use(database);
await handler.run(context.req, context.res);
const { token } = context.params;
const deletedToken = await db
.collection("tokens")
.findOneAndDelete({ _id: token, type: "emailVerify" });
if (!deletedToken) return { props: { valid: false } };
await db.collection("users").updateOne(
{ _id: deletedToken.creatorId },
{
emailVerified: true,
}
);
return { props: { valid: true } };
}
For this page, we do the email verification process inside getServerSideProps
. Similar to the reset password feature, we find and delete the token from the database. If the token is found (and thus deleted), we update the user whose _id
found in the token creatorId
to have emailVerify = true
.
The returned prop valid
inform the UI to show the correct message.
The returned value of our /api/user
actually contains a property call emailVerified
, as seen above.
Therefore, we can use our useCurrentUser
SWR hook to show a message asking the user to verify his or her email.
export default () => {
const {
data: { user },
} = useCurrentUser();
if (!user.emailVerified)
return (
<p>
<strong>Note:</strong> <span>Your email</span> (<span className="link">
{user.email}
</span>) is unverified.
</p>
);
return null;
};
Tadah! We have managed to have the password change, password reset and email verification features in our app.
Check out the repository nextjs mongodb app.
I have spent hours writing these articles and even dozen more working on this project. If you find it helpful, I would love to see it getting shared and starred. Until then, good luck on your Next.js project!
14