Building a Gamified Habit Tracker on MERN Stack and Hanko for passkey authentication
Tags: javascript, webdev, programming, tutorial
Published: 2023-10-31 18:06:19

## Introduction
In this tutorial, we will create a gamified habit tracker using the MERN stack. We'll leverage MongoDB for the database, Express.js for the backend, React for the front end, and Node.js for the server. 
Additionally, we'll incorporate Hanko for passkey authentication. Hanko is a free and open-source solution to integrate passkey authentication, seamless and secure.


The project is divided into two repositories:
- frontend: https://github.com/momentumXbyLakshya/react-client
- backend: https://github.com/momentumXbyLakshya/express-server

### Backend Setup

**Prerequisites**

Node.js: Ensure you have Node.js installed (preferably >= `v18`). You can download it from [here](https://nodejs.org/).

**Clone the repository** 
```bash
git clone https://github.com/momentumXbyLakshya/express-server
```
**Navigate to the project folder**
```bash
cd express-server
```
**Setting up environment**

a. Create a `.env` file in the root directory and paste the below snippet in that:
```
MONGODB_URI=_
HANKO_API_URI=_
JWT_SECRET=_
PORT=8080
FRONTEND_URL=http://localhost:3000
```
b. Create a MongoDB Cluster on <a href="https://www.mongodb.com/atlas/database">MongoDB Atlas</a> and configure it such that it allows localhost to connect. Replace the `_` in front of `MONGODB_URI` with your database's URI. <br />
c.You must create a <a href="https://cloud.hanko.io/">Hanko Cloud Project</a>. Sign to this platform and create a project specifying the front end of the project as `http://localhost:3000`. Replace the `_` in front of `HANKO_API_URI` with your project's URL. <br />
d. Replace the `_` in front of `JWT_SECRET` with any secret of your choice.

**Install the dependencies**
```bash
npm install
```

**Start the server**
```bash
npm start
```

If everything is setup correctly, you will this log on your terminal:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bfftsvosqwxmn0yulvcm.png)

### Backend walkthrough 

You will see the following files in your project repo

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nk10avpr0vvr1i9wgotb.png)

**Let's go through these files**
- **index.js** The starting point of the application. We import middleware, router, database setup file, cronjob, and everything in this file.
```
import express from "express";
import "dotenv/config";
import cookieParser from "cookie-parser";
import cors from "cors";
import router from "./routes/index.js";
import { isAuthenticated } from "./middleware/auth.js";

import "./db.setup.js";
import "./cronjobs/habit.js";

const app = express();
const PORT = process.env.PORT || 8080;

app.use(
  cors({
    origin: process.env.FRONTEND_URL,
    credentials: true,
  })
);
app.use(cookieParser());
app.use(express.json());

app.use(isAuthenticated);

app.use("/api", router);

app.listen(PORT, (err) => {
  console.log(`Server running on port ${PORT}`);
});
```
- db.setup.js: Use Mongoose to connect to MongoDB database.
- routers: Define API endpoints for REST API
- controllers: Define handlers for every request to router
- models: Define a Mongoose schema and model to store data in the MongoDB database
- services: Define functions that will interact with the database and return to the controller
- cronjobs: Scheduled functions that run on specified time or period
- middleware: Functions that execute after a request is received and before it is passed to the controller

**Authorization middleware**
```
import * as jose from "jose";

const JWKS = jose.createRemoteJWKSet(
  new URL(`${process.env.HANKO_API_URI}/.well-known/jwks.json`)
);

export const isAuthenticated = async (req, res, next) => {
  let token = null;
  if (
    req.headers.authorization &&
    req.headers.authorization.split(" ")[0] === "Bearer"
  ) {
    token = req.headers.authorization.split(" ")[1];
  } else if (req.cookies && req.cookies.hanko) {
    console.log("hanko", req.cookies.hanko);
    token = req.cookies.hanko;
  }
  if (token === null || token.length === 0) {
    res.status(401).send("Unauthorized");
    return;
  }
  let authError = false;
  await jose.jwtVerify(token, JWKS).catch((err) => {
    authError = true;
    console.log(err);
  });
  if (authError) {
    res.status(401).send("Authentication Token not valid");
    return;
  }
  next();
};
```
- The `isAuthenticated` middleware checks if the request is coming with a `hanko` token either in cookies or in headers.
- It then verifies the token using a remote JSON Web key set.

