Building an authenticated MERN Stack App Using Material UI

Published on Nov 16, 2018

32 min read

NODEJS

cover

Originally published at Crowdbotics

It can sometime be overwhelming to build a full-stack web application using a stack like MERN.

Setting up the the back end and connecting it with a client-side library like React to fetch and display data is just the beginning. One you have the data user will interact with, you need to focus on developing a functional User Interface (UI) for your web application. For some developers, UI can be the tricky part.

MERN is full-stack because it consists of MongDB, Express, React and Nodejs. Each of these technologies can be replaced with something comparable but it is common practice to use them together.

React is the library you will use to build the front-end of the web application. Express is a Nodejs framework that helps you to build a server that communicates to and fro with a NoSQL database like MongoDB.

In this tutorial, I am going to show you how to build a small web application using this technology stack, step-by-step. Along with building a simple web app, you will learn how to use the Material UI library to make the application look good. You can then use what you learn here for to make your own applications look better and be more functional.

Pre-requisites

🔗

Before we get started, install all the tools we are going to need to set up our application.

  • Nodejs
  • MongoDB
  • yarn
  • create-react-app

The last in the above list are installed using npm.

Set up the MERN App

🔗

To get started, you need to follow the steps below by opening your terminal and typing these commands. To keep you from getting lost, I will leave a comment before each command using #.

# create an empty directory and traverse inside it
mkdir mern-material-demo
# initialize it with npm
npm init -y
# install server side dependencies (initially)
yarn add express mongoose cookie-parser express-jwt jsonwebtoken
# use babel for import statements in Node
yarn add -D babel-cli babel-preset-env babel-watch
# create a client using create-react-app from root
create-react-app client

After this step, make sure your root project looks like below with some extra files and folders.

We are going to bootstrap the server using Babel. To setup and learn what Babel is, please read here.

The next step is to define the configuration you will need to proceed with server creation. Inside server, create a new file config/index.js and define the following inside it.

1const config = {
2 port: process.env.PORT || 4000,
3 jwtSecret: process.env.JWT_SECRET || 'mkT23j#u!45',
4 mongoURI: process.env.MONGODB_URI || 'mongodb://localhost/mern-auth'
5};
6
7export default config;

For MongoDB, I am going to use a local instance. If you want to use a cloud service (free tier), please read the steps to set it up and consume in a Node server app here.

Make sure add the dev script inside package.json.

1"scripts": {
2 "dev": "babel-watch index.js"
3}

Connect Database and the Server

🔗

Inside config directory, create a new file called dbConnection.js. Let us start by defining the MongoDB connection.

1import mongoose from 'mongoose';
2import config from './index';
3
4const URI = config.mongoURI;
5mongoose.connect(URI);
6
7// When successfully connected
8mongoose.connection.on('connected', () => {
9 console.log('Established Mongoose Default Connection');
10});
11
12// When connection throws an error
13mongoose.connection.on('error', err => {
14 console.log('Mongoose Default Connection Error : ' + err);
15});

I am going to use Mongoose as ODM (Object Document Mapper). This helps write queries inside the Node server and create business logic behind it. It also provides a schema-based solution to create data models and define them in our Node app.

Although MongoDB is a schema-less database, Mongoose helps our application understand the data structure and organize it at the same time. The most basic benefit is to make a connection between the Express app when it bootstraps and the MongoDB instance on our local machine.

Let’s create a small server in the index.js file of the root of our web app. Here it is in action.

1import express from 'express';
2import cookieParser from 'cookie-parser';
3import config from './server/config';
4
5// DB connection
6require('./server/config/dbConnection');
7
8const app = express();
9
10// middleware functions
11app.use(express.json());
12app.use(express.urlencoded({ extended: true }));
13app.use(cookieParser());
14
15// Error handling middleware
16app.use((err, req, res, next) => {
17 if (err.name === 'UnauthorizedError') {
18 res.status(401).json({ error: err.name + ':' + err.message });
19 }
20});
21
22app.listen(config.port, () => {
23 console.log(`🚀 at port ${config.port}`);
24});

If you are getting a message like below (ignore the mongoose warning), this means our server is up and running and successfully connected to the local instance of the database.

Building The User Model

🔗

To demonstrate, I am going to create a user data model with properties to save the user data when a new user registers with our application. We are going to save user credentials and validate it using Mongoose in this section. Create a new file inside server/models/user.js.

We will start by importing the necessary dependencies at the top of our file and then create a new Mongoose Schema, userSchema which is an object with properties. Typically, NoSQL databases are super flexible, in that they allow us to put whatever we want in them without enforcing any specific kind of structure. However, Mongoose adds a layer of structure on top of the typical MongoDB way of doing things. This helps us perform additional validation to ensure that our users are not submitting any random data to our database without us having to write tons of boilerplate code ourselves.

1import mongoose from 'mongoose';
2import crypto from 'crypto';
3const Schema = mongoose.Schema;
4
5const userSchema = new Schema({
6 name: {
7 type: String,
8 trim: true,
9 required: 'User Name is required'
10 },
11 email: {
12 type: String,
13 trim: true,
14 unique: 'Email already exists',
15 match: [/.+\@.+\..+/, 'Please fill a valid email address'],
16 required: 'Email is required'
17 },
18 hashedPassword: {
19 type: String,
20 required: 'Password is required'
21 },
22 salt: {
23 type: String
24 }
25});
26
27userSchema
28 .virtual('password')
29 .set(function (password) {
30 this._password = password;
31 this.salt = this.makeSalt();
32 this.hashedPassword = this.encryptedPassword(password);
33 })
34 .get(function () {
35 return this._password;
36 });
37
38userSchema.methods = {
39 authenticate: function (plainText) {
40 return this.encryptedPassword(plainText) === this.hashedPassword;
41 },
42 encryptedPassword: function (password) {
43 if (!password) return '';
44 try {
45 return crypto
46 .createHmac('sha1', this.salt)
47 .update(password)
48 .digest('hex');
49 } catch (err) {
50 return '';
51 }
52 },
53 makeSalt: function () {
54 return Math.round(new Date().valueOf() * Math.random()) + '';
55 }
56};
57
58userSchema.path('hashedPassword').validate(function (v) {
59 if (this.hashedPassword && this._password.length < 6) {
60 this.invalidate('password', 'Password must be at least 6 characters long.');
61 }
62 if (this.isNew && !this._password) {
63 this.invalidate('password', 'Password is required.');
64 }
65}, null);
66
67export default mongoose.model('User', userSchema);

