Building a Authentication Form with React, React-Hook-Form, Express.js, NodeJs, and MongoDB
Building a Authentication Form with React, React-Hook-Form, Express.js, NodeJs, and MongoDB

Building a Authentication Form with React, React-Hook-Form, Express.js, NodeJs, and MongoDB

Tags
React.js
Node.js
React-Hook-Form
Express.js
mongoDB
Published
March 21, 2024
Building a robust registration form using React Vite for the frontend and for our backend Node.js, Express.js and mongoDB as the database. We will also implement several security measures to protect user data and prevent common vulnerabilities.

System interaction Flow of what we will be building here

System Interaction Flow for this Authentication with React
System Interaction Flow for this Authentication with React

Setting up our mongoDB database

Before we dive into the server setup, we need to set up a MongoDB database. For this project, we'll be using MongoDB Atlas, which is a cloud-based service provided by MongoDB. It allows us to create and manage our database without worrying about the underlying infrastructure.

Retrieving the MongoDB URI

The MongoDB URI is a string that provides all the information needed to connect to your MongoDB database. It includes details like the username, password, cluster address, and database name.
Here's an example of what the MongoDB URI might look like:
mongodb+srv://your_username:your_password@cluster0.abcde.mongodb.net/your_database_name?retryWrites=true&w=majority
By creating a new cluster on mongoDB website, after registration or login, by selecting a free tier that uses shared cluster, and after having a database created we can click on connect and we will have our MONGODB_URI.
Storing the MongoDB URI
For security reasons, it's not recommended to hard-code sensitive information like the MongoDB URI directly in your code. Instead, we'll store it in an environment variable using a .env file.
  1. Create a new file called .env in the root directory of your project.
  1. Inside the .env file, add the following line:
MONGODB_URI=your_mongodb_uri
Later, in our server code, we'll use a library dotenv to load the environment variables from the .env file and access the MongoDB URI securely.
By following these steps, you'll have a MongoDB Atlas database set up and ready to be used in your application. The MongoDB URI stored in the .env file will be used to connect to the database from your server code.

Setting up the Backend Server

Let's start by setting up the backend server using Node.js and Express.js. We'll also use Mongoose as an Object Document Mapping (ODM) library to interact with the MongoDB database.

Create a new node.js project and install dependencies

mkdir secure-registration-app cd secure-registration-app npm init -y npm install express mongoose bcrypt jsonwebtoken express-mongo-sanitize express-rate-limit yup helmet

Define the userModel

Now we need to think about what data we will store in the database and how we want it to be structured. This way we are ensuring that the user data stored in the database adheres our needs.
// /models/user.model.js const mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ firstName: { type: String, required: true, trim: true }, lastName: { type: String, required: true, trim: true }, age: { type: Number, required: true, max: 120 }, gender: { type: String, required: true, lowercase: true, trim: true, enum: ['male', 'female', 'other'] }, username: { type: String, required: true, unique: true, lowercase: true, trim: true }, email: { type: String, required: true, unique: true, lowercase: true, trim: true }, password: { type: String, required: true }, }); const User = mongoose.model('User', userSchema); module.exports = User;
This code defines the User model with fields for firstName, lastName, age, gender, email, username, and password. We set the required option to ensure that these fields are not empty, and mark the email and username fields as unique and case-insensitive to prevent duplicates.

Set up the express.js server

