Published:
Last updated:

React Boilerplate - Email Sign Up with Verification, Authentication & Forgot Password

Tutorial built with React 16.13.1 and React Hooks

In this tutorial we'll cover how to implement a boilerplate sign up and authentication system in React that includes:

  • Email sign up and verification
  • JWT authentication with refresh tokens
  • Role based authorization with support for two roles (User & Admin)
  • Forgot password and reset password functionality
  • View and update my profile section
  • Admin section with sub section for managing all users (restricted to the Admin role)

The React boilerplate app runs with a fake backend by default to enable it to run completely in the browser without a real backend api (backend-less), to switch to a real backend api you just have to remove a couple of lines of code from the main react entry file (/src/index.jsx). You can build your own api or hook it up with the Node.js + MongoDB or ASP.NET Core 3.1 boilerplate api available (instructions below).

There are no users registered in the application by default, in order to login you must first register and verify an account. The fake backend displays "email" messages on screen because it can't send real emails, so after registration a "verification email" is displayed with a link to verify the account just registered, click the link to verify the account and login to the app.

The first user registered is assigned to the Admin role and subsequent users are assigned to the regular User role. Admins can access the admin section and manage all users, while regular users can only update their own profile.

JWT authentication with refresh tokens

Authentication is implemented with JWT access tokens and refresh tokens. On successful authentication the api (or fake backend) returns a short lived JWT access token that expires after 15 minutes, and a refresh token that expires after 7 days in a cookie. The JWT is used for accessing secure routes on the api and the refresh token is used for generating new JWT access tokens when (or just before) they expire, the React app starts a timer to refresh the JWT token 1 minute before it expires to keep the user logged in.

The example project is available on GitHub at https://github.com/cornflourblue/react-signup-verification-boilerplate.