We now use the userSchema object to add a virtualpassword field. Note that whatever property is described inside the userSchema object is going to be saved in the MongoDB document. We are not saving the password directly. We are creating a virtual field first to generate an encrypted hash of the password and then save it in our database.

A virtual field is a document property that can be used to combine different fields or decompose a single value into multiple values for storage but never gets carried on inside the MongoDB database itself.

Using the Nodejs crypto module we are creating a hash that updates the virtual password. The ‘salt’ field is a randomly generated string for each password. This terminology comes from cryptography. We are also putting in the logic of validating the password field and checking whether it is 6 characters long. Lastly, we export the User model to be used with routes and controllers logic in our server.

User Routes

🔗

Now, let’s write the business logic behind the routes to create for the React end to interact with the server. Create a new file server/controllers/user.js and write the following code. Import the user model first that from the previous section.

1import User from '../models/user';
2import errorHandler from '../helpers/dbErrorHandler';
3
4export const registerUser = (req, res, next) => {
5 const user = new User(req.body);
6 user.save((err, result) => {
7 if (err) {
8 return res.status(400).json({
9 error: errorHandler.getErrorMessage(err)
10 });
11 }
12 res.status(200).json({
13 message: 'New user registered successfully!'
14 });
15 });
16};
17
18export const findUserById = (req, res, next, id) => {
19 User.findById(id).exec((err, user) => {
20 if (err || !user) {
21 return res.status(400).json({
22 error: 'No user found with that credentials!'
23 });
24 }
25 req.profile = user;
26 next();
27 });
28};
29
30export const findUserProfile = (req, res) => {
31 // eliminate password related fields before sending the user object
32 req.profile.hashedPassword = undefined;
33 req.profile.salt = undefined;
34 return res.json(req.profile);
35};
36
37export const deleteUser = (req, res, next) => {
38 let user = req.profile;
39 user.remove((err, deletedUser) => {
40 if (err) {
41 return res.status(400).json({
42 error: errorHandler.getErrorMessage(err)
43 });
44 }
45 deletedUser.hashedPassword = undefined;
46 user.salt = undefined;
47 res.json(user);
48 });
49};

I have also added a helper function inside a separate file at the location server/helpers/dbErrorHandler.js to gracefully handle any error that occurs in any of the routes like we are using in above and respond back with a meaningful message. You can download the file from here.

In the file above, we are creating three controller functions. The first one, registerUser, creates a new user in the database from the JSON object received in a POST request from the client. The JSON object is received inside req.body that contains the user credentials we need to store in the database. Further, user.save, saves the new user in the database. Notice that we are not creating a unique field which is common in this type of scenarios to identify each new user saved in our database. This is because MongoDB database creates an _id field each time a new record is saved.

The next function we are exporting is findUserById. It queries the database to find the specific details related to the user whose _id is provided in parametric route (which I will define shortly). If a matching user is found with that _id in the database, then the user object is returned and appended inside the req.profile.

findUserProfile controller function retrieves the user detail from req.profile and removes any sensitive information such as password's hash and salt values before sending this user object to the client. The last function deleteUser removes the the user details from the database.

Now let use the controller logic and add it to corresponding routes inside server/routes/user.js.

1import express from 'express';
2import {
3 registerUser,
4 findUserById,
5 findUserProfile,
6 deleteUser
7} from '../controllers/user';
8
9const router = express.Router();
10
11router.route('/api/users').post(registerUser);
12
13router.route('/api/users/:userId').get(findUserProfile).delete(deleteUser);
14
15router.param('userId', findUserById);
16
17export default router;

The controller functions are first imported and then used with their corresponding route.

Auth Routes

🔗

To restrict access to user operations — such as the logged in user can only access their profile and no one else’s — we are going to implement a JWT authentication to protect the routes. The two routes required to sign in and sign out the user from our application are going to be inside a separate file server/routes/auth.js.

1import express from 'express';
2import { signin, signout } from '../controllers/auth';
3
4const router = express.Router();
5
6router.route('/auth/signin').post(signin);
7
8router.route('/auth/signout').get(signout);
9
10export default router;

The first route uses an HTTP POST request to authenticate a user with email and password credentials. The second route is used when the user hits the signout button (which we will implement in our front-end). The logic behind how these two routes work has to be defined in another file. Create a new file server/controllers/auth.js with the following code.

