MERN Stack Authentication: Building a Secure System from Scratch (Part 4)

Photo by FLY:D on Unsplash

MERN Stack Authentication: Building a Secure System from Scratch (Part 4)

Building queries and mutations via RTK Query, protecting routes from unauthorized access, and more essentials for wrapping up the frontend

Aug 25, 2023ยท

20 min read

Introduction

In this new article, we'll continue our series on building a complete login system using the MERN stack! In the last part, we started working on the frontend by writing code for several pages, integrating React Router, and establishing storage in Redux.

If you happened to miss the previous part of our series, no worries! You can catch up by checking it out here. And if you're new to the entire series, welcome aboard! You can find the whole series here, where we've already built the backend for this application and kickstarted the frontend. Happy learning ๐Ÿš€

Today, we're continuing from where we left off last time - exploring the Redux Toolkit. We've already set up our Redux storage, so now let's get the Redux Toolkit Query configured to send requests to our backend.

By the end of the series, we will complete the primary portion of the frontend for our application, which means that we are approaching the conclusion of this captivating series.

Configuring RTK Query

First, let's create a folder named api and include a file called apiSlice.ts inside. The code for this slice might seem overwhelming, so I'll break it down into parts and explain each one.

  • We'll start by setting up our base query, which is a function that fetches data from the server using the fetchBaseQuery() function. This function acts as a wrapper around fetch, simplifying the requests. Thus, consider the initial part of the code as a way to set up the default query function.

    Inside the function, we're giving it an object with a few parameters:

    • baseUrl - This is the base URL that all our future requests will start with. It'll be combined with any other URLs we use to send requests.

    • credentials - This helps us send and receive cookies to and from the server.

    • prepareHeaders function - This allows us to modify the request headers. In our case, we're adding an authorization token to each request.

// app/api/apiSlice.ts

import { BaseQueryApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { RootState } from "../store";

const baseQuery = fetchBaseQuery({
    baseUrl: "http://localhost:5000",
    credentials: "include",
    prepareHeaders: (headers: Headers, { getState }: Pick<BaseQueryApi, "getState">) => {
        const accessToken = (getState() as RootState).auth.accessToken;
        if (accessToken) {
            headers.set("Authorization", `Bearer ${accessToken}`);
        }
        return headers;
    }
});
  • Next, we are customizing our fetchBaseQuery using a wrapper function that accepts three arguments: args, api, and extraOptions. This function must return either data or error. You can learn more about fetchBaseQuery customization here.

    Essentially, we are sending the request within the wrapper, and if the request fails with a 403 error, which corresponds to an expired access token in our backend, we will send the request to the refresh token endpoint to renew the access token. If this request is successful, we will update the Redux state with the new access token and the same user data, and then attempt to send the request again. However, if unsuccessful, the user will need to log in again.

// app/api/apiSlice.ts

import { BaseQueryApi, fetchBaseQuery, createApi } from "@reduxjs/toolkit/query/react"
import { setCredentials, logout } from "../../features/auth/authSlice"
import { RootState } from "../store"

// Define the interface for the response type of the refresh request
// I.e. Server returns access token
interface IRefreshType {
    accessToken: string;
}

// Custom baseQuery function that handles refreshing the access token
const baseQueryReauth = async (args: any, api: any, extraOptions?: any) => {
    let result = await baseQuery(args, api, extraOptions);

    if (result?.error?.status === 403) {
        const refreshResult = await baseQuery("/auth/refresh", api, extraOptions);
        if (refreshResult?.data) {
            const user = api.getState().auth.user;
            api.dispatch(setCredentials({ accessToken: (refreshResult.data as IRefreshType).accessToken, user }));

            result = await baseQuery(args, api, extraOptions);
        } else {
            api.dispatch(logout());
        }
    } 

    return result;
};
  • Alright, the final step is to create the API slice itself by using the createApi() function, which automatically generates React hooks for any queries and/or mutations defined within the endpoints. Currently, we have no queries or mutations, but we will define them later in other sections of our code. In addition to the endpoints, we are providing the customized base query that manages the user's token refresh process.
// app/api/apiSlice.ts

import { createApi } from "@reduxjs/toolkit/query/react";

export const apiSlice = createApi({
    baseQuery: baseQueryReauth,
    endpoints: builder => ({})
});

Alright, that's all for the apiSlice! Just like we did with the authSlice, we'll need to include the reducer for the API slice in the store.

// app/store.ts

import { apiSlice } from "./api/apiSlice";

export const store = configureStore({
    reducer: {
        [apiSlice.reducerPath]: apiSlice.reducer,
        auth: authReducer
    },
    middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(apiSlice.middleware).concat(errorLogger),
    devTools: true
});

Internally, the createApi method calls the createSlice method to generate a reducer and corresponding action creators, as well as middleware that manages caching, invalidation, and so on. We need to add the generated reducer and middleware inside the configureStore.

In the last step of setting up RTK Query, we need to make endpoints for queries and mutations to send to the server. To do this, we'll create a new file called authApiSlice.ts in the features/auth folder.

Redux Toolkit allows the injection of additional endpoints using the injectEndpoints method. This is great when handling many endpoints that should be in different files.

Let's begin by creating the endpoint responsible for handling user login:

// features/auth/authApiSlice.ts

import { apiSlice } from "../../app/api/apiSlice"

export const authApiSlice = apiSlice.injectEndpoints({
    endpoints: builder => ({
        login: builder.mutation({
            query: credentials => ({
                url: "/auth/login",
                method: "POST",
                body: {...credentials}
            })
        })
    })
})

We are creating the mutation using builder.mutation(), in which we pass a query callback that constructs the request URL, specifies the HTTP method to use, and defines the request body.

Similarly, for the registration endpoint:

// features/auth/authApiSlice.ts

import { apiSlice } from "../../app/api/apiSlice"

export const authApiSlice = apiSlice.injectEndpoints({
    endpoints: builder => ({
        login: builder.mutation({
            query: credentials => ({
                url: "/auth/login",
                method: "POST",
                body: {...credentials}
            })
        }),
        register: builder.mutation({
            query: credentials => ({
                url: "/auth/register",
                method: "POST",
                body: {...credentials}
            })
        }),
    })
})

However, the logout endpoint will be slightly different. We do not only want to send the request to the server, but we also need to clear the Redux state after successfully fulfilling the request. This ensures that no saved data remains about the logged-out user.

// features/auth/authApiSlice.ts

import { logout } from "./authSlice";

export const authApiSlice = apiSlice.injectEndpoints({
    endpoints: builder => ({
        login: builder.mutation({
            query: credentials => ({
                url: "/auth/login",
                method: "POST",
                body: {...credentials}
            })
        }),
        register: builder.mutation({
            query: credentials => ({
                url: "/auth/register",
                method: "POST",
                body: {...credentials}
            })
        }),
        logout: builder.query<void, void>({
            query: () => "/auth/logout",
            async onQueryStarted(_, { dispatch, queryFulfilled }) {
                await queryFulfilled;
                dispatch(logout());
            }
        }),
    })
})

For this purpose, we use the builder.query() method (as the logout request is a GET request, not a POST), and we incorporate the onQueryStarted function, which, as the name suggests, is called every time a query is started.

The function is called with an object containing multiple properties:

- dispatch: the dispatch method for the store

- queryFulfilled: a Promise that either resolves with a data property or rejects with an error.

What we are doing here is waiting for the query to be fulfilled, and then dispatching the action to the store to log the user out.

I tried my best to explain the concept, but the documentation does an excellent job too! So, don't forget to check out that section for a better understanding.

Now, the complete code for the authApiSlice appears as follows. Keep in mind that createApi automatically generates React hooks based on the created endpoints. As you can see from the export statement at the end of the code, the naming pattern for these hooks is simple: "use" + endpointName + "Mutation"/"Query"

// features/auth/authApiSlice.ts

import { apiSlice } from "../../app/api/apiSlice";
import { logout } from "./authSlice";

export const authApiSlice = apiSlice.injectEndpoints({
    endpoints: builder => ({
        login: builder.mutation({
            query: credentials => ({
                url: "/auth/login",
                method: "POST",
                body: {...credentials}
            })
        }),
        register: builder.mutation({
            query: credentials => ({
                url: "/auth/register",
                method: "POST",
                body: {...credentials}
            })
        }),
        logout: builder.query<void, void>({
            query: () => "/auth/logout",
            async onQueryStarted(_, { dispatch, queryFulfilled }) {
                await queryFulfilled;
                dispatch(logout());
            }
        }),
    })
})


export const { 
    useLoginMutation, 
    useRegisterMutation, 
    useLogoutQuery
} = authApiSlice;

Finally, I would like to add error handling. We will create a middleware responsible for catching any errors caused by our queries or mutations.

To display the messages, we will use React Toastify, a dedicated library for displaying toast notifications. It is incredibly easy to set up, and I have recently published a thread about it on Twitter/X as well:

First, we need to install React Toastify:

npm install react-toastify

Next, within the App component, we need to include the ToastContainer and import the required CSS styles for the library to function correctly:

// App.tsx

import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

const App = () => {
    return (
        <>
            <ToastContainer 
                autoClose={3000}
            />
            <Routes>
                <Route path="/login" element={<Login />} />
                <Route path="/register" element={<Register />} />
                <Route path="/logout" element={<Logout />} />
            </Routes>
        </>
    );
};

export default App;

If you're looking for more info on configuring the ToastContainer, you can find all the details in the documentation.

Next up, let's set up the middleware itself. Just create a file called errorLoggerMiddleware.ts inside the app/api folder.

// app/api/errorLoggerMiddleware.ts

import { isRejectedWithValue } from "@reduxjs/toolkit";
import type { MiddlewareAPI, Middleware } from "@reduxjs/toolkit";
import { toast } from "react-toastify";

export const errorLogger: Middleware = (api: MiddlewareAPI) => (next) => (action) => {
    if (isRejectedWithValue(action)) {
        toast.error(action.payload.data.message);
    }

    return next(action);
};

I know we've covered a lot today, so I won't go too deep into error handling right now. But if you're curious to learn more, the docs do a great job explaining everything!

Lastly, we need to define this middleware in our store.ts file:

// app/store.ts

import { errorLogger } from "./api/errorLoggerMiddleware";

export const store = configureStore({
    reducer: {
        [apiSlice.reducerPath]: apiSlice.reducer,
        auth: authReducer
    },
    middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(apiSlice.middleware).concat(errorLogger),
    devTools: true
});

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

Wow ๐Ÿ˜ฎโ€๐Ÿ’จ That was a lot of code, but we've successfully set up both storage and data fetching tools using Redux Toolkit.

Now, it's time to use them within our app.

Improving Authentication Pages

Let's return to where we began our frontend journey: the Login and Register pages. However, now we will manage sending the requests to the server. To accomplish this, we will update our handleSubmit code.

// pages/Register.tsx

import { useNavigate } from "react-router-dom";
import { useDispatch } from "react-redux";
import { AppDispatch } from "../app/store";
import { setCredentials } from "../features/auth/authSlice";
import { useState } from "react";
import { useRegisterMutation } from "../features/auth/authApiSlice";

const Register = () => {
    // Initialize action dispatcher
    const dispatch = useDispatch<AppDispatch>();
    // Navigating function
    const navigate = useNavigate();
    const [formData, setFormData] = useState<IForm>({
        email: "",
        password: "",
        firstName: "",
        lastName: ""
    });
    // Auto-generated hook to get "trigerring function"
    const [register] = useRegisterMutation();

    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        // Send the registration request to the server
        const result = await register(formData).unwrap();
        dispatch(setCredentials({user: result.user, accessToken: result.accessToken}));
        navigate("/");
    };

    return (
        // Rest of the code...
    )
}

