mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 23:46:58 +00:00
Merge 8c4e8f9ee0204c065b55e01d87534f0b22ca44cd into 3a18f9a0986db76e2d300a80466276a1040687b1
This commit is contained in:
commit
7c0a6a16b1
@ -5,3 +5,8 @@ SITE_NAME="Next.js Commerce"
|
|||||||
SHOPIFY_REVALIDATION_SECRET=""
|
SHOPIFY_REVALIDATION_SECRET=""
|
||||||
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
|
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
|
||||||
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
|
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
4
.gitignore
vendored
@ -32,6 +32,10 @@ yarn-error.log*
|
|||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
.local
|
||||||
|
.upm
|
||||||
|
.replit
|
||||||
|
.replit.nix
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
17
README.md
17
README.md
@ -74,3 +74,20 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
|
|||||||
## Vercel, Next.js Commerce, and Shopify Integration Guide
|
## 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.
|
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)
|
||||||
|
25
app/(auth)/authorize/page.tsx
Normal file
25
app/(auth)/authorize/page.tsx
Normal 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
16
app/(auth)/login/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
13
app/(auth)/logout/page.tsx
Normal file
13
app/(auth)/logout/page.tsx
Normal 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
85
app/account/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
41
components/account/account-orders-history.tsx
Normal file
41
components/account/account-orders-history.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
46
components/account/account-profile.tsx
Normal file
46
components/account/account-profile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
40
components/account/actions.ts
Normal file
40
components/account/actions.ts
Normal 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
|
||||||
|
}
|
69
components/auth/actions.ts
Normal file
69
components/auth/actions.ts
Normal 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
|
||||||
|
}
|
51
components/auth/login-form.tsx
Normal file
51
components/auth/login-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
8
components/auth/login-message.tsx
Normal file
8
components/auth/login-message.tsx
Normal 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
19
components/auth/login.tsx
Normal 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 />;
|
||||||
|
}
|
30
components/auth/user-icon.tsx
Normal file
30
components/auth/user-icon.tsx
Normal 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 />;
|
||||||
|
}
|
@ -1,14 +1,24 @@
|
|||||||
import { getCart } from 'lib/shopify';
|
import { getCart } from 'lib/shopify';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import CartModal from './modal';
|
import CartModal from './modal';
|
||||||
|
import type { Cart } from 'lib/shopify/types';
|
||||||
|
|
||||||
export default async function Cart() {
|
export default async function Cart() {
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
if (cartId) {
|
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} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CartModal cart={cart} />;
|
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ type MerchandiseSearchParams = {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
export default function CartModal({ cart }: { cart?: Cart }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const quantityRef = useRef(cart?.totalQuantity);
|
const quantityRef = useRef(cart?.totalQuantity);
|
||||||
const openCart = () => setIsOpen(true);
|
const openCart = () => setIsOpen(true);
|
||||||
|
@ -7,6 +7,7 @@ import Link from 'next/link';
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import MobileMenu from './mobile-menu';
|
import MobileMenu from './mobile-menu';
|
||||||
import Search from './search';
|
import Search from './search';
|
||||||
|
import Login from 'components/auth/login';
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export default async function Navbar() {
|
export default async function Navbar() {
|
||||||
@ -44,9 +45,26 @@ export default async function Navbar() {
|
|||||||
<Search />
|
<Search />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end md:w-1/3">
|
<div className="flex justify-end md:w-1/3">
|
||||||
<Suspense fallback={<OpenCart />}>
|
<div className="relative flex items-center">
|
||||||
<Cart />
|
<div className="flex h-full flex-row items-center justify-items-end gap-4">
|
||||||
</Suspense>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
286
lib/shopify/customer/auth-helpers.ts
Normal file
286
lib/shopify/customer/auth-helpers.ts
Normal 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;
|
||||||
|
}
|
48
lib/shopify/customer/auth-utils.ts
Normal file
48
lib/shopify/customer/auth-utils.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
10
lib/shopify/customer/constants.ts
Normal file
10
lib/shopify/customer/constants.ts
Normal 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 || '';
|
287
lib/shopify/customer/index.ts
Normal file
287
lib/shopify/customer/index.ts
Normal 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);
|
||||||
|
}
|
97
lib/shopify/customer/queries/customer.ts
Normal file
97
lib/shopify/customer/queries/customer.ts
Normal 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;
|
36
lib/shopify/customer/types.ts
Normal file
36
lib/shopify/customer/types.ts
Normal 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>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
7
lib/shopify/customer/utils/parse-json.ts
Normal file
7
lib/shopify/customer/utils/parse-json.ts
Normal 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;
|
||||||
|
}
|
@ -11,7 +11,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
|
|||||||
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
|
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
|
||||||
|
|
||||||
export const validateEnvironmentVariables = () => {
|
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[];
|
const missingEnvironmentVariables = [] as string[];
|
||||||
|
|
||||||
requiredEnvironmentVariables.forEach((envVar) => {
|
requiredEnvironmentVariables.forEach((envVar) => {
|
||||||
|
49
middleware.ts
Normal file
49
middleware.ts
Normal 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']
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user