1import User from '../models/user';
2import jwt from 'jsonwebtoken';
3import expressJwt from 'express-jwt';
4import config from '../config';
5
6export const signin = (req, res) => {
7 User.findOne({ email: req.body.email }, (err, user) => {
8 if (err || !user) {
9 return res.status(401).json({
10 error: 'User not found'
11 });
12 }
13 if (!user.authenticate(req.body.password)) {
14 return res.status(401).json({
15 error: 'Wrong Email or Password!'
16 });
17 }
18
19 const token = jwt.sign(
20 {
21 _id: user._id
22 },
23 config.jwtSecret
24 );
25
26 res.cookie('t', token, {
27 expire: new Date() + 9999
28 });
29
30 return res.json({
31 token,
32 user: { _id: user._id, name: user.name, email: user.email }
33 });
34 });
35};
36
37export const signout = (req, res) => {
38 res.clearCookie('t');
39 return res.status(200).json({
40 message: 'Sign out successful!'
41 });
42};
43
44export const requireSignin = expressJwt({
45 secret: config.jwtSecret,
46 userProperty: 'auth'
47});
48
49export const hasAuthorization = (req, res) => {
50 const authorized = req.profile && req.auth && req.profile._id == req.auth._id;
51 if (!authorized) {
52 return res.status(403).json({
53 error: 'User is not authorized!'
54 });
55 }
56};

I am using two JWT related packages from npm to enable authentication and protect our routes: express-jwt and jsonwebtoken. You already installed them when we bootstrapped this project. The first controller function signin we are exporting receives user's credentials in req.body. Email is used to retrieve the matching user from the database. Remember, we have added a unique field when defining the userSchema.

1// model/user.js
2 email: {
3 type: String,
4 trim: true,
5 unique: 'Email already exists',
6 match: [/.+\@.+\..+/, 'Please fill a valid email address'],
7 required: 'Email is required'
8 },

Since we are also receiving user’s password, we are going to verify it with the hash and the salt value that we stored in our database. The signed JWT is returned to the client to authenticate the user with their details if successful. We are using browser’s cookies here to store the JWT token. You can use the browser’s local storage for this purpose.

The signout function above clears the cookie containing the signed JWT token. The last two functions are important for our application. Both requireSignin and hasAuthorization are used to protect access to certain routes from an unauthorized user. They check and validate the user on client whether they are authenticated to give access.

requireSignin method here verifies a valid JWT in the Authorization header of the request. hasAuthorization allows a user to operate protected routes by checking that the user who is sending the request is identical to the authenticated user. In our application we are going to use this on one protected route. We are going to delete the user profile and their data from the database in that route.

Now let us use these methods to protect user routes. Open server/routes/user.js.

1import express from 'express';
2import {
3 registerUser,
4 findUserById,
5 findUserProfile,
6 deleteUser
7} from '../controllers/user';
8
9// import them to protect routes
10import { requireSignin, hasAuthorization } from '../controllers/auth';
11
12const router = express.Router();
13
14router.route('/api/users').post(registerUser);
15
16router
17 .route('/api/users/:userId')
18 .get(requireSignin, findUserProfile)
19 .delete(requireSignin, hasAuthorization, deleteUser);
20
21router.param('userId', findUserById);
22
23export default router;

Finishing the back-end

🔗

With the routing logic set up, we can now complete the server by adding our routes to index.js file.

1import express from 'express';
2import cookieParser from 'cookie-parser';
3import config from './server/config';
4// ADD these
5import userRoutes from './server/routes/user';
6import authRoutes from './server/routes/auth';
7
8// DB connection
9require('./server/config/dbConnection');
10
11const app = express();
12
13// middleware functions
14app.use(express.json());
15app.use(express.urlencoded({ extended: true }));
16app.use(cookieParser());
17
18// ADD routes
19app.use('/', userRoutes);
20app.use('/', authRoutes);
21
22app.use((err, req, res, next) => {
23 if (err.name === 'UnauthorizedError') {
24 res.status(401).json({ error: err.name + ':' + err.message });
25 }
26});
27
28app.listen(config.port, () => {
29 console.log(`🚀 at port ${config.port}`);
30});

To test these routes, open up a REST Client like Postman or Insomnia and the URL http://localhost:4000/api/users with required fields in order to create a user.

If there are no errors, you are going to receive the message Successfully signed up!. This means the user has been added to the database. If you try to make a new user with same credentials, it will throw an error this time.

If you use a MongoDB Client to view the records of your local database like Mongo Compass or Robomongo, you can easily see newly created user’s details.

Using the same user credentials, we will attempt a sign-in. It should give us a JWT back.

It works!

Except for the sensitive information that we eliminated from the route, we are receiving back the token and a user object.

Now let’s find the user profile. Hit the URL http://localhost:4000/api/users/{USER_ID} where USER_ID is the same created by MongoDB database when adding the user record.

You have to add the Bearer before signed JWT returned from the previous request at the Header Authorization. This completes our API testing and now we can focus on building the front-end of our application.

Adding Material UI in React

🔗

There are a series of steps to follow to add the Material UI Library to our react app. Traverse in the client directory and follow the below steps. We are going to use Material Icons in SVG form, so let’s add that package.

# Open terminal and install the package
yarn add @material-ui/core @material-ui/icons

Material-UI uses Roboto font and we have to add it through Google Font CDN to our client side. Open public/index.html add the following. Let’s also change the title.

1<head>
2 <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
3 <link
4 rel="stylesheet"
5 href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"
6 />
7 <title>MERN App</title>
8</head>

To see if everything installed correctly and is working, run the client project using command yarn start. This will open the default React app that comes with create-react-app at URL http://localhost:3000. To see our our assets (such as Roboto font) being loaded, go to Developer Tools and open Network tab. Refresh the page to reload the assets and you will notice that the font family is being loaded.

Defining the Home Page

🔗

Now let’s build the first component of our application. Create a new file inside src/components/Home.js and put the following content.

