DEV Community

Cover image for How I build a full-fledged app with Next.js and MongoDB Part 3: Email Verification, Password Reset/Change
Hoang
Hoang

Posted on • Updated on • Originally published at hoangvvo.com

How I build a full-fledged app with Next.js and MongoDB Part 3: Email Verification, Password Reset/Change

I have come back after a long hiatus in building nextjs-mongodb-app. This time I am adding the following features:

  • Email Verification
  • Password Reset
  • Password Change

Before this, I have made several modifications to the codebase, which you can look at in the following PRs:

  • Rewrite with next-connect (#22)
  • Replace axioswal with fetch (a custom fetch "fetchSwal") (#41)
  • The API response schema slightly changes. ({ status: 'ok' } becomes { ok: true })

Again, Below are the Github repository and a demo for this project to follow along.

Github repo

Demo

What we are making

We are working on several features, which all involve email transaction.

  • 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.

Building Password Change

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.

Password Change Section

For simplicity, I added the section right below Profile Settings page.

Password Reset in Profile Settings Page

const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const handleSubmitPasswordChange = event => {
  event.preventDefault();
  fetchSwal
    .put("/api/user/password", { oldPassword, newPassword })
    .then(data => {
      if (data.ok) {
        setNewPassword("");
        setOldPassword("");
      }
    });
};
/* ... */
return (
  <form onSubmit={handleSubmitPasswordChange}>
    <label htmlFor="oldpassword">
      Old Password
      <input
        type="password"
        id="oldpassword"
        value={oldPassword}
        onChange={e => setOldPassword(e.target.value)}
        required
      />
    </label>
    <label htmlFor="newpassword">
      New Password
      <input
        type="password"
        id="newpassword"
        value={newPassword}
        onChange={e => setNewPassword(e.target.value)}
        required
      />
    </label>
    <button type="submit">Change Password</button>
  </form>
);
Enter fullscreen mode Exit fullscreen mode

When the user submits, we make a PUT call to /api/user/password with the old and new password. If the request is successful, we clear the new and old password fields.

We now go ahead and create our API at /api/user/password.

Password Change API

Create /pages/api/user/password/index.js.

import nextConnect from 'next-connect';
import bcrypt from 'bcryptjs';
import middleware from '../../../../middlewares/middleware';

const handler = nextConnect();
handler.use(middleware);

handler.put(async (req, res) => {
  try {
    if (!req.user) throw new Error('You need to be logged in.');
    const { oldPassword, newPassword } = req.body;
    if (!(await bcrypt.compare(oldPassword, req.user.password))) {
      throw new Error('The password you has entered is incorrect.');
    }
    const password = await bcrypt.hash(newPassword, 10);
    await req.db
      .collection('users')
      .updateOne({ _id: req.user._id }, { $set: { password } });
    res.json({ message: 'Your password has been updated.' });
  } catch (error) {
    res.json({
      ok: false,
      message: error.toString(),
    });
  }
});

export default handler;
Enter fullscreen mode Exit fullscreen mode

We are reusing our middleware instance (with database, session, authentication, etc.)

We first check if the user is logged in (!req.user) and reject the request if they are not. req.user also contains all of our user data (including the hashed password at req.user.password)

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, req.user.password)). If it matches, 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 } });
Enter fullscreen mode Exit fullscreen mode

Our try/catch makes it convenient to simply throw an error at any point to inform the user if the request is a failure.

And we are ready to roll~

Password Change demo

Password Reset

Now that the user can change their current password to a new one. Yet that is only when they know their current password. We are now implementing password reset.

Forget Password API

We will have two routes for this API.

  • /pages/api/user/password/reset/index.js: Handle requests to create a password reset token
  • /pages/api/user/password/reset/[token].js: Handle the token to reset password.

Create /pages/api/user/password/reset/index.js.

import sgMail from '@sendgrid/mail';
import crypto from 'crypto';
import nextConnect from 'next-connect';
import database from '../../../../../middlewares/database';

