Building Headless Shops With WooGraphQL: Chapter 4 of 5

Geoff Taylor
By Geoff Taylor
10 months ago
banner

Table of Contents

  1. Setting Up WordPress for Your Headless eCommerce Application
  2. Creating and Setting Up Your eCommerce App for development
  3. Building Shop Listing Pages With WooGraphQL
  4. Creating User Login and Navigation
  5. Creating the Product page and Cart Options

Welcome to the next installment in our tutorial series, where we're going to enrich our eCommerce store by incorporating user navigation and a login page. This chapter is all about enhancing the user interface and interaction capabilities, thereby creating a more engaging and seamless user experience.

Creating User Login and Navigation

One of the core functionalities of any eCommerce site is the ability for users to navigate through different sections of the site and to have the ability to log in and manage their account. The top navigation bar serves as the central hub for navigation, allowing users to access various parts of the site easily and efficiently. Adding user navigation to the top navigation bar will not only improve usability but also help users to access their account and cart details effortlessly.

In addition, we will be walking you through the process of adding a login page to your eCommerce store. A well-designed login page is critical to any online store as it's the gateway to personalizing the user experience, allowing users to track their orders, save their favorite products, and speed up the checkout process.

To sweeten the deal further, we'll be exploring a neat trick that could save you both time and effort. We'll show you how to utilize session drop-off links to pass the end-user's session from our Next.js application to the WordPress installation hosting our WooCommerce store. The benefit of this method is that it allows us to bypass the need to create certain pages in our application, particularly those that could be time-consuming to build or that rely on functionality not readily available in our front-end application.

Prerequisites

Before getting started we should update our .env.local file with some variables that will be vital to this chapter.

SESSION_TOKEN_LS_KEY=[SESSION_TOKEN_LS_KEY]
REFRESH_TOKEN_LS_KEY=[REFRESH_TOKEN_LS_KEY]
AUTH_TOKEN_SS_KEY=[AUTH_TOKEN_SS_KEY]
AUTH_TOKEN_EXPIRY_SS_KEY=[AUTH_TOKEN_EXPIRY_SS_KEY]
CLIENT_CREDENTIALS_LS_KEY=[CLIENT_CREDENTIALS_LS_KEY]
CLIENT_SESSION_SS_KEY=[CLIENT_SESSION_SS_KEY]
CLIENT_SESSION_EXP_SS_KEY=[CLIENT_SESSION_EXP_SS_KEY]
NONCE_KEY=[NONCE_KEY]
NONCE_SALT=[NONCE_SALT]

The values can be random except for NONCE_KEY and NONCE_SALT the must match their equivalents in WordPress. You can typically find this values by checking you WordPress installation's wp-config.php file. Remember to update your next.config.js as well

...

const nextConfig = {
  ...
  
  },
  env: {
    ...
    SESSION_TOKEN_LS_KEY: process.env.SESSION_TOKEN_LS_KEY,
    REFRESH_TOKEN_LS_KEY: process.env.REFRESH_TOKEN_LS_KEY,
    AUTH_TOKEN_SS_KEY: process.env.AUTH_TOKEN_SS_KEY,
    AUTH_TOKEN_EXP_SS_KEY: process.env.AUTH_TOKEN_EXP_SS_KEY,
    CLIENT_CREDENTIALS_LS_KEY: process.env.CLIENT_CREDENTIALS_LS_KEY,
    CLIENT_SESSION_SS_KEY: process.env.CLIENT_SESSION_SS_KEY,
    CLIENT_SESSION_EXP_SS_KEY: process.env.CLIENT_SESSION_EXP_SS_KEY,
    NONCE_KEY: process.env.NONCE_KEY,
    NONCE_SALT: process.env.NONCE_SALT,
  },
}
...

By the end of this tutorial, you'll be equipped with the knowledge to improve user navigation, add a user-friendly login page, and implement session drop-off links, taking your eCommerce store to the next level of user interactivity and functionality. Let's dive in and get started!

Part 1 Create Login Page and UserNav

1. Install zod, react-hook-form, and @hookform/resolvers packages:

npm install zod react-hook-form @hookform/resolvers

We'll be using this packages to manage and validate the login form.

2. Create /app/login/page.tsx:

// app/login/page.tsx
import { Login } from '@/client/Login';

export default function LoginPage() {
  return (
    <div className="min-h-screen">
      <Login />
    </div>
  );
}

Simple enough. Onwards.

3. Create /client/Login:

// client/Login/Login.tsx
import { useEffect } from 'react';
import * as z from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';

import { useSession } from '@/client/SessionProvider';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/ui/form';
import { Input } from '@/ui/input';
import { Button } from '@/ui/button';
import { LoadingSpinner } from '@/ui/LoadingSpinner';