1import React, { Component } from 'react';
2import { withStyles } from '@material-ui/core/styles';
3import Card from '@material-ui/core/Card';
4import CardContent from '@material-ui/core/CardContent';
5import CardMedia from '@material-ui/core/CardMedia';
6import Typography from '@material-ui/core/Typography';
7import logo from '../logo.svg';
8
9const styles = theme => ({
10 card: {
11 maxWidth: 700,
12 margin: 'auto',
13 marginTop: theme.spacing.unit * 5
14 },
15 title: {
16 padding: `${theme.spacing.unit * 3}px ${theme.spacing.unit * 2.5}px ${
17 theme.spacing.unit * 2
18 }px`,
19 color: theme.palette.text.secondary,
20 fontSize: 24
21 },
22 media: {
23 minHeight: 450
24 }
25});
26
27class Home extends Component {
28 render() {
29 const { classes } = this.props;
30 return (
31 <div>
32 <Card className={classes.card}>
33 <Typography type="headline" component="h2" className={classes.title}>
34 Welcome to the MERN APP
35 </Typography>
36 <CardMedia
37 className={classes.media}
38 image={logo}
39 title="Auth with MERN"
40 />
41 <CardContent>
42 <Typography type="body1" component="p">
43 This is a demo application that uses a Node + MongoDB API for user
44 authentication. Built With React + Material UI.
45 </Typography>
46 </CardContent>
47 </Card>
48 </div>
49 );
50 }
51}
52
53export default withStyles(styles)(Home);

The first component we are importing from @material-ui in this file is withStyles. It allows us to style a component by declaring a styles object with access top-level styles such as we are using theme with our home component. We will define these top-level theme related styles shortly in App.js. Next, we are importing Card, CardContent, CardMedia to create a card view. CardMedia is used to display any media file whereas CardContent is used with Typography to output text. Typography is used to present hierarchy based styles over text to the content as clearly and efficiently as possible.

Now open up App.js and add the following content.

1import React, { Component } from 'react';
2import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider';
3import { createMuiTheme } from '@material-ui/core/styles';
4import green from '@material-ui/core/colors/green';
5import red from '@material-ui/core/colors/red';
6
7import Home from './components/Home';
8
9const theme = createMuiTheme({
10 palette: {
11 primary: {
12 light: '#C8E6C9',
13 main: '#4CAF50',
14 dark: '#2E7D32',
15 contrastText: '#fff'
16 },
17 secondary: {
18 light: '#EF9A9A',
19 main: '#F44336',
20 dark: '#C62828',
21 contrastText: '#000'
22 },
23 openTitle: green['400'],
24 protectTitle: red['400'],
25 type: 'dark'
26 }
27});
28
29class App extends Component {
30 render() {
31 return (
32 <MuiThemeProvider theme={theme}>
33 <Home />
34 </MuiThemeProvider>
35 );
36 }
37}
38
39export default App;

MuiThemeProvider and createMuiTheme classes are used to create default theme. The theme specifies the color of the components, darkness of the surfaces, level of shadow, appropriate opacity of ink elements, and so on. If you wish to customize the theme, you need to use the MuiThemeProvider component in order to inject a theme into your application. To configure a theme of your own, createMuiTheme is used. You can also make the theme dark by setting type to dark like we have done above. Lastly, <MuiThemeProvider theme={theme}> is where the top level styles are being passed to child components, in our case Home.

If you render the app by running yarn start, you will get the below output.

Adding React Router

🔗

We need a way to navigate different routes for the user to sign in and sign out. In this section, we will add react-router library to our app for this purpose.

yarn add react-router react-router-dom

react-router library is a collection of navigational components. To get started, create a new file inside src folder called Routes.js.

1import React, { Component } from 'react';
2import { Route, Switch } from 'react-router-dom';
3
4import Home from './components/Home';
5
6class Routes extends Component {
7 render() {
8 return (
9 <Switch>
10 <Route exact path="/" component={Home} />
11 </Switch>
12 );
13 }
14}
15
16export default Routes;

The Route component is the main building block of React Router. Anywhere that you want to only render content based on the location’s pathname, you should use a Route element. Switch is used to group different Route components. The route for the homepage, our Home component does include an exact prop. This is used to state that route should only match when the pathname matches the route’s path exactly. To use the newly created Routes, we have to make some changes to App.js to make it work.

1import React, { Component } from 'react';
2import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider';
3import { createMuiTheme } from '@material-ui/core/styles';
4import green from '@material-ui/core/colors/green';
5import red from '@material-ui/core/colors/red';
6import { BrowserRouter } from 'react-router-dom';
7
8import Routes from './Routes';
9
10const theme = createMuiTheme({
11 palette: {
12 primary: {
13 light: '#C8E6C9',
14 main: '#4CAF50',
15 dark: '#2E7D32',
16 contrastText: '#fff'
17 },
18 secondary: {
19 light: '#EF9A9A',
20 main: '#F44336',
21 dark: '#C62828',
22 contrastText: '#000'
23 },
24 openTitle: green['400'],
25 protectTitle: red['400'],
26 type: 'dark'
27 }
28});
29
30class App extends Component {
31 render() {
32 return (
33 <BrowserRouter>
34 <MuiThemeProvider theme={theme}>
35 <Routes />
36 </MuiThemeProvider>
37 </BrowserRouter>
38 );
39 }
40}
41
42export default App;

The BrowserRouter defined above is used when you have a server that will handle dynamic requests.

Connecting Node server and React

🔗

I wrote an article for Crowdbotics dealing how to connect a Node.js server with the React front end here. We do not need to review the whole process. Just open your package.json and add the following for our app to kickstart.

1"proxy": "http://localhost:4000/"

Next, I am going to add methods to be used in different components that will handle API calls from our server side code. Create two new files inside utils directory: api-auth.js and api-user.js.

