DEV Community

Lakshya Satpal
Lakshya Satpal

Posted on

Building a Gamified Habit Tracker on MERN Stack and Hanko for passkey authentication

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:

Backend Setup

Prerequisites

Node.js: Ensure you have Node.js installed (preferably >= v18). You can download it from here.

Clone the repository

git clone https://github.com/momentumXbyLakshya/express-server
Enter fullscreen mode Exit fullscreen mode

Navigate to the project folder

cd express-server
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

b. Create a MongoDB Cluster on MongoDB Atlas and configure it such that it allows localhost to connect. Replace the _ in front of MONGODB_URI with your database's URI.

c.You must create a Hanko Cloud Project. 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.

d. Replace the _ in front of JWT_SECRET with any secret of your choice.

Install the dependencies

npm install
Enter fullscreen mode Exit fullscreen mode

Start the server

npm start
Enter fullscreen mode Exit fullscreen mode

If everything is setup correctly, you will this log on your terminal:
Image description

Backend walkthrough

You will see the following files in your project repo

Image description

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}`);
});
Enter fullscreen mode Exit fullscreen mode
  • 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();
};
Enter fullscreen mode Exit fullscreen mode
  • 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.
Backend: Ensure the backend server is up and running

Clone the repository

git clone https://github.com/momentumXbyLakshya/react-client
Enter fullscreen mode Exit fullscreen mode

Navigate to the project folder

cd react-client
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Replace the _ with the Hanko Cloud API URL that you should have created while setting up the backend server.

Install the dependencies

npm install
Enter fullscreen mode Exit fullscreen mode

Start the app

npm start
Enter fullscreen mode Exit fullscreen mode

Frontend walkthrough

The project structure of react-app looks like this:

Image description

  • 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;
Enter fullscreen mode Exit fullscreen mode
  • 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;
Enter fullscreen mode Exit fullscreen mode
  • 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

Top comments (0)