Building a Gamified Habit Tracker on MERN Stack and Hanko for passkey authentication
Lakshya Satpal
Posted on October 31, 2023
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.
Clone the repository
git clone https://github.com/momentumXbyLakshya/express-server
Navigate to the project folder
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 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
Start the server
npm start
If everything is setup correctly, you will this log on your terminal:
Backend walkthrough
You will see the following files in your project repo
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 ahanko
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
Navigate to the project folder
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
npm install
Start the app
npm start
Frontend walkthrough
The project structure of react-app looks like this:
- 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
Posted on October 31, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 20, 2024