1// api-user.js
2
3export const registerUser = user => {
4 return fetch('/api/users/', {
5 method: 'POST',
6 headers: {
7 Accept: 'application/json',
8 'Content-Type': 'application/json'
9 },
10 body: JSON.stringify(user)
11 })
12 .then(response => {
13 return response.json();
14 })
15 .catch(err => console.log(err));
16};
17
18export const findUserProfile = (params, credentials) => {
19 return fetch('/api/users/' + params.userId, {
20 method: 'GET',
21 headers: {
22 Accept: 'application/json',
23 'Content-Type': 'application/json',
24 Authorization: 'Bearer ' + credentials.t
25 }
26 })
27 .then(response => {
28 return response.json();
29 })
30 .catch(err => console.error(err));
31};
32
33export const deleteUser = (params, credentials) => {
34 return fetch('/api/users/' + params.userId, {
35 method: 'DELETE',
36 headers: {
37 Accept: 'application/json',
38 'Content-Type': 'application/json',
39 Authorization: 'Bearer ' + credentials.t
40 }
41 })
42 .then(response => {
43 return response.json();
44 })
45 .catch(err => console.error(err));
46};

In api-auth.js, add the following.

1// api-auth.js
2export const signin = user => {
3 return fetch('/auth/signin/', {
4 method: 'POST',
5 headers: {
6 Accept: 'application/json',
7 'Content-Type': 'application/json'
8 },
9 credentials: 'include',
10 body: JSON.stringify(user)
11 })
12 .then(response => {
13 return response.json();
14 })
15 .catch(err => console.log(err));
16};
17
18export const signout = () => {
19 return fetch('/auth/signout/', {
20 method: 'GET'
21 })
22 .then(response => {
23 return response.json();
24 })
25 .catch(err => console.log(err));
26};

The signin method takes care of user credentials from the view component (which we will create shortly), then uses fetch to make a POST call to verify the user credentials with the backend. The signout method uses fetch to make a GET call to the sign-out API endpoint on the back-end.

Front-End: Auth Components

🔗

Next, we will setup all the necessary components required for authentication.

One by one, I am going to create new files so please follow closely.

Create a new directory inside components and call it auth. Then, create a new file auth-helper.js.

1import { signout } from '../../utils/api-auth.js';
2
3const auth = {
4 isAuthenticated() {
5 if (typeof window == 'undefined') return false;
6
7 if (sessionStorage.getItem('jwt'))
8 return JSON.parse(sessionStorage.getItem('jwt'));
9 else return false;
10 },
11 authenticate(jwt, cb) {
12 if (typeof window !== 'undefined')
13 sessionStorage.setItem('jwt', JSON.stringify(jwt));
14 cb();
15 },
16 signout(cb) {
17 if (typeof window !== 'undefined') sessionStorage.removeItem('jwt');
18 cb();
19 //optional
20 signout().then(data => {
21 document.cookie = 't=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
22 });
23 }
24};
25
26export default auth;

These functions will help us manage the state of authentication in the browser. Using these methods our client side app will be able to check whether the user has logged in or not. To protect the routes, such as a user’s profile, from un-authorized access, we have to define a new component inside PrivateRoute.js and make use of the methods above.

1import React, { Component } from 'react';
2import { Route, Redirect } from 'react-router-dom';
3import auth from './auth-helper';
4
5const PrivateRoute = ({ component: Component, ...rest }) => (
6 <Route
7 {...rest}
8 render={props =>
9 auth.isAuthenticated() ? (
10 <Component {...props} />
11 ) : (
12 <Redirect
13 to={{
14 pathname: '/signin',
15 state: { from: props.location }
16 }}
17 />
18 )
19 }
20 />
21);
22
23export default PrivateRoute;

We are going to use this component as an auth flow in the Routes.js we have defined. Components that rendered via this route component will only load when the user is authenticated. Our last component related to user authentication is to be defined inside Signin.js.

1import React, { Component } from 'react';
2import Card from '@material-ui/core/Card';
3import CardContent from '@material-ui/core/CardContent';
4import CardMedia from '@material-ui/core/CardMedia';
5import Button from '@material-ui/core/Button';
6import TextField from '@material-ui/core/TextField';
7import Typography from '@material-ui/core/Typography';
8import Icon from '@material-ui/core/Icon';
9import { withStyles } from '@material-ui/core/styles';
10import auth from './auth-helper';
11import { Redirect } from 'react-router-dom';
12import { signin } from '../../utils/api-auth.js';
13
14const styles = theme => ({
15 card: {
16 maxWidth: 600,
17 margin: 'auto',
18 textAlign: 'center',
19 marginTop: theme.spacing.unit * 5,
20 paddingBottom: theme.spacing.unit * 2
21 },
22 error: {
23 verticalAlign: 'middle'
24 },
25 title: {
26 marginTop: theme.spacing.unit * 2,
27 color: theme.palette.openTitle
28 },
29 textField: {
30 marginLeft: theme.spacing.unit,
31 marginRight: theme.spacing.unit,
32 width: 300
33 },
34 submit: {
35 margin: 'auto',
36 marginBottom: theme.spacing.unit * 2
37 }
38});
39
40class Signin extends Component {
41 state = {
42 email: '',
43 password: '',
44 error: '',
45 redirectToReferrer: false
46 };
47
48 clickSubmit = () => {
49 const user = {
50 email: this.state.email || undefined,
51 password: this.state.password || undefined
52 };
53
54 signin(user).then(data => {
55 if (data.error) {
56 this.setState({ error: data.error });
57 } else {
58 auth.authenticate(data, () => {
59 this.setState({ redirectToReferrer: true });
60 });
61 }
62 });
63 };
64
65 handleChange = name => event => {
66 this.setState({ [name]: event.target.value });
67 };
68
69 render() {
70 const { classes } = this.props;
71 const { from } = this.props.location.state || {
72 from: {
73 pathname: '/'
74 }
75 };
76 const { redirectToReferrer } = this.state;
77 if (redirectToReferrer) {
78 return <Redirect to={from} />;
79 }
80
81 return (
82 <Card className={classes.card}>
83 <CardContent>
84 <Typography type="headline" component="h2" className={classes.title}>
85 Sign In
86 </Typography>
87 <TextField
88 id="email"
89 type="email"
90 label="Email"
91 className={classes.textField}
92 value={this.state.email}
93 onChange={this.handleChange('email')}
94 margin="normal"
95 />
96 <br />
97 <TextField
98 id="password"
99 type="password"
100 label="Password"
101 className={classes.textField}
102 value={this.state.password}
103 onChange={this.handleChange('password')}
104 margin="normal"
105 />
106 <br />{' '}
107 {this.state.error && (
108 <Typography component="p" color="error">
109 <Icon color="error" className={classes.error}>
110 error
111 </Icon>
112 {this.state.error}
113 </Typography>
114 )}
115 </CardContent>
116 <CardActions>
117 <Button
118 color="primary"
119 variant="raised"
120 onClick={this.clickSubmit}
121 className={classes.submit}
122 >
123 Submit
124 </Button>
125 </CardActions>
126 </Card>
127 );
128 }
129}
130
131export default withStyles(styles)(Signin);