Here it is in action: (See on StackBlitz at https://stackblitz.com/edit/react-signup-verification-boilerplate)

Update History:

  • 06 Jul 2020 - Added instructions for running with ASP.NET Core 3.1 boilerplate api
  • 10 Jun 2020 - Added support for JWT with refresh tokens
  • 22 Apr 2020 - Built with React 16.13.1


Running the React Boilerplate App Locally

  1. Install NodeJS and NPM from https://nodejs.org
  2. Download or clone the project source code from https://github.com/cornflourblue/react-signup-verification-boilerplate
  3. Install all required npm packages by running npm install from the command line in the project root folder (where the package.json is located).
  4. Start the application by running npm start from the command line in the project root folder, this will launch a browser displaying the application.

For more info on setting up a React development environment see React - Setup Development Environment.


Running the React App with a Boilerplate Node.js + MongoDB API

For full details about the boilerplate Node + Mongo api see Node + Mongo - Boilerplate API with Email Sign Up, Verification, Authentication & Forgot Password. But to get up and running quickly just follow the below steps.

  1. Install MongoDB Community Server from  https://www.mongodb.com/download-center/community.
  2. Run MongoDB, instructions are available on the install page for each OS at https://docs.mongodb.com/manual/administration/install-community/
  3. Download or clone the project source code from https://github.com/cornflourblue/node-mongo-signup-verification-api
  4. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  5. Configure SMTP settings for email within the smtpOptions property in the /src/config.json file. For testing you can create a free account in one click at https://ethereal.email/ and copy the options below the title Nodemailer configuration.
  6. Start the api by running npm start from the command line in the project root folder, you should see the message Server listening on port 4000.
  7. Back in the React app, remove or comment out the 2 lines below the comment // setup fake backend located in the /src/index.jsx file, then start the React app and it should now be hooked up with the Node + Mongo API.


Running the React App with a Boilerplate ASP.NET Core 3.1 API

For full details about the boilerplate ASP.NET Core api see ASP.NET Core 3.1 - Boilerplate API with Email Sign Up, Verification, Authentication & Forgot Password. But to get up and running quickly just follow the below steps.

  1. Download or clone the tutorial project code from https://github.com/cornflourblue/aspnet-core-3-signup-verification-api
  2. Configure SMTP settings for email within the AppSettings section in the /appsettings.json file. For testing you can create a free account in one click at https://ethereal.email/ and copy the options below the title SMTP configuration.
  3. Start the api by running dotnet run from the command line in the project root folder (where the WebApi.csproj file is located), you should see the message Now listening on: http://localhost:4000.
  4. Back in the React app, remove or comment out the 2 lines below the comment // setup fake backend located in the /src/index.jsx file, then start the React app and it should now be hooked up with the ASP.NET Core API.


React Boilerplate Project Structure

All source code for the React boilerplate app is located in the /src folder. Inside the src folder there is a folder per feature (account, admin, app, home, profile) as well as folders for non-feature code that can be shared across different parts of the app (_components, _helpers, _services).

I prefixed non-feature folders with an underscore _ to group them together and make it easy to distinguish between features and non-features, it also keeps the project folder structure shallow so it's quick to see everything at a glance from the top level and to navigate around the project.

The index.js files in each non-feature folder re-export all of the modules from the folder so they can be imported using only the folder path instead of the full path to each file, and to enable importing multiple modules in a single import (e.g. import { accountService, alertService } from '@/_services').

I named the root component file in each feature folder Index.jsx so it can be imported using only the folder path (e.g. import { App } from './app';), removing the need for an extra index.js file that re-exports the component.

Formik Forms

All forms in the React boilerplate app are built with the Formik component. The initial values of each field are set in the initialValues property, validation rules and error messages are set in the validationSchema property, the onSubmit function gets called when the form is submitted and valid, and the JSX template for each form is returned by the callback function contained within the <Formik>...</Formik> component tag. For more info see the Formik docs.

Click any of the below links to jump down to a description of each file along with it's code:


Alert Component

Path: /src/_components/Alert.jsx

The alert component controls the adding & removing of bootstrap alerts in the UI, it maintains an array of alerts that are rendered in the template returned by the React Hooks function component.

The useEffect() hook is used to subscribe to the observable returned from the alertService.onAlert() method, this enables the alert component to be notified whenever an alert message is sent to the alert service and add it to the alerts array for display. Sending an alert with an empty message to the alert service tells the alert component to clear the alerts array. The useEffect() hook is also used to register a route change listener by calling history.listen() which automatically clears alerts on route changes.

The empty dependency array [] passed as a second parameter to the useEffect() hook causes the react hook to only run once when the component mounts, similar to the componentDidMount() method in a traditional react class component. The function returned from the useEffect() hook cleans up the subscribtions when the component unmounts, similar to the componentWillUnmount() method in a traditional react class component.

The removeAlert() function removes the specified alert object from the array, it allows individual alerts to be closed in the UI.

The cssClasses() function returns a corresponding bootstrap alert class for each of the alert types, if you're using something other than bootstrap you could change the CSS classes returned to suit your application.

The returned JSX template renders a bootstrap alert message for each alert in the alerts array.

For more info see React Hooks + Bootstrap - Alert Notifications.

import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';

import { alertService, AlertType } from '@/_services';
import { history } from '@/_helpers';

const propTypes = {
    id: PropTypes.string,
    fade: PropTypes.bool
};

const defaultProps = {
    id: 'default-alert',
    fade: true
};

function Alert({ id, fade }) {
    const [alerts, setAlerts] = useState([]);

    useEffect(() => {
        // subscribe to new alert notifications
        const subscription = alertService.onAlert(id)
            .subscribe(alert => {
                // clear alerts when an empty alert is received
                if (!alert.message) {
                    setAlerts(alerts => {
                        // filter out alerts without 'keepAfterRouteChange' flag
                        const filteredAlerts = alerts.filter(x => x.keepAfterRouteChange);

                        // remove 'keepAfterRouteChange' flag on the rest
                        filteredAlerts.forEach(x => delete x.keepAfterRouteChange);
                        return filteredAlerts;
                    });
                } else {
                    // add alert to array
                    setAlerts(alerts => ([...alerts, alert]));

                    // auto close alert if required
                    if (alert.autoClose) {
                        setTimeout(() => removeAlert(alert), 3000);
                    }
                }
            });

        // clear alerts on location change
        const historyUnlisten = history.listen(({ pathname }) => {
            // don't clear if pathname has trailing slash because this will be auto redirected again
            if (pathname.endsWith('/')) return;

            alertService.clear(id);
        });

        // clean up function that runs when the component unmounts
        return () => {
            // unsubscribe & unlisten to avoid memory leaks
            subscription.unsubscribe();
            historyUnlisten();
        };
    }, []);

    function removeAlert(alert) {
        if (fade) {
            // fade out alert
            const alertWithFade = { ...alert, fade: true };
            setAlerts(alerts => alerts.map(x => x === alert ? alertWithFade : x));

            // remove alert after faded out
            setTimeout(() => {
                setAlerts(alerts => alerts.filter(x => x !== alertWithFade));
            }, 250);
        } else {
            // remove alert
            setAlerts(alerts => alerts.filter(x => x !== alert));
        }
    }

    function cssClasses(alert) {
        if (!alert) return;

        const classes = ['alert', 'alert-dismissable'];
                
        const alertTypeClass = {
            [AlertType.Success]: 'alert alert-success',
            [AlertType.Error]: 'alert alert-danger',
            [AlertType.Info]: 'alert alert-info',
            [AlertType.Warning]: 'alert alert-warning'
        }

        classes.push(alertTypeClass[alert.type]);

        if (alert.fade) {
            classes.push('fade');
        }

        return classes.join(' ');
    }

    if (!alerts.length) return null;

    return (
        <div className="container">
            <div className="m-3">
                {alerts.map((alert, index) =>
                    <div key={index} className={cssClasses(alert)}>
                        <a className="close" onClick={() => removeAlert(alert)}>&times;</a>
                        <span dangerouslySetInnerHTML={{__html: alert.message}}></span>
                    </div>
                )}
            </div>
        </div>
    );
}

Alert.propTypes = propTypes;
Alert.defaultProps = defaultProps;
export { Alert };


Nav Component

Path: /src/_components/Nav.jsx

The nav component displays the primary and secondary navigation bars in the example. The component subscribes to the accountService.user observable and only displays the nav if the user is logged in.

Only the admin section has a secondary nav which contains a link to the /admin/users sub section. The AdminNav component is only displayed in the admin section by using the react router Route component and setting the path to "/admin" (<Route path="/admin" component={AdminNav} />).

The react router NavLink component automatically adds the active class to the active nav item so it is highlighted in the UI.

import React, { useState, useEffect } from 'react';
import { NavLink, Route } from 'react-router-dom';

import { Role } from '@/_helpers';
import { accountService } from '@/_services';

function Nav() {
    const [user, setUser] = useState({});

    useEffect(() => {
        const subscription = accountService.user.subscribe(x => setUser(x));
        return subscription.unsubscribe;
    }, []);

    // only show nav when logged in
    if (!user) return null;

    return (
        <div>
            <nav className="navbar navbar-expand navbar-dark bg-dark">
                <div className="navbar-nav">
                    <NavLink exact to="/" className="nav-item nav-link">Home</NavLink>
                    <NavLink to="/profile" className="nav-item nav-link">Profile</NavLink>
                    {user.role === Role.Admin &&
                        <NavLink to="/admin" className="nav-item nav-link">Admin</NavLink>
                    }
                    <a onClick={accountService.logout} className="nav-item nav-link">Logout</a>
                </div>
            </nav>
            <Route path="/admin" component={AdminNav} />
        </div>
    );
}

function AdminNav({ match }) {
    const { path } = match;

    return (
        <nav className="admin-nav navbar navbar-expand navbar-light">
            <div className="navbar-nav">
                <NavLink to={`${path}/users`} className="nav-item nav-link">Users</NavLink>
            </div>
        </nav>
    );
}

export { Nav };


Private Route

Path: /src/_components/PrivateRoute.jsx

The react private route component renders a route component if the user is logged in and in an authorized role for the route, if the user isn't logged in they're redirected to the /login page, if the user is logged in but not in an authorised role they're redirected to the home page.

import React from 'react';
import { Route, Redirect } from 'react-router-dom';

import { accountService } from '@/_services';

function PrivateRoute({ component: Component, roles, ...rest }) {
    return (
        <Route {...rest} render={props => {
            const user = accountService.userValue;
            if (!user) {
                // not logged in so redirect to login page with the return url
                return <Redirect to={{ pathname: '/account/login', state: { from: props.location } }} />
            }

            // check if route is restricted by role
            if (roles && roles.indexOf(user.role) === -1) {
                // role not authorized so redirect to home page
                return <Redirect to={{ pathname: '/'}} />
            }

            // authorized so return component
            return <Component {...props} />
        }} />
    );
}

export { PrivateRoute };


Fake Backend

Path: /src/_helpers/fake-backend.js

The fake backend is enabled by executing the below configureFakeBackend() function which monkey patches fetch() to create a custom fetch function.

The new custom fetch function returns a javascript Promise that resolves after a half second delay to simulate a real api call. The fake backend is organised into a top level handleRoute() function that checks the request url and method to determine how the request should be handled. For intercepted routes one of the below // route functions is called, for all other routes the request is passed through to the real backend via the realFetch() function which points to the original window.fetch function. Below the route functions there are // helper functions for returning different response types and performing small tasks.

The fake backend can't send emails so instead displays "email" messages on the screen by calling alertService.info() with the contents of the email e.g. "verify email" and "reset password" emails.

For more info see React + Fetch - Fake Backend Example for Backendless Development.

import { Role } from './'
import { alertService } from '@/_services';

// array in local storage for registered users
const usersKey = 'react-signup-verification-boilerplate-users';
const users = JSON.parse(localStorage.getItem(usersKey)) || [];

export function configureFakeBackend() {
    let realFetch = window.fetch;
    window.fetch = function (url, opts) {
        return new Promise((resolve, reject) => {
            // wrap in timeout to simulate server api call
            setTimeout(handleRoute, 500);

            function handleRoute() {
                const { method } = opts;
                switch (true) {
                    case url.endsWith('/accounts/authenticate') && method === 'POST':
                        return authenticate();
                    case url.endsWith('/accounts/refresh-token') && method === 'POST':
                        return refreshToken();
                    case url.endsWith('/accounts/revoke-token') && method === 'POST':
                        return revokeToken();
                    case url.endsWith('/accounts/register') && method === 'POST':
                        return register();
                    case url.endsWith('/accounts/verify-email') && method === 'POST':
                        return verifyEmail();
                    case url.endsWith('/accounts/forgot-password') && method === 'POST':
                        return forgotPassword();
                    case url.endsWith('/accounts/validate-reset-token') && method === 'POST':
                        return validateResetToken();
                    case url.endsWith('/accounts/reset-password') && method === 'POST':
                        return resetPassword();
                    case url.endsWith('/accounts') && method === 'GET':
                        return getUsers();
                    case url.match(/\/accounts\/\d+$/) && method === 'GET':
                        return getUserById();
                    case url.endsWith('/accounts') && method === 'POST':
                        return createUser();
                    case url.match(/\/accounts\/\d+$/) && method === 'PUT':
                        return updateUser();
                    case url.match(/\/accounts\/\d+$/) && method === 'DELETE':
                        return deleteUser();
                    default:
                        // pass through any requests not handled above
                        return realFetch(url, opts)
                            .then(response => resolve(response))
                            .catch(error => reject(error));
                }
            }

            // route functions

            function authenticate() {
                const { email, password } = body();
                const user = users.find(x => x.email === email && x.password === password && x.isVerified);

                if (!user) return error('Email or password is incorrect');

                // add refresh token to user
                user.refreshTokens.push(generateRefreshToken());
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok({
                    id: user.id,
                    email: user.email,
                    title: user.title,
                    firstName: user.firstName,
                    lastName: user.lastName,
                    role: user.role,
                    jwtToken: generateJwtToken(user)
                });
            }

            function refreshToken() {
                const refreshToken = getRefreshToken();
                
                if (!refreshToken) return unauthorized();

                const user = users.find(x => x.refreshTokens.includes(refreshToken));
                
                if (!user) return unauthorized();

                // replace old refresh token with a new one and save
                user.refreshTokens = user.refreshTokens.filter(x => x !== refreshToken);
                user.refreshTokens.push(generateRefreshToken());
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok({
                    id: user.id,
                    email: user.email,
                    title: user.title,
                    firstName: user.firstName,
                    lastName: user.lastName,
                    role: user.role,
                    jwtToken: generateJwtToken(user)
                })
            }

            function revokeToken() {
                if (!isAuthenticated()) return unauthorized();
                
                const refreshToken = getRefreshToken();
                const user = users.find(x => x.refreshTokens.includes(refreshToken));
                
                // revoke token and save
                user.refreshTokens = user.refreshTokens.filter(x => x !== refreshToken);
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok();
            }

            function register() {
                const user = body();
    
                if (users.find(x => x.email === user.email)) {
                    // display email already registered "email" in alert
                    setTimeout(() => {
                        alertService.info(`
                            <h4>Email Already Registered</h4>
                            <p>Your email ${user.email} is already registered.</p>
                            <p>If you don't know your password please visit the <a href="${location.origin}/account/forgot-password">forgot password</a> page.</p>
                            <div><strong>NOTE:</strong> The fake backend displayed this "email" so you can test without an api. A real backend would send a real email.</div>
                        `, { autoClose: false });
                    }, 1000);

                    // always return ok() response to prevent email enumeration
                    return ok();
                }
    
                // assign user id and a few other properties then save
                user.id = newUserId();
                if (user.id === 1) {
                    // first registered user is an admin
                    user.role = Role.Admin;
                } else {
                    user.role = Role.User;
                }
                user.dateCreated = new Date().toISOString();
                user.verificationToken = new Date().getTime().toString();
                user.isVerified = false;
                user.refreshTokens = [];
                delete user.confirmPassword;
                users.push(user);
                localStorage.setItem(usersKey, JSON.stringify(users));

                // display verification email in alert
                setTimeout(() => {
                    const verifyUrl = `${location.origin}/account/verify-email?token=${user.verificationToken}`;
                    alertService.info(`
                        <h4>Verification Email</h4>
                        <p>Thanks for registering!</p>
                        <p>Please click the below link to verify your email address:</p>
                        <p><a href="${verifyUrl}">${verifyUrl}</a></p>
                        <div><strong>NOTE:</strong> The fake backend displayed this "email" so you can test without an api. A real backend would send a real email.</div>
                    `, { autoClose: false });
                }, 1000);

                return ok();
            }
    
            function verifyEmail() {
                const { token } = body();
                const user = users.find(x => !!x.verificationToken && x.verificationToken === token);
                
                if (!user) return error('Verification failed');
                
                // set is verified flag to true if token is valid
                user.isVerified = true;
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok();
            }

            function forgotPassword() {
                const { email } = body();
                const user = users.find(x => x.email === email);
                
                // always return ok() response to prevent email enumeration
                if (!user) return ok();
                
                // create reset token that expires after 24 hours
                user.resetToken = new Date().getTime().toString();
                user.resetTokenExpires = new Date(Date.now() + 24*60*60*1000).toISOString();
                localStorage.setItem(usersKey, JSON.stringify(users));

                // display password reset email in alert
                setTimeout(() => {
                    const resetUrl = `${location.origin}/account/reset-password?token=${user.resetToken}`;
                    alertService.info(`
                        <h4>Reset Password Email</h4>
                        <p>Please click the below link to reset your password, the link will be valid for 1 day:</p>
                        <p><a href="${resetUrl}">${resetUrl}</a></p>
                        <div><strong>NOTE:</strong> The fake backend displayed this "email" so you can test without an api. A real backend would send a real email.</div>
                    `, { autoClose: false });
                }, 1000);

                return ok();
            }

            function validateResetToken() {
                const { token } = body();
                const user = users.find(x =>
                    !!x.resetToken && x.resetToken === token &&
                    new Date() < new Date(x.resetTokenExpires)
                );
                
                if (!user) return error('Invalid token');
                
                return ok();
            }

            function resetPassword() {
                const { token, password } = body();
                const user = users.find(x =>
                    !!x.resetToken && x.resetToken === token &&
                    new Date() < new Date(x.resetTokenExpires)
                );
                
                if (!user) return error('Invalid token');
                
                // update password and remove reset token
                user.password = password;
                user.isVerified = true;
                delete user.resetToken;
                delete user.resetTokenExpires;
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok();
            }

            function getUsers() {
                if (!isAuthorized(Role.Admin)) return unauthorized();

                return ok(users);
            }

            function getUserById() {
                if (!isAuthenticated()) return unauthorized();
    
                let user = users.find(x => x.id === idFromUrl());

                // users can get own profile and admins can get all profiles
                if (user.id !== currentUser().id && !isAuthorized(Role.Admin)) {
                    return unauthorized();
                }

                return ok(user);
            }
    
            function createUser() {
                if (!isAuthorized(Role.Admin)) return unauthorized();
    
                const user = body();
                if (users.find(x => x.email === user.email)) {
                    return error(`Email ${user.email} is already registered`);
                }

                // assign user id and a few other properties then save
                user.id = newUserId();
                user.dateCreated = new Date().toISOString();
                user.isVerified = true;
                delete user.confirmPassword;
                users.push(user);
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok();
            }
    
            function updateUser() {
                if (!isAuthenticated()) return unauthorized();
    
                let params = body();
                let user = users.find(x => x.id === idFromUrl());

                // users can update own profile and admins can update all profiles
                if (user.id !== currentUser().id && !isAuthorized(Role.Admin)) {
                    return unauthorized();
                }

                // only update password if included
                if (!params.password) {
                    delete params.password;
                }
                // don't save confirm password
                delete params.confirmPassword;

                // update and save user
                Object.assign(user, params);
                localStorage.setItem(usersKey, JSON.stringify(users));

                return ok({
                    id: user.id,
                    email: user.email,
                    title: user.title,
                    firstName: user.firstName,
                    lastName: user.lastName,
                    role: user.role
                });
            }
    
            function deleteUser() {
                if (!isAuthenticated()) return unauthorized();
    
                let user = users.find(x => x.id === idFromUrl());

                // users can delete own account and admins can delete any account
                if (user.id !== currentUser().id && !isAuthorized(Role.Admin)) {
                    return unauthorized();
                }

                // delete user then save
                users = users.filter(x => x.id !== idFromUrl());
                localStorage.setItem(usersKey, JSON.stringify(users));
                return ok();
            }
    
            // helper functions

            function ok(body) {
                resolve({ ok: true, text: () => Promise.resolve(JSON.stringify(body)) });
            }

            function unauthorized() {
                resolve({ status: 401, text: () => Promise.resolve(JSON.stringify({ message: 'Unauthorized' })) });
            }

            function error(message) {
                resolve({ status: 400, text: () => Promise.resolve(JSON.stringify({ message })) });
            }

            function isAuthenticated() {
                return !!currentUser();
            }
    
            function isAuthorized(role) {
                const user = currentUser();
                if (!user) return false;
                return user.role === role;
            }
    
            function idFromUrl() {
                const urlParts = url.split('/');
                return parseInt(urlParts[urlParts.length - 1]);
            }

            function body() {
                return opts.body && JSON.parse(opts.body);    
            }

            function newUserId() {
                return users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
            }

            function generateJwtToken(user) {
                // create token that expires in 15 minutes
                const tokenPayload = { 
                    exp: Math.round(new Date(Date.now() + 15*60*1000).getTime() / 1000),
                    id: user.id
                }
                return `fake-jwt-token.${btoa(JSON.stringify(tokenPayload))}`;
            }

            function currentUser() {
                // check if jwt token is in auth header
                const authHeader = opts.headers['Authorization'] || '';
                if (!authHeader.startsWith('Bearer fake-jwt-token')) return;

                // check if token is expired
                const jwtToken = JSON.parse(atob(authHeader.split('.')[1]));
                const tokenExpired = Date.now() > (jwtToken.exp * 1000);
                if (tokenExpired) return;

                const user = users.find(x => x.id === jwtToken.id);
                return user;
            }

            function generateRefreshToken() {
                const token = new Date().getTime().toString();

                // add token cookie that expires in 7 days
                const expires = new Date(Date.now() + 7*24*60*60*1000).toUTCString();
                document.cookie = `fakeRefreshToken=${token}; expires=${expires}; path=/`;

                return token;
            }

            function getRefreshToken() {
                // get refresh token from cookie
                return (document.cookie.split(';').find(x => x.includes('fakeRefreshToken')) || '=').split('=')[1];
            }
        });
    }
}


Fetch Wrapper

Path: /src/_helpers/fetch-wrapper.js

The fetch wrapper is a lightweight wrapper around the native browser fetch() function used to simplify the code for making HTTP requests. It contains methods for get, post, put and delete requests, it automatically handles the parsing of JSON data from responses, and throws an error if the HTTP response is not successful (!response.ok). If the response is 401 Unauthorized or 403 Forbidden the user is automatically logged out.

The authHeader() function is used to automatically add a JWT auth token to the HTTP Authorization header of the request if the user is logged in and the request is to the application api url.

With the fetch wrapper a POST request can be made as simply as: fetchWrapper.post(url, body);. It is used in the example app by the account service.

import config from 'config';
import { accountService } from '@/_services';

export const fetchWrapper = {
    get,
    post,
    put,
    delete: _delete
}

function get(url) {
    const requestOptions = {
        method: 'GET',
        headers: authHeader(url)
    };
    return fetch(url, requestOptions).then(handleResponse);
}

function post(url, body) {
    const requestOptions = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...authHeader(url) },
        credentials: 'include',
        body: JSON.stringify(body)
    };
    return fetch(url, requestOptions).then(handleResponse);
}