sgMail.setApiKey(process.env.SENDGRID_API_KEY);

const handler = nextConnect();

handler.use(database);

handler.post(async (req, res) => {
  try {
    const user = await req.db
      .collection('users')
      .findOne({ email: req.body.email });
    if (!user)
      throw new Error('This email is not associated with any account.');
    const token = crypto.randomBytes(32).toString('hex');
    await req.db.collection('tokens').insertOne({
      token,
      userId: user._id,
      type: 'passwordReset',
      expireAt: new Date(Date.now() + 1000 * 60 * 20)
    });
    const msg = {
      to: user.email,
      from: process.env.EMAIL_FROM,
      templateId: process.env.SENDGRID_TEMPLATEID_PASSWORDRESET,
      dynamic_template_data: {
        subject: '[nextjs-mongodb-app] Reset your password.',
        name: user.name,
        url: `${process.env.WEB_URI}/forgetpassword/${token}`
      }
    };
    await sgMail.send(msg);
    res.json({ message: 'An email has been sent to your inbox.' });
  } catch (error) {
    res.json({
      ok: false,
      message: error.toString()
    });
  }
});

export default handler;
Enter fullscreen mode Exit fullscreen mode

We only need our database middleware in this API because we only need to communicate to the database.

We first verify if the email user input exists in the database req.db.collection('users').findOne({ email: req.body.email }). If the user exists, we create a passwordReset token with the userId attached. The token is generated by Node module crypto (although you can replace it with anything).

set to be expire after 20 minutes for security reason: expireAt: new Date(Date.now() + 1000 * 60 * 20). In /middlewares/database.js, we set the tokens collection to expire the token based on expireAt field:

db
  .collection('tokens')
  .createIndex('expireAt', { expireAfterSeconds: 0 });
Enter fullscreen mode Exit fullscreen mode

The token is then inserted into our tokens MongoDB collection.

We then send an email to the user with a password reset link (website_url/forgetpassword/{token}).

Forget Password page

Password Reset Page

We now create a forget password page at /pages/forgetpassword/index.jsx

const ForgetPasswordPage = () => {
  const [email, setEmail] = useState();

  function handleSubmit(event) {
    event.preventDefault();
    fetchSwal
      .post('/api/user/password/reset', { email })
      .then(resp => resp.ok !== false && redirectTo('/'));
  }

  return (
    <Layout>
      <Head>
        <title>Forget password</title>
      </Head>
      <h2>Forget password</h2>
      <form onSubmit={handleSubmit}>
        <p>Do not worry. Simply enter your email address below.</p>
        <label htmlFor="email">
          <input
            id="email"
            type="email"
            placeholder="Email"
            value={email}
            onChange={e => setEmail(e.target.value)}
          />
        </label>
        <button type="submit">Submit</button>
      </form>
    </Layout>
  );
};
Enter fullscreen mode Exit fullscreen mode

We simply ask the user for their email, which we then send to our just-created API above at '/api/user/password/reset'.

Reset Password API

We need an API to resolve the reset token.

Create /pages/api/user/password/reset/[token].js:

import nextConnect from 'next-connect';
import bcrypt from 'bcryptjs';
import database from '../../../../../middlewares/database';

const handler = nextConnect();

handler.use(database);

handler.post(async (req, res) => {
  // check valid token
  const tokenDoc = await req.db.collection('tokens').findOne({
    token: req.query.token,
    type: 'passwordReset'
  });
  res.end(tokenDoc ? 'true' : 'false');
});

handler.put(async (req, res) => {
  // password reset
  try {
    if (!req.body.password) throw new Error('No password provided.');
    const { value: tokenDoc } = await req.db
      .collection('tokens')
      .findOneAndDelete({ _id: req.query.token, type: 'passwordReset' });
    if (!tokenDoc) throw new Error('This link may have been expired.');
    const password = await bcrypt.hash(req.body.password, 10);
    await req.db
      .collection('users')
      .updateOne({ _id: tokenDoc.userId }, { $set: { password } });
    res.json({ message: 'Your password has been updated.' });
  } catch (error) {
    res.json({
      ok: false,
      message: error.toString()
    });
  }
});