This is a form component that contains email and password field (_as we defined in state above) for the user to enter to get authenticated. redirectToReferrer property in state is what we are using if the user gets verified by the server or not. If the credentials entered by the user are valid, this property will trigger Redirect component of react-router-dom.

Front-End: User Components

🔗

Similarly to our auth routes, we are going to separate our user components inside components/user/ folder. First, we need a React component to register a new user. Create a file called Signup.js.

1import React, { Component } from 'react';
2import Card from '@material-ui/core/Card';
3import CardContent from '@material-ui/core/CardContent';
4import CardActions from '@material-ui/core/CardActions';
5import Button from '@material-ui/core/Button';
6import TextField from '@material-ui/core/TextField';
7import Typography from '@material-ui/core/Typography';
8import Icon from '@material-ui/core/Icon';
9import { withStyles } from '@material-ui/core/styles';
10import DialogTitle from '@material-ui/core/DialogTitle';
11import DialogActions from '@material-ui/core/DialogActions';
12import DialogContentText from '@material-ui/core/DialogContentText';
13import DialogContent from '@material-ui/core/DialogContent';
14import Dialog from '@material-ui/core/Dialog';
15import { Link } from 'react-router-dom';
16
17import { registerUser } from '../../utils/api-user.js';
18
19const styles = theme => ({
20 card: {
21 maxWidth: 600,
22 margin: 'auto',
23 textAlign: 'center',
24 marginTop: theme.spacing.unit * 5,
25 paddingBottom: theme.spacing.unit * 2
26 },
27 error: {
28 verticalAlign: 'middle'
29 },
30 title: {
31 marginTop: theme.spacing.unit * 2,
32 color: theme.palette.openTitle
33 },
34 textField: {
35 marginLeft: theme.spacing.unit,
36 marginRight: theme.spacing.unit,
37 width: 300
38 },
39 submit: {
40 margin: 'auto',
41 marginBottom: theme.spacing.unit * 2
42 }
43});
44
45class Signup extends Component {
46 state = {
47 name: '',
48 password: '',
49 email: '',
50 open: false,
51 error: ''
52 };
53
54 handleChange = name => event => {
55 this.setState({ [name]: event.target.value });
56 };
57
58 clickSubmit = () => {
59 const user = {
60 name: this.state.name || undefined,
61 email: this.state.email || undefined,
62 password: this.state.password || undefined
63 };
64 registerUser(user).then(data => {
65 if (data.error) {
66 this.setState({ error: data.error });
67 } else {
68 this.setState({ error: '', open: true });
69 }
70 });
71 };
72
73 render() {
74 const { classes } = this.props;
75 return (
76 <div>
77 <Card className={classes.card}>
78 <CardContent>
79 <Typography
80 type="headline"
81 component="h2"
82 className={classes.title}
83 >
84 Sign Up
85 </Typography>
86 <TextField
87 id="name"
88 label="Name"
89 className={classes.textField}
90 value={this.state.name}
91 onChange={this.handleChange('name')}
92 margin="normal"
93 />
94 <br />
95 <TextField
96 id="email"
97 type="email"
98 label="Email"
99 className={classes.textField}
100 value={this.state.email}
101 onChange={this.handleChange('email')}
102 margin="normal"
103 />
104 <br />
105 <TextField
106 id="password"
107 type="password"
108 label="Password"
109 className={classes.textField}
110 value={this.state.password}
111 onChange={this.handleChange('password')}
112 margin="normal"
113 />
114 <br />{' '}
115 {this.state.error && (
116 <Typography component="p" color="error">
117 <Icon color="error" className={classes.error}>
118 error
119 </Icon>
120 {this.state.error}
121 </Typography>
122 )}
123 </CardContent>
124 <CardActions>
125 <Button
126 color="primary"
127 variant="raised"
128 onClick={this.clickSubmit}
129 className={classes.submit}
130 >
131 Submit
132 </Button>
133 </CardActions>
134 </Card>
135 <Dialog open={this.state.open} disableBackdropClick={true}>
136 <DialogTitle>New Account</DialogTitle>
137 <DialogContent>
138 <DialogContentText>
139 New account successfully created.
140 </DialogContentText>
141 </DialogContent>
142 <DialogActions>
143 <Link to="/signin">
144 <Button color="primary" autoFocus="autoFocus" variant="raised">
145 Sign In
146 </Button>
147 </Link>
148 </DialogActions>
149 </Dialog>
150 </div>
151 );
152 }
153}
154
155export default withStyles(styles)(Signup);