export const LoginSchema = z.object({
  username: z.string().min(4, {
    message: 'Username must be at least 4 characters long',
  }),
  password: z.string().min(1, {
    message: 'Password must enter a password',
  })
});

export function Login() {
  const { login, isAuthenticated, fetching } = useSession();
  const router = useRouter();
  const form = useForm<z.infer<typeof LoginSchema>>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      username: '',
      password: '',
    },
  });

  useEffect(() => {
    if (isAuthenticated) {
      router.push('/');
    }
  }, [isAuthenticated]);

  const onSubmit = (data: z.infer<typeof LoginSchema>) => {
    login(data.username, data.password);
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-screen-lg mx-auto px-4">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="Enter your username or e-mail associate with your account." {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" placeholder="Enter your password." {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button
          type="submit"
          disabled={fetching}
          className="flex gap-x-2 items-center"
        >
          Submit
          {fetching && <LoadingSpinner noText />}
        </Button>
      </form>
    </Form>
  );
}

If you're familiar with react-hook-form this shouldn't look to abnormal to you. The part I'd like to call out though is

const { login, isAuthenticated, fetching } = useSession();

useSession is a hook to the SessionProvider which we have not implemented yet, so don't expect this to work right away. We will get to it soon.

4. Create /client/UserNav:

import { useRouter } from 'next/navigation';

import { cn } from '@/utils/ui';
import { deleteClientSessionId } from '@woographql/utils/client';
import { useSession } from '@/client/SessionProvider';
import { NavLink, linkClassName } from '@/ui/NavLink';
import { Button } from '@/ui/button';

export function UserNav() {
  const { push } = useRouter();
  const {
    cart,
    customer,
    cartUrl,
    checkoutUrl,
    accountUrl,
    logout: killSession,
    isAuthenticated,
    fetching,
  } = useSession();
  
  const goToCartPage = () => {
    deleteClientSessionId();
    console.log('Go to cart page');
    window.location.href = cartUrl;
  };
  const goToCheckoutPage = () => {
    deleteClientSessionId();
    window.location.href = checkoutUrl;
  };
  const goToAccountPage = () => {
    deleteClientSessionId();
    window.location.href = accountUrl;
  };

  const logout = () => {
    killSession(`Goodbye, ${customer?.firstName}`);
  };

  return (
    <>
      <li className="group">
        <Button
          className={cn(
            'flex flex-row gap-x-2 items-center p-0 hover:no-underline text-base font-normal',
            linkClassName,
          )}
          disabled={fetching}
          variant='link'
          onClick={goToCartPage}
        >
          <span>{cart?.contents?.itemCount || 0}</span>
          Cart
        </Button>
      </li>
      <li className="group w-auto">
        <Button
          className={cn(
            'p-0 hover:no-underline text-base font-normal',
            linkClassName,
          )}
          disabled={fetching}
          variant='link'
          onClick={goToCheckoutPage}
        >
          Checkout
        </Button>
      </li>
      {isAuthenticated ? (
        <>
          <li className="group">
            <Button
              className={cn(
                'p-0 hover:no-underline text-base font-normal',
                linkClassName,
              )}
              disabled={fetching}
              variant='link'
              onClick={goToAccountPage}
            >
              Account
            </Button>
          </li>
          <li className="group">
            <Button
              className={cn(
                'p-0 hover:no-underline text-base font-normal',
                linkClassName,
              )}
              disabled={fetching}
              variant='link'
              onClick={logout}
            >
              Logout
            </Button>
          </li>
        </>
      ) : (
        <li className="group">
          <NavLink href="/login">
            Login
          </NavLink>
        </li>
      )}
    </>
  )
}

The UserNav component is responsible for display user actions based upon values received from the SessionProvider.

const {
    cart, // Cart data object.
    customer, // Customer data object.
    goToCartPage, // Callback for sending the user to the Cart Page.
    goToAccountPage, // Callback for sending the user to the Account Page.
    goToCheckoutPage, // Callback for sending the user to the Checkout Page.
    logout,  // Callback for deleting end-user's session.
    isAuthenticated, // Flag determining end-user's login status
    refetchUrls, // Callback to begin process of generating new session drop-off urls.
    fetching, // Flag determining session handler fetcher status.
} = useSession();

Let's add this to the /server/TopNav component before finally moving onto the SessionProvider.

5. Update /server/TopNav/TopNav.tsx:

// server/TopNav/TopNav.tsx
...

import { UserNav } from '@/client/UserNav';
...

export function TopNav({ menu }: TopNavProps) {
  return (
    <nav className="w-full bg-white min-h-24 py-4 px-4">
      <ul className="max-w-screen-lg m-auto w-full flex flex-row gap-x-4 justify-end items-center">
        ...
        <UserNav />
      </ul>
    </nav>
  )
}

Part 2: Create SessionProvider

1. Create /client/SessionProvider:

// client/SessionProvider/SessionProvider.tsx
import {
  createContext,
  PropsWithChildren,
  useContext,
  useReducer,
  useEffect,
} from 'react';

import {
  Customer,
  Cart,
} from '@/graphql';

import { useToast } from '@/ui/use-toast';

export interface SessionContext {
  isAuthenticated: boolean;
  hasCredentials: boolean;
  cart: Cart|null;
  customer: Customer|null;
  cartUrl: string;
  checkoutUrl: string;
  accountUrl: string;
  fetching: boolean;
  logout: (message?: string) => void;
  login: (username: string, password: string, successMessage?: string) => Promise<boolean>;
  refetch: () => Promise<boolean>;
}

const initialContext: SessionContext = {
  isAuthenticated: false,
  hasCredentials: false,
  cart: null,
  customer: null,
  cartUrl: '',
  checkoutUrl: '',
  accountUrl: '',
  fetching: false,
  logout: (message?: string) => null,
  login: (username: string, password: string) => new Promise((resolve) => { resolve(false); }),
  refetch: () => new Promise((resolve) => { resolve(false); }),
};

export const sessionContext = createContext<SessionContext>(initialContext);

type SessionAction = {
  type: 'UPDATE_STATE';
  payload: SessionContext;
} | {
  type: 'LOGOUT';
};

const reducer = (state: SessionContext, action: SessionAction): SessionContext => {
  switch (action.type) {
    case 'UPDATE_STATE':
      return {
        ...state,
        ...action.payload,
      };
    case 'LOGOUT':
      return {
        ...state,
        customer: null,
        cart: null,
      };
    default:
      throw new Error('Invalid action dispatched to session data reducer');
  }
};

const { Provider } = sessionContext;

export function SessionProvider({ children }: PropsWithChildren) {
  const { toast } = useToast();
  const [state, dispatch] = useReducer(reducer, initialContext);

  // Delete logout state and creds store locally.
  const logout = () => {
    dispatch({ type: 'LOGOUT' });
  };
      

  const store: SessionContext = {
    ...state,
    isAuthenticated: !!state.customer?.id && 'guest' !== state.customer.id,
    hasCredentials: false,
    logout,
  };
  return (
    <Provider value={store}>{children}</Provider>
  );
}

export const useSession = () => useContext(sessionContext);

Now add it to the /app/layout.tsx

2. Update /app/layout.tsx:

// app/layout.tsx
...

import { SessionProvider } from '@/client/SessionProvider';
...

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  ...

  return (
    <html lang="en">
      <body>
        <SessionProvider>
          <TopNav menu={menu} />
          <main className="w-full">
            {children}
          </main>
        </SessionProvider>
        <Toaster />
      </body>
    </html>
  )
}

With this the application will build and run successfully, but none of the components we've introduced actually work. Those with a keen eye would have noticed we haven't implemented any logic for communicate with our endpoint.

This is because dealing with the end-user's securely requires sophisticated management of session credentials and restricted visibility of the GraphQL endpoint. It should be noted that despite everything that has been created so far, so actions occur in the live application that expose the GraphQL endpoint. Don't believe. Open your browser developer tools and check the Network tab. Refresh the page and click around, do whatever, but you will see no visible requests to your GraphQL endpoint are made.

Up until now all GraphQL request have been made at the server-level only every run during the pages build time, but we'll have to make some client-side request to deal with the end-user's session which would expose the endpoint if we ran the GraphQL queries directly on the client.

So we will not be running them on the client but instead be taking advantage of Next 13 Route pages. We will make few Route pages that will run our GraphQL requests out of the view of the client. Before that we'll have to create some utility files that will define the logic to communicate with these route pages and manage the time-sensitive session credentials. So let's begin.

Part 3: Create utility files.

1. Install crypto-js and jwt-decode packages:

npm install crypto-js jwt-decode

These libraries with to recreate WordPress' hashing functionality.

2. Create /utils/nonce.ts:

// utils/nonce.ts
import HmacMD5 from 'crypto-js/hmac-md5';
import jwtDecode from 'jwt-decode';

export const MINUTE_IN_SECONDS = 60;
export const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS;
export const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS;

export function time() {
  return Math.floor(new Date().getTime() / 1000);
}

export function nonceTick() {
  const nonceLife = DAY_IN_SECONDS;
  return Math.ceil(time() / (nonceLife / 2));
}

export function wpNonceHash(data: string) {
  const nonceSalt = process.env.NONCE_KEY as string + process.env.NONCE_SALT as string;
  const hash = HmacMD5(data, nonceSalt).toString();

  return hash;
}

export function createNonce(action: string, uId:string|number, token:string) {
  const i = nonceTick();

  const nonce = wpNonceHash(`${i}|${action}|${uId}|${token}`).slice(-12, -2);

  return nonce;
}

