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
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 aroundfetch
, 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
, andextraOptions
. 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 theendpoints
. Currently, we have no queries or mutations, but we will define them later in other sections of our code. In addition to theendpoints
, 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 thecreateApi
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.