From c0ec77141fb1d7a713d91219b8777bc541780ae8 Mon Sep 17 00:00:00 2001 From: Michelle Chen Date: Thu, 8 Feb 2024 10:46:15 -0500 Subject: [PATCH] passing customer login to checkout fix (#1719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pass login param to checkout * dont use chaining to update cookie * refactor cart promise * move to cart.get method * update roots * update doc * fix no cart issue * add doc * Improve the Customer Account API README instrunctions * Fix orders route missing fullfilmentStatus * Add CSP ngrok domain instructions for connect-src * Fix ngrok CSP rule * add buyer identity action back * remove csp option * ♻️ the api to pass customerAccount into cart from the server --------- Co-authored-by: Juan P. Prieto --- .changeset/soft-sloths-double.md | 7 ++ .../cli/src/lib/setups/i18n/replacers.test.ts | 1 + .../docs/generated/generated_docs_data.json | 106 +++++++++++++++++- .../hydrogen/src/cart/cart-test-helper.ts | 3 + .../hydrogen/src/cart/createCartHandler.ts | 4 + .../src/cart/queries/cartGetDefault.test.ts | 40 +++++++ .../src/cart/queries/cartGetDefault.ts | 55 ++++++--- packages/hydrogen/src/customer/BadRequest.ts | 4 +- .../hydrogen/src/customer/auth.helpers.ts | 3 + packages/hydrogen/src/customer/customer.ts | 15 ++- templates/demo-store/app/root.tsx | 8 +- .../demo-store/app/routes/($locale).cart.tsx | 6 +- templates/demo-store/server.ts | 1 + templates/skeleton/README.md | 27 ++++- templates/skeleton/app/root.tsx | 2 - .../app/routes/account.orders._index.tsx | 4 +- templates/skeleton/app/routes/cart.tsx | 6 +- templates/skeleton/server.ts | 1 + 18 files changed, 255 insertions(+), 38 deletions(-) create mode 100644 .changeset/soft-sloths-double.md diff --git a/.changeset/soft-sloths-double.md b/.changeset/soft-sloths-double.md new file mode 100644 index 0000000000..0958460e2d --- /dev/null +++ b/.changeset/soft-sloths-double.md @@ -0,0 +1,7 @@ +--- +'skeleton': patch +'@shopify/hydrogen': patch +--- + +🐛 Fix issue where customer login does not persist to checkout +✨ Add `customerAccount` option to `createCartHandler`. Where a `?logged_in=true` will be added to the checkoutUrl for cart query if a customer is logged in. diff --git a/packages/cli/src/lib/setups/i18n/replacers.test.ts b/packages/cli/src/lib/setups/i18n/replacers.test.ts index 5aa7feb913..7c5334217b 100644 --- a/packages/cli/src/lib/setups/i18n/replacers.test.ts +++ b/packages/cli/src/lib/setups/i18n/replacers.test.ts @@ -210,6 +210,7 @@ describe('i18n replacers', () => { */ const cart = createCartHandler({ storefront, + customerAccount, getCartId: cartGetIdDefault(request.headers), setCartId: cartSetIdDefault(), cartQueryFragment: CART_QUERY_FRAGMENT, diff --git a/packages/hydrogen/docs/generated/generated_docs_data.json b/packages/hydrogen/docs/generated/generated_docs_data.json index 95e32c17fd..0a9b582462 100644 --- a/packages/hydrogen/docs/generated/generated_docs_data.json +++ b/packages/hydrogen/docs/generated/generated_docs_data.json @@ -4628,9 +4628,9 @@ "description": "", "params": [ { - "name": "options", + "name": "input1", "description": "", - "value": "CartQueryOptions", + "value": "CartGetOptions", "filePath": "/cart/queries/cartGetDefault.ts" } ], @@ -4640,7 +4640,14 @@ "name": "CartGetFunction", "value": "CartGetFunction" }, - "value": "export function cartGetDefault(options: CartQueryOptions): CartGetFunction {\n return async (cartInput?: CartGetProps) => {\n const cartId = options.getCartId();\n\n if (!cartId) return null;\n\n const {cart, errors} = await options.storefront.query<{\n cart: Cart;\n errors: StorefrontApiErrors;\n }>(CART_QUERY(options.cartFragment), {\n variables: {\n cartId,\n ...cartInput,\n },\n cache: options.storefront.CacheNone(),\n });\n\n return formatAPIResult(cart, errors);\n };\n}" + "value": "export function cartGetDefault({\n storefront,\n customerAccount,\n getCartId,\n cartFragment,\n}: CartGetOptions): CartGetFunction {\n return async (cartInput?: CartGetProps) => {\n const cartId = getCartId();\n\n if (!cartId) return null;\n\n const [isCustomerLoggedIn, {cart, errors}] = await Promise.all([\n customerAccount ? customerAccount.isLoggedIn() : false,\n storefront.query<{\n cart: Cart;\n errors: StorefrontApiErrors;\n }>(CART_QUERY(cartFragment), {\n variables: {\n cartId,\n ...cartInput,\n },\n cache: storefront.CacheNone(),\n }),\n ]);\n\n return formatAPIResult(\n addCustomerLoggedInParam(isCustomerLoggedIn, cart),\n errors,\n );\n };\n}" + }, + "CartGetOptions": { + "filePath": "/cart/queries/cartGetDefault.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CartGetOptions", + "value": "CartQueryOptions & {\n /**\n * The customer account client instance created by [`createCustomerAccountClient`](docs/api/hydrogen/latest/utilities/createcustomeraccountclient).\n */\n customerAccount?: CustomerAccount;\n}", + "description": "" }, "CartQueryOptions": { "filePath": "/cart/queries/cart-types.ts", @@ -4979,6 +4986,99 @@ ], "value": "export interface AllCacheOptions {\n /**\n * The caching mode, generally `public`, `private`, or `no-store`.\n */\n mode?: string;\n /**\n * The maximum amount of time in seconds that a resource will be considered fresh. See `max-age` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#:~:text=Response%20Directives-,max%2Dage,-The%20max%2Dage).\n */\n maxAge?: number;\n /**\n * Indicate that the cache should serve the stale response in the background while revalidating the cache. See `stale-while-revalidate` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-while-revalidate).\n */\n staleWhileRevalidate?: number;\n /**\n * Similar to `maxAge` but specific to shared caches. See `s-maxage` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#s-maxage).\n */\n sMaxAge?: number;\n /**\n * Indicate that the cache should serve the stale response if an error occurs while revalidating the cache. See `stale-if-error` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-if-error).\n */\n staleIfError?: number;\n}" }, + "CustomerAccount": { + "filePath": "/customer/types.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CustomerAccount", + "value": "{\n /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the customer to a Shopify login domain. It also defined the final path the customer lands on at the end of the oAuth flow with the value of the `return_to` query param. (This is automatically setup unless `customAuthStatusHandler` option is in use) */\n login: () => Promise;\n /** On successful login, the customer redirects back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings in admin. */\n authorize: () => Promise;\n /** Returns if the customer is logged in. It also checks if the access token is expired and refreshes it if needed. */\n isLoggedIn: () => Promise;\n /** Check for a not logged in customer and redirect customer to login page. The redirect can be overwritten with `customAuthStatusHandler` option. */\n handleAuthStatus: () => void | DataFunctionValue;\n /** Returns CustomerAccessToken if the customer is logged in. It also run a expiry check and does a token refresh if needed. */\n getAccessToken: () => Promise;\n /** Creates the fully-qualified URL to your store's GraphQL endpoint.*/\n getApiUrl: () => string;\n /** Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin.*/\n logout: () => Promise;\n /** Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query. */\n query: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n query: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountQueries,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n /** Execute a GraphQL mutation against the Customer Account API. This method execute `handleAuthStatus()` ahead of mutation. */\n mutate: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n mutation: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountMutations,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n}", + "description": "", + "members": [ + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "login", + "value": "() => Promise", + "description": "Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the customer to a Shopify login domain. It also defined the final path the customer lands on at the end of the oAuth flow with the value of the `return_to` query param. (This is automatically setup unless `customAuthStatusHandler` option is in use)" + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "authorize", + "value": "() => Promise", + "description": "On successful login, the customer redirects back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings in admin." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "isLoggedIn", + "value": "() => Promise", + "description": "Returns if the customer is logged in. It also checks if the access token is expired and refreshes it if needed." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "handleAuthStatus", + "value": "() => void | DataFunctionValue", + "description": "Check for a not logged in customer and redirect customer to login page. The redirect can be overwritten with `customAuthStatusHandler` option." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "getAccessToken", + "value": "() => Promise", + "description": "Returns CustomerAccessToken if the customer is logged in. It also run a expiry check and does a token refresh if needed." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "getApiUrl", + "value": "() => string", + "description": "Creates the fully-qualified URL to your store's GraphQL endpoint." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "logout", + "value": "() => Promise", + "description": "Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "query", + "value": "(query: RawGqlString, ...options: IsOptionalVariables> extends true ? [({} & ClientVariables>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>)?] : [{} & ClientVariables>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>]) => Promise>>", + "description": "Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "mutate", + "value": "(mutation: RawGqlString, ...options: IsOptionalVariables> extends true ? [({} & ClientVariables>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>)?] : [{} & ClientVariables>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>]) => Promise>>", + "description": "Execute a GraphQL mutation against the Customer Account API. This method execute `handleAuthStatus()` ahead of mutation." + } + ] + }, + "DataFunctionValue": { + "filePath": "/customer/types.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "DataFunctionValue", + "value": "Response | NonNullable | null", + "description": "" + }, + "CustomerAccountQueries": { + "filePath": "/customer/types.ts", + "name": "CustomerAccountQueries", + "description": "", + "members": [], + "value": "export interface CustomerAccountQueries {\n // Example of how a generated query type looks like:\n // '#graphql query q1 {...}': {return: Q1Query; variables: Q1QueryVariables};\n}" + }, + "CustomerAccountMutations": { + "filePath": "/customer/types.ts", + "name": "CustomerAccountMutations", + "description": "", + "members": [], + "value": "export interface CustomerAccountMutations {\n // Example of how a generated mutation type looks like:\n // '#graphql mutation m1 {...}': {return: M1Mutation; variables: M1MutationVariables};\n}" + }, "CartGetFunction": { "filePath": "/cart/queries/cartGetDefault.ts", "name": "CartGetFunction", diff --git a/packages/hydrogen/src/cart/cart-test-helper.ts b/packages/hydrogen/src/cart/cart-test-helper.ts index b612e0d97b..76c7b5d41e 100644 --- a/packages/hydrogen/src/cart/cart-test-helper.ts +++ b/packages/hydrogen/src/cart/cart-test-helper.ts @@ -5,6 +5,8 @@ import {CacheNone} from '../cache/strategies'; export const CART_ID = 'gid://shopify/Cart/c1-123'; export const NEW_CART_ID = 'c1-new-cart-id'; +export const CHECKOUT_URL = + 'https://demostore.mock.shop/cart/c/Z2NwLXVzLWNlbnRyYWwxOjAxSE5aSFBWVjhKSEc5NDA5MTlWM0ZTUVJE?key=66f3266a23df83f84f2aee087ec244b2'; function storefrontQuery( query: string, @@ -16,6 +18,7 @@ function storefrontQuery( return Promise.resolve({ cart: { id: payload?.variables?.cartId, + checkoutUrl: CHECKOUT_URL, query, }, }); diff --git a/packages/hydrogen/src/cart/createCartHandler.ts b/packages/hydrogen/src/cart/createCartHandler.ts index f52009e7d6..89a6139409 100644 --- a/packages/hydrogen/src/cart/createCartHandler.ts +++ b/packages/hydrogen/src/cart/createCartHandler.ts @@ -1,4 +1,5 @@ import {Storefront} from '../storefront'; +import type {CustomerAccount} from '../customer/types'; import {type CartGetFunction, cartGetDefault} from './queries/cartGetDefault'; import { type CartCreateFunction, @@ -47,6 +48,7 @@ import { export type CartHandlerOptions = { storefront: Storefront; + customerAccount?: CustomerAccount; getCartId: () => string | undefined; setCartId: (cartId: string) => Headers; cartQueryFragment?: string; @@ -97,6 +99,7 @@ export function createCartHandler( getCartId, setCartId, storefront, + customerAccount, cartQueryFragment, cartMutateFragment, } = options; @@ -113,6 +116,7 @@ export function createCartHandler( const methods: HydrogenCart = { get: cartGetDefault({ storefront, + customerAccount, getCartId, cartFragment: cartQueryFragment, }), diff --git a/packages/hydrogen/src/cart/queries/cartGetDefault.test.ts b/packages/hydrogen/src/cart/queries/cartGetDefault.test.ts index d5c8fca2d3..1f2f2b4b98 100644 --- a/packages/hydrogen/src/cart/queries/cartGetDefault.test.ts +++ b/packages/hydrogen/src/cart/queries/cartGetDefault.test.ts @@ -1,6 +1,7 @@ import {describe, it, expect} from 'vitest'; import {cartGetDefault} from './cartGetDefault'; import {CART_ID, mockCreateStorefrontClient} from '../cart-test-helper'; +import type {CustomerAccount} from '../../customer/types'; describe('cartGetDefault', () => { it('should return a default cart get implementation', async () => { @@ -40,4 +41,43 @@ describe('cartGetDefault', () => { // @ts-expect-error expect(result.query).toContain(cartFragment); }); + + it('should return a cartId passed in', async () => { + const cartGet = cartGetDefault({ + storefront: mockCreateStorefrontClient(), + getCartId: () => CART_ID, + }); + + const result = await cartGet({cartId: 'gid://shopify/Cart/c1-456'}); + + expect(result).toHaveProperty('id', 'gid://shopify/Cart/c1-456'); + }); + + describe('run with customerAccount option', () => { + it('should add logged_in search param to checkout link if customer is logged in', async () => { + const cartGet = cartGetDefault({ + storefront: mockCreateStorefrontClient(), + customerAccount: { + isLoggedIn: () => Promise.resolve(true), + } as CustomerAccount, + getCartId: () => CART_ID, + }); + + const result = await cartGet(); + expect(result?.checkoutUrl).toContain('logged_in=true'); + }); + + it('should NOT add logged_in search param to checkout link if customer is NOT logged in', async () => { + const cartGet = cartGetDefault({ + storefront: mockCreateStorefrontClient(), + customerAccount: { + isLoggedIn: () => Promise.resolve(false), + } as CustomerAccount, + getCartId: () => CART_ID, + }); + + const result = await cartGet(); + expect(result?.checkoutUrl).not.toContain('logged_in=true'); + }); + }); }); diff --git a/packages/hydrogen/src/cart/queries/cartGetDefault.ts b/packages/hydrogen/src/cart/queries/cartGetDefault.ts index ecaa3a9820..a2ac2f2c0f 100644 --- a/packages/hydrogen/src/cart/queries/cartGetDefault.ts +++ b/packages/hydrogen/src/cart/queries/cartGetDefault.ts @@ -1,4 +1,5 @@ import {StorefrontApiErrors, formatAPIResult} from '../../storefront'; +import type {CustomerAccount} from '../../customer/types'; import type {CartQueryOptions, CartReturn} from './cart-types'; import type { Cart, @@ -33,27 +34,55 @@ export type CartGetFunction = ( cartInput?: CartGetProps, ) => Promise; -export function cartGetDefault(options: CartQueryOptions): CartGetFunction { +type CartGetOptions = CartQueryOptions & { + /** + * The customer account client instance created by [`createCustomerAccountClient`](docs/api/hydrogen/latest/utilities/createcustomeraccountclient). + */ + customerAccount?: CustomerAccount; +}; + +export function cartGetDefault({ + storefront, + customerAccount, + getCartId, + cartFragment, +}: CartGetOptions): CartGetFunction { return async (cartInput?: CartGetProps) => { - const cartId = options.getCartId(); + const cartId = getCartId(); if (!cartId) return null; - const {cart, errors} = await options.storefront.query<{ - cart: Cart; - errors: StorefrontApiErrors; - }>(CART_QUERY(options.cartFragment), { - variables: { - cartId, - ...cartInput, - }, - cache: options.storefront.CacheNone(), - }); + const [isCustomerLoggedIn, {cart, errors}] = await Promise.all([ + customerAccount ? customerAccount.isLoggedIn() : false, + storefront.query<{ + cart: Cart; + errors: StorefrontApiErrors; + }>(CART_QUERY(cartFragment), { + variables: { + cartId, + ...cartInput, + }, + cache: storefront.CacheNone(), + }), + ]); - return formatAPIResult(cart, errors); + return formatAPIResult( + addCustomerLoggedInParam(isCustomerLoggedIn, cart), + errors, + ); }; } +function addCustomerLoggedInParam(isCustomerLoggedIn: boolean, cart: Cart) { + if (isCustomerLoggedIn && cart && cart.checkoutUrl) { + const finalCheckoutUrl = new URL(cart.checkoutUrl); + finalCheckoutUrl.searchParams.set('logged_in', 'true'); + cart.checkoutUrl = finalCheckoutUrl.toString(); + } + + return cart; +} + //! @see https://shopify.dev/docs/api/storefront/latest/queries/cart const CART_QUERY = (cartFragment = DEFAULT_CART_FRAGMENT) => `#graphql query CartQuery( diff --git a/packages/hydrogen/src/customer/BadRequest.ts b/packages/hydrogen/src/customer/BadRequest.ts index 33733c7e60..ea39e65e98 100644 --- a/packages/hydrogen/src/customer/BadRequest.ts +++ b/packages/hydrogen/src/customer/BadRequest.ts @@ -1,11 +1,11 @@ export class BadRequest extends Response { - constructor(message?: string, helpMessage?: string) { + constructor(message?: string, helpMessage?: string, headers?: HeadersInit) { // A lot of things can go wrong when configuring the customer account api // oauth flow. In dev mode, log a helper message. if (helpMessage && process.env.NODE_ENV === 'development') { console.error('Customer Account API Error: ' + helpMessage); } - super(`Bad request: ${message}`, {status: 400}); + super(`Bad request: ${message}`, {status: 400, headers}); } } diff --git a/packages/hydrogen/src/customer/auth.helpers.ts b/packages/hydrogen/src/customer/auth.helpers.ts index 1737c0116d..f5ff9e1d42 100644 --- a/packages/hydrogen/src/customer/auth.helpers.ts +++ b/packages/hydrogen/src/customer/auth.helpers.ts @@ -191,6 +191,9 @@ export async function checkExpires({ throw new BadRequest( 'Unauthorized', 'Login before querying the Customer Account API.', + { + 'Set-Cookie': await session.commit(), + }, ); } } diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts index e2dddc2548..4a039c5f70 100644 --- a/packages/hydrogen/src/customer/customer.ts +++ b/packages/hydrogen/src/customer/customer.ts @@ -151,7 +151,12 @@ export function createCustomerAccountClient({ if (response.status === 401) { // clear session because current access token is invalid clearSession(session); - throw authStatusHandler(); + + const authFailResponse = authStatusHandler(); + if (authFailResponse instanceof Response) { + authFailResponse.headers.set('Set-Cookie', await session.commit()); + } + throw authFailResponse; } /** @@ -302,17 +307,25 @@ export function createCustomerAccountClient({ if (!code || !state) { clearSession(session); + throw new BadRequest( 'Unauthorized', 'No code or state parameter found in the redirect URL.', + { + 'Set-Cookie': await session.commit(), + }, ); } if (session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.state !== state) { clearSession(session); + throw new BadRequest( 'Unauthorized', 'The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerAccountClient`.', + { + 'Set-Cookie': await session.commit(), + }, ); } diff --git a/templates/demo-store/app/root.tsx b/templates/demo-store/app/root.tsx index 9a26727810..204ef0a559 100644 --- a/templates/demo-store/app/root.tsx +++ b/templates/demo-store/app/root.tsx @@ -72,9 +72,11 @@ export const useRootLoaderData = () => { }; export async function loader({request, context}: LoaderFunctionArgs) { - const {storefront, cart} = context; + const {storefront, customerAccount, cart} = context; const layout = await getLayoutData(context); - const isLoggedInPromise = context.customerAccount.isLoggedIn(); + + const isLoggedInPromise = customerAccount.isLoggedIn(); + const cartPromise = cart.get(); const seo = seoPayload.root({shop: layout.shop, url: request.url}); @@ -83,7 +85,7 @@ export async function loader({request, context}: LoaderFunctionArgs) { isLoggedIn: isLoggedInPromise, layout, selectedLocale: storefront.i18n, - cart: cart.get(), + cart: cartPromise, analytics: { shopifySalesChannel: ShopifySalesChannel.hydrogen, shopId: layout.shop.id, diff --git a/templates/demo-store/app/routes/($locale).cart.tsx b/templates/demo-store/app/routes/($locale).cart.tsx index e89fd58715..71e84fd57f 100644 --- a/templates/demo-store/app/routes/($locale).cart.tsx +++ b/templates/demo-store/app/routes/($locale).cart.tsx @@ -14,10 +14,7 @@ import {useRootLoaderData} from '~/root'; export async function action({request, context}: ActionFunctionArgs) { const {cart} = context; - const [formData, customerAccessToken] = await Promise.all([ - request.formData(), - context.customerAccount.getAccessToken(), - ]); + const formData = await request.formData(); const {action, inputs} = CartForm.getFormInput(formData); invariant(action, 'No cartAction defined'); @@ -51,7 +48,6 @@ export async function action({request, context}: ActionFunctionArgs) { case CartForm.ACTIONS.BuyerIdentityUpdate: result = await cart.updateBuyerIdentity({ ...inputs.buyerIdentity, - customerAccessToken, }); break; default: diff --git a/templates/demo-store/server.ts b/templates/demo-store/server.ts index fecd352cf9..68f6c5c8ce 100644 --- a/templates/demo-store/server.ts +++ b/templates/demo-store/server.ts @@ -66,6 +66,7 @@ export default { const cart = createCartHandler({ storefront, + customerAccount, getCartId: cartGetIdDefault(request.headers), setCartId: cartSetIdDefault(), }); diff --git a/templates/skeleton/README.md b/templates/skeleton/README.md index ccca8d2f75..1f422eedef 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -41,12 +41,18 @@ npm run dev ## Setup for using Customer Account API (`/account` section) +### Enabled new Customer Account Experience + +1. Go to your Shopify admin => Settings => Customer accounts => New customer account + ### Setup public domain using ngrok 1. Setup a [ngrok](https://ngrok.com/) account and add a permanent domain (ie. `https://.app`). 1. Install the [ngrok CLI](https://ngrok.com/download) to use in terminal 1. Start ngrok using `ngrok http --domain=.app 3000` +> [!IMPORTANT] +> To successfully interact with the Customer Account API routes you will need to use the ngrok domain during development instead of localhost ### Include public domain in Customer Account API settings 1. Go to your Shopify admin => `Hydrogen` or `Headless` app/channel => Customer Account API => Application setup @@ -54,10 +60,27 @@ npm run dev 1. Edit `Javascript origin(s)` to include your public domain `https://.app` or keep it blank 1. Edit `Logout URI` to include your public domain `https://.app` or keep it blank +### Add the ngrok domain to the CSP policy + +Modify your `/app/entry.server.tsx` to allow the ngrok domain as a connect-src + +```diff +- const {nonce, header, NonceProvider} = createContentSecurityPolicy() ++ const {nonce, header, NonceProvider} = createContentSecurityPolicy({ ++ connectSrc: [ ++ 'wss://.app:*', // Your ngrok websocket domain ++ ], ++ }); +``` + ### Prepare Environment variables Run [`npx shopify hydrogen link`](https://shopify.dev/docs/custom-storefronts/hydrogen/cli#link) or [`npx shopify hydrogen env pull`](https://shopify.dev/docs/custom-storefronts/hydrogen/cli#env-pull) to link this app to your own test shop. -Alternatly, the values of the required environment varaibles "PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID" and "PUBLIC_CUSTOMER_ACCOUNT_API_URL" can be found in customer account api settings in the Hydrogen admin channel. +Alternately, the values of the required environment variables "PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID" and "PUBLIC_CUSTOMER_ACCOUNT_API_URL" can be found in customer account api settings in the Hydrogen admin channel. + +> [!IMPORTANT] +> Note that `mock.shop` doesn't supply these variables automatically and your own test shop is required for using Customer Account API -🗒️ Note that mock.shop doesn't supply these variables automatically. +> [!NOTE] +> B2B features such as contextual pricing is not available in SF API with Customer Account API login. If you require this feature, we suggest using the [legacy-customer-account-flow](https://github.com/Shopify/hydrogen/tree/main/examples/legacy-customer-account-flow). This feature should be available in the Customer Account API in the 2024-04 release. diff --git a/templates/skeleton/app/root.tsx b/templates/skeleton/app/root.tsx index c9a9c512b4..89fe68bab1 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx @@ -73,8 +73,6 @@ export async function loader({context}: LoaderFunctionArgs) { const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN; const isLoggedInPromise = customerAccount.isLoggedIn(); - - // defer the cart query by not awaiting it const cartPromise = cart.get(); // defer the footer query (below the fold) diff --git a/templates/skeleton/app/routes/account.orders._index.tsx b/templates/skeleton/app/routes/account.orders._index.tsx index 2d6abe0105..a7409395bd 100644 --- a/templates/skeleton/app/routes/account.orders._index.tsx +++ b/templates/skeleton/app/routes/account.orders._index.tsx @@ -95,7 +95,7 @@ function EmptyOrders() { } function OrderItem({order}: {order: OrderItemFragment}) { - const fulfillmentStatus = flattenConnection(order.fulfillments)[0].status; + const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status; return ( <>
@@ -104,7 +104,7 @@ function OrderItem({order}: {order: OrderItemFragment}) {

{new Date(order.processedAt).toDateString()}

{order.financialStatus}

-

{fulfillmentStatus}

+ {fulfillmentStatus &&

{fulfillmentStatus}

} View Order →
diff --git a/templates/skeleton/app/routes/cart.tsx b/templates/skeleton/app/routes/cart.tsx index 6399c1a567..495b71143b 100644 --- a/templates/skeleton/app/routes/cart.tsx +++ b/templates/skeleton/app/routes/cart.tsx @@ -13,10 +13,7 @@ export const meta: MetaFunction = () => { export async function action({request, context}: ActionFunctionArgs) { const {cart} = context; - const [formData, customerAccessToken] = await Promise.all([ - request.formData(), - await context.customerAccount.getAccessToken(), - ]); + const formData = await request.formData(); const {action, inputs} = CartForm.getFormInput(formData); @@ -54,7 +51,6 @@ export async function action({request, context}: ActionFunctionArgs) { case CartForm.ACTIONS.BuyerIdentityUpdate: { result = await cart.updateBuyerIdentity({ ...inputs.buyerIdentity, - customerAccessToken, }); break; } diff --git a/templates/skeleton/server.ts b/templates/skeleton/server.ts index ec5a2ddbf7..9b8416f71e 100644 --- a/templates/skeleton/server.ts +++ b/templates/skeleton/server.ts @@ -70,6 +70,7 @@ export default { */ const cart = createCartHandler({ storefront, + customerAccount, getCartId: cartGetIdDefault(request.headers), setCartId: cartSetIdDefault(), cartQueryFragment: CART_QUERY_FRAGMENT,