From d4fd03d3d54806fcce230e7f9dbdace774a5719f Mon Sep 17 00:00:00 2001 From: Shlomo Date: Mon, 4 Mar 2024 17:32:01 +0000 Subject: [PATCH 1/8] Initial Customer Files --- .env.example | 7 - .gitignore | 4 + lib/shopify/customer/auth-helpers.ts | 285 ++++++++++++++++++++++ lib/shopify/customer/auth-utils.ts | 48 ++++ lib/shopify/customer/constants.ts | 10 + lib/shopify/customer/index.ts | 287 +++++++++++++++++++++++ lib/shopify/customer/queries/customer.ts | 97 ++++++++ lib/shopify/customer/types.ts | 36 +++ lib/shopify/customer/utils/parse-json.ts | 7 + replit.nix | 3 + 10 files changed, 777 insertions(+), 7 deletions(-) delete mode 100644 .env.example create mode 100644 lib/shopify/customer/auth-helpers.ts create mode 100644 lib/shopify/customer/auth-utils.ts create mode 100644 lib/shopify/customer/constants.ts create mode 100644 lib/shopify/customer/index.ts create mode 100644 lib/shopify/customer/queries/customer.ts create mode 100644 lib/shopify/customer/types.ts create mode 100644 lib/shopify/customer/utils/parse-json.ts create mode 100644 replit.nix diff --git a/.env.example b/.env.example deleted file mode 100644 index 9ff0463db9..0000000000 --- a/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -COMPANY_NAME="Vercel Inc." -TWITTER_CREATOR="@vercel" -TWITTER_SITE="https://nextjs.org/commerce" -SITE_NAME="Next.js Commerce" -SHOPIFY_REVALIDATION_SECRET="" -SHOPIFY_STOREFRONT_ACCESS_TOKEN="" -SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" diff --git a/.gitignore b/.gitignore index 0298027e4f..1eee959c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ yarn-error.log* # vercel .vercel +.local +.upm +.replit +.replit.nix # typescript *.tsbuildinfo diff --git a/lib/shopify/customer/auth-helpers.ts b/lib/shopify/customer/auth-helpers.ts new file mode 100644 index 0000000000..3cabe2b16a --- /dev/null +++ b/lib/shopify/customer/auth-helpers.ts @@ -0,0 +1,285 @@ +import type { NextRequest, NextResponse as NextResponseType } from 'next/server'; //you need to remain this as type so as not to confuse with the actual function +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', //should be 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', //should be 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', //should be 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; +} diff --git a/lib/shopify/customer/auth-utils.ts b/lib/shopify/customer/auth-utils.ts new file mode 100644 index 0000000000..63baa8a07f --- /dev/null +++ b/lib/shopify/customer/auth-utils.ts @@ -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 + }; +} diff --git a/lib/shopify/customer/constants.ts b/lib/shopify/customer/constants.ts new file mode 100644 index 0000000000..3c04d91624 --- /dev/null +++ b/lib/shopify/customer/constants.ts @@ -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 || ''; diff --git a/lib/shopify/customer/index.ts b/lib/shopify/customer/index.ts new file mode 100644 index 0000000000..57873472c8 --- /dev/null +++ b/lib/shopify/customer/index.ts @@ -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 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({ + cache = 'no-store', + customerToken, + query, + tags, + variables +}: { + cache?: RequestCache; + customerToken: string; + query: string; + tags?: string[]; + variables?: ExtractVariables; +}): 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); +} diff --git a/lib/shopify/customer/queries/customer.ts b/lib/shopify/customer/queries/customer.ts new file mode 100644 index 0000000000..a121101158 --- /dev/null +++ b/lib/shopify/customer/queries/customer.ts @@ -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; diff --git a/lib/shopify/customer/types.ts b/lib/shopify/customer/types.ts new file mode 100644 index 0000000000..2f9915148d --- /dev/null +++ b/lib/shopify/customer/types.ts @@ -0,0 +1,36 @@ +export type Maybe = T | null; + +export type Connection = { + edges: Array>; +}; + +export type Edge = { + 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; + }; + }; +}; diff --git a/lib/shopify/customer/utils/parse-json.ts b/lib/shopify/customer/utils/parse-json.ts new file mode 100644 index 0000000000..3bf536d16d --- /dev/null +++ b/lib/shopify/customer/utils/parse-json.ts @@ -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; +} diff --git a/replit.nix b/replit.nix new file mode 100644 index 0000000000..5a4647a2c5 --- /dev/null +++ b/replit.nix @@ -0,0 +1,3 @@ +{pkgs}: { + deps = [ ]; +} From 3ed3e34758163c70d30c1ad30ff4c869210ac13b Mon Sep 17 00:00:00 2001 From: Shlomo Date: Mon, 4 Mar 2024 18:02:30 +0000 Subject: [PATCH 2/8] Additional Files --- README.md | 28 ++++++++ app/(auth)/authorize/page.tsx | 25 +++++++ app/(auth)/login/page.tsx | 16 +++++ app/(auth)/logout/page.tsx | 13 ++++ components/account/account-orders-history.tsx | 41 +++++++++++ components/account/account-profile.tsx | 53 ++++++++++++++ components/account/actions.ts | 40 +++++++++++ components/auth/actions.ts | 69 +++++++++++++++++++ components/auth/login-form.tsx | 62 +++++++++++++++++ components/auth/login-message.tsx | 12 ++++ components/auth/login.tsx | 19 +++++ components/auth/user-icon.tsx | 33 +++++++++ middleware.ts | 50 ++++++++++++++ 13 files changed, 461 insertions(+) create mode 100644 app/(auth)/authorize/page.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/logout/page.tsx create mode 100644 components/account/account-orders-history.tsx create mode 100644 components/account/account-profile.tsx create mode 100644 components/account/actions.ts create mode 100644 components/auth/actions.ts create mode 100644 components/auth/login-form.tsx create mode 100644 components/auth/login-message.tsx create mode 100644 components/auth/login.tsx create mode 100644 components/auth/user-icon.tsx create mode 100644 middleware.ts diff --git a/README.md b/README.md index 981685d2ba..8f4262e7e0 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,31 @@ Your app should now be running on [localhost:3000](http://localhost:3000/). ## Vercel, Next.js Commerce, and Shopify Integration Guide You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel. + +## Shopify Customer Accounts + +This fork is designed to provide a basic implementation of [Shopify's new Customer Accounts API](), which will allow a customer to login into their Next.js Shopify Website to update information and view orders. It uses the concepts of Next.js middleware and server actions to implemnt the Shopify Customer Accounts API Integration. All the new code for the Customer Accounts API is included in: lib/shopify/customer folder, middleware + +The code for this repo is adapted for Next.js from code provided by Shopify + +To Set This Up, please follow: + +1. Get +2. Set up URLs +3. + +to do: env settings file + +https://shopify.dev/docs/custom-storefronts/building-with-the-customer-account-api/hydrogen +https://shopify.dev/docs/api/customer + +There are several issues that make this code much more complex on NextJs: + +1. Get can't origin in RSC - you can get this in middleware and pass down as props + https://blog.stackademic.com/how-next-js-middlewares-work-103cae315163 + +2. Can't set Cookies in RSC! + +So to do this correctly, we have to use a fixed origin based on ENV variables, which makes testing difficult. Can only test in one environment. + +And 2, we need to pass the tokens to a client component, which sets the cookies client side. We couldn't figure out any other way to get this to work. diff --git a/app/(auth)/authorize/page.tsx b/app/(auth)/authorize/page.tsx new file mode 100644 index 0000000000..723f8befb3 --- /dev/null +++ b/app/(auth)/authorize/page.tsx @@ -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 ( + <> +
+
+
Loading...
+
+
+ + ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000000..dce7c33dd4 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -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 ( + <> +
+
+
+ +
+
+
+ + ); +} diff --git a/app/(auth)/logout/page.tsx b/app/(auth)/logout/page.tsx new file mode 100644 index 0000000000..3b7080c251 --- /dev/null +++ b/app/(auth)/logout/page.tsx @@ -0,0 +1,13 @@ +export const runtime = 'edge'; + +export default async function LogoutPage() { + return ( + <> +
+
+
Loading...
+
+
+ + ); +} diff --git a/components/account/account-orders-history.tsx b/components/account/account-orders-history.tsx new file mode 100644 index 0000000000..6ad8046c3d --- /dev/null +++ b/components/account/account-orders-history.tsx @@ -0,0 +1,41 @@ +'use client'; +type OrderCardsProps = { + orders: any; +}; + +export function AccountOrdersHistory({ orders }: { orders: any }) { + return ( +
+
+

Order History

+ {orders?.length ? : } +
+
+ ); +} + +function EmptyOrders() { + return ( +
+
You haven't placed any orders yet.
+
+ +
+
+ ); +} + +function Orders({ orders }: OrderCardsProps) { + return ( +
    + {orders.map((order: any) => ( +
  • {order.node.number}
  • + ))} +
+ ); +} diff --git a/components/account/account-profile.tsx b/components/account/account-profile.tsx new file mode 100644 index 0000000000..b560fe3a74 --- /dev/null +++ b/components/account/account-profile.tsx @@ -0,0 +1,53 @@ +'use client'; +import clsx from 'clsx'; +import { LogOutIcon, TriangleIcon } from '@heroicons/react/24/outline'; +import { doLogout } from './actions'; +import LoadingDots from 'components/loading-dots'; +import { useFormState, useFormStatus } from 'react-dom'; +import { Alert, AlertDescription, AlertTitle } from 'components/ui/alert'; + +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 ( + <> + + {props?.message && ( + + + Error + {props?.message} + + )} + + ); +} + +export function AccountProfile() { + const [message, formAction] = useFormState(doLogout, null); + + return ( +
+ +

+ {message} +

+ + ); +} diff --git a/components/account/actions.ts b/components/account/actions.ts new file mode 100644 index 0000000000..1c4d526796 --- /dev/null +++ b/components/account/actions.ts @@ -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 +} diff --git a/components/auth/actions.ts b/components/auth/actions.ts new file mode 100644 index 0000000000..36517cfd98 --- /dev/null +++ b/components/auth/actions.ts @@ -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 +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000000..3730c52962 --- /dev/null +++ b/components/auth/login-form.tsx @@ -0,0 +1,62 @@ +'use client'; +import { ExclamationTriangleIcon, UserIcon as LogInIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import { doLogin } from './actions'; +import LoadingDots from 'components/loading-dots'; +import { useFormState, useFormStatus } from 'react-dom'; +import { Alert, AlertTitle } from 'components/ui/alert'; +import { Button } from 'components/ui/button'; + +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 && ( + + + {props?.message} + + )} + + + ); +} + +export function LoginShopify() { + const [message, formAction] = useFormState(doLogin, null); + + return ( +
+ +

+ {message} +

+ + ); +} diff --git a/components/auth/login-message.tsx b/components/auth/login-message.tsx new file mode 100644 index 0000000000..c2ee334610 --- /dev/null +++ b/components/auth/login-message.tsx @@ -0,0 +1,12 @@ +import { TriangleIcon } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from 'components/ui/alert'; + +export function LoginMessage() { + return ( + + + Error + Your session has expired. Please log in again. + + ); +} diff --git a/components/auth/login.tsx b/components/auth/login.tsx new file mode 100644 index 0000000000..8346013e12 --- /dev/null +++ b/components/auth/login.tsx @@ -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 ? : ; +} diff --git a/components/auth/user-icon.tsx b/components/auth/user-icon.tsx new file mode 100644 index 0000000000..40e3ded66b --- /dev/null +++ b/components/auth/user-icon.tsx @@ -0,0 +1,33 @@ +'use client'; +import { User2Icon } from 'lucide-react'; +import clsx from 'clsx'; +import { Button } from 'components/ui/button'; + +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 ( + <> + + + ); +} + +export function UserIcon() { + return ; +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000000..c8b3e49600 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,50 @@ +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) + //just cleaner to return everything in this one function and not have to pass back shit back and forth with booleans + return await isLoggedIn(request, origin); + } + + /**** + END OF Account + *****/ +} + +export const config = { + matcher: ['/authorize', '/logout', '/account'] +}; From b972e2387619742fa8b44029b90fa6e8b86b8842 Mon Sep 17 00:00:00 2001 From: Shlomo Date: Mon, 4 Mar 2024 21:11:59 +0000 Subject: [PATCH 3/8] Additional Files and Edits --- README.md | 9 +++++---- components/account/account-profile.tsx | 11 ++--------- components/auth/login-form.tsx | 15 ++++----------- components/auth/login-message.tsx | 12 ++++-------- components/auth/user-icon.tsx | 9 +++------ components/layout/navbar/index.tsx | 24 +++++++++++++++++++++--- 6 files changed, 39 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 8f4262e7e0..d2f6ddc16c 100644 --- a/README.md +++ b/README.md @@ -77,15 +77,16 @@ You can use this comprehensive [integration guide](http://vercel.com/docs/integr ## Shopify Customer Accounts -This fork is designed to provide a basic implementation of [Shopify's new Customer Accounts API](), which will allow a customer to login into their Next.js Shopify Website to update information and view orders. It uses the concepts of Next.js middleware and server actions to implemnt the Shopify Customer Accounts API Integration. All the new code for the Customer Accounts API is included in: lib/shopify/customer folder, middleware +This fork is designed to provide a basic implementation of [Shopify's new Customer Accounts API](), which will allow a customer to login into their Next.js Shopify Website to update information and view orders. It 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 The code for this repo is adapted for Next.js from code provided by Shopify To Set This Up, please follow: -1. Get -2. Set up URLs -3. +1. icons/client components +2. Get +3. Set up URLs +4. to do: env settings file diff --git a/components/account/account-profile.tsx b/components/account/account-profile.tsx index b560fe3a74..92bc2d867f 100644 --- a/components/account/account-profile.tsx +++ b/components/account/account-profile.tsx @@ -1,10 +1,9 @@ 'use client'; import clsx from 'clsx'; -import { LogOutIcon, TriangleIcon } from '@heroicons/react/24/outline'; +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'; -import { Alert, AlertDescription, AlertTitle } from 'components/ui/alert'; function SubmitButton(props: any) { const { pending } = useFormStatus(); @@ -28,13 +27,7 @@ function SubmitButton(props: any) { {pending ? 'Logging out...' : 'Log Out'} - {props?.message && ( - - - Error - {props?.message} - - )} + {props?.message &&
{props?.message}
} ); } diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 3730c52962..845c036e85 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -1,11 +1,9 @@ 'use client'; -import { ExclamationTriangleIcon, UserIcon as LogInIcon } from '@heroicons/react/24/outline'; +import { UserIcon as LogInIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import { doLogin } from './actions'; import LoadingDots from 'components/loading-dots'; import { useFormState, useFormStatus } from 'react-dom'; -import { Alert, AlertTitle } from 'components/ui/alert'; -import { Button } from 'components/ui/button'; function SubmitButton(props: any) { const { pending } = useFormStatus(); @@ -15,13 +13,8 @@ function SubmitButton(props: any) { return ( <> - {props?.message && ( - - - {props?.message} - - )} - + ); } diff --git a/components/auth/login-message.tsx b/components/auth/login-message.tsx index c2ee334610..f32d8bd1c6 100644 --- a/components/auth/login-message.tsx +++ b/components/auth/login-message.tsx @@ -1,12 +1,8 @@ -import { TriangleIcon } from 'lucide-react'; -import { Alert, AlertDescription, AlertTitle } from 'components/ui/alert'; - export function LoginMessage() { return ( - - - Error - Your session has expired. Please log in again. - +
+

Error

+ Your session has expired. Please log in again. +
); } diff --git a/components/auth/user-icon.tsx b/components/auth/user-icon.tsx index 40e3ded66b..fa39fdece4 100644 --- a/components/auth/user-icon.tsx +++ b/components/auth/user-icon.tsx @@ -1,7 +1,6 @@ 'use client'; -import { User2Icon } from 'lucide-react'; +import { UserIcon as User2Icon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; -import { Button } from 'components/ui/button'; function UserButton(props: any) { const buttonClasses = @@ -10,9 +9,7 @@ function UserButton(props: any) { return ( <> - + ); } diff --git a/components/layout/navbar/index.tsx b/components/layout/navbar/index.tsx index 0058d5ec8d..9c240fa062 100644 --- a/components/layout/navbar/index.tsx +++ b/components/layout/navbar/index.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import { Suspense } from 'react'; import MobileMenu from './mobile-menu'; import Search from './search'; +import Login from 'components/auth/login'; const { SITE_NAME } = process.env; export default async function Navbar() { @@ -44,9 +45,26 @@ export default async function Navbar() {
- }> - - +
+
+
+ }> + + +
+
+ Login

}> + +
+
+
+
From f2fcbde534536ab20e82599ec72bb084a61c06ba Mon Sep 17 00:00:00 2001 From: Shlomo Date: Tue, 5 Mar 2024 02:38:31 +0000 Subject: [PATCH 4/8] checkout from cart to keep user logged in --- README.md | 12 ++++++++---- components/auth/login-form.tsx | 6 +----- components/cart/index.tsx | 8 ++++++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d2f6ddc16c..ae0c355fbc 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,13 @@ You can use this comprehensive [integration guide](http://vercel.com/docs/integr ## Shopify Customer Accounts -This fork is designed to provide a basic implementation of [Shopify's new Customer Accounts API](), which will allow a customer to login into their Next.js Shopify Website to update information and view orders. It 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 +to do: env settings file +update Shopify stuff so points here +instructions + +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 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 The code for this repo is adapted for Next.js from code provided by Shopify @@ -86,9 +92,7 @@ To Set This Up, please follow: 1. icons/client components 2. Get 3. Set up URLs -4. - -to do: env settings file +4. Add the following ENV variables to your .env (and Vercel dashboard) https://shopify.dev/docs/custom-storefronts/building-with-the-customer-account-api/hydrogen https://shopify.dev/docs/api/customer diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 845c036e85..3410b0e84b 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -1,8 +1,6 @@ 'use client'; -import { UserIcon as LogInIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import { doLogin } from './actions'; -import LoadingDots from 'components/loading-dots'; import { useFormState, useFormStatus } from 'react-dom'; function SubmitButton(props: any) { @@ -27,12 +25,10 @@ function SubmitButton(props: any) { > {pending ? ( <> - - Logging In + Logging In... ) : ( <> - Log-In )} diff --git a/components/cart/index.tsx b/components/cart/index.tsx index 3e250ba93c..14d6a04c8f 100644 --- a/components/cart/index.tsx +++ b/components/cart/index.tsx @@ -8,6 +8,14 @@ export default async function Cart() { if (cartId) { cart = await getCart(cartId); + //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 ; From 41469d3f857f975e6de39f8727897d1a33311db7 Mon Sep 17 00:00:00 2001 From: Shlomo Date: Tue, 5 Mar 2024 22:04:33 +0000 Subject: [PATCH 5/8] fixing checkout url --- app/account/page.tsx | 85 +++++++++++++++++++++++++++++++++++++++ components/cart/index.tsx | 8 ++-- components/cart/modal.tsx | 2 +- lib/utils.ts | 9 ++++- 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 app/account/page.tsx diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000000..62eb7de5f3 --- /dev/null +++ b/app/account/page.tsx @@ -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({ + 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 ( + <> +
+
+
+
Welcome: {customerData?.emailAddress.emailAddress}
+
+
+
+ +
+
+
+
{orders && }
+
+
+
+ + ); +} diff --git a/components/cart/index.tsx b/components/cart/index.tsx index 14d6a04c8f..55e2c5123f 100644 --- a/components/cart/index.tsx +++ b/components/cart/index.tsx @@ -1,13 +1,14 @@ import { getCart } from 'lib/shopify'; import { cookies } from 'next/headers'; import CartModal from './modal'; +import type { Cart } from 'lib/shopify/types'; export default async function Cart() { const cartId = cookies().get('cartId')?.value; let cart; if (cartId) { - cart = await getCart(cartId); + cart = (await getCart(cartId)) as Cart; //pass logged_in true to shopify checout to utilize customer api //see: https://shopify.dev/docs/api/customer#step-stay-authenticated-on-checkout const newCheckoutUrl = new URL(cart?.checkoutUrl || ''); @@ -16,7 +17,8 @@ export default async function Cart() { ...cart, checkoutUrl: newCheckoutUrl.toString() }; + return ; + } else { + return ; } - - return ; } diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index aee2f7a47a..cecabcf601 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -18,7 +18,7 @@ type MerchandiseSearchParams = { [key: string]: string; }; -export default function CartModal({ cart }: { cart: Cart | undefined }) { +export default function CartModal({ cart }: { cart?: Cart }) { const [isOpen, setIsOpen] = useState(false); const quantityRef = useRef(cart?.totalQuantity); const openCart = () => setIsOpen(true); diff --git a/lib/utils.ts b/lib/utils.ts index 69b76d29b9..89df928008 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -11,7 +11,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) => stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`; export const validateEnvironmentVariables = () => { - const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN']; + const requiredEnvironmentVariables = [ + 'SHOPIFY_STORE_DOMAIN', + 'SHOPIFY_STOREFRONT_ACCESS_TOKEN', + 'SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID', + 'SHOPIFY_CUSTOMER_ACCOUNT_API_URL', + 'SHOPIFY_CUSTOMER_API_VERSION', + 'SHOPIFY_ORIGIN_URL' + ]; const missingEnvironmentVariables = [] as string[]; requiredEnvironmentVariables.forEach((envVar) => { From bd832f0d2046aa461984dbd147c9b50cbaf381a1 Mon Sep 17 00:00:00 2001 From: Shlomo Date: Mon, 11 Mar 2024 00:56:01 +0000 Subject: [PATCH 6/8] final changes --- .env.example | 12 +++++++++++ README.md | 32 ++++++---------------------- lib/shopify/customer/auth-helpers.ts | 9 ++++---- middleware.ts | 1 - 4 files changed, 24 insertions(+), 30 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..b3960fae2f --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +COMPANY_NAME="Vercel Inc." +TWITTER_CREATOR="@vercel" +TWITTER_SITE="https://nextjs.org/commerce" +SITE_NAME="Next.js Commerce" +SHOPIFY_REVALIDATION_SECRET="" +SHOPIFY_STOREFRONT_ACCESS_TOKEN="" +SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" +# for customer account api +# SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID="" +# SHOPIFY_CUSTOMER_ACCOUNT_API_URL="" +# SHOPIFY_CUSTOMER_API_VERSION="" +# SHOPIFY_ORIGIN_URL="" \ No newline at end of file diff --git a/README.md b/README.md index ae0c355fbc..a1c9870f9c 100644 --- a/README.md +++ b/README.md @@ -77,33 +77,15 @@ You can use this comprehensive [integration guide](http://vercel.com/docs/integr ## Shopify Customer Accounts -to do: env settings file -update Shopify stuff so points here -instructions - 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 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 - -The code for this repo is adapted for Next.js from code provided by Shopify - -To Set This Up, please follow: - -1. icons/client components -2. Get -3. Set up URLs -4. Add the following ENV variables to your .env (and Vercel dashboard) - -https://shopify.dev/docs/custom-storefronts/building-with-the-customer-account-api/hydrogen -https://shopify.dev/docs/api/customer - -There are several issues that make this code much more complex on NextJs: - -1. Get can't origin in RSC - you can get this in middleware and pass down as props - https://blog.stackademic.com/how-next-js-middlewares-work-103cae315163 +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 -2. Can't set Cookies in RSC! +The following files were changed in the core commerce repo: -So to do this correctly, we have to use a fixed origin based on ENV variables, which makes testing difficult. Can only test in one environment. +- components/cart/index.tsx (to add logged_in true for checkout) +- components/layout/navbar/index.tsx (to add a login button to menu) +- Read Me +- env.example -And 2, we need to pass the tokens to a client component, which sets the cookies client side. We couldn't figure out any other way to get this to work. +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) diff --git a/lib/shopify/customer/auth-helpers.ts b/lib/shopify/customer/auth-helpers.ts index 3cabe2b16a..314d5548c5 100644 --- a/lib/shopify/customer/auth-helpers.ts +++ b/lib/shopify/customer/auth-helpers.ts @@ -1,4 +1,5 @@ -import type { NextRequest, NextResponse as NextResponseType } from 'next/server'; //you need to remain this as type so as not to confuse with the actual function +//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 { @@ -242,7 +243,7 @@ export async function createAllCookies({ }) { response.cookies.set('shop_customer_token', customerAccessToken, { httpOnly: true, //if true can only read the cookie in server - sameSite: 'lax', //should be lax??? + sameSite: 'lax', secure: true, path: '/', maxAge: expires_in //value from shopify, seems like this is 2 hours @@ -252,7 +253,7 @@ export async function createAllCookies({ //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', //should be lax??? + sameSite: 'lax', secure: true, path: '/', maxAge: 604800 //one week @@ -262,7 +263,7 @@ export async function createAllCookies({ //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', //should be lax??? + sameSite: 'lax', secure: true, path: '/', maxAge: 604800 //one week diff --git a/middleware.ts b/middleware.ts index c8b3e49600..9d831e7f3b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -36,7 +36,6 @@ export async function middleware(request: NextRequest) { //const newHeaders = new Headers(request.headers) const origin = getOrigin(request); //console.log ("origin", origin) - //just cleaner to return everything in this one function and not have to pass back shit back and forth with booleans return await isLoggedIn(request, origin); } From 06f83f61ba67138789a7604e38385618ecb11d80 Mon Sep 17 00:00:00 2001 From: Shlomo Date: Mon, 11 Mar 2024 01:05:56 +0000 Subject: [PATCH 7/8] Fix to readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a1c9870f9c..08c5f6ff34 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,11 @@ It is based on Shopify's Hydrogen implementation and uses the concepts of Next.j The following files were changed in the core commerce repo: -- components/cart/index.tsx (to add logged_in true for checkout) +- 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) -- Read Me +- 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) From 8c4e8f9ee0204c065b55e01d87534f0b22ca44cd Mon Sep 17 00:00:00 2001 From: Shlomo Date: Sun, 10 Mar 2024 21:07:36 -0400 Subject: [PATCH 8/8] Delete replit.nix --- replit.nix | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 replit.nix diff --git a/replit.nix b/replit.nix deleted file mode 100644 index 5a4647a2c5..0000000000 --- a/replit.nix +++ /dev/null @@ -1,3 +0,0 @@ -{pkgs}: { - deps = [ ]; -}