Inventory management MERN App (With Authentication)
There are probably tutorials on how to this already but i just wanted to share my experience and hopefully help someone who is struggling with it.
Here is a list of the tech stack we will be using:
- NodeJS
- Express
- React
- Mongo DB
- Mongoose ORM
- Atlas Realm SDK
- GCP Authentication
Here is a set of tools that we will be utilizing:
- create-react-app package to kick things off but you can certainly use any boiler plate react toolkit/starter app if you choose
- realm-sdk for authentication
- redux and context api for state management
- jsonwebtoken for managing and storing cookies
There are two main components to this full stack app. Server and Client. Server will host the server set up, middlewares, API endpoints and connection/authentication code to our database. Client contains the front end implementation of the web app.
Environment Files: Each component has its own configuration. We will use dotenv package to access .env files. For the client environment variables create-react-app allows us to use REACT_APP_ prefix and use its contents similarly). For local communication between server and client we will be using a proxy configuration. For this simply add the following to package.json file of the client .
"proxy": "http://localhost:${SERVER_PORT_NUMBER}"
To get started you will need to create three different accounts if you don’t already have them (GCP is optional as this is just allows for an additional way to authenticate users).
- Mongo DB Atlas (https://www.mongodb.com/basics/mongodb-atlas-tutorial). Take your time to familiarize yourself with the interface and create a test database.
- Render Express Quick starter (https://render.com/docs/deploy-node-express-app)
- GCP Setup (https://www.mongodb.com/docs/atlas/app-services/authentication/google)
Now lets dive into the code
Server implementation in express
const mongoose = require("mongoose");
const express = require("express");
const path = require("path");
const cors = require("cors");
const morgan = require("morgan");
const axios = require("axios");
const routes = require("./routes/api");
require("dotenv").config() // to use process.env
const cp = require("cookie-parser")
const jwt = require("jsonwebtoken")
const PORT = process.env.PORT || 5001;
const app = express();
// for local development
app.use(cors());
// Init Middleware
app.use(express.json({ extended: false })); // for put, post requests
app.use(cp()) // for authorization
// (optional) only made for logging
app.use(morgan("dev"));
// api endpoints
app.use("/api", routes);
// reference data endpoints
app.get("/reference/currencies", (req, res, next) => {
const baseUrl = "http://api.exchangerate.host/latest?amount=1"
const apiKey = process.env.CUREENCY_API_KEY
axios(`${baseUrl}&symbols=USD,ALL&access_key=${apiKey}`)
.then(({ data }) => {
return res.json({ success: true, data })
}).catch(err => {
return res.status(400).json({ success: false, error: err})
})
});
// cookie management
app.post("/set/cookie", (req, res) => {
const token = jwt.sign(req.body.token, "inventory")
res.cookie('token', token, { maxAge: 900000, httpOnly: true });
res.send("Cookie set")
})
// can be used to restrict funcationality authorized users only
app.get("/get/cookie", (req, res) => {
const token = req.cookies.token
const payload = jwt.verify(token, "inventory")
return res.json({ token, payload })
})
// fallback middleware to handle malformed requests
app.use(function(err, req, res, next) {
res.status(422).send({ error: err.message });
});
// ES6 promises
mongoose.Promise = global.Promise;
// connects our back end code with the database
mongoose.connect(process.env.ATLAS_URI, {});
mongoose.connection
.once("open", () => console.log("connected to the database"))
.on("error", console.error.bind(console, "MongoDB connection error:"));
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '../front_end/build')));
}
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../front_end/build/index.html'));
})
// launch our backend into the specified port
app.listen(PORT, () => console.log(`LISTENING ON PORT ${PORT}`));
- We start by initializing the express app.
- Then we add the CORS middleware to allow cross origin requests.
- Next we add express.json() middleware which allows us to verify and parse json payloads from client requests.
- Next we enable promises for mongoose (*Note: this is actually deprecated for newer versions of mongoose, more info)
- Next initiate database connection (this needs to be done before registering CRUD API routes since they will be using mongoose models)
- Next we add cookie parser middleware to be able to parse any cookies attached to client requests.
- Next initiate logger for development environment
- Register api endpoints, reference data and other internal endpoints
- Finally serve main html and static build files
Client implementation
import React from 'react';
import ReactDOM from 'react-dom';
import { createBrowserHistory } from 'history';
import Router from './Router';
import NavBar from './NavBar';
import Footer from './Footer';
import {
isLoggedIn,
loginAnonymous,
loginWithKey,
logoutUser,
} from "./Realm"
import Login from "./Login"
export const history = createBrowserHistory();
class MyApp extends React.Component {
render() {
return isLoggedIn() ? (
<>
<NavBar handleLogout={logoutUser} isLoggedIn={isLoggedIn} /><br />
<Router history={history} />
<Footer />
</>
) : (
<>
<NavBar handleLogout={logoutUser} isLoggedIn={isLoggedIn} />
<br />
<Login loginAnonymous={loginAnonymous} loginWithKey={loginWithKey} />
<Footer />
</>
);
}
}
ReactDOM.render(<MyApp />, document.getElementById('root'));
- We start with a conditional render. If logged in render the authenticated dashboard with a logout action in the navbar. If not render a simple login page.
- There is a shared Navbar which contains different components based on whether the client is logged in or not.
- We also pass in the history to the Router component in case we want to implement breadcrumbs or a similar feature.
- Here is the Router Implementation:
import * as React from 'react';
import {
BrowserRouter, Route, Switch, Redirect,
} from 'react-router-dom';
import Dashboard from './App';
const Router = (props) => (
<BrowserRouter>
<Switch>
<Route exact path="/" render={() => <Redirect to="/dashboard" />} />
<Route
path="/dashboard"
render={() => (
<Switch>
<Route
exact
path="/dashboard"
render={(innerProps) => (
<Dashboard {...innerProps} />
)}
/>
<Route
exact
path="/some_other_route"
render={(innerProps) => (
<Other_Component {...innerProps} />
)}
/>
</Switch>
)}
/>
</Switch>
</BrowserRouter>
);
export default Router;
- Here is the Authentication component
import * as Realm from "realm-web";
const app = new Realm.App({ id: process.env.REACT_APP_REALM_APP_ID });
// Log in a user with the specified email and password
// Note: The user must already be registered with the Realm app.
// See https://docs.mongodb.com/Realm/authentication/userpass/#create-a-new-user-account
export async function loginEmailPassword(email, password) {
// Create an email/password credential
const credentials = Realm.Credentials.emailPassword(email, password);
// Authenticate the user
const user = await app.logIn(credentials);
// `App.currentUser` updates to match the logged in user
console.assert(user.id === app.currentUser.id);
return user;
}
// Log in a user anonymously.
// Note: When the user logs out, all data is lost.
// See https://docs.mongodb.com/Realm/authentication/anonymous/
export async function loginAnonymous() {
// Create an anonymous credential
const credentials = Realm.Credentials.anonymous();
// Authenticate the user
const user = await app.logIn(credentials);
// `App.currentUser` updates to match the logged in user
console.assert(user.id === app.currentUser.id);
return user;
}
// Log in a user using API key
// TO DO: In production we need to make sure this is moved to a config file
export async function loginWithKey(apiKey) {
// Create an API Key credential
const credentials = Realm.Credentials.apiKey(process.env.REACT_APP_REALM_API_KEY);
// Authenticate the user
const user = await app.logIn(credentials);
// `App.currentUser` updates to match the logged in user
console.assert(user.id === app.currentUser.id);
return user;
}
export function hasLoggedInUser() {
return app.auth.isLoggedIn
}
export function getAllUsers() {
// Return a list of all users that are associated with the app
return app.auth.listUsers()
}
export async function logoutUser() {
// aletrnative way to fetch instance
// const appInstance = Realm.App.getApp("stock_viewer-szhrv");
// Log a user out of the app. Logged out users are still associated with
// the app and will appear in the result of app.auth.listUsers()
const userId = Object.keys(app.allUsers)[0]
const user = app.allUsers[userId]
await user.logOut();
await app.removeUser(user)
window.location = "/" // redirect home
}
export function isLoggedIn() { return app.users.length }
export { app }
- The important piece here is to initialize the Realm App. See documentation above to set this up. The initialized object is an instance of the Realm SDK which allows us to get a list of users, their corresponding status and different ways to authenticate or verify authentication.
- Realm also has a useful logout method and remove user to clean up a user session.
- And this is App.js implementation (i.e: the main dashboard)
import React, { useState, useEffect } from "react";
import { Provider } from "react-redux";
import { Redirect } from "react-router";
import { withTranslation } from "react-i18next";
import { Container, Row, Col, Input, Alert} from "reactstrap";
import "../i18n";
import store from '../redux/store'
import { isLoggedIn } from "../Realm";
import { useTheme } from '../Themes/ThemeContext';
import Inventory from "../Components/Inventory/";
import EditArticle from "../Components/EditArticle"
import "bootstrap/dist/css/bootstrap.min.css";
import "./styles.css";
const App = ({ i18n }) => {
const [alertOpen, setAlertOpen] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [language, setLanguage] = useState("en")
const { currentTheme } = useTheme();
useEffect(() => {
i18n.changeLanguage(language)
}, [language])
const onLanguageHandle = (event) => {
const newLang = event.target.value;
setLanguage(newLang);
};
const handleAlert = () => {
setAlertOpen(!alertOpen);
};
if (!isLoggedIn()) {
return <Redirect to="/login" />;
}
return (
<Provider store={store}>
<div style={{ color: currentTheme.text }}>
<Container>
<Alert
isOpen={alertOpen}
toggle={handleAlert}
color="success"
fade={true}
>
{alertMessage}
</Alert>
<Row>
<Col lg="6" sm="12" xs="12">
<Inventory />
</Col>
<EditArticle
setAlertOpen={setAlertOpen}
setAlertMessage={setAlertMessage}
/>
</Row>
</Container>
</div>
</Provider>
);
};
export default withTranslation()(App);
- We are using redux for shared state between Inventory and Data modification form.
- We are also using a custom Theme hook which is based on react’s context API.
- And we are using elements from react-strap which is a bootsrap library for react.
- withTranslation is a HOC (Higher Order Component) That implements translation functionality and wraps the main app so that it can be utilized (notice the t in the props)
- Here is a basic implementation for the new reducer.
import { createSlice } from '@reduxjs/toolkit'
export const pageSlice = createSlice({
name: 'page',
initialState: {
pageData: [],
currentPage: 0,
totalCount: 0,
recordsPerPage: 15
},
reducers: {
updatePage: (state, action) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.currentPage = action.payload
},
updatePageData: (state, action) => {
state.pageData = action.payload
},
updateTotalCount: (state, action) => {
state.totalCount = action.payload
},
},
})
export const {
updatePage,
updatePageData,
updateTotalCount
} = pageSlice.actions
// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched
export const incrementAsync = (amount) => (dispatch) => {
setTimeout(() => {
dispatch(incrementByAmount(amount))
}, 1000)
}
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state) => state.page.currentPage)`
export const selectPage = (state) => state.page.currentPage
export default pageSlice.reducer
- Notice the syntax here, reducer actions are already mapped to actions so no need to use the legacy connect api.
- The async is just an example of async updates to the store which we don’t need yet.
- Here is an example usage of this shared state:
import { useSelector, useDispatch } from 'react-redux'
const dispatch = useDispatch()
const pageData = useSelector((state) => state.page.pageData)
...
...
useEffect(() => {
axios
.get(`/api/articles?pageNumber=${currentPage}`)
.then(({ data }) => {
dispatch(updatePageData(data.data))
})
}, [currentPage])
This is a lot to process but i just wanted to provide a way of building a MERN stack app with latest version of react/react/mongo/atlas as of October 2023. I hope to convert everything to typescript add more features soon. Here is a link to the repo if you want to try on your development environment: https://github.com/Aldizh/retail_dashboard
For deployment you can specify the following on render:
Build Command: yarn build
Start COmmand: node back_end/server.js
Repository Link: YOUR_REMOTE_REPO_LINK
Here is the deployed app: Happy Coding!