export default handler;
Enter fullscreen mode Exit fullscreen mode

There are two handlers: a POST and PUT one.

The POST one will only check if the token is valid by querying tokens document, which then returns either true or false. This can be used to immediately show to the user that clicks the link if the token (or link) is valid (and optionally allowing them to request a new one). Later, we will see this check in getInitialProps.

The PUT one will do the same check for the token as in POST, but we also delete the token (findOneAndDelete) if it is found. If the token is found (and deleted), we hash the new password bcrypt.hash(req.body.password, 10) and save it to the user whose id is attached to the token (tokenDoc.userId):

req.db
  .collection('users')
  .updateOne({ _id: tokenDoc.userId }, { $set: { password } });
Enter fullscreen mode Exit fullscreen mode

Reset Password page

This page represents the link that is sent to the user's email (website_url/forgetpassword/{token}). Create /pages/forgetpassword/[token].jsx:

const ResetPasswordTokenPage = ({ valid, token }) => {
  const [password, setPassword] = useState('');
  function handleSubmit(event) {
    event.preventDefault();
    fetchSwal
      .post(`/api/user/password/reset/${token}`, { password })
      .then(resp => resp.ok !== false && redirectTo('/'));
  }

  return (
    <Layout>
      <Head>
        <title>Forget password</title>
      </Head>
      <h2>Forget password</h2>
      {valid ? (
        <>
          <p>Enter your new password.</p>
          <form onSubmit={handleSubmit}>
            <div>
              <input
                type="password"
                placeholder="New password"
                value={password}
                onChange={e => setPassword(e.target.value)}
              />
            </div>
            <button type="submit">Set new password</button>
          </form>
        </>
      ) : (
        <p>This link may have been expired</p>
      )}
    </Layout>
  );
};

ResetPasswordTokenPage.getInitialProps = async ctx => {
  const { token } = ctx.query;
  const valid = await fetch(
    `${process.env.WEB_URI}/api/user/password/reset/${token}`,
    { method: 'POST' }
  )
    .then(res => res.text())
    .then(bol => bol === 'true');
  return { token, valid };
};

export default ResetPasswordTokenPage;
Enter fullscreen mode Exit fullscreen mode

As we can see in getInitialProps, we first query the POST handler to check if the token is valid and return it as a pageProp along with the token.

Based on the valid pageProp, we have a conditional render to show the password reset form if the token is valid or the text "This link may have been expired" otherwise:

return (
  valid ? (
    <>
      <p>Enter your new password.</p>
      <form onSubmit={handleSubmit}>
        <div>
          <input
            type="password"
            placeholder="New password"
            value={password}
            onChange={e => setPassword(e.target.value)}
          />
        </div>
        <button type="submit">Set new password</button>
      </form>
    </>
  ) : (
    <p>This link may have been expired</p>
  );
)
Enter fullscreen mode Exit fullscreen mode

After the user input their new password, the new password is sent along with the token to our PUT handler.

Password Reset Page Token

That is how we do password reset.

Building Email Verification

Let's finish up with email verification. This feature is similar to password reset as we similarly are sending a token to the user.

Email Verification API

Get started by creating /pages/api/user/email/verify/index.js. This will handle users' requests to receive a confirmation email.

const handler = nextConnect();

handler.use(middleware);

handler.post(async (req, res) => {
  try {
    if (!req.user) throw new Error('You need to be logged in.');
    const token = crypto.randomBytes(32).toString('hex');
    await req.db.collection('tokens').insertOne({
      token,
      userId: req.user._id,
      type: 'emailVerify',
      expireAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
    });
    const msg = {
      to: req.user.email,
      from: process.env.EMAIL_FROM,
      templateId: process.env.SENDGRID_TEMPLATEID_EMAILVERIFY,
      dynamic_template_data: {
        subject: '[nextjs-mongodb-app] Please verify your email address.',
        name: req.user.name,
        url: `${process.env.WEB_URI}/api/user/email/verify/${token}`,
      },
    };
    await sgMail.send(msg);
    res.json({ message: 'An email has been sent to your inbox.' });
  } catch (error) {
    res.json({
      ok: false,
      message: error.toString(),
    });
  }
});