Alright, there are quite a few changes in our code! Let me guide you through them step by step:

  • We'll begin by defining the state that holds the form data (we've already done this, but I'll include it here since it's important).

  • We'll dispatch the actions to the store using the useDispatch hook.

  • We're using the useRegisterMutation hook provided by the createApi method. It returns an array containing a function that triggers the mutation, as well as an object containing properties about the mutation result. In our case, we'll only use the former.

  • Next, we're using the useNavigate function to obtain the function that allows us to navigate programmatically through the application. For example, we can redirect the user to another route (which is precisely what we're going to do).

  • We'll then update the handleSubmit function. We're sending the register mutation with a request body that includes all the input data from the user.

  • Afterward, we obtain the response using the unwrap property, and we dispatch the data from the response to the Redux store, which includes the user's data and the access token.

  • Upon successful registration, the user is redirected to the home page, which we have yet to create.

The complete code for the Register page appears as follows:

// pages/Register.tsx

import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
import { useNavigate } from "react-router-dom";
import { useDispatch } from "react-redux";
import { AppDispatch } from "../app/store";
import { setCredentials } from "../features/auth/authSlice";
import { useState } from "react";
import { useRegisterMutation } from "../features/auth/authApiSlice";

const Register = () => {
    const dispatch = useDispatch<AppDispatch>();
    const navigate = useNavigate();
    const [formData, setFormData] = useState<IForm>({
        email: "",
        password: "",
        firstName: "",
        lastName: ""
    });
    const [register] = useRegisterMutation();

    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        const result = await register(formData).unwrap();
        dispatch(setCredentials({user: result.user, accessToken: result.accessToken}));
        navigate("/");
    };

    return (
        <Container component="main" maxWidth="xs">
            <Box
                sx={{
                    marginTop: 8,
                    display: "flex",
                    flexDirection: "column",
                    alignItems: "center",
                }}
            >
                <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
                    <LockOutlinedIcon />
                </Avatar>
                <Typography component="h1" variant="h5">
            Sign up
                </Typography>
                <Box
                    component="form"
                    noValidate
                    onSubmit={handleSubmit}
                    sx={{ mt: 3 }}
                >
                    <Grid container spacing={2}>
                        <Grid item xs={12} sm={6}>
                            <TextField
                                autoComplete="given-name"
                                name="firstName"
                                required
                                fullWidth
                                id="firstName"
                                label="First Name"
                                autoFocus
                                value={formData.firstName}
                                onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
                            />
                        </Grid>
                        <Grid item xs={12} sm={6}>
                            <TextField
                                required
                                fullWidth
                                id="lastName"
                                label="Last Name"
                                name="lastName"
                                autoComplete="family-name"
                                value={formData.lastName}
                                onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
                            />
                        </Grid>
                        <Grid item xs={12}>
                            <TextField
                                required
                                fullWidth
                                label="Email"
                                name="email"
                                autoComplete="email"
                                value={formData.email}
                                onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}

                            />
                        </Grid>
                        <Grid item xs={12}>
                            <TextField
                                required
                                fullWidth
                                name="password"
                                label="Password"
                                type="password"
                                id="password"
                                autoComplete="new-password"
                                value={formData.password}
                                onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
                            />
                        </Grid>
                    </Grid>
                    <Button
                        type="submit"
                        fullWidth
                        variant="contained"
                        sx={{ mt: 3, mb: 2 }}
                    >
              Sign Up
                    </Button>
                    <Grid container justifyContent="flex-end">
                        <Grid item>
                            <Box component={Link} to="/login">
                  Already have an account? Sign in
                            </Box>
                        </Grid>
                    </Grid>
                </Box>
            </Box>
        </Container>
    );
};

