# Add authentication

Most of Workleap's applications, if not all, will eventually require user authentication. While Squide doesn't offer built-in primitives for this process, it can assist by providing a well-established recipe to integrate an authentication flow with Squide.

# Add a login page

First, open a terminal at the root of the host application and install the @squide/fakes package:

pnpm add @squide/fakes
yarn add @squide/fakes
npm install @squide/fakes

Then, add a Mock Service Worker (MSW) request handler to authenticate a user:

host/mocks/handlers.ts
import { HttpResponse, http, type HttpHandler } from "msw";
import { LocalStorageSessionManager } from "@squide/fakes";

interface LoginCredentials {
    username: string;
    password: string;
}

const Users = [
    {
        username: "temp",
        password: "temp"
    }
];

export interface Session {
    username: string;
    preferredLanguage: string;
}

// For simplicity, we are using a local storage session manager for this guide.
export const sessionManager = new LocalStorageSessionManager<Session>();

export const requestHandlers: HttpHandler[] = [
    http.post("/api/login", async ({ request }) => {
        const { username, password } = await request.json() as LoginCredentials;

        // Try to match the credentials against existing users.
        const user = Users.find(x => {
            return x.username === username && x.password === password;
        });

        // If the user doesn't exist, return a 401.
        if (!user) {
            return new HttpResponse(null, {
                status: 401
            });
        }

        // Login the user by storing the session to the local storage.
        sessionManager.setSession({
            username: user.username,
            preferredLanguage: user.preferredLanguage
        });

        return new HttpResponse(null, {
            status: 200
        });
    })
];

In the previous code sample, the endpoint attempts to authenticate the provided credentials against existing users. If there's a match, the user session is stored in the local storage using a LocalStorageSessionManager instance, and a 200 status code is returned.

Next, register the request handler using the host application registration function:

host/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";

export const registerHost: ModuleRegisterFunction<FireflyRuntime> = async runtime => {
    if (runtime.isMswEnabled) {
        // Files that includes an import to the "msw" package are included dynamically to prevent adding
        // unused MSW stuff to the application bundles.
        const requestHandlers = (await import("../mocks/handlers.ts")).requestHandlers;

        runtime.registerRequestHandlers(requestHandlers);
    }
};

Then, create a login page:

host/src/Login.tsx
import { useCallback, useState, type ChangeEvent, type MouseEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";

export function Login() {
    const [username, setUserName] = useState("");
    const [password, setPassword] = useState("");

    const navigate = useNavigate();

    const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
        event.preventDefault();

        const response = await fetch("/api/login", {
            body: JSON.stringify({ 
                username, 
                password
            }),
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            }
        });

        if (response.ok) {
            // Reload the application after a login.
            window.location.href = "/";
        }
    }, [username, password, navigate]);

    const handleUserNameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
        setUserName(event.target.value);
    }, []);

    const handlePasswordChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
        setPassword(event.target.value);
    }, []);

    return (
        <main>
            <h1>Login</h1>
            <form>
                <div>
                    <label htmlFor="username">Username</label>
                    <input id="username" type="text" onChange={handleUserNameChange} />
                </div>
                <div>
                    <label htmlFor="password">Password</label>
                    <input id="password" type="password" onChange={handlePasswordChange} />
                </div>
                <div>
                    <button type="submit" onClick={handleClick}>
                        Login
                    </button>
                </div>
            </form>
        </main>
    );
}

After the user logs in, the application is reloaded, this is a requirement of the AppRouter component. Nevertheless, it's not a concern because Workleap's applications use a third-party service for authentication which requires a full refresh of the application.

# Create a session manager

Next, create a shared type for the session and the session manager:

shared/src/session.ts
export interface Session {
    user: {
        name: string;
    };
}

export interface SessionManager {
    getSession: () => Session | undefined;
    clearSession: () => void;
}

Then, create a shared SessionManagerContext along with some utility hooks. This React context will be used to share the SessionManager instance down the components tree:

shared/src/session.ts
export const SessionManagerContext = createContext<SessionManager | undefined>(undefined);

export function useSessionManager() {
    return useContext(SessionManagerContext);
}