function put(url, body) {
    const requestOptions = {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json', ...authHeader(url) },
        body: JSON.stringify(body)
    };
    return fetch(url, requestOptions).then(handleResponse);    
}

// prefixed with underscored because delete is a reserved word in javascript
function _delete(url) {
    const requestOptions = {
        method: 'DELETE',
        headers: authHeader(url)
    };
    return fetch(url, requestOptions).then(handleResponse);
}

// helper functions

function authHeader(url) {
    // return auth header with jwt if user is logged in and request is to the api url
    const user = accountService.userValue;
    const isLoggedIn = user && user.jwtToken;
    const isApiUrl = url.startsWith(config.apiUrl);
    if (isLoggedIn && isApiUrl) {
        return { Authorization: `Bearer ${user.jwtToken}` };
    } else {
        return {};
    }
}

function handleResponse(response) {
    return response.text().then(text => {
        const data = text && JSON.parse(text);
        
        if (!response.ok) {
            if ([401, 403].includes(response.status) && accountService.userValue) {
                // auto logout if 401 Unauthorized or 403 Forbidden response returned from api
                accountService.logout();
            }

            const error = (data && data.message) || response.statusText;
            return Promise.reject(error);
        }

        return data;
    });
}


History

Path: /src/_helpers/history.js

The history helper creates the browser history object used by the react app, it is passed to the Router component in the main react entry file and enables us to access the history object outside of react components, for example from the logout() method of the account service.

import { createBrowserHistory } from 'history';

export const history = createBrowserHistory();


Role Object / Enum

Path: /src/_helpers/role.js

The role object defines the all the roles in the example application, I created it to use like an enum to avoid passing roles around as strings, so instead of 'Admin' we can use Role.Admin.

export const Role = {
    Admin: 'Admin',
    User: 'User'    
};


Account Service

Path: /src/_services/account.service.js