The registration page will appear as follows:

Next, we need to apply fairly similar changes to the Login page:

// pages/Login.tsx

const Login = () => {
    const [formData, setFormData] = useState<IForm>({
        email: "",
        password: ""
    });
    const dispatch = useDispatch<AppDispatch>();
    const navigate = useNavigate();
    const [login] = useLoginMutation();

    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        const result = await login(formData).unwrap();
        dispatch(setCredentials({ user: result.user, accessToken: result.accessToken }));
        navigate("/");
    };

    return (
        // Rest of the code...
    )
}

You might have noticed that we're doing something quite similar here! The only difference is that this time, we're logging the user in by using another generated hook. Here's how the entire code for the Login component looks like:

// pages/Login.tsx

import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
import { Link, useNavigate } from "react-router-dom";
import { useState } from "react";
import { useDispatch } from "react-redux";
import { AppDispatch } from "../app/store";
import { setCredentials } from "../features/auth/authSlice";
import { useLoginMutation } from "../features/auth/authApiSlice";

const Login = () => {
    const [formData, setFormData] = useState<IForm>({
        email: "",
        password: ""
    });
    const dispatch = useDispatch<AppDispatch>();
    const navigate = useNavigate();
    const [login] = useLoginMutation();

    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        const result = await login(formData).unwrap();
        dispatch(setCredentials({ user: result.user, accessToken: result.accessToken }));
        navigate("/");
    };

    return (
        <Container component="main" maxWidth="xs">
            <Box
                sx={{
                    marginTop: 8,
                    display: "flex",
                    flexDirection: "column",
                    alignItems: "center",
                }}
            >
                <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
                    <LockOutlinedIcon />
                </Avatar>
                <Typography component="h1" variant="h5">
            Login
                </Typography>
                <Box
                    component="form"
                    onSubmit={handleSubmit}
                    noValidate
                    sx={{ mt: 1 }}
                >
                    <TextField
                        margin="normal"
                        required
                        fullWidth
                        label="Email"
                        name="email"
                        autoComplete="email"
                        autoFocus
                        value={formData.email}
                        onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
                    />
                    <TextField
                        margin="normal"
                        required
                        fullWidth
                        name="password"
                        label="Password"
                        type="password"
                        autoComplete="current-password"
                        value={formData.password}
                        onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
                    />
                    <Button
                        type="submit"
                        fullWidth
                        variant="contained"
                        sx={{ mt: 3, mb: 2 }}
                    >
              Login
                    </Button>
                    <Grid container>
                        <Grid item xs>
                            <Box component={Link} to="/forgot-password">
                  Forgot password?
                            </Box>
                        </Grid>
                        <Grid item>
                            <Box component={Link} to="/register">
                                {"Don't have an account? Sign Up"}
                            </Box>
                        </Grid>
                    </Grid>
                </Box>
            </Box>
        </Container>
    );
};

