Merge 8c4e8f9ee0204c065b55e01d87534f0b22ca44cd into 3a18f9a0986db76e2d300a80466276a1040687b1

This commit is contained in:
Shlomo 2024-03-10 21:21:41 -04:00 committed by GitHub
commit 7c0a6a16b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1332 additions and 8 deletions

View File

@ -5,3 +5,8 @@ SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
# for customer account api
# SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID=""
# SHOPIFY_CUSTOMER_ACCOUNT_API_URL=""
# SHOPIFY_CUSTOMER_API_VERSION=""
# SHOPIFY_ORIGIN_URL=""

4
.gitignore vendored
View File

@ -32,6 +32,10 @@ yarn-error.log*
# vercel
.vercel
.local
.upm
.replit
.replit.nix
# typescript
*.tsbuildinfo

View File

@ -74,3 +74,20 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
## Vercel, Next.js Commerce, and Shopify Integration Guide
You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.
## Shopify Customer Accounts
This fork is designed to provide a basic implementation of [Shopify's new Customer Accounts API](https://shopify.dev/docs/api/customer), which will allow a customer to login into their Next.js Shopify Website to update information and view orders, see Shopify's [launch announcement](https://www.shopify.com/partners/blog/introducing-customer-account-api-for-headless-stores) to learn more.
It is based on Shopify's Hydrogen implementation and uses the concepts of Next.js middleware and server actions to implement the Shopify Customer Accounts API Integration. All the new code for the Customer Accounts API is included in: lib/shopify/customer folder, middleware.ts, and components/account
The following files were changed in the core commerce repo:
- components/cart/index.tsx (to add logged_in true for checkout for Customer Account)
- components/layout/navbar/index.tsx (to add a login button to menu)
- components/cart/modal.tsx (had to fix a TS error here)
- lib/utils.ts (add required ENV)
- README
- env.example
For instructions on how to get everything working properly, please see [Setup for using Shopify Customer Account API](https://www.dalicommerce.com/docs/nextjs/create-a-headless-shopify-nextjs#iii-setup-for-using-shopify-customer-account-api-log-in-and-account-section)

View File

@ -0,0 +1,25 @@
import { headers } from 'next/headers';
export const runtime = 'edge';
export default async function AuthorizationPage() {
const headersList = headers();
const access = headersList.get('x-shop-access');
if (!access) {
console.log('ERROR: No access header');
throw new Error('No access header');
}
console.log('Authorize Access code header:', access);
if (access === 'denied') {
console.log('Access Denied for Auth');
throw new Error('No access allowed');
}
return (
<>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full">Loading...</div>
</div>
</div>
</>
);
}

16
app/(auth)/login/page.tsx Normal file
View File

@ -0,0 +1,16 @@
import { LoginMessage } from 'components/auth/login-message';
export const runtime = 'edge'; //this needs to be here on thie page. I don't know why
export default async function LoginPage() {
return (
<>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full">
<LoginMessage />
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,13 @@
export const runtime = 'edge';
export default async function LogoutPage() {
return (
<>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full">Loading...</div>
</div>
</div>
</>
);
}

85
app/account/page.tsx Normal file
View File

@ -0,0 +1,85 @@
import { headers } from 'next/headers';
import { AccountProfile } from 'components/account/account-profile';
import { AccountOrdersHistory } from 'components/account/account-orders-history';
import { redirect } from 'next/navigation';
import { shopifyCustomerFetch } from 'lib/shopify/customer/index';
import { CUSTOMER_DETAILS_QUERY } from 'lib/shopify/customer/queries/customer';
import { CustomerDetailsData } from 'lib/shopify/customer/types';
import { TAGS } from 'lib/shopify/customer/constants';
export const runtime = 'edge';
export default async function AccountPage() {
const headersList = headers();
const access = headersList.get('x-shop-customer-token');
if (!access) {
console.log('ERROR: No access header account');
//I'm not sure what's better here. Throw error or just log out??
//redirect gets rid of call cookies
redirect('/logout');
//throw new Error("No access header")
}
//console.log("Authorize Access code header:", access)
if (access === 'denied') {
console.log('Access Denied for Auth account');
redirect('/logout');
//throw new Error("No access allowed")
}
const customerAccessToken = access;
//this is needed b/c of strange way server components handle redirects etc.
//see https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#redirecting
//can only redirect outside of try/catch!
let success = true;
let errorMessage;
let customerData;
let orders;
try {
const responseCustomerDetails = await shopifyCustomerFetch<CustomerDetailsData>({
customerToken: customerAccessToken,
cache: 'no-store',
query: CUSTOMER_DETAILS_QUERY,
tags: [TAGS.customer]
});
//console.log("userDetails", responseCustomerDetails)
const userDetails = responseCustomerDetails.body;
if (!userDetails) {
throw new Error('Error getting actual user data Account page.');
}
customerData = userDetails?.data?.customer;
orders = customerData?.orders?.edges;
//console.log ("Details",orders)
} catch (e) {
//they don't recognize this error in TS!
//@ts-ignore
errorMessage = e?.error?.toString() ?? 'Unknown Error';
console.log('error customer fetch account', e);
if (errorMessage !== 'unauthorized') {
throw new Error('Error getting actual user data Account page.');
} else {
console.log('Unauthorized access. Set to false and redirect');
success = false;
}
}
if (!success && errorMessage === 'unauthorized') redirect('/logout');
//revalidateTag('posts') // Update cached posts //FIX
return (
<>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full">
<div> Welcome: {customerData?.emailAddress.emailAddress}</div>
</div>
<div className="h-full w-full">
<div className="mt-5">
<AccountProfile />
</div>
</div>
<div className="h-full w-full">
<div className="mt-5">{orders && <AccountOrdersHistory orders={orders} />}</div>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,41 @@
'use client';
type OrderCardsProps = {
orders: any;
};
export function AccountOrdersHistory({ orders }: { orders: any }) {
return (
<div className="mt-6">
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
<h2 className="text-lead font-bold">Order History</h2>
{orders?.length ? <Orders orders={orders} /> : <EmptyOrders />}
</div>
</div>
);
}
function EmptyOrders() {
return (
<div>
<div className="mb-1">You haven&apos;t placed any orders yet.</div>
<div className="w-48">
<button
className="mt-2 w-full text-sm"
//variant="secondary"
>
Start Shopping
</button>
</div>
</div>
);
}
function Orders({ orders }: OrderCardsProps) {
return (
<ul className="false grid grid-flow-row grid-cols-1 gap-2 gap-y-6 sm:grid-cols-3 md:gap-4 lg:gap-6">
{orders.map((order: any) => (
<li key={order.node.id}>{order.node.number}</li>
))}
</ul>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import clsx from 'clsx';
import { ArrowRightIcon as LogOutIcon } from '@heroicons/react/24/outline';
import { doLogout } from './actions';
import LoadingDots from 'components/loading-dots';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton(props: any) {
const { pending } = useFormStatus();
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
return (
<>
<button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Log Out"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
'cursor-not-allowed opacity-60 hover:opacity-60': pending
})}
>
<div className="absolute left-0 ml-4">
{pending ? <LoadingDots className="mb-3 bg-white" /> : <LogOutIcon className="h-5" />}
</div>
{pending ? 'Logging out...' : 'Log Out'}
</button>
{props?.message && <div className="my-5">{props?.message}</div>}
</>
);
}
export function AccountProfile() {
const [message, formAction] = useFormState(doLogout, null);
return (
<form action={formAction}>
<SubmitButton message={message} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@ -0,0 +1,40 @@
'use server';
import { TAGS } from 'lib/shopify/customer/constants';
import { removeAllCookiesServerAction } from 'lib/shopify/customer/auth-helpers';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { SHOPIFY_ORIGIN, SHOPIFY_CUSTOMER_ACCOUNT_API_URL } from 'lib/shopify/customer/constants';
//import {generateCodeVerifier,generateCodeChallenge,generateRandomString} from 'lib/shopify/customer/auth-utils'
export async function doLogout(prevState: any) {
//let logoutUrl = '/logout'
const origin = SHOPIFY_ORIGIN;
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
let logoutUrl;
try {
const idToken = cookies().get('shop_id_token');
const idTokenValue = idToken?.value;
if (!idTokenValue) {
//you can also throw an error here with page and middleware
//throw new Error ("Error No Id Token")
//if there is no idToken, then sending to logout url will redirect shopify, so just
//redirect to login here and delete cookies (presumably they don't even exist)
logoutUrl = new URL(`${origin}/login`);
} else {
logoutUrl = new URL(
`${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}`
);
}
await removeAllCookiesServerAction();
revalidateTag(TAGS.customer);
} catch (e) {
console.log('Error', e);
//you can throw error here or return - return goes back to form b/c of state, throw will throw the error boundary
//throw new Error ("Error")
return 'Error logging out. Please try again';
}
redirect(`${logoutUrl}`); // Navigate to the new post page
}

View File

@ -0,0 +1,69 @@
//See https://react.dev/reference/react-dom/hooks/useFormState
//https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#forms
'use server';
import { TAGS } from 'lib/shopify/customer/constants';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
//import { getOrigin } from 'lib/shopify/customer'
import {
generateCodeVerifier,
generateCodeChallenge,
generateRandomString
} from 'lib/shopify/customer/auth-utils';
import {
SHOPIFY_CUSTOMER_ACCOUNT_API_URL,
SHOPIFY_CLIENT_ID,
SHOPIFY_ORIGIN
} from 'lib/shopify/customer/constants';
export async function doLogin(prevState: any) {
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
const clientId = SHOPIFY_CLIENT_ID;
const origin = SHOPIFY_ORIGIN;
const loginUrl = new URL(`${customerAccountApiUrl}/auth/oauth/authorize`);
//console.log ("previous", prevState)
try {
//await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
loginUrl.searchParams.set('client_id', clientId);
loginUrl.searchParams.append('response_type', 'code');
loginUrl.searchParams.append('redirect_uri', `${origin}/authorize`);
loginUrl.searchParams.set(
'scope',
'openid email https://api.customers.com/auth/customer.graphql'
);
const verifier = await generateCodeVerifier();
//const newVerifier = verifier.replace("+", '_').replace("-",'_').replace("/",'_').trim()
const challenge = await generateCodeChallenge(verifier);
cookies().set('shop_verifier', verifier as string, {
// @ts-ignore
//expires: auth?.expires, //not necessary here
});
const state = await generateRandomString();
const nonce = await generateRandomString();
cookies().set('shop_state', state as string, {
// @ts-ignore
//expires: auth?.expires, //not necessary here
});
cookies().set('shop_nonce', nonce as string, {
// @ts-ignore
//expires: auth?.expires, //not necessary here
});
loginUrl.searchParams.append('state', state);
loginUrl.searchParams.append('nonce', nonce);
loginUrl.searchParams.append('code_challenge', challenge);
loginUrl.searchParams.append('code_challenge_method', 'S256');
//console.log ("loginURL", loginUrl)
//throw new Error ("Error") //this is how you throw an error, if you want to. Then the catch will execute
} catch (e) {
console.log('Error', e);
//you can throw error here or return - return goes back to form b/c of state, throw will throw the error boundary
//throw new Error ("Error")
return 'Error logging in. Please try again';
}
revalidateTag(TAGS.customer);
redirect(`${loginUrl}`); // Navigate to the new post page
}

View File

@ -0,0 +1,51 @@
'use client';
import clsx from 'clsx';
import { doLogin } from './actions';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton(props: any) {
const { pending } = useFormStatus();
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
//const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
return (
<>
{props?.message && <div className="my-5">{props?.message}</div>}
<button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Log in"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
'cursor-not-allowed opacity-60 hover:opacity-60': pending
})}
>
{pending ? (
<>
<span>Logging In...</span>
</>
) : (
<>
<span>Log-In</span>
</>
)}
</button>
</>
);
}
export function LoginShopify() {
const [message, formAction] = useFormState(doLogin, null);
return (
<form action={formAction}>
<SubmitButton message={message} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@ -0,0 +1,8 @@
export function LoginMessage() {
return (
<div>
<h2>Error</h2>
<span>Your session has expired. Please log in again.</span>
</div>
);
}

19
components/auth/login.tsx Normal file
View File

@ -0,0 +1,19 @@
import { cookies } from 'next/headers';
import { LoginShopify } from 'components/auth/login-form';
import { UserIcon } from 'components/auth/user-icon';
export default async function Login() {
const customerToken = cookies().get('shop_customer_token')?.value;
const refreshToken = cookies().get('shop_refresh_token')?.value;
let isLoggedIn;
//obviously just checking for the cookies without verifying the cookie itself is not ideal. However, the cookie is validated on the
//account page, so a "fake" cookie does nothing, except show the UI and then it would be deleted when clicking on account
//so for now, just checking the cookie for the UI is sufficient. Alternatively, we can do a query here, or a custom JWT
if (!customerToken && !refreshToken) {
isLoggedIn = false;
} else {
isLoggedIn = true;
}
console.log('LoggedIn', isLoggedIn);
return isLoggedIn ? <UserIcon /> : <LoginShopify />;
}

View File

@ -0,0 +1,30 @@
'use client';
import { UserIcon as User2Icon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
function UserButton(props: any) {
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
//const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
return (
<>
<button
aria-label="My Profile"
className={clsx(buttonClasses, {
'hover:opacity-90': true
})}
>
{/*Purposesly a href here and NOT Link component b/c of router caching*/}
<a href="/account">
<User2Icon className="mr-2 h-4 w-4" />
<span>Profile</span>
</a>
</button>
</>
);
}
export function UserIcon() {
return <UserButton />;
}

View File

@ -1,14 +1,24 @@
import { getCart } from 'lib/shopify';
import { cookies } from 'next/headers';
import CartModal from './modal';
import type { Cart } from 'lib/shopify/types';
export default async function Cart() {
const cartId = cookies().get('cartId')?.value;
let cart;
if (cartId) {
cart = await getCart(cartId);
}
cart = (await getCart(cartId)) as Cart;
//pass logged_in true to shopify checout to utilize customer api
//see: https://shopify.dev/docs/api/customer#step-stay-authenticated-on-checkout
const newCheckoutUrl = new URL(cart?.checkoutUrl || '');
newCheckoutUrl.searchParams.append('logged_in', 'true');
cart = {
...cart,
checkoutUrl: newCheckoutUrl.toString()
};
return <CartModal cart={cart} />;
} else {
return <CartModal cart={cart} />;
}
}

View File

@ -18,7 +18,7 @@ type MerchandiseSearchParams = {
[key: string]: string;
};
export default function CartModal({ cart }: { cart: Cart | undefined }) {
export default function CartModal({ cart }: { cart?: Cart }) {
const [isOpen, setIsOpen] = useState(false);
const quantityRef = useRef(cart?.totalQuantity);
const openCart = () => setIsOpen(true);

View File

@ -7,6 +7,7 @@ import Link from 'next/link';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';
import Search from './search';
import Login from 'components/auth/login';
const { SITE_NAME } = process.env;
export default async function Navbar() {
@ -44,10 +45,27 @@ export default async function Navbar() {
<Search />
</div>
<div className="flex justify-end md:w-1/3">
<div className="relative flex items-center">
<div className="flex h-full flex-row items-center justify-items-end gap-4">
<div
className="text-primary relative ml-4 flex cursor-pointer items-center
outline-none transition duration-100 ease-in-out sm:ml-6"
>
<Suspense fallback={<OpenCart />}>
<Cart />
</Suspense>
</div>
<div
className="text-primary relative ml-4 flex cursor-pointer items-center
outline-none transition duration-100 ease-in-out sm:ml-6"
>
<Suspense fallback={<p>Login</p>}>
<Login />
</Suspense>
</div>
</div>
</div>
</div>
</div>
</nav>
);

View File

@ -0,0 +1,286 @@
//you need to remain this as type so as not to confuse with the actual function
import type { NextRequest, NextResponse as NextResponseType } from 'next/server';
import { cookies } from 'next/headers';
import { getNonce } from 'lib/shopify/customer/auth-utils';
import {
SHOPIFY_CUSTOMER_ACCOUNT_API_URL,
SHOPIFY_USER_AGENT,
SHOPIFY_CLIENT_ID
} from './constants';
export async function initialAccessToken(
request: NextRequest,
newOrigin: string,
customerAccountApiUrl: string,
clientId: string
) {
const code = request.nextUrl.searchParams.get('code');
const state = request.nextUrl.searchParams.get('state');
/*
STEP 1: Check for all necessary cookies and other information
*/
if (!code) {
console.log('Error: No Code Auth');
return { success: false, message: `No Code` };
}
if (!state) {
console.log('Error: No State Auth');
return { success: false, message: `No State` };
}
const shopState = request.cookies.get('shop_state');
const shopStateValue = shopState?.value;
if (!shopStateValue) {
console.log('Error: No Shop State Value');
return { success: false, message: `No Shop State` };
}
if (state !== shopStateValue) {
console.log('Error: Shop state mismatch');
return { success: false, message: `No Shop State Mismatch` };
}
const codeVerifier = request.cookies.get('shop_verifier');
const codeVerifierValue = codeVerifier?.value;
if (!codeVerifierValue) {
console.log('No Code Verifier');
return { success: false, message: `No Code Verifier` };
}
/*
STEP 2: GET ACCESS TOKEN
*/
const body = new URLSearchParams();
body.append('grant_type', 'authorization_code');
body.append('client_id', clientId);
body.append('redirect_uri', `${newOrigin}/authorize`);
body.append('code', code);
body.append('code_verifier', codeVerifier?.value);
const userAgent = '*';
const headersNew = new Headers();
headersNew.append('Content-Type', 'application/x-www-form-urlencoded');
headersNew.append('User-Agent', userAgent);
headersNew.append('Origin', newOrigin || '');
const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`;
const response = await fetch(tokenRequestUrl, {
method: 'POST',
headers: headersNew,
body
});
const data = await response.json();
console.log('data initial access token', data);
if (!response.ok) {
console.log('data response error auth', data.error);
console.log('response auth', response.status);
return { success: false, message: `Response error auth` };
}
if (data?.errors) {
const errorMessage = data?.errors?.[0]?.message ?? 'Unknown error auth';
return { success: false, message: `${errorMessage}` };
}
const nonce = await getNonce(data?.id_token || '');
const shopNonce = request.cookies.get('shop_nonce');
const shopNonceValue = shopNonce?.value;
console.log('sent nonce', nonce);
console.log('original nonce', shopNonceValue);
if (nonce !== shopNonceValue) {
//make equal === to force error for testing
console.log('Error nonce match');
return { success: false, message: `Error: Nonce mismatch` };
}
return { success: true, data };
}
export async function exchangeAccessToken(
token: string,
customerAccountId: string,
customerAccountApiUrl: string,
origin: string
) {
const clientId = customerAccountId;
//this is a constant - see the docs. https://shopify.dev/docs/api/customer#useaccesstoken-propertydetail-audience
const customerApiClientId = '30243aa5-17c1-465a-8493-944bcc4e88aa';
const accessToken = token;
const body = new URLSearchParams();
body.append('grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange');
body.append('client_id', clientId);
body.append('audience', customerApiClientId);
body.append('subject_token', accessToken);
body.append('subject_token_type', 'urn:ietf:params:oauth:token-type:access_token');
body.append('scopes', 'https://api.customers.com/auth/customer.graphql');
const userAgent = '*';
const headers = new Headers();
headers.append('Content-Type', 'application/x-www-form-urlencoded');
headers.append('User-Agent', userAgent);
headers.append('Origin', origin);
// Token Endpoint goes here
const response = await fetch(`${customerAccountApiUrl}/auth/oauth/token`, {
method: 'POST',
headers,
body
});
const data = await response.json();
if (data.error) {
return { success: false, data: data?.error_description };
}
return { success: true, data };
}
export async function refreshToken({ request, origin }: { request: NextRequest; origin: string }) {
const newBody = new URLSearchParams();
const refreshToken = request.cookies.get('shop_refresh_token');
const refreshTokenValue = refreshToken?.value;
if (!refreshTokenValue) {
console.log('Error: No Refresh Token');
return { success: false, message: `no_refresh_token` };
}
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
const clientId = SHOPIFY_CLIENT_ID;
const userAgent = SHOPIFY_USER_AGENT;
newBody.append('grant_type', 'refresh_token');
newBody.append('refresh_token', refreshTokenValue);
newBody.append('client_id', clientId);
const headers = {
'content-type': 'application/x-www-form-urlencoded',
'User-Agent': userAgent,
Origin: origin
};
const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`;
const response = await fetch(tokenRequestUrl, {
method: 'POST',
headers,
body: newBody
});
if (!response.ok) {
const text = await response.text();
console.log('response error in refresh token', text);
return { success: false, message: `no_refresh_token` };
}
const data = await response.json();
console.log('data response from initial fetch to refresh', data);
const { access_token, expires_in, refresh_token } = data;
const customerAccessToken = await exchangeAccessToken(
access_token,
clientId,
customerAccountApiUrl,
origin
);
// console.log("Customer Access Token in refresh request", customerAccessToken)
if (!customerAccessToken.success) {
return { success: false, message: `no_refresh_token` };
}
//const expiresAt = new Date(new Date().getTime() + (expires_in - 120) * 1000).getTime() + ''
//const idToken = id_token
return {
success: true,
data: { customerAccessToken: customerAccessToken.data.access_token, expires_in, refresh_token }
};
}
export async function checkExpires({
request,
expiresAt,
origin
}: {
request: NextRequest;
expiresAt: string;
origin: string;
}) {
let isExpired = false;
if (parseInt(expiresAt, 10) - 1000 < new Date().getTime()) {
isExpired = true;
console.log('Isexpired is true, we are running refresh token!');
const refresh = await refreshToken({ request, origin });
console.log('refresh', refresh);
//this will return success: true or success: false - depending on result of refresh
return { ranRefresh: isExpired, refresh };
}
console.log('is expired is false - just sending back success', isExpired);
return { ranRefresh: isExpired, success: true };
}
export function removeAllCookies(response: NextResponseType) {
//response.cookies.delete('shop_auth_token') //never set. We don't use it anywhere.
response.cookies.delete('shop_customer_token');
response.cookies.delete('shop_refresh_token');
response.cookies.delete('shop_id_token');
response.cookies.delete('shop_state');
response.cookies.delete('shop_nonce');
response.cookies.delete('shop_verifier');
response.cookies.delete('shop_expires_at');
return response;
}
export async function removeAllCookiesServerAction() {
cookies().delete('shop_customer_token');
cookies().delete('shop_refresh_token');
cookies().delete('shop_id_token');
cookies().delete('shop_state');
cookies().delete('shop_nonce');
cookies().delete('shop_verifier');
cookies().delete('shop_expires_at');
return { success: true };
}
export async function createAllCookies({
response,
customerAccessToken,
expires_in,
refresh_token,
expiresAt,
id_token
}: {
response: NextResponseType;
customerAccessToken: string;
expires_in: number;
refresh_token: string;
expiresAt: string;
id_token?: string;
}) {
response.cookies.set('shop_customer_token', customerAccessToken, {
httpOnly: true, //if true can only read the cookie in server
sameSite: 'lax',
secure: true,
path: '/',
maxAge: expires_in //value from shopify, seems like this is 2 hours
});
//you need to set an expiration here, because otherwise its a sessions cookie
//and will disappear after the user closes the browser and then we can never refresh - same with expires at below
response.cookies.set('shop_refresh_token', refresh_token, {
httpOnly: true, //if true can only read the cookie in server
sameSite: 'lax',
secure: true,
path: '/',
maxAge: 604800 //one week
});
//you need to set an expiration here, because otherwise its a sessions cookie
//and will disappear after the user closes the browser and then we can never refresh
response.cookies.set('shop_expires_at', expiresAt, {
httpOnly: true, //if true can only read the cookie in server
sameSite: 'lax',
secure: true,
path: '/',
maxAge: 604800 //one week
});
//required for logout - this must be the same as the original expires - it;s long lived so they can logout, otherwise it will expire
//because that's how we got the token, if this is different, it won't work
//we don't always send in id_token here. For example, on refresh it's not available, it's only sent in on the initial authorization
if (id_token) {
response.cookies.set('shop_id_token', id_token, {
httpOnly: true, //if true can only read the cookie in server
sameSite: 'lax', //should be lax???
secure: true,
path: '/',
maxAge: 604800 //one week
});
}
return response;
}

View File

@ -0,0 +1,48 @@
// @ts-nocheck
export async function generateCodeVerifier() {
const randomCode = generateRandomCode();
return base64UrlEncode(randomCode);
}
export async function generateCodeChallenge(codeVerifier: string) {
const digestOp = await crypto.subtle.digest(
{ name: 'SHA-256' },
new TextEncoder().encode(codeVerifier)
);
const hash = convertBufferToString(digestOp);
return base64UrlEncode(hash);
}
function generateRandomCode() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return String.fromCharCode.apply(null, Array.from(array));
}
function base64UrlEncode(str: string) {
const base64 = btoa(str);
// This is to ensure that the encoding does not have +, /, or = characters in it.
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function convertBufferToString(hash: ArrayBuffer) {
const uintArray = new Uint8Array(hash);
const numberArray = Array.from(uintArray);
return String.fromCharCode(...numberArray);
}
export async function generateRandomString() {
const timestamp = Date.now().toString();
const randomString = Math.random().toString(36).substring(2);
return timestamp + randomString;
}
export async function getNonce(token: string) {
return decodeJwt(token).payload.nonce;
}
function decodeJwt(token: string) {
const [header, payload, signature] = token.split('.');
const decodedHeader = JSON.parse(atob(header || ''));
const decodedPayload = JSON.parse(atob(payload || ''));
return {
header: decodedHeader,
payload: decodedPayload,
signature
};
}

View File

@ -0,0 +1,10 @@
export const TAGS = {
customer: 'customer'
};
//ENVs
export const SHOPIFY_CUSTOMER_ACCOUNT_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL || '';
export const SHOPIFY_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || '';
export const SHOPIFY_CUSTOMER_API_VERSION = process.env.SHOPIFY_CUSTOMER_API_VERSION || '';
export const SHOPIFY_USER_AGENT = '*';
export const SHOPIFY_ORIGIN = process.env.SHOPIFY_ORIGIN_URL || '';

View File

@ -0,0 +1,287 @@
import type { NextRequest, NextResponse as NextResponseType } from 'next/server';
import { NextResponse } from 'next/server';
//import { revalidateTag } from 'next/cache';
import {
checkExpires,
removeAllCookies,
initialAccessToken,
exchangeAccessToken,
createAllCookies
} from './auth-helpers';
import { isShopifyError } from 'lib/type-guards';
import { parseJSON } from 'lib/shopify/customer/utils/parse-json';
import {
SHOPIFY_CUSTOMER_ACCOUNT_API_URL,
SHOPIFY_USER_AGENT,
SHOPIFY_CUSTOMER_API_VERSION,
SHOPIFY_CLIENT_ID,
SHOPIFY_ORIGIN
} from './constants';
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
const apiVersion = SHOPIFY_CUSTOMER_API_VERSION;
const userAgent = SHOPIFY_USER_AGENT;
const customerEndpoint = `${customerAccountApiUrl}/account/customer/api/${apiVersion}/graphql`;
//NEVER CACHE THIS! Doesn't see to be cached anyway b/c
//https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching
//The fetch request comes after the usage of headers or cookies.
//and we always send this anyway after getting a cookie for the customer
export async function shopifyCustomerFetch<T>({
cache = 'no-store',
customerToken,
query,
tags,
variables
}: {
cache?: RequestCache;
customerToken: string;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
const customerOrigin = SHOPIFY_ORIGIN;
const result = await fetch(customerEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
Origin: customerOrigin,
Authorization: customerToken
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache: 'no-store', //NEVER CACHE THE CUSTOMER REQUEST!!!
...(tags && { next: { tags } })
});
const body = await result.json();
if (!result.ok) {
//the statuses here could be different, a 401 means
//https://shopify.dev/docs/api/customer#endpoints
//401 means the token is bad
console.log('Error in Customer Fetch Status', body.errors);
if (result.status === 401) {
// clear session because current access token is invalid
const errorMessage = 'unauthorized';
throw errorMessage; //this should throw in the catch below in the non-shopify catch
}
let errors;
try {
errors = parseJSON(body);
} catch (_e) {
errors = [{ message: body }];
}
throw errors;
}
//this just throws an error and the error boundary is called
if (body.errors) {
//throw 'Error'
console.log('Error in Customer Fetch', body.errors[0]);
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
cause: e.cause?.toString() || 'unknown',
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
export async function isLoggedIn(request: NextRequest, origin: string) {
const customerToken = request.cookies.get('shop_customer_token');
const customerTokenValue = customerToken?.value;
const refreshToken = request.cookies.get('shop_refresh_token');
const refreshTokenValue = refreshToken?.value;
const newHeaders = new Headers(request.headers);
if (!customerTokenValue && !refreshTokenValue) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
}
const expiresToken = request.cookies.get('shop_expires_at');
const expiresTokenValue = expiresToken?.value;
if (!expiresTokenValue) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
//return { success: false, message: `no_expires_at` }
}
const isExpired = await checkExpires({
request: request,
expiresAt: expiresTokenValue,
origin: origin
});
console.log('is Expired?', isExpired);
//only execute the code below to reset the cookies if it was expired!
if (isExpired.ranRefresh) {
const isSuccess = isExpired?.refresh?.success;
if (!isSuccess) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
//return { success: false, message: `no_refresh_token` }
} else {
const refreshData = isExpired?.refresh?.data;
//console.log ("refresh data", refreshData)
console.log('We used the refresh token, so now going to reset the token and cookies');
const newCustomerAccessToken = refreshData?.customerAccessToken;
const expires_in = refreshData?.expires_in;
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`);
const resetCookieResponse = NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
return await createAllCookies({
response: resetCookieResponse,
customerAccessToken: newCustomerAccessToken,
expires_in,
refresh_token: refreshData?.refresh_token,
expiresAt
});
}
}
newHeaders.set('x-shop-customer-token', `${customerTokenValue}`);
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
//when we are running on the production website we just get the origin from the request.nextUrl
export function getOrigin(request: NextRequest) {
const nextOrigin = request.nextUrl.origin;
//console.log("Current Origin", nextOrigin)
//when running localhost, we want to use fake origin otherwise we use the real origin
let newOrigin = nextOrigin;
if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') {
newOrigin = SHOPIFY_ORIGIN;
} else {
newOrigin = nextOrigin;
}
return newOrigin;
}
export async function authorizeFn(request: NextRequest, origin: string) {
const clientId = SHOPIFY_CLIENT_ID;
const newHeaders = new Headers(request.headers);
/***
STEP 1: Get the initial access token or deny access
****/
const dataInitialToken = await initialAccessToken(
request,
origin,
customerAccountApiUrl,
clientId
);
if (!dataInitialToken.success) {
console.log('Error: Access Denied. Check logs', dataInitialToken.message);
newHeaders.set('x-shop-access', 'denied');
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data;
/***
STEP 2: Get a Customer Access Token
****/
const customerAccessToken = await exchangeAccessToken(
access_token,
clientId,
customerAccountApiUrl,
origin || ''
);
if (!customerAccessToken.success) {
console.log('Error: Customer Access Token');
newHeaders.set('x-shop-access', 'denied');
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
//console.log("customer access Token", customerAccessToken.data.access_token)
/**STEP 3: Set Customer Access Token cookies
We are setting the cookies here b/c if we set it on the request, and then redirect
it doesn't see to set sometimes
**/
newHeaders.set('x-shop-access', 'allowed');
/*
const authResponse = NextResponse.next({
request: {
// New request headers
headers: newHeaders,
},
})
*/
const accountUrl = new URL(`${origin}/account`);
const authResponse = NextResponse.redirect(`${accountUrl}`);
//sets an expires time 2 minutes before expiration which we can use in refresh strategy
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
return await createAllCookies({
response: authResponse,
customerAccessToken: customerAccessToken?.data?.access_token,
expires_in,
refresh_token,
expiresAt,
id_token
});
}
export async function logoutFn(request: NextRequest, origin: string) {
//console.log("New Origin", newOrigin)
const idToken = request.cookies.get('shop_id_token');
const idTokenValue = idToken?.value;
//revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now
//if there is no idToken, then sending to logout url will redirect shopify, so just
//redirect to login here and delete cookies (presumably they don't even exist)
if (!idTokenValue) {
const logoutUrl = new URL(`${origin}/login`);
const response = NextResponse.redirect(`${logoutUrl}`);
return removeAllCookies(response);
}
//console.log ("id toke value", idTokenValue)
const logoutUrl = new URL(
`${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}`
);
//console.log ("logout url", logoutUrl)
const logoutResponse = NextResponse.redirect(logoutUrl);
return removeAllCookies(logoutResponse);
}

View File

@ -0,0 +1,97 @@
//https://shopify.dev/docs/api/customer/2024-01/queries/customer
export const CUSTOMER_ME_QUERY = /* GraphQL */ `
query customer {
customer {
emailAddress {
emailAddress
}
firstName
lastName
tags
}
}
`;
const CUSTOMER_FRAGMENT = `#graphql
fragment OrderCard on Order {
id
number
processedAt
financialStatus
fulfillments(first: 1) {
nodes {
status
}
}
totalPrice {
amount
currencyCode
}
lineItems(first: 2) {
edges {
node {
title
image {
altText
height
url
width
}
}
}
}
}
fragment AddressPartial on CustomerAddress {
id
formatted
firstName
lastName
company
address1
address2
territoryCode
zoneCode
city
zip
phoneNumber
}
fragment CustomerDetails on Customer {
firstName
lastName
phoneNumber {
phoneNumber
}
emailAddress {
emailAddress
}
defaultAddress {
...AddressPartial
}
addresses(first: 6) {
edges {
node {
...AddressPartial
}
}
}
orders(first: 250, sortKey: PROCESSED_AT, reverse: true) {
edges {
node {
...OrderCard
}
}
}
}
` as const;
// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
export const CUSTOMER_DETAILS_QUERY = `#graphql
query CustomerDetails {
customer {
...CustomerDetails
}
}
${CUSTOMER_FRAGMENT}
` as const;

View File

@ -0,0 +1,36 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type CustomerData = {
data: {
customer: {
emailAddress: {
emailAddress: string;
};
firstName: string;
lastName: string;
tags: any[];
};
};
};
export type GenericObject = { [key: string]: any };
export type CustomerDetailsData = {
data: {
customer: {
emailAddress: {
emailAddress: string;
};
// Using GenericObject to type 'orders' since the fields are not known in advance
orders: Connection<GenericObject>;
};
};
};

View File

@ -0,0 +1,7 @@
export function parseJSON(json: any) {
if (String(json).includes('__proto__')) return JSON.parse(json, noproto);
return JSON.parse(json);
}
function noproto(k: string, v: string) {
if (k !== '__proto__') return v;
}

View File

@ -11,7 +11,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
const requiredEnvironmentVariables = [
'SHOPIFY_STORE_DOMAIN',
'SHOPIFY_STOREFRONT_ACCESS_TOKEN',
'SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID',
'SHOPIFY_CUSTOMER_ACCOUNT_API_URL',
'SHOPIFY_CUSTOMER_API_VERSION',
'SHOPIFY_ORIGIN_URL'
];
const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => {

49
middleware.ts Normal file
View File

@ -0,0 +1,49 @@
import type { NextRequest } from 'next/server';
import { isLoggedIn, getOrigin, authorizeFn, logoutFn } from 'lib/shopify/customer';
// This function can be marked `async` if using `await` inside
export async function middleware(request: NextRequest) {
/****
Authorize Middleware to get access tokens
*****/
if (request.nextUrl.pathname.startsWith('/authorize')) {
console.log('Running Initial Authorization Middleware');
const origin = getOrigin(request);
//console.log ("origin", origin)
return await authorizeFn(request, origin);
}
/****
END OF Authorize Middleware to get access tokens
*****/
/****
LOGOUT -
*****/
if (request.nextUrl.pathname.startsWith('/logout')) {
console.log('Running Logout middleware');
const origin = getOrigin(request);
return await logoutFn(request, origin);
}
/****
END OF LOGOUT
*****/
/****
Account
*****/
if (request.nextUrl.pathname.startsWith('/account')) {
console.log('Running Account middleware');
//const newHeaders = new Headers(request.headers)
const origin = getOrigin(request);
//console.log ("origin", origin)
return await isLoggedIn(request, origin);
}
/****
END OF Account
*****/
}
export const config = {
matcher: ['/authorize', '/logout', '/account']
};