The account service handles communication between the react app and the backend api for everything related to accounts. It contains methods for the sign up, verification, authentication, refresh token and forgot password flows, as well as standard CRUD methods for retrieving and modifying user data. All HTTP requests to the api are made by calling the fetch wrapper.

On successful login the api returns the user details and a JWT token which are published to all subscribers with the call to userSubject.next(user), the api also returns a refresh token cookie which is stored by the browser. The method then starts a countdown timer by calling startRefreshTokenTimer() to auto refresh the JWT token in the background (silent refresh) one minute before it expires in order to keep the user logged in.

The logout() method makes a POST request to the API to revoke the refresh token that is stored in the refreshToken cookie in the browser, then cancels the silent refresh running in the background by calling stopRefreshTokenTimer(), then logs the user out by publishing a null value to all subscriber components (userSubject.next(null)), and finally redirects the user to the login page.

The user property exposes an RxJS observable (userSubject.asObservable()) so any component can subscribe to be notified when a user logs in, logs out, has their token refreshed or updates their profile. The notification is triggered by the call to userSubject.next() from each of the corresponding methods in the service. For more info about using React with RxJS see React Hooks + RxJS - Communicating Between Components with Observable & Subject.

import { BehaviorSubject } from 'rxjs';

import config from 'config';
import { fetchWrapper, history } from '@/_helpers';

const userSubject = new BehaviorSubject(null);
const baseUrl = `${config.apiUrl}/accounts`;

export const accountService = {
    login,
    logout,
    refreshToken,
    register,
    verifyEmail,
    forgotPassword,
    validateResetToken,
    resetPassword,
    getAll,
    getById,
    create,
    update,
    delete: _delete,
    user: userSubject.asObservable(),
    get userValue () { return userSubject.value }
};

function login(email, password) {
    return fetchWrapper.post(`${baseUrl}/authenticate`, { email, password })
        .then(user => {
            // publish user to subscribers and start timer to refresh token
            userSubject.next(user);
            startRefreshTokenTimer();
            return user;
        });
}

function logout() {
    // revoke token, stop refresh timer, publish null to user subscribers and redirect to login page
    fetchWrapper.post(`${baseUrl}/revoke-token`, {});
    stopRefreshTokenTimer();
    userSubject.next(null);
    history.push('/account/login');
}

function refreshToken() {
    return fetchWrapper.post(`${baseUrl}/refresh-token`, {})
        .then(user => {
            // publish user to subscribers and start timer to refresh token
            userSubject.next(user);
            startRefreshTokenTimer();
            return user;
        });
}

function register(params) {
    return fetchWrapper.post(`${baseUrl}/register`, params);
}

function verifyEmail(token) {
    return fetchWrapper.post(`${baseUrl}/verify-email`, { token });
}

function forgotPassword(email) {
    return fetchWrapper.post(`${baseUrl}/forgot-password`, { email });
}

function validateResetToken(token) {
    return fetchWrapper.post(`${baseUrl}/validate-reset-token`, { token });
}

function resetPassword({ token, password, confirmPassword }) {
    return fetchWrapper.post(`${baseUrl}/reset-password`, { token, password, confirmPassword });
}

function getAll() {
    return fetchWrapper.get(baseUrl);
}

function getById(id) {
    return fetchWrapper.get(`${baseUrl}/${id}`);
}

function create(params) {
    return fetchWrapper.post(baseUrl, params);
}

function update(id, params) {
    return fetchWrapper.put(`${baseUrl}/${id}`, params)
        .then(user => {
            // update stored user if the logged in user updated their own record
            if (user.id === userSubject.value.id) {
                // publish updated user to subscribers
                user = { ...userSubject.value, ...user };
                userSubject.next(user);
            }
            return user;
        });
}

// prefixed with underscore because 'delete' is a reserved word in javascript
function _delete(id) {
    return fetchWrapper.delete(`${baseUrl}/${id}`)
        .then(x => {
            // auto logout if the logged in user deleted their own record
            if (id === userSubject.value.id) {
                logout();
            }
            return x;
        });
}

// helper functions

let refreshTokenTimeout;

function startRefreshTokenTimer() {
    // parse json object from base64 encoded jwt token
    const jwtToken = JSON.parse(atob(userSubject.value.jwtToken.split('.')[1]));

    // set a timeout to refresh the token a minute before it expires
    const expires = new Date(jwtToken.exp * 1000);
    const timeout = expires.getTime() - Date.now() - (60 * 1000);
    refreshTokenTimeout = setTimeout(refreshToken, timeout);
}

function stopRefreshTokenTimer() {
    clearTimeout(refreshTokenTimeout);
}


Alert Service

Path: /src/_services/alert.service.js

The alert service (/src/app/_services/alert.service.js) acts as the bridge between any component in the React application and the alert component that actually displays the alert notification. It contains methods for sending, clearing and subscribing to alerts.

The AlertType object defines the types of alerts supported by the application.

You can trigger alert notifications from any component or service by calling one of the convenience methods for displaying the different types of alerts: success(), error(), info() and warn().

Alert convenience method parameters

  • The first parameter is the alert message string, which can be plain text or HTML
  • The second parameter is an optional options object that supports an autoClose boolean property and keepAfterRouteChange boolean property:
    • autoClose - if true tells the alert component to automatically close the alert after three seconds. Default is true.
    • keepAfterRouteChange - if true prevents the alert from being closed after one route change, this is handy for displaying messages after a redirect such as when a new user is created or updated. Default is false.

For more info see React Hooks + Bootstrap - Alert Notifications.

import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';

const alertSubject = new Subject();
const defaultId = 'default-alert';

export const alertService = {
    onAlert,
    success,
    error,
    info,
    warn,
    alert,
    clear
};

export const AlertType = {
    Success: 'Success',
    Error: 'Error',
    Info: 'Info',
    Warning: 'Warning'
}

// enable subscribing to alerts observable
function onAlert(id = defaultId) {
    return alertSubject.asObservable().pipe(filter(x => x && x.id === id));
}

// convenience methods
function success(message, options) {
    alert({ ...options, type: AlertType.Success, message });
}

function error(message, options) {
    alert({ ...options, type: AlertType.Error, message });
}

function info(message, options) {
    alert({ ...options, type: AlertType.Info, message });
}

function warn(message, options) {
    alert({ ...options, type: AlertType.Warning, message });
}

// core alert method
function alert(alert) {
    alert.id = alert.id || defaultId;
    alert.autoClose = (alert.autoClose === undefined ? true : alert.autoClose);
    alertSubject.next(alert);
}

// clear alerts
function clear(id = defaultId) {
    alertSubject.next({ id });
}


Forgot Password Component

Path: /src/account/ForgotPassword.jsx

The forgot password component contains a form built with the Formik library with a single field for entering the email of the account that you have forgotten password for.

On submit the component calls accountService.forgotPassword(email) and displays either a success or error message. If the email matches a registered account the fake backend displays a password reset "email" with instructions in the UI below the success message (A real backend api would send an actual email for this step), the instructions include a link to reset the password of the account.

import React from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';

import { accountService, alertService } from '@/_services';