export default Login;

This is the final appearance of the login page:

Awesome ๐Ÿš€ Now let's move on to creating the "home" page where users will land after they've successfully logged in. We'll be adding some neat features along the way, like route protection, authorization, and building a few more endpoints using RTK Query!

Creating Home Page

Let's create the Home.tsx file inside the pages folder with the following code:

// pages/Home.tsx

import { useSelector } from "react-redux";
import { selectUser } from "../features/auth/authSlice";
import { Navbar } from "../components/ui/Navbar";

const Home = () => {
    const user = useSelector(selectUser);

    return (
        <>
            <Navbar />
            <Container>
                <Paper elevation={3} sx={{ mt: 4, p: 3 }}>
                    <Typography variant="h2" gutterBottom>
              Welcome, {user?.firstName + " " + user?.lastName}
                    </Typography>
                    <List>
                        <ListItem>
                            <ListItemText primary={`Email: ${user?.email}`} />
                        </ListItem>
                        <ListItem>
                            <ListItemText primary={`First Name: ${user?.firstName}`} />
                        </ListItem>
                        <ListItem>
                            <ListItemText primary={`Last Name: ${user?.lastName}`} />
                        </ListItem>
                    </List>
                </Paper>
            </Container>
        </>
    );
};

export default Home;

Within the Home component, we are getting data from the store using the useSelector hook and the selectUser function to access the data from the state. If you've been paying attention, you'll recall that this function was previously created within our authSlice. But I'll write it again:

export const selectUser = (state: RootState) => state.auth.user;

Essentially, we are displaying a welcome message to the user and providing a few navigation options within the app.

The Navbar component is a simple custom navigation bar that I created inside the new components/ui folder, and it appears as follows:

// components/ui/Navbar.tsx

import { Link } from "react-router-dom";

const pages = ["Home", "Admin", "Logout"];
const links = ["/", "/admin", "/logout"];