We start the component by declaring an empty state that contains various properties such as name, email, password and error. The open property is used to capture the state of a Dialog box.

In Material UI, a Dialog is a type of modal window that appears in front of app content to provide critical information or ask for a decision. The modal in our case will either render an error message or the confirmation message depending on the status returned from the server.

We are also defining two handler functions. handleChange changes the new value of every input field entered. clickSubmit invokes when a user after entering their credentials, submit the registration form. This function calls registerUser from the API to send the data to the backend for further actions.

Create a new file called Profile.js.

1import React, { Component } from 'react';
2import { withStyles } from '@material-ui/core/styles';
3import Paper from '@material-ui/core/Paper';
4import ListItem from '@material-ui/core/ListItem';
5import ListItemAvatar from '@material-ui/core/ListItemAvatar';
6import ListItemText from '@material-ui/core/ListItemText';
7import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
8import Avatar from '@material-ui/core/Avatar';
9import Typography from '@material-ui/core/Typography';
10import Person from '@material-ui/icons/Person';
11import Divider from '@material-ui/core/Divider';
12import auth from '../auth/auth-helper';
13import { findUserProfile } from '../../utils/api-user.js';
14import { Redirect, Link } from 'react-router-dom';
15
16import DeleteUser from './DeleteUser';
17
18const styles = theme => ({
19 root: theme.mixins.gutters({
20 maxWidth: 600,
21 margin: 'auto',
22 padding: theme.spacing.unit * 3,
23 marginTop: theme.spacing.unit * 5
24 }),
25 title: {
26 margin: `${theme.spacing.unit * 3}px 0 ${theme.spacing.unit * 2}px`,
27 color: theme.palette.protectedTitle
28 }
29});
30
31class Profile extends Component {
32 constructor({ match }) {
33 super();
34 this.state = {
35 user: '',
36 redirectToSignin: false
37 };
38 this.match = match;
39 }
40 init = userId => {
41 const jwt = auth.isAuthenticated();
42 findUserProfile(
43 {
44 userId: userId
45 },
46 { t: jwt.token }
47 ).then(data => {
48 if (data.error) {
49 this.setState({ redirectToSignin: true });
50 } else {
51 this.setState({ user: data });
52 }
53 });
54 };
55 componentWillReceiveProps = props => {
56 this.init(props.match.params.userId);
57 };
58 componentDidMount = () => {
59 this.init(this.match.params.userId);
60 };
61 render() {
62 const { classes } = this.props;
63 const redirectToSignin = this.state.redirectToSignin;
64 if (redirectToSignin) {
65 return <Redirect to="/signin" />;
66 }
67 return (
68 <Paper className={classes.root} elevation={4}>
69 <Typography type="title" className={classes.title}>
70 Profile
71 </Typography>
72 <List dense>
73 <ListItem>
74 <ListItemAvatar>
75 <Avatar>
76 <Person />
77 </Avatar>
78 </ListItemAvatar>
79 <ListItemText
80 primary={this.state.user.name}
81 secondary={this.state.user.email}
82 /> {auth.isAuthenticated().user &&
83 auth.isAuthenticated().user._id == this.state.user._id && (
84 <ListItemSecondaryAction>
85 <DeleteUser userId={this.state.user._id} />
86 </ListItemSecondaryAction>
87 )}
88 </ListItem>
89 <Divider />
90 </List>
91 </Paper>
92 );
93 }
94}
95
96export default withStyles(styles)(Profile);

This component shows a single user who is authenticated by the back-end of our application. The profile information of each user is stored in the database. This is done by the init function we have defined above the render function of our component.

We are using redirectToSignin redirect to the user on sign-out. We are also adding a delete profile button as a separate component which has to be defined in a separate file called DeleteUser.js.

1import React, { Component } from 'react';
2import IconButton from '@material-ui/core/IconButton';
3import Button from '@material-ui/core//Button';
4import DialogTitle from '@material-ui/core/DialogTitle';
5import DialogActions from '@material-ui/core/DialogActions';
6import DialogContentText from '@material-ui/core/DialogContentText';
7import DialogContent from '@material-ui/core/DialogContent';
8import Dialog from '@material-ui/core/Dialog';
9import Delete from '@material-ui/icons/Delete';
10import auth from '../auth/auth-helper';
11import { deleteUser } from '../../utils/api-user';
12import { Redirect, Link } from 'react-router-dom';
13
14class DeleteUser extends Component {
15 state = {
16 redirect: false,
17 open: false
18 };
19 clickButton = () => {
20 this.setState({ open: true });
21 };
22 deleteAccount = () => {
23 const jwt = auth.isAuthenticated();
24 deleteUser(
25 {
26 userId: this.props.userId
27 },
28 { t: jwt.token }
29 ).then(data => {
30 if (data.error) {
31 console.log(data.error);
32 } else {
33 auth.signout(() => console.log('deleted'));
34 this.setState({ redirect: true });
35 }
36 });
37 };
38 handleRequestClose = () => {
39 this.setState({ open: false });
40 };
41 render() {
42 const redirect = this.state.redirect;
43 if (redirect) {
44 return <Redirect to="/" />;
45 }
46 return (
47 <span>
48 <IconButton
49 aria-label="Delete"
50 onClick={this.clickButton}
51 color="secondary"
52 >
53 <Delete />
54 </IconButton>
55
56 <Dialog open={this.state.open} onClose={this.handleRequestClose}>
57 <DialogTitle>{'Delete Account'}</DialogTitle>
58 <DialogContent>
59 <DialogContentText>
60 Confirm to delete your account.
61 </DialogContentText>
62 </DialogContent>
63 <DialogActions>
64 <Button onClick={this.handleRequestClose} color="primary">
65 Cancel
66 </Button>
67 <Button
68 onClick={this.deleteAccount}
69 color="secondary"
70 autoFocus="autoFocus"
71 >
72 Confirm
73 </Button>
74 </DialogActions>
75 </Dialog>
76 </span>
77 );
78 }
79}
80
81export default DeleteUser;

