Building Headless Shops With WooGraphQL: Chapter 4 of 5
Geoff Taylor
Jul 19, 2023·1 min read
Heads up: this tutorial covers an earlier WooGraphQL release and is no longer up to date. A rewritten version is in progress — we’ll link to it from here once it’s published.
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.
Geoff Taylor
Meh, I code stuff.
Creator of WooGraphQL.
WPGraphQL Enthusiast
Keep reading.
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.
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
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:
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.
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
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.
With the all the utility files created. Let’s move onto the Route pages.
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.
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.
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.
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.