export const Navbar = () => {
    return (
        <AppBar position="static">
            <Container maxWidth="xl">
                <Toolbar disableGutters>
                    <LockOutlinedIcon
                        sx={{ display: "flex", mr: 1 }}
                    />
                    <Typography
                        variant="h6"
                        noWrap
                        component="a"
                        sx={{
                            mr: 2,
                            display: "flex",
                            fontFamily: "monospace",
                            fontWeight: 700,
                            letterSpacing: ".3rem",
                            color: "inherit",
                            textDecoration: "none",
                        }}
                    >
              AUTH
                    </Typography>
                    <Box sx={{ flexGrow: 1, display: "flex" }}>
                        {pages.map((page, index) => (
                            <Box
                                key={page}
                                component={Link}
                                to={links[index]}
                                sx={{ my: 2, color: "white", display: "block", mr: 3 }}
                            >
                                {page}
                            </Box>
                        ))}
                    </Box>
                </Toolbar>
            </Container>
        </AppBar>
    );
};

I won't go into too much detail about this code, since you can easily find all the info on Material UI components in their documentation. In a nutshell, I'm just using a slightly modified Appbar component from Material UI.

So, when the user is logged in, the whole home page looks like this:

Alright, let's assume we want to protect the specified route against unauthorized users, meaning only authenticated users can access this page. That's where protected routes come into play! ๐Ÿซก

Implementing Protected Routes for Secure Access

Protected routes function in a very simple manner. They serve as a wrapper for all the routes we want to protect. Since only authenticated users can obtain the access token, this wrapper checks if the user has the access token. If they do, it redirects the user to the desired page; if not, it redirects them to the login page.

In the components folder, let's create a new folder called routes and include a file named ProtectedRoute.tsx within it:

// components/routes/ProtectedRoute.tsx

import { useSelector } from "react-redux";
import { selectToken } from "../../features/auth/authSlice";
import { Outlet, Navigate } from "react-router-dom";

const ProtectedRoute = () => {
    const accessToken = useSelector(selectToken);

    return accessToken ? <Outlet /> : <Navigate to="/login" replace />;
};

export default ProtectedRoute;

We're using the useSelector to access the Redux state, specifically the access token. If the user has the token, we'll use the Outlet component from React Router.

This component is used in the parent route elements to render their child route elements. So, if we make ProtectedRoute the parent of all the routes we want to protect, they'll automatically become its children and will be shown only if the user is logged in. You can learn more about Outlet component here.

However, if the user doesn't have the token, they'll be redirected to the login page.

Now, let's go ahead and wrap the route for the Home component with the ProtectedRoute inside the App component:

// App.tsx

import { Route, Routes } from "react-router-dom";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Logout from "./pages/Logout";
import Home from "./pages/Home";
import CssBaseline from "@mui/material/CssBaseline";
import ProtectedRoute from "./components/routes/ProtectedRoute";

const App = () => {
    return (
        <>
            <ToastContainer 
                autoClose={3000}
            />
            <CssBaseline />
            <Routes>
                <Route path="/login" element={<Login />} />
                <Route path="/register" element={<Register />} />
                <Route path="/logout" element={<Logout />} />

                <Route element={<ProtectedRoute />}>
                    <Route path="/" element={<Home />} />
                </Route>
            </Routes>
        </>
    );
};

export default App;

Setting Up Admin Page with Role-Based Authorization

Now, let's focus on authorization, i.e., the access rights provided to each user. Previously, the Home page could be accessed by any authenticated user. Now, let's create a new page called Admin that can be accessed only by users who have the role of administrator.

Before diving into the frontend, I need to remind you of what we've done on the backend side of the application. We defined a special route that returns a list of all users in the database as a response and ensured that only users with the admin role can send a request to this route. To achieve the latter, we created a special middleware:

// middleware/verifyRolesMiddleware.ts

import { Response, NextFunction } from "express";
import { AuthRequest } from "./authMiddleware";

export const verifyRolesMiddleware = (roles: string[]) => (req: AuthRequest, res: Response, next: NextFunction) => {
    if (req.user && roles.includes(req.user.role)) {
        next();
    } else {
        res.status(401).json({ message: "Unauthorized"});
    }
};

We could create a new wrapper specifically for routes that can only be accessed by admins. However, this time I want to take a different approach.

We'll simply allow all authenticated users to access the Admin page, but if sending the request results in an Unauthorized error, we'll display the error message, and the user can return to the Home page.

In a real app, I would probably go with the wrapper approach and protect the route on the client side, as it seems to provide better user interaction. But for now, let's embrace our powerful backend ๐Ÿ˜ƒ