## Frontend 

**Prerequisites**

Node.js: Ensure you have Node.js installed (preferably >= `v18`). You can download it from [here](https://nodejs.org/).
Backend: Ensure the backend server is up and running

**Clone the repository** 
```bash
git clone https://github.com/momentumXbyLakshya/react-client
```
**Navigate to the project folder**
```bash
cd react-client
```
**Setting up environment variables**
Create a `.env.local` file in the root directory and paste the following snippet in that file.
```
REACT_APP_HANKO_API_URI=_
REACT_APP_BACKEND_URI=http://localhost:8080
```
Replace the `_` with the Hanko Cloud API URL that you should have created while setting up the backend server.

**Install the dependencies**
```bash
npm install
```
**Start the app**
```bash
npm start
```

### Frontend walkthrough
The project structure of react-app looks like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ijw5f6meni99fzl3faux.png)
- App.js: The first component that mounts into the DOM. 
```
import { useContext, useEffect, useMemo } from "react";
import { Routes, Route, useNavigate } from "react-router-dom";
import { useCookies } from "react-cookie";
import { register, Hanko } from "@teamhanko/hanko-elements";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

import Home from "./pages/Home";
import Dashboard from "./pages/Dashboard";
import Register from "./pages/Register";
import { axiosInstance } from "./lib/axios";

import AppContext from "./store/app-context";
import Loader from "./components/Loader";

const hankoApi = process.env.REACT_APP_HANKO_API_URI;

function App() {
  const navigate = useNavigate();
  const [cookies] = useCookies("hanko");
  const {
    isAuthenticated,
    authToken,
    appLoading,
    setAppLoading,
    handleCompleteUserAuth,
    handlePartialUserAuth,
    handleNewUserAuth,
    handleLogout,
  } = useContext(AppContext);
  const hanko = useMemo(() => new Hanko(hankoApi), []);

  useEffect(() => {
    hanko.onAuthFlowCompleted(async (detail) => {
      if (isAuthenticated) {
        navigate("/dashboard");
      } else if (authToken) {
        navigate("/register");
      } else {
        setAppLoading(true);
        const session = hanko.session.get();
        const hankoUser = await hanko.user.getCurrent();
        const token = session.jwt;
        axiosInstance
          .get(`/user/${detail.userID}`)
          .then((res) => {
            const user = res.data.data.user;
            if (user && user.name && user.avatar) {
              // already registered user
              handleCompleteUserAuth(token, user);
              navigate("/dashboard");
            } else if (user && user.hankoId && user.email) {
              // user was authentiated with hanko before, but profile needs to be completed
              handlePartialUserAuth(token, user);
              navigate("/register");
            } else {
              // new user
              handleNewUserAuth(token, hankoUser.id, hankoUser.email);
            }
            setAppLoading(false);
          })
          .catch(() => {
            toast.error("Something went wrong!");
            setAppLoading(false);
          });
      }
    });
    hanko.onSessionExpired(() => {
      toast("Session Expired. Please Login again.");
      handleLogout();
      navigate("/");
    });
  }, [
    authToken,
    isAuthenticated,
    hanko,
    navigate,
    handleCompleteUserAuth,
    handlePartialUserAuth,
    handleNewUserAuth,
    handleLogout,
  ]);

  useEffect(() => {
    register(hankoApi).catch((error) => {
      console.log("Something went wrong in Hanko Authentication");
    });

    const setStateBasedOnUser = async () => {
      setAppLoading(true);
      const session = hanko.session.get();
      const hankoUser = await hanko.user.getCurrent();
      const token = session.jwt;
      axiosInstance
        .get(`/user/${hankoUser.id}`)
        .then((res) => {
          const user = res.data.data.user;
          if (user && user.name && user.avatar) {
            // already registered user
            handleCompleteUserAuth(token, user);
            setAppLoading(false);
          } else if (user && user.email && user.hankoId) {
            handlePartialUserAuth(token, user);
            setAppLoading(false);
          }
        })
        .catch((err) => {
          console.log(err);
          toast("Something went wrong!");
          setAppLoading(false);
        });
    };

    if (cookies && cookies.hanko) {
      setStateBasedOnUser();
    }
  }, [cookies, hanko, handleCompleteUserAuth, handlePartialUserAuth]);

  if (appLoading) {
    return <Loader />;
  }

  return (
    <>
      <Routes>
        <Route path="/" index element={<Home />}></Route>
        {authToken && !isAuthenticated && (
          <Route path="/register" element={<Register />}></Route>
        )}
        {isAuthenticated && (
          <Route path="dashboard" element={<Dashboard hanko={hanko} />} />
        )}
      </Routes>
      <ToastContainer />
    </>
  );
}

export default App;
```