This component is used for deleting the user profile that exists in the database. It uses the same deleteUser API endpoint we defined in our back-end. deleteAccount method is responsible for handling this task.

Front-End: Completing the Navbar

🔗

In this section we are going to complete our client side routes by leveraging a Navbar component. Create a new file component/Navbar.js.

1import React from 'react';
2import AppBar from '@material-ui/core/AppBar';
3import Toolbar from '@material-ui/core/Toolbar';
4import Typography from '@material-ui/core/Typography';
5import IconButton from '@material-ui/core/IconButton';
6import Home from '@material-ui/icons/Home';
7import Button from '@material-ui/core/Button';
8import auth from './auth/auth-helper';
9import { Link, withRouter } from 'react-router-dom';
10
11const isActive = (history, path) => {
12 if (history.location.pathname == path) return { color: '#F44336' };
13 else return { color: '#ffffff' };
14};
15const Menu = withRouter(({ history }) => (
16 <AppBar position="static">
17 <Toolbar>
18 <Typography type="title" color="inherit">
19 MERN App
20 </Typography>
21 <Link to="/">
22 <IconButton aria-label="Home" style={isActive(history, '/')}>
23 <Home />
24 </IconButton>
25 </Link>
26 {!auth.isAuthenticated() && (
27 <span>
28 <Link to="/signup">
29 <Button style={isActive(history, '/signup')}>Sign up</Button>
30 </Link>
31 <Link to="/signin">
32 <Button style={isActive(history, '/signin')}>Sign In</Button>
33 </Link>
34 </span>
35 )}
36 {auth.isAuthenticated() && (
37 <span>
38 <Link to={'/user/' + auth.isAuthenticated().user._id}>
39 <Button
40 style={isActive(
41 history,
42 '/user/' + auth.isAuthenticated().user._id
43 )}
44 >
45 My Profile
46 </Button>
47 </Link>
48 <Button
49 color="inherit"
50 onClick={() => {
51 auth.signout(() => history.push('/'));
52 }}
53 >
54 Sign out
55 </Button>
56 </span>
57 )}
58 </Toolbar>
59 </AppBar>
60));
61
62export default Menu;

This Navbar component will allow us to access routes as views on the front-end. From react-router we are importing a High Order Component called withRouter to get access to history object's properties and consume our front-end routes dynamically.

Using Link from react-router and auth.isAuthenticated() from our authentication flow, we are checking for whether the user has access to authenticated routes or not, that is, if they are logged in to our application or not.

isActive highlights the view to which the current route is activated by the navigation component.

Running the Application

🔗

The next step is to import this navigation component inside Routes.js and define other necessary routes we need in our app. Open Routes.js and add the following.

1import React, { Component } from 'react';
2import { Route, Switch } from 'react-router-dom';
3import Navbar from './components/Navbar';
4
5import Home from './components/Home';
6import PrivateRoutes from './components/auth/PrivateRoutes';
7import Signin from './components/auth/Signin';
8import Profile from './components/user/Profile';
9import Signup from './components/user/Signup';
10
11class Routes extends Component {
12 render() {
13 return (
14 <div>
15 <Navbar />
16 <Switch>
17 <Route exact path="/" component={Home} />
18 <PrivateRoutes path="/user/edit/:userId" />
19 <Route path="/user/:userId" component={Profile} />
20 <Route path="/signup" component={Signup} />
21 <Route path="/signin" component={Signin} />
22 </Switch>
23 </div>
24 );
25 }
26}
27
28export default Routes;

After completing this test, let’s test our application. Make sure you are running the backend server using nr dev command in one tab in your terminal. Using another tab or window, traverse to client directory and run the command yarn start. Once the application starts, you will be welcomed by the Homepage, as below.

Notice in the navbar above there are three buttons. The home icon is for Home page highlighted red in color. If you move on to the sign in page, you will see the sign in button highlighted. We already have one user registered to our application (when we were building the API). Please enter the credentials (email: jane@doe.com and password: pass1234 or the credentials you entered) as shown below and submit the form.

On submitting the form you will be redirected to the home page as per the component logic. The changes can be noticed at the navigation menu. Instead of sign-up and sign-in, you will see My Profile and Sign Out button. Click My Profile and you can see the current user’s details.

On clicking the delete icon it will delete the user. You can also try signing out of the application by clicking on the sign out button from navigation and then you will be redirected to the home page.

Conclusion

🔗

We have reached the end. Even though this tutorial is lengthy and, a lot is going on, I am sure if you take your time, you will understand the concepts and the logic behind it. It is after all, a full-stack MERN application. It uses JSON Web Tokens as an authentication strategy.

If you want to learn how to deploy this application, you can continue to read this article.

The complete code for the tutorial at this Github repository


More Posts

Browse all posts

Aman Mittal author

I'm a software developer and a technical writer. On this blog, I write about my learnings in software development and technical writing.

Currently, working maintaining docs at 𝝠 Expo. Read more about me on the About page.


Copyright ©  2019-2024 Aman Mittal · All Rights Reserved.