export function useSession() {
    const sessionManager = useSessionManager();

    return sessionManager?.getSession();
}

export function useIsAuthenticated() {
    const sessionManager = useSessionManager();

    return !!sessionManager?.getSession();
}

Finally, let's go back to the host application and create a TanStack Query implementation of the shared SessionManager interface created previously:

host/src/sessionManager.ts
import type { SessionManager, Session } from "@sample/shared";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";

class TanstackQuerySessionManager implements SessionManager {
    #session: Session | undefined;
    readonly #queryClient: QueryClient;

    constructor(session: Session, queryClient: QueryClient) {
        this.#session = session;
        this.#queryClient = queryClient;
    }

    getSession() {
        return this.#session;
    }

    clearSession() {
        this.#session = undefined;

        this.#queryClient.invalidateQueries({ queryKey: ["/api/session"], refetchType: "inactive" });
    }
}

export function useSessionManagerInstance(session: Session) {
    const queryClient = useQueryClient();

    return useMemo(() => new TanstackQuerySessionManager(session, queryClient), [session, queryClient]);
}

# Fetch the session

Next, create an MSW request handler that returns a session object if a user is authenticated:

host/mocks/handlers.ts
import { HttpResponse, http, type HttpHandler } from "msw";
import { LocalStorageSessionManager } from "@squide/fakes";

interface LoginCredentials {
    username: string;
    password: string;
}

const Users = [
    {
        username: "temp",
        password: "temp"
    }
];

export interface Session {
    username: string;
    preferredLanguage: string;
}

// For simplicity, we are using a local storage session manager for this guide.
export const sessionManager = new LocalStorageSessionManager<Session>();

export const requestHandlers: HttpHandler[] = [
    http.post("/api/login", async ({ request }) => {
        const { username, password } = await request.json() as LoginCredentials;

        // Try to match the credentials against existing users.
        const user = Users.find(x => {
            return x.username === username && x.password === password;
        });

        // If the user doesn't exist, return a 401.
        if (!user) {
            return new HttpResponse(null, {
                status: 401
            });
        }

        // Login the user by storing the session to the local storage.
        sessionManager.setSession({
            username: user.username
        });

        return new HttpResponse(null, {
            status: 200
        });
    }),

    http.post("/api/session", ({ request }) => {
        // Retrieve the session stored by the /api/login endpoint.
        const session = sessionManager.getSession();

        if (!session) {
            return new HttpResponse(null, {
                status: 401
            });
        }

        return HttpResponse.json(session);
    })
];

Then, update the host application App component to load the session with the useProtectedDataQueries hook and create an instance of TanstackQuerySessionManager with the retrieved session to share the sessuib via the SessionManagerContext:

host/src/App.tsx
import { AppRouter, useProtectedDataQueries, useIsBootstrapping } from "@squide/firefly";
import { RouterProvider, createBrowserRouter, Outlet } from "react-router-dom";
import { SessionManagerContext, ApiError, isApiError, type Session } from "@sample/shared";
import { useSessionManagerInstance } from "./sessionManager.ts";

function BootstrappingRoute() {
    const [session] = useProtectedDataQueries([
        {
            queryKey: ["/api/session"],
            queryFn: async () => {
                const response = await fetch("/api/session");

                if (!response.ok) {
                    throw new ApiError(response.status, response.statusText);
                }

                const data = await response.json();

                const result: Session = {
                    user: {
                        name: data.username,
                    }
                };

                return result;
            }
        }
    ], error => isApiError(error) && error.status === 401);

    const sessionManager = useSessionManagerInstance(session!);

    if (useIsBootstrapping()) {
        return <div>Loading...</div>;
    }

    return (
        <SessionManagerContext.Provider value={sessionManager}>
            <Outlet />
        </SessionManagerContext.Provider>
    );
}

export function App() {
    return (
        <AppRouter 
            waitForMsw
            waitForProtectedData
        >
            {({ rootRoute, registeredRoutes, routerProviderProps }) => {
                return (
                    <RouterProvider
                        router={createBrowserRouter([
                            {
                                element: rootRoute,
                                children: [
                                    {
                                        element: <BootstrappingRoute />,
                                        children: registeredRoutes
                                    }
                                ]
                            }
                        ])}
                        {...routerProviderProps}
                    />
                );
            }}
        </AppRouter>
    );
}

