React Boilerplate - Email Sign Up with Verification, Authentication & Forgot Password
Tutorial built with React 16.13.1 and React Hooks
Other versions available:
- Angular: Angular 15, 10
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)
Tutorial Contents
- React boilerplate app overview
- Run the boilerplate app locally
- Run with a Node.js + MongoDB api
- Run with a Node.js + MySQL api
- Run with an .NET api
- React boilerplate project structure
React Boilerplate App Overview
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 .NET 6.0 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:
- 28 Feb 2021 - Updated backend api instructions from ASP.NET Core 3.1 to .NET 6.0
- 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
Run the React Boilerplate App Locally
- Install NodeJS and NPM from https://nodejs.org
- Download or clone the project source code from https://github.com/cornflourblue/react-signup-verification-boilerplate
- Install all required npm packages by running
npm install
from the command line in the project root folder (where the package.json is located). - 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.
Run 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.
- Install MongoDB Community Server from https://www.mongodb.com/download-center/community.
- Run MongoDB, instructions are available on the install page for each OS at https://docs.mongodb.com/manual/administration/install-community/
- Download or clone the project source code from https://github.com/cornflourblue/node-mongo-signup-verification-api
- Install all required npm packages by running
npm install
ornpm i
from the command line in the project root folder (where the package.json is located). - 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. - Start the api by running
npm start
from the command line in the project root folder, you should see the messageServer listening on port 4000
. - 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.
Run the React App with a Boilerplate Node.js + MySQL API
For full details about the boilerplate Node.js + MySQL api see Node.js + MySQL - Boilerplate API with Email Sign Up, Verification, Authentication & Forgot Password. But to get up and running quickly just follow the below steps.
- Install MySQL Community Server from https://dev.mysql.com/downloads/mysql/ and ensure it is started. Installation instructions are available at https://dev.mysql.com/doc/refman/8.0/en/installing.html.
- Download or clone the project source code from https://github.com/cornflourblue/node-mysql-signup-verification-api
- Install all required npm packages by running
npm install
ornpm i
from the command line in the project root folder (where the package.json is located). - 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. - Start the api by running
npm start
from the command line in the project root folder, you should see the messageServer listening on port 4000
. - 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 + MySQL API.
Run the React App with a Boilerplate .NET 6.0 API
For full details about the boilerplate .NET api see .NET 6.0 - Boilerplate API Tutorial with Email Sign Up, Verification, Authentication & Forgot Password. But to get up and running quickly just follow the below steps.
- Download or clone the tutorial project code from https://github.com/cornflourblue/dotnet-6-signup-verification-api
- 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. - 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 messageNow listening on: http://localhost:4000
. - 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 .NET 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:
- src
- _components
- Alert.jsx
- Nav.jsx
- PrivateRoute.jsx
- index.js
- _helpers
- fake-backend.js
- fetch-wrapper.js
- history.js
- role.js
- index.js
- _services
- account.service.js
- alert.service.js
- index.js
- account
- admin
- app
- home
- profile
- index.html
- index.jsx
- styles.less
- _components
- .babelrc
- package.json
- webpack.config.js
Alert Component
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)}>×</a>
<span dangerouslySetInnerHTML={{__html: alert.message}}></span>
</div>
)}
</div>
</div>
);
}
Alert.propTypes = propTypes;
Alert.defaultProps = defaultProps;
export { Alert };
Nav Component
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
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
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
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
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
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
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
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 anautoClose
boolean property andkeepAfterRouteChange
boolean property:autoClose
- if true tells the alert component to automatically close the alert after three seconds. Default istrue
.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 isfalse
.
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
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
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
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
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
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
The verify email component is used to verify new accounts before they can login to the boilerplate 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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'
})
}
}
Need Some React Help?
Search fiverr for freelance React developers.
Follow me for updates
When I'm not coding...
Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!