mirror of
https://github.com/vercel/commerce.git
synced 2025-05-13 05:07:51 +00:00
add customer account api
This commit is contained in:
parent
8f82f6299e
commit
3694fef9a6
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>
|
||||
);
|
||||
}
|
38
components/account/actions.ts
Normal file
38
components/account/actions.ts
Normal file
@ -0,0 +1,38 @@
|
||||
'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';
|
||||
|
||||
export async function doLogout() {
|
||||
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 />;
|
||||
}
|
120
components/button.tsx
Normal file
120
components/button.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: [
|
||||
// base
|
||||
'relative inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-center text-sm font-medium transition-all duration-100 ease-in-out',
|
||||
// disabled
|
||||
'disabled:pointer-events-none disabled:shadow-none'
|
||||
],
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-xs px-2.5 py-1.5',
|
||||
md: 'text-sm px-3 py-2',
|
||||
lg: 'text-base px-4 py-2.5'
|
||||
},
|
||||
variant: {
|
||||
primary: [
|
||||
// border
|
||||
'border-transparent',
|
||||
// text color
|
||||
'text-white',
|
||||
// background color
|
||||
'bg-tremor-brand',
|
||||
// hover color
|
||||
'hover:bg-tremor-brand-emphasis',
|
||||
// disabled
|
||||
'disabled:bg-gray-100',
|
||||
'disabled:bg-tremor-brand-muted'
|
||||
],
|
||||
secondary: [
|
||||
// border
|
||||
'border-gray-300',
|
||||
// text color
|
||||
'text-gray-900',
|
||||
// background color
|
||||
' bg-white',
|
||||
//hover color
|
||||
'hover:bg-gray-50',
|
||||
// disabled
|
||||
'disabled:text-gray-400'
|
||||
],
|
||||
text: [
|
||||
// border
|
||||
'border-transparent',
|
||||
// text color
|
||||
'text-tremor-brand',
|
||||
// background color
|
||||
'bg-transparent',
|
||||
// hover color
|
||||
'disabled:text-gray-400'
|
||||
],
|
||||
destructive: [
|
||||
// text color
|
||||
'text-white',
|
||||
// border
|
||||
'border-transparent',
|
||||
// background color
|
||||
'bg-red-600',
|
||||
// hover color
|
||||
'hover:bg-red-700',
|
||||
// disabled
|
||||
'disabled:bg-red-300 disabled:text-white'
|
||||
]
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
});
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ComponentPropsWithoutRef<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
asChild,
|
||||
isLoading = false,
|
||||
loadingText,
|
||||
className,
|
||||
disabled,
|
||||
variant,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
forwardedRef
|
||||
) => {
|
||||
const Component = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Component
|
||||
ref={forwardedRef}
|
||||
className={clsx(buttonVariants({ variant }), className)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="pointer-events-none flex shrink-0 items-center justify-center gap-1.5">
|
||||
<span className="sr-only">{loadingText ? loadingText : 'Loading'}</span>
|
||||
{loadingText ? loadingText : children}
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
@ -9,6 +9,7 @@ type ManufacturersGridProps = {
|
||||
};
|
||||
|
||||
const ManufacturersGrid = ({ manufacturers, variant = 'home' }: ManufacturersGridProps) => {
|
||||
console.log('manufacturers', manufacturers);
|
||||
const popularManufacturers = manufacturers.filter(
|
||||
(manufacturer) => manufacturer.is_popular === 'true'
|
||||
);
|
||||
|
@ -5,12 +5,43 @@ import { ArrowRightIcon } from '@heroicons/react/16/solid';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { Fragment } from 'react';
|
||||
import OpenProfile from './open-profile';
|
||||
import { useFormState, useFormStatus } from 'react-dom';
|
||||
import { doLogin } from 'components/auth/actions';
|
||||
import { Button } from 'components/button';
|
||||
|
||||
type ProfilePopoverProps = {
|
||||
menu: Menu[];
|
||||
};
|
||||
|
||||
function SubmitButton(props: any) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
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}
|
||||
>
|
||||
{pending ? (
|
||||
<>
|
||||
<span>Logging In...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Log-In</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const ProfilePopover = ({ menu }: ProfilePopoverProps) => {
|
||||
const [message, formAction] = useFormState(doLogin, null);
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton aria-label="Open Profile Menu" className="flex">
|
||||
@ -25,15 +56,15 @@ const ProfilePopover = ({ menu }: ProfilePopoverProps) => {
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute -right-10 z-10 mt-2 w-72 max-w-lg px-4 sm:px-0 lg:right-0">
|
||||
<PopoverPanel className="absolute -right-10 z-50 mt-2 w-72 max-w-lg px-4 sm:px-0 lg:right-0">
|
||||
<div className="flex flex-col gap-2 overflow-hidden rounded-md bg-white px-4 py-3 text-black shadow-xl ring-1 ring-black/5">
|
||||
<span className="text-sm font-medium">My Account</span>
|
||||
<a
|
||||
href="#"
|
||||
className="mt-1 rounded-sm bg-primary p-2 text-center text-xs font-medium uppercase text-white hover:bg-secondary "
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<form action={formAction}>
|
||||
<SubmitButton message={message} />
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
</form>
|
||||
{menu.length ? (
|
||||
<ul className="mt-2 flex w-full flex-col divide-y text-sm">
|
||||
{menu.map((menuItem) => (
|
||||
|
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 || '';
|
285
lib/shopify/customer/index.ts
Normal file
285
lib/shopify/customer/index.ts
Normal file
@ -0,0 +1,285 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
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>({
|
||||
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',
|
||||
...(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;
|
||||
}
|
20
lib/supabase/index.ts
Normal file
20
lib/supabase/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
const uploadFile = async (req: Request, res: Response) => {
|
||||
const { file } = req.files as { file: UploadedFile[] };
|
||||
const { name, type, size } = file;
|
||||
|
||||
if (size > 1000000) {
|
||||
return res.status(400).json({ message: 'File size must be less than 1MB' });
|
||||
}
|
||||
|
||||
const uploadPath = path.join(__dirname, 'uploads', name);
|
||||
|
||||
try {
|
||||
await file.mv(uploadPath);
|
||||
res.json({ message: 'File uploaded' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ message: 'Error uploading file' });
|
||||
}
|
||||
};
|
||||
|
||||
export default uploadFile;
|
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']
|
||||
};
|
@ -26,6 +26,7 @@
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@hookform/resolvers": "^3.6.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"clsx": "^2.1.0",
|
||||
"geist": "^1.3.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
@ -37,6 +38,7 @@
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-tooltip": "^5.26.3",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwind-variants": "^0.2.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
4988
pnpm-lock.yaml
generated
4988
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user