From 67cddd1ea4e93c79b2a85b8322ff6d89b2436260 Mon Sep 17 00:00:00 2001 From: Bret Little Date: Tue, 8 Apr 2025 16:54:31 -0400 Subject: [PATCH] Fix the customer account implementation to clear all session data on logout --- .changeset/four-trainers-attend.md | 13 +++++++ .../hydrogen/src/customer/customer.test.ts | 37 ++++++++++++++++++- packages/hydrogen/src/customer/customer.ts | 19 +++++++++- packages/hydrogen/src/customer/types.ts | 7 ++++ packages/hydrogen/src/types.d.ts | 3 ++ 5 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 .changeset/four-trainers-attend.md diff --git a/.changeset/four-trainers-attend.md b/.changeset/four-trainers-attend.md new file mode 100644 index 0000000000..5d36da1697 --- /dev/null +++ b/.changeset/four-trainers-attend.md @@ -0,0 +1,13 @@ +--- +'@shopify/hydrogen': patch +--- + +Fix the customer account implementation to clear all session data on logout. Previously we would only clear customer account credentials on logout. This change also clears any custom data in the session as well. You can opt out and keep custom data in the session by passing the `keepSession` option to logout: + +```js +export async function action({context}: ActionFunctionArgs) { + return context.customerAccount.logout({ + keepSession: true + }); +} +``` diff --git a/packages/hydrogen/src/customer/customer.test.ts b/packages/hydrogen/src/customer/customer.test.ts index 18ff8340ab..7723cdc9db 100644 --- a/packages/hydrogen/src/customer/customer.test.ts +++ b/packages/hydrogen/src/customer/customer.test.ts @@ -68,6 +68,7 @@ describe('customer', () => { }) as HydrogenSession['get'], set: vi.fn(), unset: vi.fn(), + destroy: vi.fn(() => Promise.resolve('logout cookie')), }; }); @@ -261,6 +262,8 @@ describe('customer', () => { expect(session.unset).toHaveBeenCalledWith( CUSTOMER_ACCOUNT_SESSION_KEY, ); + + expect(session.destroy).toHaveBeenCalled(); }); it('Redirects to the customer account api logout url with postLogoutRedirectUri in the param', async () => { @@ -295,7 +298,6 @@ describe('customer', () => { it('Redirects to the customer account api logout url with optional headers from params included', async () => { const origin = 'https://shop123.com'; - const postLogoutRedirectUri = '/post-logout-landing-page'; const headers = {'Set-Cookie': 'cookie=test;'}; const customer = createCustomerAccountClient({ @@ -312,7 +314,8 @@ describe('customer', () => { expect(url.origin).toBe('https://shopify.com'); expect(url.pathname).toBe('/authentication/1/logout'); - expect(response.headers.get('Set-Cookie')).toBe('cookie=test;'); + // Session destroyed + expect(response.headers.get('Set-Cookie')).toBe('logout cookie'); // Session is cleared expect(session.unset).toHaveBeenCalledWith( @@ -320,6 +323,32 @@ describe('customer', () => { ); }); + it('Keeps session data when keepSession is true', async () => { + const origin = 'https://shop123.com'; + const headers = {'Set-Cookie': 'cookie=test;'}; + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout({headers, keepSession: true}); + + const url = new URL(response.headers.get('location')!); + expect(url.origin).toBe('https://shopify.com'); + expect(url.pathname).toBe('/authentication/1/logout'); + + // Standard cookie, session isn't destroyed + expect(response.headers.get('Set-Cookie')).toBe('cookie=test;'); + + expect(session.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + it('Redirects to app origin when customer is not login by default', async () => { const origin = 'https://shop123.com'; const mockSession: HydrogenSession = { @@ -327,6 +356,7 @@ describe('customer', () => { get: vi.fn(() => undefined) as HydrogenSession['get'], set: vi.fn(), unset: vi.fn(), + destroy: vi.fn(() => Promise.resolve('logout cookie')), }; const customer = createCustomerAccountClient({ @@ -357,6 +387,7 @@ describe('customer', () => { get: vi.fn(() => undefined) as HydrogenSession['get'], set: vi.fn(), unset: vi.fn(), + destroy: vi.fn(() => Promise.resolve('logout cookie')), }; const customer = createCustomerAccountClient({ @@ -390,6 +421,7 @@ describe('customer', () => { get: vi.fn(() => undefined) as HydrogenSession['get'], set: vi.fn(), unset: vi.fn(), + destroy: vi.fn(() => Promise.resolve('logout cookie')), }; const customer = createCustomerAccountClient({ @@ -720,6 +752,7 @@ describe('customer', () => { }) as HydrogenSession['get'], set: vi.fn(), unset: vi.fn(), + destroy: vi.fn(() => Promise.resolve('logout cookie')), }; const customer = createCustomerAccountClient({ diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts index 6985d2a010..6bf145973c 100644 --- a/packages/hydrogen/src/customer/customer.ts +++ b/packages/hydrogen/src/customer/customer.ts @@ -405,7 +405,24 @@ export function createCustomerAccountClient({ clearSession(session); - return redirect(logoutUrl, {headers: options?.headers || {}}); + const headers = + options?.headers instanceof Headers + ? options?.headers + : new Headers(options?.headers); + + if (!options?.keepSession) { + if (session.destroy) { + headers.set('Set-Cookie', await session.destroy()); + } else { + console.warn( + '[h2:warn:customerAccount] session.destroy is not available on your session implementation. All session data might not be cleared on logout.', + ); + } + + session.isPending = false; + } + + return redirect(logoutUrl, {headers}); }, isLoggedIn, handleAuthStatus, diff --git a/packages/hydrogen/src/customer/types.ts b/packages/hydrogen/src/customer/types.ts index a029ca176d..0b6f7c7e7f 100644 --- a/packages/hydrogen/src/customer/types.ts +++ b/packages/hydrogen/src/customer/types.ts @@ -54,8 +54,12 @@ export type LoginOptions = { }; export type LogoutOptions = { + /** The url to redirect customer to after logout, should be a relative URL. This url will need to included in Customer Account API's application setup for logout URI. The default value is current app origin, which is automatically setup in admin when using `--customer-account-push` flag with dev. */ postLogoutRedirectUri?: string; + /** Add custom headers to the logout redirect. */ headers?: HeadersInit; + /** If true, custom data in the session will not be cleared on logout. */ + keepSession?: boolean; }; export type CustomerAccount = { @@ -83,6 +87,7 @@ export type CustomerAccount = { * * @param options.postLogoutRedirectUri - The url to redirect customer to after logout, should be a relative URL. This url will need to included in Customer Account API's application setup for logout URI. The default value is current app origin, which is automatically setup in admin when using `--customer-account-push` flag with dev. * @param options.headers - These will be passed along to the logout redirect. You can use these to set/clear cookies on logout, like the cart. + * @param options.keepSession - If true, custom data in the session will not be cleared on logout. * */ logout: (options?: LogoutOptions) => Promise; /** Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query. */ @@ -188,6 +193,8 @@ export type CustomerAccountForDocs = { /** 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. * * @param options.postLogoutRedirectUri - The url to redirect customer to after logout, should be a relative URL. This url will need to included in Customer Account API's application setup for logout URI. The default value is current app origin, which is automatically setup in admin when using `--customer-account-push` flag with dev. + * @param options.headers - These will be passed along to the logout redirect. You can use these to set/clear cookies on logout, like the cart. + * @param options.keepSession - If true, custom data in the session will not be cleared on logout. * */ logout?: (options?: LogoutOptions) => Promise; /** Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query. */ diff --git a/packages/hydrogen/src/types.d.ts b/packages/hydrogen/src/types.d.ts index 96d0829a82..b24ebf026d 100644 --- a/packages/hydrogen/src/types.d.ts +++ b/packages/hydrogen/src/types.d.ts @@ -35,6 +35,9 @@ export interface HydrogenSession< commit: () => ReturnType< SessionStorage['commitSession'] >; + destroy?: () => ReturnType< + SessionStorage['destroySession'] + >; isPending?: boolean; }