The previous example uses the following implementation of the ApiError class:

shared/src/apiError.ts
export class ApiError extends Error {
    readonly #status: number;
    readonly #statusText: string;
    readonly #stack?: string;

    constructor(status: number, statusText: string, innerStack?: string) {
        super(`${status} ${statusText}`);

        this.#status = status;
        this.#statusText = statusText;
        this.#stack = innerStack;
    }

    get status() {
        return this.#status;
    }

    get statusText() {
        return this.#statusText;
    }

    get stack() {
        return this.#stack;
    }
}

export function isApiError(error?: unknown): error is ApiError {
    return error !== undefined && error !== null && error instanceof ApiError;
}

# Add an authentication boundary

Next, create an authentication boundary component using the shared useIsAuthenticated hook created earlier to redirect unauthenticated user to the login page:

host/src/AuthenticationBoundary.tsx
import { Navigate, Outlet } from "react-router-dom";
import { useIsAuthenticated } from "@sample/shared";

export function AuthenticationBoundary() {
    const isAuthenticated = useIsAuthenticated();

    if (isAuthenticated) {
        return <Outlet />;
    }

    return <Navigate to="/login" />;
}

# Define an authenticated layout

Now, let's add a specific layout for authenticated users that passes through the AuthenticationBoundary component.

First, add a MSW request handler to log out a user:

host/mocks/handlers.ts
import { HttpResponse, http, type HttpHandler } from "msw";
import { LocalStorageSessionManager } from "@squide/fakes";

interface LoginCredentials {
    username: string;
    password: string;
}

const Users = [
    {
        username: "temp",
        password: "temp"
    }
];

export interface Session {
    username: string;
    preferredLanguage: string;
}

// For simplicity, we are using a local storage session manager for this guide.
export const sessionManager = new LocalStorageSessionManager<Session>();

export const requestHandlers: HttpHandler[] = [
    http.post("/api/login", async ({ request }) => {
        const { username, password } = await request.json() as LoginCredentials;

        // Try to match the credentials against existing users.
        const user = Users.find(x => {
            return x.username === username && x.password === password;
        });

        // If the user doesn't exist, return a 401.
        if (!user) {
            return new HttpResponse(null, {
                status: 401
            });
        }

        // Login the user by storing the session to the local storage.
        sessionManager.setSession({
            username: user.username
        });

        return new HttpResponse(null, {
            status: 200
        });
    }),

    http.post("/api/logout", () => {
        // Remove the session from the local storage.
        sessionManager.clearSession();

        return new HttpResponse(null, {
            status: 200
        });
    }),

    http.post("/api/session", ({ request }) => {
        // Retrieve the session stored by the /api/login endpoint.
        const session = sessionManager.getSession();

        if (!session) {
            return new HttpResponse(null, {
                status: 401
            });
        }

        return HttpResponse.json(session);
    })
];

Then, introduce a new AuthenticatedLayout component displaying the name of the logged-in user along with a logout button. This layout will retrieve the active user session from the shared useSessionManager hook introduced earlier:

host/src/AuthenticatedLayout.tsx
import { Suspense, useCallback, type ReactNode, type MouseEvent, type HTMLButtonElement } from "react";
import { Link, Outlet, navigate } from "react-router-dom";
import { 
    useNavigationItems,
    useRenderedNavigationItems,
    isNavigationLink,
    type RenderItemFunction,
    type RenderSectionFunction
} from "@squide/react-router";
import { useSessionManager } from "@sample/shared";

const renderItem: RenderItemFunction = (item, key) => {
    // To keep things simple, this sample doesn't support nested navigation items.
    // For an example including support for nested navigation items, have a look at
    // https://gsoft-inc.github.io/wl-squide/reference/routing/userenderednavigationitems/
    if (!isNavigationLink(item)) {
        return null;
    }

    const { label, linkProps, additionalProps } = item;

    return (
        <li key={key}>
            <Link {...linkProps} {...additionalProps}>
                {label}
            </Link>
        </li>
    );
};