export enum ActionTypes {
  Cart = 'cart',
  Checkout = 'checkout',
  Account = 'account',
}

function getAction(action: ActionTypes, uId: string|number) {
  switch (action) {
    case ActionTypes.Cart:
      return `load-cart_${uId}`;
    case ActionTypes.Checkout:
      return `load-checkout_${uId}`;
    case ActionTypes.Account:
      return `load-account_${uId}`;
    default:
      throw new Error('Invalid nonce action provided.');
  }
}

function getNonceParam(action: ActionTypes) {
  switch (action) {
    case ActionTypes.Cart:
      return '_wc_cart';
    case ActionTypes.Checkout:
      return '_wc_checkout';
    case ActionTypes.Account:
      return '_wc_account';
    default:
      throw new Error('Invalid nonce action provided.');
  }
}

type DecodedToken = {
  data: { customer_id: string };
}
export function getUidFromToken(sessionToken: string) {
  const decodedToken = jwtDecode<DecodedToken>(sessionToken);
  if (!decodedToken?.data?.customer_id) {
    throw new Error('Failed to decode session token');
  }
  return decodedToken.data.customer_id;
}

export function generateUrl(sessionToken:string, clientSessionId:string, actionType: ActionTypes) {
  const uId = getUidFromToken(sessionToken);
  const action = getAction(actionType, uId);

  // Create nonce
  const nonce = createNonce(action, uId, clientSessionId);

  // Create URL.
  const param = getNonceParam(actionType);
  let url = `${process.env.BACKEND_URL}/wp/transfer-session?session_id=${uId}&${param}=${nonce}`;

  return url;
}

I'm not gonna go into a lot of details on this file here, but it's mostly a JS clone of a couple PHP/WP functions/constants. In the case of our session utility we care about the time function and MINUTE_IN_SECONDS constant. Onwards.

3. Create /utils/client.ts:

// utils/client.ts
import {
  wpNonceHash,
  time,
  HOUR_IN_SECONDS,
  MINUTE_IN_SECONDS,
  DAY_IN_SECONDS,
} from '@/utils/nonce';

type Creds = {
  userAgent: string;
  ip: string;
  issued: number;
}

async function createClientSessionId() {
  const encodedCredentials = localStorage.getItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string);
  let credentials: null|Creds = encodedCredentials ? JSON.parse(encodedCredentials) : null;
  if (!credentials || time() > credentials.issued + (14 * DAY_IN_SECONDS)) {
    // Create credentials object with UserAgent.
    credentials = {
      userAgent: window?.navigator?.userAgent || '',
      ip: '',
      issued: 0,
    };

    // Fetch IP.
    const response = await fetch('https://api.ipify.org/?format=json');
    const { data } = await response.json();
    credentials.ip = data?.ip || '';
  }

  // Update timestamp to ensure new nonces are generated everytime
  // the end-user starts that application.
  credentials.issued = time();
  localStorage.setItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string, JSON.stringify(credentials));

  // Generate Client Session ID.
  const clientSessionId = wpNonceHash(JSON.stringify(credentials));
  const timeout = `${credentials.issued + HOUR_IN_SECONDS}`;

  // Save Client Session ID.
  sessionStorage.setItem(process.env.CLIENT_SESSION_SS_KEY as string, clientSessionId);
  sessionStorage.setItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string, timeout);

  // Return Client Session ID.
  return { clientSessionId, timeout };
}

function hasSessionToken() {
  const sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);

  return !!sessionToken;
}

let clientSetter: ReturnType<typeof setInterval>;

/**
 * Creates timed fetcher for renewing client credentials and client session id.
 * 
 * @returns {void}
 */
function setClientFetcher() {
  if (clientSetter) {
    clearInterval(clientSetter);
  }
  clientSetter = setInterval(
    async () => {
      if (!hasSessionToken()) {
        clearInterval(clientSetter);
        return;
      }
      createClientSessionId();
    },
    Number(45 * MINUTE_IN_SECONDS),
  );
}

export async function getClientSessionId() {
  let clientSessionId = sessionStorage.getItem(process.env.CLIENT_SESSION_SS_KEY as string);
  let timeout = sessionStorage.getItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string);
  if (!clientSessionId || !timeout || time() > Number(timeout)) {
    ({ clientSessionId, timeout } = await createClientSessionId());
    setClientFetcher();
  }

  return { clientSessionId, timeout };
}

export function deleteClientSessionId() {
  if (clientSetter) {
    clearInterval(clientSetter);
  }
  sessionStorage.removeItem(process.env.CLIENT_SESSION_SS_KEY as string);
  sessionStorage.removeItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string);
}

export function deleteClientCredentials() {
  deleteClientSessionId();
  localStorage.removeItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string);
}