Let’s set up the Express.js server, connect to the MongoDB database using the MONGODB_URI environment variable, and configure middleware, sanitizing user input with express-mongo-sanitize, rate limiting with express-rate-limit, and securing the app with helmet.
We will need a strong string for our JWT_SECRET for the .env file, we can use node and OS console to generate for us random secure strings. node -e "console.log(require('crypto').randomBytes(64).toString('base64'))” This uses Node crypto module to generate 64 bytes of random data and then encodes that data as a base64 string. By changing the randomBytes you change the size of it.
const express = require('express'); const mongoose = require('mongoose'); const cors = require('cors'); const mongoSanitize = require('express-mongo-sanitize'); const rateLimit = require('express-rate-limit'); const helmet = require('helmet'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const yup = require('yup'); const User = require('./models/user.model'); const authMiddleware = require('./authMiddleware'); require('dotenv').config(); const app = express(); const port = process.env.PORT || 5001; // Middleware app.use(cors({ origin: 'http://localhost:5173' })); app.use(express.json()); app.use( rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs }) ); app.use( mongoSanitize({ onSanitize: ({ req, key }) => { // It is a good idea to log brute forces and ban their ip console.log(`This request[${key}] is sanitized`, req); }, }), ); app.use(helmet()); // Connect to MongoDB mongoose.connect(process.env.MONGODB_URI); const db = mongoose.connection; db.on('error', console.error.bind(console, 'MongoDB connection error:')); db.once('open', function () { console.log("Successfully connected to MongoDB!"); }); // Start the server app.listen(port, () => { console.log(`Server is running on port ${port}`); });

Create the user registration route

const bcrypt = require('bcrypt'); const yup = require('yup'); const jwt = require('jsonwebtoken'); const User = require('./models/user.model'); const registrationSchema = yup .object() .shape({ firstName: yup.string().trim().required(), lastName: yup.string().trim().required(), age: yup .number() .transform((value, originalValue) => String(originalValue).trim() === '' ? undefined : value ) .required().positive().integer().max(120), gender: yup .string() .oneOf(['male', 'female', 'other']) .required(), email: yup .string().trim().email().required() // no empty spaces regex .matches(/^[\S]+$/) .transform(value => value.toLowerCase()), username: yup .string().trim().required().min(4) // no empty spaces regex .matches(/^[\S]+$/) .transform(value => value.toLowerCase()), password: yup.string().required().min(8) //Password must be at least 8 characters long, contain at least one uppercase letter, one lowercase letter, one number, one special character, and cannot contain spaces .matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&_.+-])[^\s]+$/, ), }).required(); app.post('/register', async (req, res) => { try { // Validate the request body const validatedData = await registrationSchema.validate(req.body, { abortEarly: false, }); // Check if the email or username already exists const existingUser = await User.findOne({ $or: [{ email: validatedData.email }, { username: validatedData.username }], }); if (existingUser) { return res.status(400).json({ error: 'Email or username already exists' }); } // Hash the password const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(validatedData.password, salt); // Create a new user const newUser = new User({ ...validatedData, password: hashedPassword, }); // Save the new user to the database await newUser.save(); const token = jwt.sign( { userId: newUser._id }, process.env.JWT_SECRET, { expiresIn: '1h' } ); res.status(201).json({ message: 'User registered successfully', token }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Server error' }); } });
Here's what's happening:
  1. We validate the user's input data using the registrationSchema we defined.
  1. We check if the email or username already exists in our database. If it does, we return an error.
  1. If the email and username are unique, we hash the user's password using bcrypt. The salt is a random string of characters that is combined with the user's password to produce the final hash and the argument passed is the cost factor, which determines the computational complexity. This will make passwords that are equal in value will not be equal in visual representation at the db.
  1. We create a new User instance with the validated data and replace the text-based password for the created hashed password.
  1. We save the new user to our MongoDB database.
  1. We generate a JSON Web Token (JWT) for the user using the jsonwebtoken library. This token will be used for authentication later.
  1. We send a success response with the JWT.

Create the user login route

const loginSchema = yup.object().shape({ username: yup.string().trim().min(4).required().matches(/^[\S]+$/).transform(value => value.toLowerCase()), password: yup.string().required().matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&_.+-])[^\s]+$/, ), }); app.post('/login', async (req, res) => { try { const validatedData = await loginSchema.validate(req.body, { abortEarly: false, }); const { username, password } = validatedData; // Find the user by username const user = await User.findOne({ username }); if (!user) { return res.status(400).json({ error: 'Invalid username or password' }); } // Compare the provided password with the hashed password const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { return res.status(400).json({ error: 'Invalid username or password' }); } // Generate a JSON Web Token const token = jwt.sign( { userId: user._id }, process.env.JWT_SECRET, { expiresIn: '1h', } ); res.json({ token }); } catch (error) { res.status(500).json({ error: 'Server error' }); } });
Here's what's happening:
  1. We validate the user's input data using the loginSchema we defined.
  1. We query the user in our database by their username with mongoose.
  1. If the user doesn't exist, we return an error.
  1. We compare the provided password with the hashed password stored in the database using bcrypt.compare.
  1. If the passwords don't match, we return an error.
  1. If the username and password are valid, we generate a new JWT for the user.
  1. We send the JWT in the response.

Validate the user's JWT

// authMiddleware.js const jwt = require('jsonwebtoken'); const authMiddleware = () => { return (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) { return res.status(401).json({ error: 'No token provided' }); } const parts = authHeader.split(' '); if (parts.length !== 2 || parts[0] !== 'Bearer') { return res.status(401).json({ error: 'Token error' }); } const token = parts[1]; try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch (error) { if (error instanceof jwt.TokenExpiredError) { return res.status(401).json({ error: 'Token expired' }); } else if (error instanceof jwt.JsonWebTokenError) { return res.status(403).json({ error: 'Invalid token' }); } else { next(); } } }; }; module.exports = authMiddleware; // validate-token route app.get('/validate-token', authMiddleware(), (req, res) => { // If the token is valid, authMiddleware will allow reaching this point res.json({ valid: true, user: req.user }); });
This concludes the server-side implementation of our registration and login system. We used Node.js and Express to build our backend API, and connected it to a MongoDB database using Mongoose.
We implemented JSON Web Tokens (JWTs) for secure user authentication and authorization. JWTs are a popular and industry-standard way of transmitting information between parties securely. They allow us to verify that the user is authenticated and authorised to access certain routes or resources.
We also used the yup library for schema validation, ensuring that user input data conforms to our defined rules and constraints. This helps us prevent invalid or malicious data from being stored in our database.
To securely store user passwords, we used the bcrypt library for hashing passwords before saving them to the database. Bcrypt is a recommended and secure way of storing passwords, as it uses a salt and a key derivation function to make the hashing process more computationally expensive and resistant to brute-force attacks.
For added security and performance, we implemented several middleware functions:
  • cors: Allowed us to control which origins (e.g., our React app) can make requests to our backend.
  • express-mongo-sanitize: Prevented MongoDB operator injection by sanitizing user input data.
  • express-rate-limit: Limited the number of requests from a single IP address to prevent DDoS attacks.
  • helmet: Set various security headers to enhance the security of our Express app.
We are ready to go into our frontend and build the forms that will interact with the server and the server is ready to interact with the mongoDB database. We will be able to try and exploit the server and understand better how a real server behaves against requests. We handled vulnerabilities, errors that would cause a crash and interrupt our development, with that said, let’s build some forms.

Setting up the Frontend

We'll cover the design and development of the user interface components, form validation, handling user input, and integrating with the backend API we built earlier.

Create a new Vite Project

npm create vite@latest my-secure-app-frontend -- --template react

Pages and Routing

Let’s start by building out our pages and secure routes we want to force the user to be logged in to see any info from the page. Let’s use react-router-dom setup the router for our pages and setup a component ProtectedRoute.jsx to ensure access is only given to authorised users.
// App.jsx import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import ProtectedRoute from './components/ProtectedRoute.jsx'; import LoginPage from './pages/LoginPage.jsx'; import Dashboard from './pages/Dashboard.jsx'; import RegisterPage from './pages/RegisterPage.jsx'; const App = () => { return ( <Router> <Routes> <Route path="/" element={<LoginPage />} /> <Route path="/register" element={<RegisterPage />} /> <Route path="/dashboard" element={ <ProtectedRoute> <Dashboard /> </ProtectedRoute> } /> </Routes> </Router> ); }; export default App;
// LoginPage.tsx import { Link } from 'react-router-dom'; import LoginForm from '../components/LoginForm.jsx'; import useAuthNavigation from '../lib/useAuthNavigation.js'; import useTokenValidation from '../lib/useTokenValidation.js'; const LoginPage = () => { const { isLoading, isValidToken } = useTokenValidation(); useAuthNavigation(isValidToken, '/dashboard'); if (isLoading) return <></>; return ( <section> <h1>Welcome</h1> <LoginForm /> <p> Don&apos;t have an account? <Link to="/register">Register here</Link> </p> </section> ); }; export default LoginPage;
// RegisterPage.jsx import { Link } from 'react-router-dom'; import RegistrationForm from '../components/RegistrationForm.jsx'; import useAuthNavigation from '../lib/useAuthNavigation.js'; import useTokenValidation from '../lib/useTokenValidation.js'; const RegisterPage = () => { const { isLoading, isValidToken } = useTokenValidation(); useAuthNavigation(isValidToken, '/dashboard'); if (isLoading) return <></>; return ( <section> <h1>Welcome</h1> <RegistrationForm /> <p> Already have an account? <Link to="/">Login here</Link> </p> </section> ); }; export default RegisterPage;
// Dashboard.jsx const Dashboard = () => { return ( <section> <h1>Hello</h1> </section> ); }; export default Dashboard;

ProtectedRoute component

Let's now think about the ProtectedRoute component. We want to ensure that no data is accessible to the client without validating the token first. Only after successful token validation will the client be granted access. For this, we need to handle the loading state and prevent access to the page information while loading or validating the token.
In the login and register pages, we want to check if the user is already logged in (i.e., has a valid token). If they do, we need to redirect them to the protected routes.
To handle token validation and navigation, we will create two custom hooks:
  1. useTokenValidation: This hook will handle token validation and update the isLoading and isValidToken states accordingly.
  1. useAuthNavigation: This hook will navigate to a specific route (e.g., /dashboard) if the token is valid. It will be used in the LoginPage and RegisterPage components to redirect the user to the protected routes if they already have a valid token.
// useTokenValidation.js import { useState, useEffect } from 'react'; import axios from 'axios'; const useTokenValidation = () => { const [isLoading, setIsLoading] = useState(true); const [isValidToken, setIsValidToken] = useState(false); useEffect(() => { const validateToken = async () => { const token = localStorage.getItem('authToken'); if (!token) { setIsLoading(false); setIsValidToken(false); return; } axios .get('http://localhost:5001/validate-token', { headers: { Authorization: `Bearer ${token}` }, }) .then(() => { setIsValidToken(true); }) .catch((error) => { console.error('Validation error:', error); setIsValidToken(false); localStorage.removeItem('authToken'); // Remove the invalid token }) .finally(() => { setIsLoading(false); }); }; validateToken(); }, []); return { isLoading, isValidToken }; }; export default useTokenValidation;
import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; const useAuthNavigation = (isValidToken, navigateTo = '/dashboard') => { const navigate = useNavigate(); useEffect(() => { if (isValidToken) { navigate(navigateTo); } }, [isValidToken, navigate, navigateTo]); }; export default useAuthNavigation;
// ProtectedRoute.jsx import { Navigate } from 'react-router-dom'; import useTokenValidation from '../lib/useTokenValidation.js'; const ProtectedRoute = ({ children }) => { const { isLoading, isValidToken } = useTokenValidation(); if (isLoading) { return <></>; } if (!isValidToken) { // the replace prop ensures that the redirect doesn't create a new entry in the history stack return <Navigate to="/" replace />; } return children; }; export default ProtectedRoute;
And now in our login and register pages we will want to check if the user already have a token and if it is valid and handle redirect.
const { isLoading, isValidToken } = useTokenValidation(); useAuthNavigation(isValidToken, '/dashboard'); if(isLoading) return <></>
This route handling is a fast way of handling and protecting out routes and will let us test build and manage our forms and their respective responses.

Login and Registration Forms

In the LoginForm and RegistrationForm components, we will handle the navigation to the protected routes after successful authentication and token storage.

Registration Form

Let’s use the react-hook-form library along with yup for form validation and do our Registration Form:
// RegistrationForm.jsx import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import axios from 'axios'; import { yupResolver } from '@hookform/resolvers/yup'; import { useNavigate } from 'react-router-dom'; import * as yup from 'yup'; const registrationSchema = yup .object() .shape({ firstName: yup.string().trim().required('First name is required'), lastName: yup.string().trim().required('Last name is required'), age: yup .number() // we transform to fix the strange error of yup.number() that throws // age must be a `number` type, but the final value was: `NaN` (cast from the value `""`). // this way skips to the .required() validation .transform((value, originalValue) => String(originalValue).trim() === '' ? undefined : value ) .required('Age is required') .positive('Age must be greater than zero') .integer('Age must be an number') .max(120, 'Age must be less than 120'), gender: yup .string() .oneOf(['male', 'female', 'other'], 'Invalid gender selection') .required('Gender is required'), email: yup .string() .trim() .email('Invalid email address') .required('Email is required') .matches(/^[\S]+$/, 'Email cannot contain spaces'), username: yup .string() .trim() .required('Username is required') .min(4, 'Username must be at least 4 characters long') .matches(/^[\S]+$/, 'Username cannot contain spaces'), password: yup .string() .required('Password is required') .min(8, 'Password must be at least 8 characters long') .matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&_.+-])[^\s]+$/, 'Password must be at least 8 characters long, contain at least one uppercase letter, one lowercase letter, one number, one special character, and cannot contain spaces' ), }) .required(); const RegistrationForm = () => { const navigate = useNavigate(); const [serverError, setServerError] = useState(''); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: yupResolver(registrationSchema), }); useEffect(() => { const firstNameInput = document.getElementById('firstName'); if (firstNameInput) { firstNameInput.focus(); } }, []); const onSubmit = async (data) => { try { const response = await axios.post('http://localhost:5001/register', data); const { token } = response.data; localStorage.setItem('authToken', token); console.log('User registered successfully'); navigate('/dashboard'); } catch (error) { const errorMessage = error?.response?.data?.error ? `${error.response.data.error}` : 'Try again'; setServerError(errorMessage); } }; const clearServerError = useCallback(() => { setServerError(''); }, []); return ( <form id="register" onSubmit={handleSubmit(onSubmit)} onFocus={clearServerError} noValidate aria-labelledby="registration-form-title" > {/* hidden h2 for screen readers */} <h2 id="registration-form-title" style={{ visibility: 'hidden' }}> Registration Form </h2> {serverError && ( <div role="alert" aria-live="assertive" style={{ color: 'red' }}> {serverError} </div> )} <div> <label htmlFor="firstName"> First Name <input id="firstName" aria-invalid={errors.firstName ? 'true' : 'false'} aria-describedby="firstName-error" autoComplete="cc-name" {...register('firstName')} /> </label> {errors.firstName && ( <div id="firstName-error" role="alert" aria-live="assertive" className="error"> {errors.firstName.message} </div> )} </div> <div> <label htmlFor="lastName"> Last Name <input id="lastName" aria-invalid={errors.lastName ? 'true' : 'false'} aria-describedby="lastName-error" autoComplete="cc-family-name" {...register('lastName')} /> </label> {errors.lastName && ( <div id="lastName-error" role="alert" aria-live="assertive" className="error"> {errors.lastName.message} </div> )} </div> <div> <label htmlFor="age"> Age <input id="age" type="number" aria-invalid={errors.age ? 'true' : 'false'} aria-describedby="age-error" {...register('age')} /> </label> {errors.age && ( <div id="age-error" role="alert" aria-live="assertive" className="error"> {errors.age.message} </div> )} </div> <div> <label htmlFor="gender"> Gender <select id="gender" aria-invalid={errors.gender ? 'true' : 'false'} aria-describedby="gender-error" {...register('gender')} > <option value="" aria-label="Selection of gender options"> Select gender </option> <option value="male" aria-label="Male"> Male </option> <option value="female" aria-label="Female"> Female </option> <option value="other" aria-label="Other"> Other </option> </select> </label> {errors.gender && ( <div id="gender-error" role="alert" aria-live="assertive" className="error"> {errors.gender.message} </div> )} </div> <div> <label htmlFor="email"> Email <input id="email" type="email" aria-invalid={errors.email ? 'true' : 'false'} aria-describedby="email-error" autoComplete="email" {...register('email')} /> </label> {errors.email && ( <div id="email-error" role="alert" aria-live="assertive" className="error"> {errors.email.message} </div> )} </div> <div> <label htmlFor="username"> Username <input id="username" aria-invalid={errors.username ? 'true' : 'false'} aria-describedby="username-error" autoComplete="username" {...register('username')} /> </label> {errors.username && ( <div id="username-error" role="alert" aria-live="assertive" className="error"> {errors.username.message} </div> )} </div> <div> <label htmlFor="password"> Password <input id="password" type="password" aria-invalid={errors.password ? 'true' : 'false'} aria-describedby="password-error" autoComplete="new-password" {...register('password')} /> </label> {errors.password && ( <div id="password-error" role="alert" aria-live="assertive" className="error"> {errors.password.message} </div> )} </div> <button type="submit" disabled={isSubmitting} aria-disabled={isSubmitting}> {isSubmitting ? 'Registering...' : 'Register Account'} </button> </form> ); }; export default RegistrationForm;
Let's break down what's happening in the RegistrationForm component:
  1. We define a registrationSchema using yup to specify the validation rules.
  1. The useForm hook from react-hook-form is initialised with the yupResolver and the registrationSchema.
  1. The onSubmit function is called when the form is submitted. It makes a POST request to the /register endpoint with the form data.
  1. If the registration is successful, the server responds with a token, which is stored in the browser's local storage using localStorage.setItem('authToken', token).
  1. The user is then navigated to the /dashboard route using navigate('/dashboard').
  1. If there's an error from server during the registration process, an error message is set in the serverError state, which is displayed in the form.
  1. The clearServerError function is used to clear the serverError state when the user interacts with the form (using the onFocus event).
  1. The form renders the various input fields (firstName, lastName, age, gender, email, username, password) with their respective validation error messages (if any) leveraging the errors from react-hook-form.
  1. We are disabling the Register button when submitting the form.

Login Form

Let’s use the react-hook-form library along with yup for form validation and do our Login Form:
// LoginForm.jsx import { useCallback, useEffect, useState } from 'react'; import axios from 'axios'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; const loginSchema = yup .object() .shape({ username: yup.string().required('Username is required'), password: yup.string().required('Password is required'), }) .required(); const LoginForm = () => { const navigate = useNavigate(); const [serverError, setServerError] = useState(''); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: yupResolver(loginSchema), }); useEffect(() => { const usernameInput = document.getElementById('username'); if (usernameInput) { usernameInput.focus(); } }, []); const onSubmit = async (data) => { try { const response = await axios.post('http://localhost:5001/login', data); const { token } = response.data; localStorage.setItem('authToken', token); console.log('Login successful'); navigate('/dashboard'); } catch (error) { const errorMessage = error?.response?.data?.error ? `${error.response.data.error}` : 'Try again'; setServerError(errorMessage); } }; const clearServerError = useCallback(() => { setServerError(''); }, []); return ( <form id="login" onSubmit={handleSubmit(onSubmit)} onFocus={clearServerError} noValidate aria-labelledby="login-form-title" > {/* hidden h2 for screen readers */} <h2 id="login-form-title" style={{ visibility: 'hidden' }}> Login Form </h2> {serverError && ( <div role="alert" aria-live="assertive" style={{ color: 'red' }}> {serverError} </div> )} <div> <label htmlFor="username"> Username <input id="username" type="text" aria-invalid={errors.username ? 'true' : 'false'} aria-describedby="username-error" autoComplete="username" {...register('username')} /> </label> {errors.username && ( <div id="username-error" role="alert" aria-live="assertive" style={{ color: 'red' }}> {errors.username.message} </div> )} </div> <div> <label htmlFor="password"> Password <input id="password" type="password" aria-invalid={errors.password ? 'true' : 'false'} aria-describedby="password-error" autoComplete="current-password" {...register('password')} /> </label> {errors.password && ( <div id="password-error" role="alert" aria-live="assertive" style={{ color: 'red' }}> {errors.password.message} </div> )} </div> <button type="submit" disabled={isSubmitting} aria-disabled={isSubmitting}> {isSubmitting ? 'Logging in...' : 'Log in'} </button> </form> ); }; export default LoginForm;
Let's break down what's happening in the LoginForm component:
  1. We define a loginSchema using yup to specify the validation rules.
  1. The useForm hook from react-hook-form is initialised with the yupResolver and the loginSchema.
  1. The onSubmit function is called when the form is submitted. It makes a POST request to the /login endpoint with the form data.
  1. If the login is successful, the server responds with a token, which is stored in the browser's local storage using localStorage.setItem('authToken', token).
  1. The user is then navigated to the /dashboard route using navigate('/dashboard').
  1. If there's an error from server during the login process, an error message is set in the serverError state, which is displayed in the form.
  1. The clearServerError function is used to clear the serverError state when the user interacts with the form (using the onFocus event).
  1. The form renders the username and password fields with their respective validation error messages (if any).
  1. We are disabling the Login button when submitting the form.
Both components follow a similar pattern: validate the form data using react-hook-form and yup, handle form submission by making a POST request to the server, store the token in local storage upon successful authentication, navigate to the /dashboard route, and display error messages if any occur during the process.
Let’s discuss some of the things we are doing on this form in detail:

Benefits of using react-hook-form and yup:

react-hook-form:
  • Provides a simple and efficient way to manage form state and validation in React applications.
  • Offers better performance compared to other form libraries by leveraging React's built-in state management and reducing unnecessary re-renders.
  • Supports advanced features like form validation, error handling, and form submission handling out of the box.
  • Integrates well with other libraries and tools, such as yup for schema validation.
yup:
  • Offers a simple and expressive way to define and validate data structures.
  • Provides a user-friendly error messaging system, making it easier to provide meaningful validation feedback to users.
  • Supports complex validation scenarios, including nested data structures, conditional logic, and custom validation rules.
  • Integrates well with react-hook-form through the yupResolver provided by the @hookform/resolvers/yup package.
Handling serverError and clearing error messages:
  • The serverError state is used to store any error messages received from the server during the authentication process.
  • If an error occurs, the setServerError function is called with the error message received from the server's response (error.response.data.error).
  • To clear the serverError message, a clearServerError function is defined using the useCallback hook. This function simply sets the serverError state to an empty string (setServerError('')).
  • The clearServerError function is called when the user interacts with the form by attaching it to the onFocus event of the form (onFocus={clearServerError}). This way, any error messages displayed in the form will be cleared when the user starts interacting with the form again.
  • User experience: By providing clear validation error messages and clearing them when the user interacts with the form again, the application offers a better user experience and guides users in correcting any errors.
Implementing accessibility:
  • Proper labeling: The form fields are wrapped in <label> elements, ensuring that screen readers can correctly associate the labels with their respective input fields.
  • ARIA attributes: Additional ARIA attributes are used to improve accessibility:
aria-describedby: Associates the input field with its corresponding error message element, enabling screen readers to read both the input field and its error message.
role="alert": Marks the error message elements as live regions, allowing screen readers to announce any changes to the error message content.
aria-live="assertive": Instructs screen readers to immediately announce any changes to the live region (error message).
Using yupResolver from @hookform/resolvers/yup:
  • yupResolver is a resolver provided by the @hookform/resolvers/yup package, which integrates yup with react-hook-form.
  • It allows you to define validation schemas using yup and leverage those schemas within react-hook-form for form validation.
  • By passing the yupResolver and the yup schema to the useForm hook, react-hook-form can automatically handle validation based on the defined schema.
  • This integration simplifies the process of defining and applying validation rules in your React forms, while benefiting from the powerful validation capabilities of yup.

Wrap up what we have done till now

In this project, we successfully built a secure registration and login system using React Vite for the frontend, on our backend Node.js, Express.js, with moongose to connect to our mongoDB. We implemented various security measures and best practices to protect user data and prevent common vulnerabilities.
  1. We successfully implemented a server using Node.js and Express.js that interacts with a MongoDB database.
  1. We successfully defined a user model schema to ensure that user data adheres to our requirements and implemented routes for user registration and login.
  1. We successfully implemented JSON Web Tokens (JWTs) for secure user authentication and authorization, allowing us to verify that the user is authenticated.
  1. We successfully implemented additional security by setting up cors, sanitization of data with express-mongo-sanitize, reduce the risk of DDoS attacks with express-rate-limit, set various security headers with helmet.
  1. We successfully implemented a frontend with React Vite to build our user interface components, including the registration and login forms.
  1. We successfully implemented react-hook-form library along with yup for form validation and handling user input
  1. We successfully implemented navigation and access control using React Router and custom hooks to ensure that only authenticated users can access protected routes.
  1. We successfully implemented a secure user authentication system using JSON Web Tokens (JWTs). Based on the user's authentication status, we navigate them to the appropriate pages.
  1. We successfully implemented accessibility features, such as proper labeling, ARIA attributes, and live regions, to ensure that our application is inclusive and usable for all users.
  1. We successfully built forms that are focused on providing a good user experience by displaying clear validation error messages and guiding users to correct any errors.
By following best practices and implementing security measures at every step, we have created a robust and secure registration and login system that is expandable and can serve as a foundation for building a more complex application.

Unit Testing with Vitest and React Testing Library

In this section, we'll explore how to set up unit testing for a React application using Vitest and React Testing Library.

Setting up Vitest

Let's configure Vitest by creating a vitest.config.js file at the root of your project.
import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { globals: true, environment: 'jsdom', setupFiles: './setupTests.js', css: true, }, });
This configuration specifies the use of the React plugin, enabling support for JSX in our tests. We also set the testing environment to jsdom, which simulates a browser environment for our tests to run in. Additionally, the globals: true option makes Vitest's functions globally available in our tests, avoiding the need for imports. The setupFiles option points to a script that runs before our tests, allowing us to globally set up necessary imports or configurations.
// setupTests.js import '@testing-library/jest-dom';
The setupTests.js file is straightforward, importing @testing-library/jest-dom to extend Vitest with custom DOM element matchers.

Testing the LoginForm component

// LoginForm.test.jsx import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router-dom'; import LoginForm from './LoginForm'; import axios from 'axios'; vi.mock('axios'); const textVariables = { inputLabelText: { username: 'Username', password: 'Password', }, loginFormButtonText: { submit: 'Log in', }, errors: { noInput: { username: 'Username is required', password: 'Password is required', }, server: { genericMockedServerError: 'Try again later', }, }, }; describe('LoginForm', () => { let history; let usernameInput, passwordInput, submitButton; beforeEach(() => { history = createMemoryHistory(); axios.post.mockResolvedValue({ data: { token: 'mocked-token' } }); render( <Router location={history.location} navigator={history}> <LoginForm /> </Router> ); usernameInput = screen.getByLabelText(textVariables.inputLabelText.username); passwordInput = screen.getByLabelText(textVariables.inputLabelText.password); submitButton = screen.getByRole('button', { name: textVariables.loginFormButtonText.submit, }); }); afterEach(() => { vi.resetAllMocks(); }); test('renders form elements correctly', () => { expect(usernameInput).toBeInTheDocument(); expect(passwordInput).toBeInTheDocument(); expect(submitButton).toBeInTheDocument(); }); test('shows error messages for invalid inputs', async () => { await userEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(textVariables.errors.noInput.username)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.password)).toBeInTheDocument(); }); }); test('calls login API on successful submission', async () => { await userEvent.type(usernameInput, 'testuser'); await userEvent.type(passwordInput, 'testpassword'); await userEvent.click(submitButton); await waitFor(() => { expect(axios.post).toHaveBeenCalledWith('http://localhost:5001/login', { username: 'testuser', password: 'testpassword', }); }); }); test('stores token in localStorage and navigates to dashboard on successful login', async () => { const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); await userEvent.type(usernameInput, 'testuser'); await userEvent.type(passwordInput, 'testpassword'); await userEvent.click(submitButton); await waitFor(() => { expect(setItemSpy).toHaveBeenCalledWith('authToken', 'mocked-token'); expect(history.location.pathname).toBe('/dashboard'); }); expect(setItemSpy).toHaveBeenCalledTimes(1); }); test('shows server error message on failed login', async () => { await axios.post.mockRejectedValueOnce({ response: { data: { error: textVariables.errors.server.genericMockedServerError }, status: 400, statusText: 'Bad Request', }, }); await userEvent.type(usernameInput, 'testuser'); await userEvent.type(passwordInput, 'testpassword'); await userEvent.click(submitButton); // Wait for the serverError state to be updated await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent(textVariables.errors.server.genericMockedServerError); }); }); });
The LoginForm test suite includes the following test cases:
  1. Renders form elements correctly: This test ensures that the login form renders all the necessary elements (username input, password input, and login button).
  1. Shows error messages for invalid inputs: This test verifies that the appropriate error messages are displayed when the form is submitted without entering any values.
  1. Calls login API on successful submission: This test mocks the axios library and ensures that the correct API endpoint (http://localhost:5001/login) is called with the correct payload when the form is submitted with valid inputs.
  1. Stores token in localStorage and navigates to dashboard on successful login: This test mocks the localStorage object and the history object from react-router-dom. It checks that the authentication token is stored in localStorage and that the user is navigated to the dashboard after a successful login.
  1. Shows server error message on failed login: This test simulates a failed login attempt by mocking the axios library to return an error response. It verifies that the appropriate server error message is displayed when the login fails.

Testing the RegistrationForm component

// RegistrationForm.test.jsx import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import RegistrationForm from './RegistrationForm'; import axios from 'axios'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router-dom'; vi.mock('axios'); const textVariables = { inputLabelText: { firstName: 'First Name', lastName: 'Last Name', age: 'Age', gender: 'Gender', email: 'Email', username: 'Username', password: 'Password', }, registerFormButtonText: { submit: 'Register Account', }, errors: { noInput: { firstName: 'First name is required', lastName: 'Last name is required', age: 'Age is required', gender: 'Invalid gender selection', email: 'Email is required', username: 'Username is required', password: 'Password is required', }, server: { genericMockServerError: 'Try again', }, }, }; // Utility function to fill out the registration form async function fillOutRegistrationForm(formData) { const { firstName, lastName, age, gender, email, username, password } = formData; /* * We are simulating user keystrokes with userEvent.type and users can only input text (which is treated as strings) into form fields * formData.age is sent to API as a number, but typed on the form as a string */ const ageString = age.toString(); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.firstName), firstName); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.lastName), lastName); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.age), ageString); await userEvent.selectOptions(screen.getByLabelText(textVariables.inputLabelText.gender), gender); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.email), email); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.username), username); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.password), password); } describe('RegistrationForm', () => { let history; let firstNameInput, lastNameInput, ageInput, genderSelect, emailInput, usernameInput, passwordInput, submitButton; beforeEach(() => { history = createMemoryHistory(); axios.post.mockResolvedValue({ data: { token: 'mocked-token' } }); render( <Router location={history.location} navigator={history}> <RegistrationForm /> </Router> ); firstNameInput = screen.getByLabelText(textVariables.inputLabelText.firstName); lastNameInput = screen.getByLabelText(textVariables.inputLabelText.lastName); ageInput = screen.getByLabelText(textVariables.inputLabelText.age); genderSelect = screen.getByLabelText(textVariables.inputLabelText.gender); emailInput = screen.getByLabelText(textVariables.inputLabelText.email); usernameInput = screen.getByLabelText(textVariables.inputLabelText.username); passwordInput = screen.getByLabelText(textVariables.inputLabelText.password); submitButton = screen.getByRole('button', { name: textVariables.registerFormButtonText.submit, }); }); afterEach(() => { vi.resetAllMocks(); }); test('renders form elements correctly', () => { expect(firstNameInput).toBeInTheDocument(); expect(lastNameInput).toBeInTheDocument(); expect(ageInput).toBeInTheDocument(); expect(genderSelect).toBeInTheDocument(); expect(emailInput).toBeInTheDocument(); expect(usernameInput).toBeInTheDocument(); expect(passwordInput).toBeInTheDocument(); expect(submitButton).toBeInTheDocument(); }); test('shows error messages for invalid inputs', async () => { await userEvent.click(submitButton); expect(await screen.findByText(textVariables.errors.noInput.firstName)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.lastName)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.age)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.gender)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.email)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.username)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.password)).toBeInTheDocument(); }); test('calls registration API on successful submission', async () => { const formData = { firstName: 'John', lastName: 'Doe', age: 30, gender: 'male', email: 'johndoe@example.com', username: 'johndoe', password: 'Password123!', }; await fillOutRegistrationForm(formData); await userEvent.click(submitButton); await waitFor(() => { expect(axios.post).toHaveBeenCalledWith('http://localhost:5001/register', formData); }); }); test('stores token in localStorage and navigates to dashboard on successful registration', async () => { const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); const formData = { firstName: 'John', lastName: 'Doe', age: 30, gender: 'male', email: 'johndoe@example.com', username: 'johndoe', password: 'Password123!', }; await fillOutRegistrationForm(formData); await userEvent.click(submitButton); await waitFor(() => { expect(setItemSpy).toHaveBeenCalledWith('authToken', 'mocked-token'); expect(history.location.pathname).toBe('/dashboard'); }); expect(setItemSpy).toHaveBeenCalledTimes(1); }); test('shows server error message on failed registration', async () => { axios.post.mockRejectedValueOnce({ response: { data: { error: textVariables.errors.server.genericMockServerError } }, }); const formData = { firstName: 'John', lastName: 'Doe', age: 30, gender: 'male', email: 'johndoe@example.com', username: 'johndoe', password: 'Password123!', }; await fillOutRegistrationForm(formData); await userEvent.click(submitButton); expect( await screen.findByText(textVariables.errors.server.genericMockServerError) ).toBeInTheDocument(); }); });
The RegistrationForm test suite includes similar test cases to the LoginForm suite, with a few additions:
  1. Renders form elements correctly: This test ensures that the registration form renders all the necessary elements (first name input, last name input, age input, gender select, email input, username input, password input, and register button).
  1. Shows error messages for invalid inputs: This test verifies that the appropriate error messages are displayed when the form is submitted with invalid or missing inputs.
  1. Calls registration API on successful submission: This test mocks the axios library and ensures that the correct API endpoint is called with the correct payload when the form is submitted with valid inputs.
  1. Stores token in localStorage and navigates to dashboard on successful registration: This test mocks the localStorage object and the history object from react-router-dom. It checks that the authentication token is stored in localStorage and that the user is navigated to the dashboard after a successful registration.
  1. Shows server error message on failed registration: This test simulates a failed registration attempt by mocking the axios library to return an error response. It verifies that the appropriate server error message is displayed when the registration fails.

End-To-End testing with Cypress

Before we dive into the details of our Cypress tests, it's important to note that for simplicity, we'll be connecting directly to our MongoDB database from the frontend to register and delete test users. While this approach is not recommended for production applications due to security concerns, it allows us to avoid the need for creating separate testing routes or extra endpoints in our backend API.
With that said, let's take a closer look at our Cypress configuration and the scripts responsible for managing test user data.

Installing and Setting Up Cypress

Let’s install cypress
npm install --save-dev cypress
Next, we'll need to set up our Cypress configuration and scripts. In the cypress.config.js file, we define our Cypress configuration and specify tasks for setting up test data in our MongoDB database. We'll use these tasks to create and clean up a test user before and after each test suite.
// cypress.config.js import {defineConfig} from 'cypress'; import cleanupDbTestUser from './cypress/scripts/cleanupDbTestUser.js'; import generateRandomUser from './cypress/scripts/generateRandomUser.js'; import registerDbTestUser from './cypress/scripts/registerDbTestUser.js'; export default defineConfig({ e2e: { setupNodeEvents(on, config) { on('task', { async cleanupDbTestUser() { const randomUser = generateRandomUser(); const messages = await cleanupDbTestUser(randomUser.EMAIL_FIXED); return { messages, cleanedDbUser: randomUser }; }, }); on('task', { async registerDbTestUser() { const randomUser = generateRandomUser(); const messages = await registerDbTestUser(randomUser); return { messages, registeredDbUser: randomUser }; }, }); return config; }, trashAssetsBeforeRuns: false, }, component: { devServer: { framework: 'react', bundler: 'vite', }, }, });
The cleanupDbTestUser.js and registerDbTestUser.js scripts handle the creation and deletion of test users in our MongoDB database, while generateRandomUser.js generates random user data for our tests.
// generateRandomUser.js import { faker } from '@faker-js/faker'; faker.seed(123); function generateRandomUser() { const USERNAME_FIXED = '7a59f3c8add07cdb20996662c3c7795dc6c203285945a5e1c011bbc082d3a4827ee16a6dd060e71bbb2d089a3f02c4b13174bba15af44b0102ed9650368fe88d'; const EMAIL_FIXED = '7a59f3c8add07cdb20996662c3c7795dc6c203285945a5e1c011bbc082d3a4827ee16a6dd060e71bbb2d089a3f02c4b13174bba15af44b0102ed9650368fe88d@gmail.com'; const PASSWORD_FIXED = 'MyVerySecurePassword_123'; const randomUser = { firstName: faker.person.firstName(), lastName: faker.person.lastName(), age: faker.number.int({ min: 1, max: 120 }), gender: 'male', EMAIL_FIXED: EMAIL_FIXED, USERNAME_FIXED: USERNAME_FIXED, PASSWORD_FIXED: PASSWORD_FIXED, }; return randomUser; } export default generateRandomUser;
In this generateRandomUser we are actually using fixed values for username, email and password. This serves more as a proof of concept and ability to use random data and implement it now. Generating random user data is very useful and will prove to be even more useful when you are ready to expand the tests to a more complex level.
// cleanupDbTestUser.js import mongoose from 'mongoose'; import dotenv from 'dotenv'; import User from '../../src/models/user.model.js'; dotenv.config(); async function cleanupDbTestUser(email) { console.log('cleanupDatabase STARTED'); let messages = []; if (!email) { console.log('no email provided', email); return []; } try { await mongoose.connect(process.env.MONGODB_URI); console.log('Connected to MongoDB'); messages.push('Connected to MongoDB'); const result = await User.deleteOne({ email }); console.log(`Deleted ${result.deletedCount} user with email ${email}`); messages.push(`Deleted ${result.deletedCount} user with email ${email}`); } catch (error) { messages.push(`Error cleaning up database: ${error.message}`); console.error('Error cleaning up database:', error); } finally { await mongoose.disconnect(); console.log('Disconnected from MongoDB'); messages.push('Disconnected from MongoDB'); } return messages; } export default cleanupDbTestUser;
This script connects to the MongoDB database, deletes a user with the specified email address, and returns messages about the operation's status. It's used to clean up any existing test user data before running tests. We will not be using the messages by now but there is a possibility of using them, for example, on a CI environment would be very useful.
// registerDbTestUser.js import mongoose from 'mongoose'; import dotenv from 'dotenv'; import User from '../../src/models/user.model.js'; dotenv.config(); async function registerDbTestUser(randomUser) { console.log('registerDbTestUser STARTED'); let messages = []; try { await mongoose.connect(process.env.MONGODB_URI); console.log('Connected to MongoDB'); messages.push('Connected to MongoDB'); // Check if a user with the same email or username already exists const existingUserEmail = await User.findOne({ email: randomUser.EMAIL_FIXED }); const existingUserUsername = await User.findOne({ username: randomUser.USERNAME_FIXED, }); if (existingUserEmail || existingUserUsername) { const errorMessage = `User with email ${existingUserEmail ? existingUserEmail.email : ''} or username ${existingUserUsername ? existingUserUsername.username : ''} already exists.`; console.log(errorMessage); messages.push(errorMessage); return messages; } const newUser = new User({ firstName: randomUser.firstName, lastName: randomUser.lastName, age: randomUser.age, gender: randomUser.gender, email: randomUser.EMAIL_FIXED, username: randomUser.USERNAME_FIXED, password: randomUser.password, }); const result = await newUser.save(); console.log(`Created user with email ${result.email}`); messages.push(`Created user with email ${result.email}`); } catch (error) { messages.push(`Error registering user: ${error.message}`); console.error('Error registering user:', error); } finally { await mongoose.disconnect(); console.log('Disconnected from MongoDB'); messages.push('Disconnected from MongoDB'); } return messages; } export default registerDbTestUser;
This script connects to the MongoDB database, checks if a user with the same email or username already exists, and creates a new user if not. It returns messages about the operation's status. It's used to set up a test user before running tests. We will not be using the messages by now but there is a possibility of using them, for example, on a CI environment would be very useful.

Writing Tests with Cypress

With Cypress set up, we can start writing our end-to-end tests. In our project, we have two main test suites: one for user registration (registration.cy.js) and one for user login (login.cy.js).
// registration.cy.js let cleanedUser = {}; describe('Registration', () => { before(() => { cy.task('cleanupDbTestUser').then(({ messages, cleanedDbUser }) => { expect(messages).to.include('Connected to MongoDB'); expect(messages.some((message) => message.startsWith('Deleted'))).to.be.true; expect(messages.some((message) => message.startsWith('Disconnected'))).to.be.true; expect(messages.some((message) => message.includes('Error'))).to.be.false; cleanedUser = cleanedDbUser; }); }); beforeEach(() => { cy.visit('localhost:5173/register'); cy.get('input#firstName').type(cleanedUser.firstName); cy.get('input#lastName').type(cleanedUser.lastName); cy.get('input#age').type(cleanedUser.age); cy.get('select#gender').select(cleanedUser.gender); cy.get('input#email').type(cleanedUser.EMAIL_FIXED); cy.get('input#username').type(cleanedUser.USERNAME_FIXED); cy.get('input#password').type(cleanedUser.PASSWORD_FIXED); cy.get('form').submit(); }); it('should register a new user, redirect to dashboard and store token in local storage', () => { // Assert that the user is registered successfully cy.url().should('include', '/dashboard'); cy.window().should((window) => { const token = window.localStorage.getItem('authToken'); expect(token).not.to.be.empty; expect(token).to.be.a('string'); }); }); it('registering with already registered email should not register', () => { cy.get('div[role="alert"]').contains('Email or username already exists').should('be.visible'); }); });
let registeredUser = {}; describe('Login', () => { before(() => { cy.task('registerDbTestUser').then(({ registeredDbUser }) => { registeredUser = registeredDbUser; }); }); beforeEach(() => { cy.visit('http://localhost:5173/'); cy.get('input#username').type(registeredUser.USERNAME_FIXED); cy.get('input#password').type(registeredUser.PASSWORD_FIXED); cy.get('form').submit(); cy.window().should((window) => { const token = window.localStorage.getItem('authToken'); expect(token).to.not.be.empty; }); }); it('should redirect to the dashboard if the user is already logged in', () => { cy.visit('http://localhost:5173/'); cy.url().should('include', '/dashboard'); cy.visit('http://localhost:5173/register'); cy.url().should('include', '/dashboard'); }); it('Removing token and should be able to visit login and register', () => { cy.visit('http://localhost:5173/dashboard'); cy.url().should('include', '/dashboard'); { /* Simulate a logout */ } cy.window().should((window) => { window.localStorage.removeItem('authToken'); expect(window.localStorage.getItem('authToken')).to.be.null; }); cy.visit('http://localhost:5173/'); cy.url().should('equal', 'http://localhost:5173/'); cy.visit('http://localhost:5173/register'); cy.url().should('equal', 'http://localhost:5173/register'); }); it('Manipulate Token does not allow to visit dashboard', () => { { /* Manipulate while maintaining same length */ } cy.window().should((window) => { const originalToken = window.localStorage.getItem('authToken'); window.localStorage.setItem('authToken', 'a' + originalToken.slice(1)); const manipulatedToken = window.localStorage.getItem('authToken'); expect(manipulatedToken.length).to.eq(originalToken.length); }); cy.visit('http://localhost:5173/dashboard'); cy.url().should('not.include', '/dashboard'); cy.visit('http://localhost:5173/'); cy.url().should('equal', 'http://localhost:5173/'); cy.visit('http://localhost:5173/register'); cy.url().should('equal', 'http://localhost:5173/register'); }); });
This tests handle some of the common scenarios on the login and registration process, including, route navigation and route protection, JWT token manipulation and route protection.

Script to CLI run cypress tests on every available browser

Now, let’s make something really cool, let’s automate the process of using our cypress tests and automatically run our cypress tests for all available browsers that we have installed on out local environment.
We will start by using npx cypress info and we will read that info from the console, we will parse that info and find out what available browsers we have on our system.
// run-tests-on-all-browsers.js import { exec as execCallback } from 'child_process'; import { promisify } from 'util'; const exec = promisify(execCallback); function parseBrowsers(output) { const browserLines = output.split('\n').filter((line) => line.trim().startsWith('- Name:')); return browserLines.map((line) => line.split(': ')[1]); } async function runCypressTests(browser) { console.log(`Running tests on ${browser}...`); try { const { stdout } = await exec(`npx cypress run --browser ${browser}`); console.log(`Tests completed on ${browser}:\n`, stdout); } catch (error) { console.error(`Error running tests on ${browser}:`, error.stderr); } } async function getCypressInfo() { try { const { stdout } = await exec('npx cypress info'); return stdout; } catch (error) { console.error('Error getting Cypress info:', error.stderr); throw error; } } async function runTestsOnAllBrowsers() { try { const cypressInfoOutput = await getCypressInfo(); const browsers = parseBrowsers(cypressInfoOutput); console.log('Detected browsers:', browsers); browsers.push('electron'); console.log('Starting tests on:', browsers); for (const browser of browsers) { await runCypressTests(browser); } } catch (error) { console.error('Failed to run tests on all browsers:', error); } } runTestsOnAllBrowsers();
Here is what is happening here:
  1. The child_process module allows executing shell commands, while util provides the promisify utility to convert callback-based functions to Promise-based functions.
  1. getCypressInfo function: This function will read the info from the console by executing npx cypress info and return it as a string.
  1. parseBrowsers function: This function parses the string to extract the names of the available browsers. It filters the output lines that start with "- Name:" splits and extract the browser name.
  1. runCypressTests function: This function runs the Cypress tests on a specific browser. It executes the npx cypress run --browser <browser> command using child_process.exec and logs the output to the console.
  1. runTestsOnAllBrowsers function: This is the main function that orchestrates this symphony of test execution on all available browsers. It does the following:
We are adding the "electron" browser to the list, since it's bundled with Cypress CLI by default.

Conclusion

We have explored the process of building a secure registration and login system for a web application using React Vite for the frontend and Node.js, Express.js, and MongoDB for the backend. We've covered various aspects of the project, including implementing security measures, defining user models and routes, integrating JSON Web Tokens (JWTs) for authentication and authorization, and setting up additional security features like CORS, data sanitization, rate limiting, and security headers.
We have implemented form validation using the react-hook-form library and yup, ensuring that user input is properly validated before submission. Additionally, we've incorporated React Router and custom hooks to handle navigation and access control, ensuring that only authenticated users can access protected routes.
By combining a secure backend, a user-friendly frontend, and thorough testing, we've created a reliable and scalable registration and login system that can be expanded and integrated into larger applications. The principles and techniques demonstrated in this project can be applied to a wide range of web development projects, ensuring that security and accessibility remain at the forefront of the development process.

Full code repository


Other project examples