function ForgotPassword() {
    const initialValues = {
        email: ''
    };

    const validationSchema = Yup.object().shape({
        email: Yup.string()
            .email('Email is invalid')
            .required('Email is required')
    });

    function onSubmit({ email }, { setSubmitting }) {
        alertService.clear();
        accountService.forgotPassword(email)
            .then(() => alertService.success('Please check your email for password reset instructions'))
            .catch(error => alertService.error(error))
            .finally(() => setSubmitting(false));
    }

    return (
        <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={onSubmit}>
            {({ errors, touched, isSubmitting }) => (
                <Form>
                    <h3 className="card-header">Forgot Password</h3>
                    <div className="card-body">
                        <div className="form-group">
                            <label>Email</label>
                            <Field name="email" type="text" className={'form-control' + (errors.email && touched.email ? ' is-invalid' : '')} />
                            <ErrorMessage name="email" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-row">
                            <div className="form-group col">
                                <button type="submit" disabled={isSubmitting} className="btn btn-primary">
                                    {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                                    Submit
                                </button>
                                <Link to="login" className="btn btn-link">Cancel</Link>
                            </div>
                        </div>
                    </div>
                </Form>
            )}
        </Formik>        
    )
}

export { ForgotPassword };


Account Index Component

Path: /src/account/Index.jsx

The Account component is the root component of the account section / feature, it defines routes for each of the pages within the account section which handle all of the authentication and related functionality.

A useEffect hook is used check if the user is already logged in when they try to access an accounts page so they can be automatically redirected to the home page ('/'), since authenticated users have no use for any of the accounts pages.

As a convention I named the root component file in each feature folder Index.jsx so it can be imported using only the folder path (import { Account } from '@/account';), removing the need for an extra index.js file that re-exports the Account component.

import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';

import { accountService } from '@/_services';

import { Login } from './Login';
import { Register } from './Register';
import { VerifyEmail } from './VerifyEmail';
import { ForgotPassword } from './ForgotPassword';
import { ResetPassword } from './ResetPassword';

function Account({ history, match }) {
    const { path } = match;

    useEffect(() => {
        // redirect to home if already logged in
        if (accountService.userValue) {
            history.push('/');
        }
    }, []);

    return (
        <div className="container">
            <div className="row">
                <div className="col-sm-8 offset-sm-2 mt-5">
                    <div className="card m-3">
                        <Switch>
                            <Route path={`${path}/login`} component={Login} />
                            <Route path={`${path}/register`} component={Register} />
                            <Route path={`${path}/verify-email`} component={VerifyEmail} />
                            <Route path={`${path}/forgot-password`} component={ForgotPassword} />
                            <Route path={`${path}/reset-password`} component={ResetPassword} />
                        </Switch>
                    </div>
                </div>
            </div>
        </div>
    );
}

export { Account };


Login Component

Path: /src/account/Login.jsx

The login component contains a pretty standard login form built with the Formik library that contains fields for email and password.

On successful login the user is redirected to the page they were trying to access before logging in or to the home page ("/") by default. The from path is added to the location.state when redirected by the private route component. On failed login the error returned from the backend is displayed in the UI.

import React from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';

import { accountService, alertService } from '@/_services';

function Login({ history, location }) {
    const initialValues = {
        email: '',
        password: ''
    };

    const validationSchema = Yup.object().shape({
        email: Yup.string()
            .email('Email is invalid')
            .required('Email is required'),
        password: Yup.string().required('Password is required')
    });

    function onSubmit({ email, password }, { setSubmitting }) {
        alertService.clear();
        accountService.login(email, password)
            .then(() => {
                const { from } = location.state || { from: { pathname: "/" } };
                history.push(from);
            })
            .catch(error => {
                setSubmitting(false);
                alertService.error(error);
            });
    }

    return (
        <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={onSubmit}>
            {({ errors, touched, isSubmitting }) => (
                <Form>
                    <h3 className="card-header">Login</h3>
                    <div className="card-body">
                        <div className="form-group">
                            <label>Email</label>
                            <Field name="email" type="text" className={'form-control' + (errors.email && touched.email ? ' is-invalid' : '')} />
                            <ErrorMessage name="email" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-group">
                            <label>Password</label>
                            <Field name="password" type="password" className={'form-control' + (errors.password && touched.password ? ' is-invalid' : '')} />
                            <ErrorMessage name="password" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-row">
                            <div className="form-group col">
                                <button type="submit" disabled={isSubmitting} className="btn btn-primary">
                                    {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                                    Login
                                </button>
                                <Link to="register" className="btn btn-link">Register</Link>
                            </div>
                            <div className="form-group col text-right">
                                <Link to="forgot-password" className="btn btn-link pr-0">Forgot Password?</Link>
                            </div>
                        </div>
                    </div>
                </Form>
            )}
        </Formik>
    )
}

export { Login };


Register Component

Path: /src/account/Register.jsx

The register component contains a account registration form with fields for title, first name, last name, email, password, confirm password and an accept Ts & Cs checkbox. All fields are required including the checkbox, the email field must be a valid email address, the password field must have a min length of 6 and must match the confirm password field.

On successful registration a success message is displayed and the user is redirected to the login page, then the fake backend displays a verification "email" with instructions in the UI below the success message (A real backend api would send an actual email for this step), the instructions include a link to verify the account.

import React from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';

import { accountService, alertService } from '@/_services';

function Register({ history }) {
    const initialValues = {
        title: '',
        firstName: '',
        lastName: '',
        email: '',
        password: '',
        confirmPassword: '',
        acceptTerms: false
    };

    const validationSchema = Yup.object().shape({
        title: Yup.string()
            .required('Title is required'),
        firstName: Yup.string()
            .required('First Name is required'),
        lastName: Yup.string()
            .required('Last Name is required'),
        email: Yup.string()
            .email('Email is invalid')
            .required('Email is required'),
        password: Yup.string()
            .min(6, 'Password must be at least 6 characters')
            .required('Password is required'),
        confirmPassword: Yup.string()
            .oneOf([Yup.ref('password'), null], 'Passwords must match')
            .required('Confirm Password is required'),
        acceptTerms: Yup.bool()
            .oneOf([true], 'Accept Terms & Conditions is required')
    });

    function onSubmit(fields, { setStatus, setSubmitting }) {
        setStatus();
        accountService.register(fields)
            .then(() => {
                alertService.success('Registration successful, please check your email for verification instructions', { keepAfterRouteChange: true });
                history.push('login');
            })
            .catch(error => {
                setSubmitting(false);
                alertService.error(error);
            });
    }

    return (
        <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={onSubmit}>
            {({ errors, touched, isSubmitting }) => (
                <Form>
                    <h3 className="card-header">Register</h3>
                    <div className="card-body">
                        <div className="form-row">
                            <div className="form-group col">
                                <label>Title</label>
                                <Field name="title" as="select" className={'form-control' + (errors.title && touched.title ? ' is-invalid' : '')}>
                                    <option value=""></option>
                                    <option value="Mr">Mr</option>
                                    <option value="Mrs">Mrs</option>
                                    <option value="Miss">Miss</option>
                                    <option value="Ms">Ms</option>
                                </Field>
                                <ErrorMessage name="title" component="div" className="invalid-feedback" />
                            </div>
                            <div className="form-group col-5">
                                <label>First Name</label>
                                <Field name="firstName" type="text" className={'form-control' + (errors.firstName && touched.firstName ? ' is-invalid' : '')} />
                                <ErrorMessage name="firstName" component="div" className="invalid-feedback" />
                            </div>
                            <div className="form-group col-5">
                                <label>Last Name</label>
                                <Field name="lastName" type="text" className={'form-control' + (errors.lastName && touched.lastName ? ' is-invalid' : '')} />
                                <ErrorMessage name="lastName" component="div" className="invalid-feedback" />
                            </div>
                        </div>
                        <div className="form-group">
                            <label>Email</label>
                            <Field name="email" type="text" className={'form-control' + (errors.email && touched.email ? ' is-invalid' : '')} />
                            <ErrorMessage name="email" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-row">
                            <div className="form-group col">
                                <label>Password</label>
                                <Field name="password" type="password" className={'form-control' + (errors.password && touched.password ? ' is-invalid' : '')} />
                                <ErrorMessage name="password" component="div" className="invalid-feedback" />
                            </div>
                            <div className="form-group col">
                                <label>Confirm Password</label>
                                <Field name="confirmPassword" type="password" className={'form-control' + (errors.confirmPassword && touched.confirmPassword ? ' is-invalid' : '')} />
                                <ErrorMessage name="confirmPassword" component="div" className="invalid-feedback" />
                            </div>
                        </div>
                        <div className="form-group form-check">
                            <Field type="checkbox" name="acceptTerms" id="acceptTerms" className={'form-check-input ' + (errors.acceptTerms && touched.acceptTerms ? ' is-invalid' : '')} />
                            <label htmlFor="acceptTerms" className="form-check-label">Accept Terms & Conditions</label>
                            <ErrorMessage name="acceptTerms" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-group">
                            <button type="submit" disabled={isSubmitting} className="btn btn-primary">
                                {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                                Register
                            </button>
                            <Link to="login" className="btn btn-link">Cancel</Link>
                        </div>
                    </div>
                </Form>
            )}
        </Formik>
    )
}

export { Register };


Reset Password Component

Path: /src/account/ResetPassword.jsx

The reset password component displays a form for resetting an account password when it receives a valid password reset token in the url querystring parameters. The token is validated when the component mounts by calling accountService.validateResetToken(token) from inside a useEffect() react hook function, the empty dependency array passed to the react hook makes it run only once when the component mounts.

The tokenStatus controls what is rendered on the page, the initial status is Validating before changing to either Valid or Invalid. The TokenStatus object / enum is used to set the status so we don't have to use string values.

On form submit the password is reset by calling accountService.resetPassword(token, password) which sends the token and new password to the backend. The backend should validate the token again before updating the password, see the resetPassword() function in the fake backend for an example.

On successful password reset the user is redirected to the login page with a success message and can login with the new password.

import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import queryString from 'query-string';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';

import { accountService, alertService } from '@/_services';

function ResetPassword({ history }) {
    const TokenStatus = {
        Validating: 'Validating',
        Valid: 'Valid',
        Invalid: 'Invalid'
    }
    
    const [token, setToken] = useState(null);
    const [tokenStatus, setTokenStatus] = useState(TokenStatus.Validating);

    useEffect(() => {
        const { token } = queryString.parse(location.search);

        // remove token from url to prevent http referer leakage
        history.replace(location.pathname);

        accountService.validateResetToken(token)
            .then(() => {
                setToken(token);
                setTokenStatus(TokenStatus.Valid);
            })
            .catch(() => {
                setTokenStatus(TokenStatus.Invalid);
            });
    }, []);

    function getForm() {
        const initialValues = {
            password: '',
            confirmPassword: ''
        };

        const validationSchema = Yup.object().shape({
            password: Yup.string()
                .min(6, 'Password must be at least 6 characters')
                .required('Password is required'),
            confirmPassword: Yup.string()
                .oneOf([Yup.ref('password'), null], 'Passwords must match')
                .required('Confirm Password is required'),
        });

        function onSubmit({ password, confirmPassword }, { setSubmitting }) {
            alertService.clear();
            accountService.resetPassword({ token, password, confirmPassword })
                .then(() => {
                    alertService.success('Password reset successful, you can now login', { keepAfterRouteChange: true });
                    history.push('login');
                })
                .catch(error => {
                    setSubmitting(false);
                    alertService.error(error);
                });
        }

        return (
            <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={onSubmit}>
                {({ errors, touched, isSubmitting }) => (
                    <Form>
                        <div className="form-group">
                            <label>Password</label>
                            <Field name="password" type="password" className={'form-control' + (errors.password && touched.password ? ' is-invalid' : '')} />
                            <ErrorMessage name="password" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-group">
                            <label>Confirm Password</label>
                            <Field name="confirmPassword" type="password" className={'form-control' + (errors.confirmPassword && touched.confirmPassword ? ' is-invalid' : '')} />
                            <ErrorMessage name="confirmPassword" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-row">
                            <div className="form-group col">
                                <button type="submit" disabled={isSubmitting} className="btn btn-primary">
                                    {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                                    Reset Password
                                </button>
                                <Link to="/login" className="btn btn-link">Cancel</Link>
                            </div>
                        </div>
                    </Form>
                )}
            </Formik>
        );
    }

    function getBody() {
        switch (tokenStatus) {
            case TokenStatus.Valid:
                return getForm();
            case TokenStatus.Invalid:
                return <div>Token validation failed, if the token has expired you can get a new one at the <Link to="forgot-password">forgot password</Link> page.</div>;
            case TokenStatus.Validating:
                return <div>Validating token...</div>;
        }
    }

    return (
        <div>
            <h3 className="card-header">Reset Password</h3>
            <div className="card-body">{getBody()}</div>
        </div>
    )
}

export { ResetPassword };


Verify Email Component

Path: /src/account/VerifyEmail.jsx

The verify email component is used to verify new accounts before they can login to the app. When a new account is registered an email is sent to the user containing a link back to this component with a verification token in the querystring parameters. The token from the email link is verified when the component mounts by calling accountService.verifyEmail(token) from inside a useEffect() react hook function, the empty dependency array passed to the react hook makes it run only once when the component mounts.

On successful verification the user is redirected to the login page with a success message and can login to the account, if token verification fails an error message is displayed and a link to the forgot password page which can also be used to verify an account.

NOTE: When using the app with the fake backend the verification "email" is displayed on the screen when a new account is registered.

import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import queryString from 'query-string';

import { accountService, alertService } from '@/_services';

function VerifyEmail({ history }) {
    const EmailStatus = {
        Verifying: 'Verifying',
        Failed: 'Failed'
    }

    const [emailStatus, setEmailStatus] = useState(EmailStatus.Verifying);

    useEffect(() => {
        const { token } = queryString.parse(location.search);

        // remove token from url to prevent http referer leakage
        history.replace(location.pathname);

        accountService.verifyEmail(token)
            .then(() => {
                alertService.success('Verification successful, you can now login.', { keepAfterRouteChange: true });
                history.push('login');
            })
            .catch(() => {
                setEmailStatus(EmailStatus.Failed);
            });
    }, []);

    function getBody() {
        switch (emailStatus) {
            case EmailStatus.Verifying:
                return <div>Verifying...</div>;
            case EmailStatus.Failed:
                return <div>Verification failed, you can also verify your account using the <Link to="forgot-password">forgot password</Link> page.</div>;
        }
    }

    return (
        <div>
            <h3 className="card-header">Verify Email</h3>
            <div className="card-body">{getBody()}</div>
        </div>
    )
}

export { VerifyEmail };


Users Add/Edit Component

Path: /src/admin/users/AddEdit.jsx

The users add/edit component is used for both adding and editing users, the form is in "add mode" when there is no user id route parameter (match.params.id), otherwise it is in "edit mode". The variable isAddMode is used to change the form behaviour based on which mode it is in, for example in "add mode" the password field is required, and in "edit mode" (!isAddMode) the account service is called when the component mounts to get the user details (accountService.getById(id)) to preset the field values.

On submit a user is either created or updated by calling the account service, and on success you are redirected back to the users list page with a success message.

import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';

import { accountService, alertService } from '@/_services';

function AddEdit({ history, match }) {
    const { id } = match.params;
    const isAddMode = !id;
    
    const initialValues = {
        title: '',
        firstName: '',
        lastName: '',
        email: '',
        role: '',
        password: '',
        confirmPassword: ''
    };

    const validationSchema = Yup.object().shape({
        title: Yup.string()
            .required('Title is required'),
        firstName: Yup.string()
            .required('First Name is required'),
        lastName: Yup.string()
            .required('Last Name is required'),
        email: Yup.string()
            .email('Email is invalid')
            .required('Email is required'),
        role: Yup.string()
            .required('Role is required'),
        password: Yup.string()
            .concat(isAddMode ? Yup.string().required('Password is required') : null)
            .min(6, 'Password must be at least 6 characters'),
        confirmPassword: Yup.string()
            .when('password', (password, schema) => {
                if (password) return schema.required('Confirm Password is required');
            })
            .oneOf([Yup.ref('password')], 'Passwords must match')
    });

    function onSubmit(fields, { setStatus, setSubmitting }) {
        setStatus();
        if (isAddMode) {
            createUser(fields, setSubmitting);
        } else {
            updateUser(id, fields, setSubmitting);
        }
    }

    function createUser(fields, setSubmitting) {
        accountService.create(fields)
            .then(() => {
                alertService.success('User added successfully', { keepAfterRouteChange: true });
                history.push('.');
            })
            .catch(error => {
                setSubmitting(false);
                alertService.error(error);
            });
    }

    function updateUser(id, fields, setSubmitting) {
        accountService.update(id, fields)
            .then(() => {
                alertService.success('Update successful', { keepAfterRouteChange: true });
                history.push('..');
            })
            .catch(error => {
                setSubmitting(false);
                alertService.error(error);
            });
    }

    return (
        <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={onSubmit}>
            {({ errors, touched, isSubmitting, setFieldValue }) => {
                useEffect(() => {
                    if (!isAddMode) {
                        // get user and set form fields
                        accountService.getById(id).then(user => {
                            const fields = ['title', 'firstName', 'lastName', 'email', 'role'];
                            fields.forEach(field => setFieldValue(field, user[field], false));
                        });
                    }
                }, []);

                return (
                    <Form>
                        <h1>{isAddMode ? 'Add User' : 'Edit User'}</h1>
                        <div className="form-row">
                            <div className="form-group col">
                                <label>Title</label>
                                <Field name="title" as="select" className={'form-control' + (errors.title && touched.title ? ' is-invalid' : '')}>
                                    <option value=""></option>
                                    <option value="Mr">Mr</option>
                                    <option value="Mrs">Mrs</option>
                                    <option value="Miss">Miss</option>
                                    <option value="Ms">Ms</option>
                                </Field>
                                <ErrorMessage name="title" component="div" className="invalid-feedback" />
                            </div>
                            <div className="form-group col-5">
                                <label>First Name</label>
                                <Field name="firstName" type="text" className={'form-control' + (errors.firstName && touched.firstName ? ' is-invalid' : '')} />
                                <ErrorMessage name="firstName" component="div" className="invalid-feedback" />
                            </div>
                            <div className="form-group col-5">
                                <label>Last Name</label>
                                <Field name="lastName" type="text" className={'form-control' + (errors.lastName && touched.lastName ? ' is-invalid' : '')} />
                                <ErrorMessage name="lastName" component="div" className="invalid-feedback" />
                            </div>
                        </div>
                        <div className="form-row">
                            <div className="form-group col-7">
                                <label>Email</label>
                                <Field name="email" type="text" className={'form-control' + (errors.email && touched.email ? ' is-invalid' : '')} />
                                <ErrorMessage name="email" component="div" className="invalid-feedback" />
                            </div>
                            <div className="form-group col">
                                <label>Role</label>
                                <Field name="role" as="select" className={'form-control' + (errors.role && touched.role ? ' is-invalid' : '')}>
                                    <option value=""></option>
                                    <option value="User">User</option>
                                    <option value="Admin">Admin</option>
                                </Field>
                                <ErrorMessage name="role" component="div" className="invalid-feedback" />
                            </div>
                        </div>
                        {!isAddMode &&
                            <div>
                                <h3 className="pt-3">Change Password</h3>
                                <p>Leave blank to keep the same password</p>
                            </div>
                        }
                        <div className="form-row">
                            <div className="form-group col">
                                <label>Password</label>
                                <Field name="password" type="password" className={'form-control' + (errors.password && touched.password ? ' is-invalid' : '')} />
                                <ErrorMessage name="password" component="div" className="invalid-feedback" />
                            </div>
                            <div className="form-group col">
                                <label>Confirm Password</label>
                                <Field name="confirmPassword" type="password" className={'form-control' + (errors.confirmPassword && touched.confirmPassword ? ' is-invalid' : '')} />
                                <ErrorMessage name="confirmPassword" component="div" className="invalid-feedback" />
                            </div>
                        </div>
                        <div className="form-group">
                            <button type="submit" disabled={isSubmitting} className="btn btn-primary">
                                {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                                Save
                            </button>
                            <Link to={isAddMode ? '.' : '..'} className="btn btn-link">Cancel</Link>
                        </div>
                    </Form>
                );
            }}
        </Formik>
    );
}

export { AddEdit };


Users Index Component

Path: /src/admin/users/Index.jsx

The Users component is the root component of the users section / feature, it defines routes for each of the pages within the users section.

The first route matches the root users path (/admin/users) making it the default route for this section, so by default the users List component is displayed. The second and third routes are for adding and editing users, they match different routes but both load the same component (AddEdit) and the component modifies its behaviour based on the route.

As a convention I named the root component file in each feature folder Index.jsx so it can be imported using only the folder path (import { Users } from './users';), removing the need for an extra index.js file that re-exports the Users component.

import React from 'react';
import { Route, Switch } from 'react-router-dom';

import { List } from './List';
import { AddEdit } from './AddEdit';

function Users({ match }) {
    const { path } = match;
    
    return (
        <Switch>
            <Route exact path={path} component={List} />
            <Route path={`${path}/add`} component={AddEdit} />
            <Route path={`${path}/edit/:id`} component={AddEdit} />
        </Switch>
    );
}

export { Users };


Users List Component

Path: /src/admin/users/List.jsx

The users list component displays a list of all users and contains buttons for adding, editing and deleting users. A useEffect hook is used to get all users from the account service and store them in the component state by calling setUsers().

The delete button calls the deleteUser() function which first updates the user in component state with an isDeleting = true property so the UI displays a spinner on the delete button, it then calls accountService.delete() to delete the user and removes the deleted user from component state so it is removed from the UI.

import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';

import { accountService } from '@/_services';

function List({ match }) {
    const { path } = match;
    const [users, setUsers] = useState(null);

    useEffect(() => {
        accountService.getAll().then(x => setUsers(x));
    }, []);

    function deleteUser(id) {
        setUsers(users.map(x => {
            if (x.id === id) { x.isDeleting = true; }
            return x;
        }));
        accountService.delete(id).then(() => {
            setUsers(users => users.filter(x => x.id !== id));
        });
    }

    return (
        <div>
            <h1>Users</h1>
            <p>All users from secure (admin only) api end point:</p>
            <Link to={`${path}/add`} className="btn btn-sm btn-success mb-2">Add User</Link>
            <table className="table table-striped">
                <thead>
                    <tr>
                        <th style={{ width: '30%' }}>Name</th>
                        <th style={{ width: '30%' }}>Email</th>
                        <th style={{ width: '30%' }}>Role</th>
                        <th style={{ width: '10%' }}></th>
                    </tr>
                </thead>
                <tbody>
                    {users && users.map(user =>
                        <tr key={user.id}>
                            <td>{user.title} {user.firstName} {user.lastName}</td>
                            <td>{user.email}</td>
                            <td>{user.role}</td>
                            <td style={{ whiteSpace: 'nowrap' }}>
                                <Link to={`${path}/edit/${user.id}`} className="btn btn-sm btn-primary mr-1">Edit</Link>
                                <button onClick={() => deleteUser(user.id)} className="btn btn-sm btn-danger" style={{ width: '60px' }} disabled={user.isDeleting}>
                                    {user.isDeleting 
                                        ? <span className="spinner-border spinner-border-sm"></span>
                                        : <span>Delete</span>
                                    }
                                </button>
                            </td>
                        </tr>
                    )}
                    {!users &&
                        <tr>
                            <td colSpan="4" className="text-center">
                                <span className="spinner-border spinner-border-lg align-center"></span>
                            </td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    );
}

export { List };


Admin Index Component

Path: /src/admin/Index.jsx

The Admin component is the root component of the admin section / feature, it defines routes for each of the pages within the admin section. The admin section is only accessible to admin users, access is controlled by a private route in the app component.

The first route matches the root admin path (/admin) making it the default route for this section, so by default the admin overview component is displayed, and the second route matches the /admin/users path to the users component for managing all users in the system.

As a convention I named the root component file in each feature folder Index.jsx so it can be imported using only the folder path (import { Admin } from './admin';), removing the need for an extra index.js file that re-exports the Admin component.

import React from 'react';
import { Route, Switch } from 'react-router-dom';

import { Overview } from './Overview';
import { Users } from './users';

function Admin({ match }) {
    const { path } = match;

    return (
        <div className="p-4">
            <div className="container">
                <Switch>
                    <Route exact path={path} component={Overview} />
                    <Route path={`${path}/users`} component={Users} />
                </Switch>
            </div>
        </div>
    );
}

export { Admin };


Admin Overview Component

Path: /src/admin/Overview.jsx

The admin overview component displays some basic HTML and a link to the user admin section.

import React from 'react';
import { Link } from 'react-router-dom';

function Overview({ match }) {
    const { path } = match;

    return (
        <div>
            <h1>Admin</h1>
            <p>This section can only be accessed by administrators.</p>
            <p><Link to={`${path}/users`}>Manage Users</Link></p>
        </div>
    );
}

export { Overview };


App Index Component

Path: /src/app/Index.jsx

The App component is the root component of the example app, it contains the outer html, main nav, global alert, and top level routes for the application.

As a convention I named the root component file in each feature folder Index.jsx so it can be imported using only the folder path (import { App } from './app';), removing the need for an extra index.js file that re-exports the App component.

The first route (<Redirect from="/:url*(/+)" to={pathname.slice(0, -1)} />) automatically removes trailing slashes from URLs which can cause issues and are a side-effect of using relative react router links (e.g. <Link to=".">). For more info see React Router - Relative Links Example.

The last route (<Redirect from="*" to="/" />) is a catch-all redirect route that redirects any unmatched paths to the home page.

All routes are restricted to authenticated users except for the account section, and the admin section is restricted to users in the Admin role. The private route component (PrivateRoute) is used to restrict access to routes.

import React, { useState, useEffect } from 'react';
import { Route, Switch, Redirect, useLocation } from 'react-router-dom';

import { Role } from '@/_helpers';
import { accountService } from '@/_services';
import { Nav, PrivateRoute, Alert } from '@/_components';
import { Home } from '@/home';
import { Profile } from '@/profile';
import { Admin } from '@/admin';
import { Account } from '@/account';

function App() {
    const { pathname } = useLocation();  
    const [user, setUser] = useState({});

    useEffect(() => {
        const subscription = accountService.user.subscribe(x => setUser(x));
        return subscription.unsubscribe;
    }, []);

    return (
        <div className={'app-container' + (user && ' bg-light')}>
            <Nav />
            <Alert />
            <Switch>
                <Redirect from="/:url*(/+)" to={pathname.slice(0, -1)} />
                <PrivateRoute exact path="/" component={Home} />
                <PrivateRoute path="/profile" component={Profile} />
                <PrivateRoute path="/admin" roles={[Role.Admin]} component={Admin} />
                <Route path="/account" component={Account} />
                <Redirect from="*" to="/" />
            </Switch>
        </div>
    );
}

export { App };


Home Index Component

Path: /src/home/Index.jsx

The Home component is a simple react function component that displays some HTML with the first name of the logged in user.

As a convention I named the root component file in each feature folder Index.jsx so it can be imported using only the folder path (import { Home } from '@/home';), removing the need for an extra index.js file that re-exports the Home component.

import React from 'react';

import { accountService } from '@/_services';

function Home() {
    const user = accountService.userValue;
    
    return (
        <div className="p-4">
            <div className="container">
                <h1>Hi {user.firstName}!</h1>
                <p>You're logged in with React & JWT!!</p>
            </div>
        </div>
    );
}

export { Home };


Profile Details Component

Path: /src/profile/Details.jsx

The profile details component displays the name and email of the authenticated user with a link to the update profile page.

import React from 'react';
import { Link } from 'react-router-dom';

import { accountService } from '@/_services';

function Details({ match }) {
    const { path } = match;
    const user = accountService.userValue;

    return (
        <div>
            <h1>My Profile</h1>
            <p>
                <strong>Name: </strong> {user.title} {user.firstName} {user.lastName}<br />
                <strong>Email: </strong> {user.email}
            </p>
            <p><Link to={`${path}/update`}>Update Profile</Link></p>
        </div>
    );
}

export { Details };


Profile Index Component

Path: /src/profile/Index.jsx

The Profile component is the root component of the profile section / feature, it defines routes for each of the pages within the profile section. The profile section is accessible to all authenticated users, access is controlled by a private route in the app component.

The first route matches the root profile path (/profile) making it the default route for this section, so by default the profile details component is displayed, and the second route matches the /profile/update path to the profile update component.

As a convention I named the root component file in each feature folder Index.jsx so it can be imported using only the folder path (import { Profile } from './profile';), removing the need for an extra index.js file that re-exports the Profile component.

import React from 'react';
import { Route, Switch } from 'react-router-dom';

import { Details } from './Details';
import { Update } from './Update';

function Profile({ match }) {
    const { path } = match;
    
    return (
        <div className="p-4">
            <div className="container">
                <Switch>
                    <Route exact path={path} component={Details} />
                    <Route path={`${path}/update`} component={Update} />
                </Switch>
            </div>
        </div>
    );
}

export { Profile };


Profile Update Component

Path: /src/profile/Update.jsx

The profile update component enables the current user to update their profile, change their password, or delete their account. It sets the initialValues of the fields with the current user from the account service (accountService.userValue).

On successful update the user is redirected back to the profile details page with a success message. On successful delete the user is logged out and a message is displayed.

import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';

import { accountService, alertService } from '@/_services';

function Update({ history }) {
    const user = accountService.userValue;
    const initialValues = {
        title: user.title,
        firstName: user.firstName,
        lastName: user.lastName,
        email: user.email,
        password: '',
        confirmPassword: ''
    };

    const validationSchema = Yup.object().shape({
        title: Yup.string()
            .required('Title is required'),
        firstName: Yup.string()
            .required('First Name is required'),
        lastName: Yup.string()
            .required('Last Name is required'),
        email: Yup.string()
            .email('Email is invalid')
            .required('Email is required'),
        password: Yup.string()
            .min(6, 'Password must be at least 6 characters'),
        confirmPassword: Yup.string()
            .when('password', (password, schema) => {
                if (password) return schema.required('Confirm Password is required');
            })
            .oneOf([Yup.ref('password')], 'Passwords must match')
    });

    function onSubmit(fields, { setStatus, setSubmitting }) {
        setStatus();
        accountService.update(user.id, fields)
            .then(() => {
                alertService.success('Update successful', { keepAfterRouteChange: true });
                history.push('.');
            })
            .catch(error => {
                setSubmitting(false);
                alertService.error(error);
            });
    }

    const [isDeleting, setIsDeleting] = useState(false);
    function onDelete() {
        if (confirm('Are you sure?')) {
            setIsDeleting(true);
            accountService.delete(user.id)
                .then(() => alertService.success('Account deleted successfully'));
        }
    }

    return (
        <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={onSubmit}>
            {({ errors, touched, isSubmitting }) => (
                <Form>
                    <h1>Update Profile</h1>
                    <div className="form-row">
                        <div className="form-group col">
                            <label>Title</label>
                            <Field name="title" as="select" className={'form-control' + (errors.title && touched.title ? ' is-invalid' : '')}>
                                <option value=""></option>
                                <option value="Mr">Mr</option>
                                <option value="Mrs">Mrs</option>
                                <option value="Miss">Miss</option>
                                <option value="Ms">Ms</option>
                            </Field>
                            <ErrorMessage name="title" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-group col-5">
                            <label>First Name</label>
                            <Field name="firstName" type="text" className={'form-control' + (errors.firstName && touched.firstName ? ' is-invalid' : '')} />
                            <ErrorMessage name="firstName" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-group col-5">
                            <label>Last Name</label>
                            <Field name="lastName" type="text" className={'form-control' + (errors.lastName && touched.lastName ? ' is-invalid' : '')} />
                            <ErrorMessage name="lastName" component="div" className="invalid-feedback" />
                        </div>
                    </div>
                    <div className="form-group">
                        <label>Email</label>
                        <Field name="email" type="text" className={'form-control' + (errors.email && touched.email ? ' is-invalid' : '')} />
                        <ErrorMessage name="email" component="div" className="invalid-feedback" />
                    </div>
                    <h3 className="pt-3">Change Password</h3>
                    <p>Leave blank to keep the same password</p>
                    <div className="form-row">
                        <div className="form-group col">
                            <label>Password</label>
                            <Field name="password" type="password" className={'form-control' + (errors.password && touched.password ? ' is-invalid' : '')} />
                            <ErrorMessage name="password" component="div" className="invalid-feedback" />
                        </div>
                        <div className="form-group col">
                            <label>Confirm Password</label>
                            <Field name="confirmPassword" type="password" className={'form-control' + (errors.confirmPassword && touched.confirmPassword ? ' is-invalid' : '')} />
                            <ErrorMessage name="confirmPassword" component="div" className="invalid-feedback" />
                        </div>
                    </div>
                    <div className="form-group">
                        <button type="submit" disabled={isSubmitting} className="btn btn-primary mr-2">
                            {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                            Update
                        </button>
                        <button type="button" onClick={() => onDelete()} className="btn btn-danger" style={{ width: '75px' }} disabled={isDeleting}>
                            {isDeleting
                                ? <span className="spinner-border spinner-border-sm"></span>
                                : <span>Delete</span>
                            }
                        </button>
                        <Link to="." className="btn btn-link">Cancel</Link>
                    </div>
                </Form>
            )}
        </Formik>
    )
}

export { Update };


Base Index HTML File

Path: /src/index.html

The base index html file contains the outer html for the whole react boilerplate application. When the app is started with npm start, webpack bundles up all of the react code into a single javascript file and injects it into the body of this page to be loaded by the browser. The react app is then rendered in the <div id="app"></div> element by the main react entry file below.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <base href="/" />
    <title>React - Email Sign Up with Verification, Authentication & Forgot Password</title>

    <!-- bootstrap css -->
    <link href="//netdna.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
    <div id="app"></div>
</body>
</html>


Main React Entry File

Path: /src/index.jsx

The root index.jsx file bootstraps the react boilerplate app by rendering the App component (wrapped in a react Router component) into the app div element defined in the base index html file above.

Before the app starts up an attempt is made to automatically authenticate by calling accountService.refreshToken() to get a new JWT token from the api. If the user has logged in previously (without logging out) and the browser still contains a valid refresh token cookie then they will be automatically logged in when the app loads.

The history helper is passed to the Router instead of using the BrowserRouter component (which comes with the history built in) so we can access the history object outside of react components, for example from the logout() method of the account service.

The boilerplate application uses a fake backend that stores data in browser local storage to mimic a real api, to switch to a real backend simply remove the 2 lines of code below the comment // setup fake backend.

import React from 'react';
import { Router } from 'react-router-dom';
import { render } from 'react-dom';

import { history } from './_helpers';
import { accountService } from './_services';
import { App } from './app';

import './styles.less';

// setup fake backend
import { configureFakeBackend } from './_helpers';
configureFakeBackend();

// attempt silent token refresh before startup
accountService.refreshToken().finally(startApp);

function startApp() { 
    render(
        <Router history={history}>
            <App />
        </Router>,
        document.getElementById('app')
    );
}


Global LESS/CSS Styles

Path: /src/styles.less

The styles.less file contains global custom LESS/CSS styles for the react boilerplate app. For more info see React - How to add Global CSS / LESS styles to React with webpack.

// global styles
a { cursor: pointer; }

.app-container {
    min-height: 320px;
}

.admin-nav {
    padding-top: 0;
    padding-bottom: 0;
    background-color: #e8e9ea;
    border-bottom: 1px solid #ccc;
}


Babel RC (Run Commands) File

Path: /.babelrc

The babel config file defines the presets used by babel to transpile the React and ES6 code. The babel transpiler is run by webpack via the babel-loader module configured in the webpack.config.js file below.

{
    "presets": [
        "@babel/preset-react",
        "@babel/preset-env"
    ]
}


Package.json

Path: /package.json

The package.json file contains project configuration information including package dependencies which get installed when you run npm install. Full documentation is available on the npm docs website.

{
    "name": "react-signup-verification-boilerplate",
    "version": "1.0.0",
    "repository": {
        "type": "git",
        "url": "https://github.com/cornflourblue/react-signup-verification-boilerplate.git"
    },
    "license": "MIT",
    "scripts": {
        "build": "webpack --mode production",
        "start": "webpack-dev-server --open"
    },
    "dependencies": {
        "formik": "^2.1.4",
        "history": "^4.10.1",
        "prop-types": "^15.7.2",
        "query-string": "^6.11.0",
        "react": "^16.8.6",
        "react-dom": "^16.8.6",
        "react-router-dom": "^5.0.0",
        "rxjs": "^6.3.3",
        "yup": "^0.28.1"
    },
    "devDependencies": {
        "@babel/core": "^7.4.3",
        "@babel/preset-env": "^7.4.3",
        "@babel/preset-react": "^7.0.0",
        "babel-loader": "^8.0.5",
        "css-loader": "^3.4.2",
        "html-webpack-plugin": "^3.2.0",
        "less": "^3.11.0",
        "less-loader": "^5.0.0",
        "path": "^0.12.7",
        "style-loader": "^1.1.3",
        "webpack": "^4.29.6",
        "webpack-cli": "^3.3.0",
        "webpack-dev-server": "^3.2.1"
    }
}


Webpack Config

Path: /webpack.config.js

Webpack is used to compile and bundle all the project files so they're ready to be loaded into a browser, it does this with the help of loaders and plugins that are configured in the webpack.config.js file. For more info about webpack check out the webpack docs.

The webpack config file also defines a global config object for the application using the externals property, you can also use this to define different config variables for your development and production environments.

var HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');

module.exports = {
    mode: 'development',
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                loader: 'babel-loader'
            },
            {
                test: /\.less$/,
                use: [
                    { loader: 'style-loader' },
                    { loader: 'css-loader' },
                    { loader: 'less-loader' }
                ]
            }
        ]
    },
    resolve: {
        mainFiles: ['index', 'Index'],
        extensions: ['.js', '.jsx'],
        alias: {
            '@': path.resolve(__dirname, 'src/'),
        }
    },
    plugins: [new HtmlWebpackPlugin({
        template: './src/index.html'
    })],
    devServer: {
        historyApiFallback: true
    },
    externals: {
        // global app config object
        config: JSON.stringify({
            apiUrl: 'http://localhost:4000'
        })
    }
}

 

Subscribe or Follow Me For Updates

Subscribe to my YouTube channel or follow me on Twitter or GitHub to be notified when I post new content.

 


Supported by