First, we need to revisit the RTK Query in order to set up fetching the list of users from the server. To do this, let's create a new folder called users inside the features directory, and a file named usersApiSlice.ts within it.

// features/users/usersApiSlice.ts

import { apiSlice } from "../../app/api/apiSlice";

export const usersApiSlice = apiSlice.injectEndpoints({
    endpoints: builder => ({
        getUsers: builder.query<IUser[], void>({
            query: () => "/users",
            keepUnusedDataFor: 30
        }),
    })
});

export const { useGetUsersQuery } = usersApiSlice;

We're returning to the injectEndpoints method to make a new query called getUsers, which gives us a list of users. In builder.query(), we create the request URL. We also pass the keepUnusedDataFor property, which tells us how long to keep data in the cache. This isn't required, but it's helpful for testing.

For example, if we set a short expiration period for the access token and a short period for retaining cached data, we force our application to send frequent GET requests to fetch new data. Since we need to provide the access token with each request, we can easily verify whether the token renewal functions correctly, as it expires rapidly. This is a small tip that you can use for testing purposes. Nothing more ๐Ÿ™‚

Now, letโ€™s create the Admin page inside pages folder:

// pages/Admin.tsx

import { useGetUsersQuery } from "../features/users/usersApiSlice";
import { Navbar } from "../components/ui/Navbar";


const Admin = () => {
    const { data: users, isSuccess} = useGetUsersQuery();

    return (
        <>
            <Navbar />
            { isSuccess && 
            <Container>
                <Paper elevation={3} sx={{ mt: 4, p: 3 }}>
                    <Typography variant="h2" gutterBottom>
                        Admin Dashboard
                    </Typography>
                    <List>
                        {users.map((user: IUser, index: number) => (
                            <ListItem key={index}>
                                <ListItemText
                                    primary={`${index+1}: ${user?.firstName} ${user?.lastName}`}
                                />
                            </ListItem>
                        ))}
                    </List>
                </Paper>
            </Container>
            }
        </>
    );
};

export default Admin;

So, in the Admin component, we are sending a GET request to our backend to obtain a list of all users. If the request is successful, we will display the list of users to the admin.

As you may recall, we previously implemented error logging middleware to display "toastified" messages whenever an error occurs. To refresh your memory, the code for this is as follows:

// app/api/errorLoggerMiddleware.ts

import { isRejectedWithValue } from "@reduxjs/toolkit";
import type { MiddlewareAPI, Middleware } from "@reduxjs/toolkit";
import { toast } from "react-toastify";

export const errorLogger: Middleware = (api: MiddlewareAPI) => (next) => (action) => {
    if (isRejectedWithValue(action)) {
        toast.error(action.payload.data.message);
    }

    return next(action);
};

If there's any issue with getting the user list, like if a regular user tries to access it, we'll show a message letting them know that only admins can view the page.

And voila! ๐ŸŽ‰ You've got a basic but functional auth system with different roles. Of course, this is just a sketch that you can expand upon in your actual application. However, I hope that it has been truly helpful for you :)

But it's not over yet. What if our user forgets their password? Oops, we don't have a way to reset it ๐Ÿ˜„

If you look at any website, the authentication system MUST include a "Forgot Password" feature. That's precisely what we'll implement in the next blog post: password resetting. I'll show you how to create a unique token for resetting the password, send an email to the user with a reset activation link, and actually change the user's password.

Conclusion

Wow, we're just one step away from wrapping up this entire series! ๐Ÿ”ฅ

In this article, we went through the process of completing the frontend for our MERN stack authentication system. We used RTK Query for requests to our backend, learned how to protect routes against unauthorized users, enabled error handling, and more!

In the next article, we'll add a "Forgot Password" feature to our authentication system. Don't miss it as we keep improving our MERN stack authentication system together! ๐Ÿš€

As usual, if you encounter any difficulties along the way, you can refer to the complete source code available on my GitHub repository. If you have any questions or need assistance, don't hesitate to reach out to me. Feel free to contact me, even if it's not related to this project.

If you enjoyed this article, remember to follow me on Twitter/X, where I share daily updates. And feel free to connect with me on LinkedIn.

ย