const renderSection: RenderSectionFunction = (elements, key) => {
    return (
        <ul key={key}>
            {elements}
        </ul>
    );
};

export function AuthenticatedLayout() {
    const sessionManager = useSessionManager();
    const session = sessionManager?.getSession();

    const handleLogout = useCallback(async (event: MouseEvent<HTMLButtonElement>) => {
        event.preventDefault();

        const response = await fetch("/api/logout", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            }
        });

        if (response.ok) {
            // Clear the in-memory session to ensure the authentication boundary can do his job.
            sessionManager?.clearSession();

            // Redirect the user to the login page.
            navigate("/login");
        }
    }, [navigate, sessionManager]);

    const navigationItems = useNavigationItems();
    const navigationElements = useRenderedNavigationItems(navigationItems, renderItem, renderSection);

    return (
        <>
            <div style={{ display: "flex", alignItems: "center" }}>
                <nav style={{ width: "100%" }}>
                    {renderedNavigationItems}
                </nav>
                <div style={{ whiteSpace: "nowrap", marginRight: "20px" }}>
                    (User: <span style={{ fontWeight: "bold" }}>{session?.user.name}</span>)
                </div>
                <div>
                    <button type="button" onClick={handleLogout}>Log out</button>
                </div>
            </div>
            <Suspense fallback={<div>Loading...</div>}>
                <Outlet />
            </Suspense>
        </>
    );
}

By creating a new AuthenticatedLayout component, much of the layout code has been transferred from the RootLayout to the AuthenticatedLayout, leaving the root layout responsible only for styling the outer wrapper of the application for now:

host/src/RootLayout.tsx
import { Suspense } from "react";
import { Outlet } from "react-router-dom";

export function RootLayout() {
    return (
        <div style={{ margin: "20px" }}>
            <Suspense fallback={<div>Loading...</div>}>
                <Outlet />
            </Suspense>
        </div>
    );
}

# Setup the routes

Finally, assemble everything:

host/src/register.tsx
import { PublicRoutes, ProtectedRoutes, type ModuleRegisterFunction, type FireflyRuntime } from "@squide/firefly";
import { RootLayout } from "./Rootlayout.tsx";
import { AuthenticationBoundary } from "./AuthenticationBoundary.tsx";
import { LoginPage } from "./LoginPage.tsx";
import { HomePage } from "./Homepage.tsx";
import { NotFoundPage } from "./NotFoundPage.tsx";

export const registerHost: ModuleRegisterFunction<FireflyRuntime> = async runtime => {
    runtime.registerRoute({
        element: <RootLayout />,
        children: [
            // All the public routes will render before the authenticated layout.
            PublicRoutes,
            {
                // Every page beyond the authenticated boundary are protected.
                element: <AuthenticationBoundary />,
                children: [
                    {
                        // All the protected routes will render the authenticated layout.
                        element: <AuthenticatedLayout />,
                        children: [
                            ProtectedRoutes
                        ]
                    }
                ]
            }
        ]
    });

    runtime.registerPublicRoute({
        path: "/login",
        element: <LoginPage />
    });

    runtime.registerPublicRoute({
        path: "*",
        element: <NotFoundPage />
    });

    runtime.registerRoute({
        index: true,
        element: <HomePage />
    });

    if (runtime.isMswEnabled) {
        // Files that includes an import to the "msw" package are included dynamically to prevent adding
        // unused MSW stuff to the application bundles.
        const requestHandlers = (await import("../mocks/handlers.ts")).requestHandlers;

        runtime.registerRequestHandlers(requestHandlers);
    }
});

# Try it 🚀

Start the application using the dev script and attempt navigating to the root page (/). You will be redirected to the /login page. Login with "temp" / "temp", you will be redirected to the root page.

# Troubleshoot issues

If you are experiencing issues with this guide:

  • Open the DevTools console. You'll find a log entry for each registration that occurs and error messages if something went wrong.
  • Refer to a working example on GitHub.
  • Refer to the troubleshooting page.