The logic here is for creating a clientSessionId. This is needed to tie restrict session drop-off link usage to the end-user's machine. We'll get more into it's usage we creating the route pages. One thing to note for now is that it's time-sensitive and most of the logic here is dedicated to it's evaluation and renewal.

4. Create /utils/session.ts:

import { GraphQLError } from 'graphql/error';

import {
  Customer,
  Cart,
} from '@/graphql';
import { getClientSessionId } from '@/utils/client';
import { MINUTE_IN_SECONDS, time } from '@/utils/nonce';

type ResponseErrors = {
  errors?: {
    message: string;
    data?: unknown;
  }
}
async function apiCall<T>(url: string, input: globalThis.RequestInit) {
  const response = await fetch(
    url,
    {
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      ...input,
    },
  );

  const json: T&ResponseErrors = await response.json();

  // Capture errors.
  if (json?.errors || response.status !== 200) {
    throw new Error(json.errors?.message || `Failed to fetch: ${url}`);
  }

  return json;
}

// Auth management.
function saveCredentials(authToken: string, sessionToken?: string, refreshToken?: string) {
  sessionStorage.setItem(process.env.AUTH_TOKEN_SS_KEY as string, authToken);
  if (!!sessionToken) {
    localStorage.setItem(process.env.SESSION_TOKEN_LS_KEY as string, sessionToken);
  }
  if (refreshToken) {
    localStorage.setItem(process.env.REFRESH_TOKEN_LS_KEY as string, refreshToken);
  }
}

function saveSessionToken(sessionToken: string) {
  localStorage.setItem(process.env.SESSION_TOKEN_LS_KEY as string, sessionToken);
}

export function hasCredentials() {
  const sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);
  const authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);

  if (!!sessionToken && !!authToken && !!refreshToken) {
    return true;
  }

  return false;
}

function setAuthTokenExpiry() {
  const authTimeout = time() + (15 * MINUTE_IN_SECONDS);
  sessionStorage.setItem(process.env.AUTH_TOKEN_EXPIRY_SS_KEY as string, `${authTimeout}`);
}

function authTokenIsExpired() {
  const authTimeout = sessionStorage.getItem(process.env.AUTH_TOKEN_EXPIRY_SS_KEY as string);
  if (!authTimeout || Number(authTimeout) < time()) {
    return true;
  }
}

type FetchAuthTokenResponse = {
  authToken: string;
  sessionToken: string;
}

async function fetchAuthToken() {
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);
  if (!refreshToken) {
    // eslint-disable-next-line no-console
    isDev() && console.error('Unauthorized');
    return null;
  }

  const json = await apiCall<FetchAuthTokenResponse>(
    '/api/auth',
    {
      method: 'POST',
      body: JSON.stringify({ refreshToken }),
    },
  );

  const { authToken, sessionToken } = json;
  saveCredentials(authToken, sessionToken);
  setAuthTokenExpiry();

  return authToken;
}

let tokenSetter: ReturnType<typeof setInterval>;
function setAutoFetcher() {
  if (tokenSetter) {
    clearInterval(tokenSetter);
  }
  tokenSetter = setInterval(
    async () => {
      if (!hasCredentials()) {
        clearInterval(tokenSetter);
        return;
      }
      fetchAuthToken();
    },
    Number(process.env.AUTH_KEY_TIMEOUT || 30000),
  );
}

type LoginResponse = {
  authToken: string
  refreshToken: string;
  sessionToken: string;
}
export async function login(username: string, password: string): Promise<boolean|string> {
  let json: LoginResponse;
  try {
    json = await apiCall<LoginResponse>(
      '/api/login',
      {
        method: 'POST',
        body: JSON.stringify({ username, password }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  const { authToken, refreshToken, sessionToken } = json;
  saveCredentials(authToken, sessionToken, refreshToken);
  setAutoFetcher();

  return true;
}

export async function getAuthToken() {
  let authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);
  if (!authToken || authTokenIsExpired()) {
    authToken = await fetchAuthToken();
  }

  if (authToken && !tokenSetter) {
    setAutoFetcher();
  }
  return authToken;
}

type FetchSessionTokenResponse = {
  sessionToken: string;
}

async function fetchSessionToken() {
  const json = await apiCall<FetchSessionTokenResponse>(
    '/api/auth',
    { method: 'GET' },
  );

  const { sessionToken } = json;

  sessionToken && saveSessionToken(sessionToken);
  return sessionToken;
}

async function getSessionToken() {
  let sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);
  if (!sessionToken) {
    sessionToken = await fetchSessionToken();
  }
  return sessionToken;
}

export function hasRefreshToken() {
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);

  return !!refreshToken;
}

export function hasAuthToken() {
  const authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);

  return !!authToken;
}