export default handler;
Enter fullscreen mode Exit fullscreen mode

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.

We then send an email with the email confirmation link (website_url/api/user/email/verify/{token}).

Okay, I actually got lazy and did not create a dedicated page like we did above with Password Reset. This is the front of our API.

Looking at it, you will realize we need at GET handler at /api/user/email/verify/{token}.

Create /pages/api/user/email/verify/[token].js:

handler.get(async (req, res) => {
  try {
    const { token } = req.query;
    const { value: tokenDoc } = await req.db
      .collection('tokens')
      .findOneAndDelete({ token, type: 'emailVerify' });

    if (!tokenDoc) {
      res.status(401).json({
        ok: false,
        message: 'This link may have been expired.',
      });
      return;
    }

    await req.db
      .collection('users')
      .updateOne({ _id: tokenDoc.userId }, { $set: { emailVerified: true } });

    res.json({
      ok: true,
      message: 'Success! Thank you for verifying your email address. You may close this page.',
    });
  } catch (error) {
    res.json({
      ok: false,
      message: error.toString(),
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Similar to Password Reset, we check if the token exists and remove it.

req.db
  .collection('tokens')
  .findOneAndDelete({ token, type: 'emailVerify' });
Enter fullscreen mode Exit fullscreen mode

If it existed (and removed), we set the emailVerified field in the user's document to true.

Expose the email verification status in the front-end

In /pages/api/session.js, I include emailVerified field:

const { name, email, bio, profilePicture, emailVerified } = req.user;
return res.json({
  data: {
    isLoggedIn: true,
    user: {
      name,
      email,
      bio,
      profilePicture,
      emailVerified
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

The value of emailVerified will be used in our profile page at pages/profile/index.js.

const {
  state: {
    isLoggedIn,
    user: { name, email, bio, profilePicture, emailVerified }
  }
} = useContext(UserContext);

function sendVerificationEmail() {
  fetchSwal.post('/api/user/email/verify');
}
/* ... */
return (
  <>
    {/* ... */}
    Email
    <p>
      {email}
      {!emailVerified ? (
        <>
          {' '}
          unverified {/* eslint-disable-next-line */}
          <a role="button" onClick={sendVerificationEmail}>
            Send verification email
          </a>
        </>
      ) : null}
    </p>
    {/* ... */}
  </>
);
Enter fullscreen mode Exit fullscreen mode

If emailVerified is not true, we display a unverified text with a button that allows user to send a verification email. The button send a POST request to /api/user/email/verify, which we went through above.

Conclusion

Tadah! We have managed to have password change/reset and email verification feature in our app. These features can be seen in real-life app.

Check out the repository here. The pull request for this particular feature is here.

I have spent hours writing this articles and even dozen more working on this project. If you find it helpful, I would love to see it getting stared. Until then, good luck on your Next.js project!

Top comments (3)

Collapse
 
mlkrsrc profile image
Mustafa ilker Sarac

You are doing great! Adding an .env.example is an awesome practice, even you do not have to add valid keys though. I am waiting for some deployment section with alternatives.

Collapse
 
hoangvvo profile image
Hoang

Thanks. Which alternatives are you looking for? There are way too many :p

In general, you actually do not use .env file, but using the environmental variable settings provided by the platform.

Collapse
 
fredestrik profile image
Frédéric Lang

Hi,
I build an app closed to nextjs-mongodb-app with SQL and Bulma CSS.
check it out at nextjs-sql-app.vercel.app
github repo : github.com/Fredestrik/Next.Js-SQL-app