Habit Tracker App

An app which lets users pick and track activity against good habits. Includes an invite only authentication flow, made possible with NextJS, NextAuth and Prisma.

Try it

Project Summary

This ongoing side project utilises the capabilities of the new App router and Server Actions in Next.js. Exciting stuff!

diagram of authentication flow flow

Development

Authentication

One of my main goals for the project was to implement a feature where users are invited to sign into the app. This is a common technique that allows a developer or administrator to restrict access, for example for a closed beta stage of the application deployment.

Here’s a high level diagram illustrating the user flow and process.

diagram of authentication flow flow

Managing Invites

The invite list is just a table where I’m storing a users email address and some flags to indicate whether the invite has been used. I created an admin panel visible to users with the ‘admin’ role in the database where invitations can be created for new users. More on this below.

diagram of authentication flow flow

First-Time Login

The signIn callback verifies whether the user has an assigned role, indicating it’s their initial login. We can be sure of this as the database schema assigns a default role when a User is created, which only occurs after a succesful sign in.

If there’s no role for the user, the callback queries the database using Prisma to find an unused invitation linked to the email attempting to sign-in. If no valid invitation is found, the callback returns a falsey value and the user won’t be able to sign in. This ensures only users who have been invited to the app by an admin are able to sign in.

async signIn({ user }) {
            let isAllowedToSignIn = true;
            // if the user doesn't have a role yet we can be sure this is the first time logging in
            // a default 'user' role us assigned to all new users in the database when created by the prisma adapter
            // in this case, we want to check if there's a valid invitation associated with the email signing in
            if (!user.role && user.email) {
                const invitation = await prisma.invitations.findFirst({
                    where: { email: user.email, used: false },
                });
                // throw error if no invitation
                if (!invitation) {
                    isAllowedToSignIn = false;
                }
            }
            return isAllowedToSignIn;
        },

The JWT callback is responsible for updating the Json Web Token with information fetched during the authentication flow with NextAuth.

In the context of this setup, it plays a key role in customising the token with specific user details such as the user information stored in the database.

async jwt({ token, user, trigger }) {
            // user is the record in the database for the user logging in
            if (user) {
                token.id = user.id;
                token.role = user.role;
                token.name = user.name;
            }
            return token;
        },

Auth Middleware

To ensure only authenticated users can access the main dashboard page of my app, I needed a mechanism to verify a user’s login status and redirect them to the sign-in page if they attempt to access the dashboard without authentication.

Additionally, I’ve incorporated an admin panel in my app, accessible only to users with the ‘admin’ role. Therefore, it became necessary to check the user’s role when navigating to protected routes.

Fortunately, NextJS supports middleware which allows code execution before a request is completed. This solution is perfect for this scenario as it allows you to consolidate all authentication-related checks in a single file and define the routes to be protected by the checks.


export async function middleware(request: NextRequest, _next: NextFetchEvent) {
    const { pathname } = request.nextUrl;
    const adminPaths = ["/admin"];

    // all paths should be protected
    const protectedPaths = [...adminPaths, "/dashboard"];

    // check if current path is in the admin paths array
    const matchesAdminPaths = adminPaths.some((path) =>
        pathname.startsWith(path)
    );
    // check if current path is protected
    const matchesProtectedPaths = protectedPaths.some((path) =>
        pathname.startsWith(path)
    );

    if (matchesProtectedPaths) {
        // get the JWT
        const token = await getToken({ req: request });

        // redirect to sign in if user is not logged in
        if (!token) {
            const url = new URL('/api/auth/signin', request.url);
            url.searchParams.set("callbackUrl", encodeURI(request.url));
            return NextResponse.redirect(url);
        }

        // redirect to error page if attempting to view admin route as non-admin user
        if (matchesAdminPaths && token.role !== UserRole.ADMIN) {
            const url = new URL('/403', request.url);
            return NextResponse.redirect(url);
        }
    }

    return NextResponse.next();
}