export type FetchSessionResponse = {
  customer: Customer;
  cart: Cart;
}
export async function getSession(): Promise<FetchSessionResponse|string> {
  const authToken = await getAuthToken();
  const sessionToken = await getSessionToken();
  let json: FetchSessionResponse;
  try {
    json = await apiCall<FetchSessionResponse>(
      '/api/session',
      {
        method: 'POST',
        body: JSON.stringify({
          sessionToken,
          authToken,
        }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  const { customer } = json;
  saveSessionToken(customer.sessionToken as string);

  return json;
}



export type FetchAuthURLResponse = {
  cartUrl: string;
  checkoutUrl: string;
  accountUrl: string
}
export async function fetchAuthURLs(): Promise<FetchAuthURLResponse|string> {
  const authToken = await getAuthToken();
  const sessionToken = await getSessionToken();
  const { clientSessionId, timeout } = await getClientSessionId();
  let json: FetchAuthURLResponse;
  try {
    json = await apiCall<FetchAuthURLResponse>(
      '/api/nonce',
      {
        method: 'POST',
        body: JSON.stringify({
          sessionToken,
          authToken,
          clientSessionId,
          timeout,
        }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  return json;
}

export function deleteCredentials() {
  if (tokenSetter) {
    clearInterval(tokenSetter);
  }
  localStorage.removeItem(process.env.SESSION_TOKEN_LS_KEY as string);
  sessionStorage.removeItem(process.env.AUTH_TOKEN_SS_KEY as string);
  localStorage.removeItem(process.env.REFRESH_TOKEN_LS_KEY as string);
}

Although it's a bit beefy this pretty straight forward. login, getSession, and fetchAuthURLs are callback to be used by our SessionProvider.

  • login: Works by sending a request to the /api/login route with the end-user's username and password input, if successfully and authToken and refreshToken is returned.
  • getSession: Works by first retrieving the end-user's session credentials from localStorage then sends a POST request to /api/session with the end-user's authToken and sessionToken for identification.
    • If end-user's has a refreshToken and no valid authToken and new authToken is retrieved by sending a POST request to /api/auth in fetchAuthToken,
    • If end-user's has no credentials whatsoever a new sessionToken is retrieved by sending a GET request to /api/auth in fetchSessionToken
    If end-user's has a refreshToken and no valid authToken and new authToken is retrieved by sending a POST request to /api/auth in fetchAuthToken, If end-user's has no credentials whatsoever a new sessionToken is retrieved by sending a GET request to /api/auth in fetchSessionToken
    • If end-user's has a refreshToken and no valid authToken and new authToken is retrieved by sending a POST request to /api/auth in fetchAuthToken,
    • If end-user's has no credentials whatsoever a new sessionToken is retrieved by sending a GET request to /api/auth in fetchSessionToken
  • fetchAuthURLs: Works exactly like getSession except it send the clientSessionId and clientSessionIdTimeout as well for session drop-off URL generation.
    • If end-user's has no valid clientSessionId, a new one is generated.
    If end-user's has no valid clientSessionId, a new one is generated.
    • If end-user's has no valid clientSessionId, a new one is generated.

With the all the utility files created. Let's move onto the Route pages.

Part 4: Create Route pages

1. Create app/api/auth/route.ts:

// app/api/auth/route.ts
import { NextResponse } from 'next/server';
import { print } from 'graphql';

import {
  GetSessionDocument,
  GetSessionQuery,
  RefreshAuthTokenDocument,
  RefreshAuthTokenMutation,
  getClient,
} from '@/graphql';

export async function GET(request: Request) {
  try {
    const client = getClient();

    const { data, headers } = await client.rawRequest<GetSessionQuery>(
      print(GetSessionDocument),
    );

    const cart = data?.cart;
    const customer = data?.customer;
    const sessionToken = headers.get('woocommerce-session');

    if (!cart || !customer || !sessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve session credentials.' } }, { status: 500 });
    }

    return NextResponse.json({ sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

export async function POST(request: Request) {
  try {
    const client = getClient();

    const body = await request.json();
    let authToken = body.authToken;
    const refreshToken = body.refreshToken;
    if (!authToken && !refreshToken) {
      return NextResponse.json({ errors: { message: 'No refresh token provided' } }, { status: 500 });
    }

    if (!authToken && refreshToken) {
      client.setHeaders({});
      const results = await client.request<RefreshAuthTokenMutation>(
        RefreshAuthTokenDocument,
        { refreshToken },
      );

      authToken = results?.refreshJwtAuthToken?.authToken;

      if (!authToken) {
        return NextResponse.json({ errors: { message: 'Failed to retrieve auth token.' } }, { status: 500 });
      }
    }

    client.setHeaders({ Authorization: `Bearer ${authToken}` });
    const { data: cartData, headers } = await client.rawRequest<GetSessionQuery>(
      print(GetSessionDocument),
    );

    const newSessionToken = cartData?.customer?.sessionToken;
    if (!newSessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to validate auth token.' } }, { status: 500 });
    }

    const sessionToken = headers.get('woocommerce-session') || newSessionToken;

    return NextResponse.json({ authToken, sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

This although this route has both a GET and POST callback, both attempt the same goal just under different circumstances.

  • POST assumes the user is a return user with either a refreshToken or authToken and if successful returns an authToken and sessionToken.
  • GET assumes the user is new with no credentials and return a new sessionToken for a guest user.

2. Create /app/api/login/route.ts:

import { NextResponse } from 'next/server';
import { print } from 'graphql';

import {
  GetSessionDocument,
  GetSessionQuery,
  LoginDocument,
  LoginMutation,
  getClient,
} from '@/graphql';

type RequestBody = ({
  auth: string;
  username: undefined;
  password: undefined;
} | {
  auth: undefined;
  username: string;
  password: string;
})

export async function POST(request: Request) {
  try {
    const { username, password } = await request.json() as RequestBody;
    const graphQLClient = getClient();

    if (!username || !password) {
      return NextResponse.json({
        errors: {
          message: 'Proper credential must be provided for authentication',
        },
      }, { status: 500 });
    }

    const data = await graphQLClient.request<LoginMutation>(LoginDocument, { username, password });
    if (!data?.login) {
      return NextResponse.json({ errors: { message: 'Login failed.' } }, { status: 500 });
    }

    const { authToken, refreshToken } = data?.login;
    if (!authToken || !refreshToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve credentials.' } }, { status: 500 });
    }

    graphQLClient.setHeader('Authorization', `Bearer ${data.login.authToken}`);
    const { data:_, headers } = await graphQLClient.rawRequest<GetSessionQuery>(print(GetSessionDocument));
    
    const sessionToken = headers.get('woocommerce-session');
    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve session token.' } }, { status: 500 });
    }


    return NextResponse.json({ authToken, refreshToken, sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Login credentials invalid.' } }, { status: 500 });
  }
}

This one is similar to the last one but simpler. It takes a username and password from the input and tries to return the end-user's authToken, refreshToken, and sessionToken.

Note, that sessionToken is being retrieved from the HTTP response of GetSession query with the authToken set in the Authorization. The reasoning for this is to get last session connected to the user in database. If the sessionToken from customer query is used instead of this one a new session with be started on login everytime and the old session will be erased the second the new sessionToken is used. The pattern was also used in the POST callback of the api/auth route as well if you noticed.

3. Create /app/api/session/route.ts:

import { NextResponse } from 'next/server';
import {
  GetSessionDocument,
  GetSessionQuery,
  Cart,
  Customer,
  getClient,
} from '@/graphql';

type RequestBody = {
  sessionToken: string;
  authToken?: string;
}

type GraphQLRequestHeaders = {
  'woocommerce-session': string;
  Authorization?: string;
}

export async function POST(request: Request) {
  try {
    const { sessionToken, authToken } = await request.json() as RequestBody;

    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'Session not started' } }, { status: 500 });
    }
    const headers: GraphQLRequestHeaders = { 'woocommerce-session': `Session ${sessionToken}` };
    if (authToken) {
      headers.Authorization = `Bearer ${authToken}`;
    }
    const graphQLClient = getClient();
    graphQLClient.setHeaders(headers);

    const results = await graphQLClient.request<GetSessionQuery>(
      GetSessionDocument,
    );

    const customer = results?.customer as Customer;
    const cart = results?.cart as Cart;

    if (!customer) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve customer data.' } }, { status: 500 });
    }

    if (!cart) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve cart data.' } }, { status: 500 });
    }

    return NextResponse.json({ customer, cart });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

This one expects a sessionToken and possibly and authToken. When given the proper credentials it returns the end-user's customer and cart data from the GetSession query.

4. Create /api/nonce/route.ts:

import { NextResponse } from 'next/server';

import { UpdateSessionDocument, UpdateSessionMutation, getClient } from '@/graphql';
import { ActionTypes, generateUrl } from '@/utils/nonce';

type RequestBody = {
  sessionToken: string;
  authToken?: string;
  clientSessionId: string;
  timeout: number;
}

type GraphQLRequestHeaders = {
  'woocommerce-session': string;
  Authorization?: string;
}

export async function POST(request: Request) {
  try {
    const {
      sessionToken,
      authToken,
      clientSessionId,
      timeout,
    } = await request.json() as RequestBody;

    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'No session started.' } }, { status: 500 });
    }

    if (!clientSessionId || !timeout) {
      return NextResponse.json({ errors: { message: 'Client Session ID and expiration must be provided.' } }, { status: 500 });
    }

    const headers: GraphQLRequestHeaders = { 'woocommerce-session': `Session ${sessionToken}` };
    if (authToken) {
      headers.Authorization = `Bearer ${authToken}`;
    }
    const client = getClient();
    client.setHeaders(headers);

    const input = {
      sessionData: [
        {
          key: 'client_session_id',
          value: clientSessionId,
        },
        {
          key: 'client_session_id_expiration',
          value: timeout,
        },
      ],
    };
    const results = await client.request<UpdateSessionMutation>(
      UpdateSessionDocument,
      { input },
    );

    if (!results.updateSession) {
      const message = 'Failed to update session';
      return NextResponse.json({ errors: { message } }, { status: 500 });
    }

    const cartUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Cart);
    const checkoutUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Checkout);
    const accountUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Account);

    return NextResponse.json({ cartUrl, checkoutUrl, accountUrl });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

This route is expects the same input as the last plus the end-user clientSessionId and clientSessionIdTimeout. When given the proper input it sets the end-user's client_session_id and client_session_id_expiration in the end-user's WooCommerce session object on the server. These values are then used to generate a series of nonces on the WP Backend to be used in the session drop-off URLs.

Now we could query for these nonces or even the whole session drop-off URLs on the GraphQL endpoint, however that run the risks of the nonces/URLs being leaked in transit. So instead we utilize the rest of the functionality defined in the utils/nonce.ts file and recreate the exact same nonces in our Route pages. generateUrl generates a session drop-off URL tailor-made for the end-user and tied to there machine. With this the last of the route pages have been create and all that left is updating the SessionProvider to utilize the utility files and by extension the route pages.

Part 5: Finish SessionProvider

1. Update /client/SessionProvider/SessionProvider.tsx:

// client/SessionProvider/SessionProvider.tsx
...

import {
  hasCredentials,
  deleteCredentials,
  getSession as getSessionApiCall,
  FetchSessionResponse as Session,
  FetchAuthURLResponse as AuthUrls,
  fetchAuthURLs as fetchAuthURLsApiCall,
  login as loginApiCall,
} from '@/utils/session';
import {
  deleteClientSessionId,
  deleteClientCredentials,
} from '@/utils/client';
import { useToast } from '@/ui/use-toast';
...

export function SessionProvider({ children }: PropsWithChildren) {
  ...

  // Delete logout state and creds store locally.
  const logout = () => {
    ...
    deleteCredentials();
    deleteClientCredentials();
    fetchSession();
  };

  // Process session fetch request response.
  const setSession = (session: Session|string, authUrls: AuthUrls|string) => {
    if (typeof session === 'string') {
      toast({
        title: 'Fetch Session Error',
        description: 'Failed to fetch session.',
        variant: 'destructive'
      });
    }
    if (typeof authUrls === 'string') {
      toast({
        title: 'Session Error',
        description: 'Failed to generate session URLs. Please refresh the page.',
        variant: 'destructive'
      });
    }

    if (typeof session === 'string' || typeof authUrls === 'string') {
      dispatch({
        type: 'UPDATE_STATE',
        payload: { fetching: false } as SessionContext,
      });
      return false;
    }

    dispatch({
      type: 'UPDATE_STATE',
      payload: {
        ...session,
        ...authUrls,
        fetching: false
      } as SessionContext,
    });

    return true;
  };

  const login = (username: string, password: string) => {
    dispatch({
      type: 'UPDATE_STATE',
      payload: { fetching: true } as SessionContext,
    });
    return loginApiCall(username, password)
      .then((success) => {
        if (typeof success === 'string') {
          toast({
            title: 'Login Error',
            description: success,
            variant: 'destructive'
          });
          dispatch({
            type: 'UPDATE_STATE',
            payload: { fetching: false } as SessionContext,
          });
          return false;
        }
        
        return fetchSession();
    });
  };

  // Fetch customer data.
  const fetchSession = () => {
    dispatch({
      type: 'UPDATE_STATE',
      payload: { fetching: true } as SessionContext,
    });

    return getSessionApiCall()
      .then(async (sessionPayload) => {
        const authUrlPayload = await fetchAuthURLsApiCall();
        return setSession(sessionPayload, authUrlPayload);
      });
  };

  useEffect(() => {
    if (state.fetching) {
      return;
    }

    if (!state.customer || !state.cart) {
      fetchSession();
    }
  }, []);
      

  const store: SessionContext = {
    ...
    refetch: fetchSession,
    login,
  };
  return (
    <Provider value={store}>{children}</Provider>
  );
}

export const useSession = () => useContext(sessionContext);

After updating the SessionProvider, the Login and other User Nav options should work as expected, although you may need to style your WP installation to look like you Next application for a seamless experience.

Conclusion

In the next tutorial we'll be completing our application with the single product page and cart options. Hopefully, this tutorial kept you entertained and I'll see you in the next one.

Continue to final chapter