- store: Store contains the context-api for the app. Below the `AppProvider.js` component.
```
import { useCallback, useState } from "react";
import AppContext from "./app-context";
import { useNavigate } from "react-router-dom";
import { axiosInstance } from "../lib/axios";
import { toast } from "react-toastify";

const AppContextProvider = ({ children }) => {
  const navigate = useNavigate();
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [user, setUser] = useState(null);
  const [authToken, setAuthToken] = useState(null);
  const [hankoDetails, setHankoDetails] = useState({
    userId: null,
    email: null,
  });
  const [appLoading, setAppLoading] = useState(false);

  const handleCompleteUserAuth = useCallback((token, user) => {
    setIsAuthenticated(true);
    setAuthToken(token);
    setUser(user);
    setHankoDetails({
      userId: user.hankoId,
      email: user.email,
    });
  }, []);

  const handlePartialUserAuth = useCallback((token, user) => {
    console.log("handlePartialUserAuth called ");
    setAuthToken(token);
    setHankoDetails({
      userId: user.hankoId,
      email: user.email,
    });
  }, []);

  const handleLogout = useCallback(() => {
    setAuthToken(null);
    setIsAuthenticated(false);
    setUser(null);
    setHankoDetails({ userId: null, email: null });
  }, []);

  const handleAddHabit = (habit) => {
    const newUser = { ...user };
    newUser.habits.push(habit);
    setUser(newUser);
  };

  const handleUpdateHabit = (index, habit) => {
    const newUser = { ...user };
    newUser.habits[index] = habit;
    setUser(newUser);
  };

  const handleDeleteHabit = (index) => {
    const newUser = { ...user };
    newUser.habits = newUser.habits.filter((hab, i) => i !== index);
    setUser(newUser);
  };

  const handleNewUserAuth = useCallback(
    (token, hankoId, email) => {
      setAuthToken(token);
      setHankoDetails({
        userId: hankoId,
        email,
      });
      axiosInstance
        .post(`/user/`, {
          hankoId,
          email,
        })
        .then(() => {
          navigate("/register");
        })
        .catch(() => {
          toast.error("Something went Wrong");
        });
    },
    [navigate]
  );

  const handleRegisterComplete = (name, avatar) => {
    setAppLoading(true);
    axiosInstance
      .put(`/user/${hankoDetails.userId}`, {
        name,
        hankoId: hankoDetails.userId,
        email: hankoDetails.email,
        avatar,
      })
      .then((res) => {
        const user = res.data.data.user;
        setIsAuthenticated(true);
        setUser(user);
        navigate("/dashboard");
        setAppLoading(false);
      })
      .catch(() => {
        setAppLoading(false);
        toast.error("Something went wrong");
      });
  };

  const appContext = {
    isAuthenticated,
    authToken,
    hankoDetails,
    user,
    setUser,
    appLoading,
    setAppLoading,
    handleCompleteUserAuth,
    handleNewUserAuth,
    handlePartialUserAuth,
    handleRegisterComplete,
    handleLogout,
    handleAddHabit,
    handleUpdateHabit,
    handleDeleteHabit,
  };

  return (
    <AppContext.Provider value={appContext}>{children}</AppContext.Provider>
  );
};

export default AppContextProvider;
```

- pages: There are three pages in the app, Home, Register, and Dashboard.

### Future Goals

Some Upcoming features on the projects:
- Accessories for the Avatar based on it's level
- More Avatars in User Registration


### Thank you for reading