From 3791b65a2519904f016cb9f5b092cd1a531d956a Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 17 Feb 2026 12:53:06 -0500 Subject: [PATCH 01/28] Implement error handling for login with passkey in AuthModal and Login components; add utility function for passkey error messages --- .../template-retail-react-app/app/hooks/use-auth-modal.js | 6 ++++-- .../template-retail-react-app/app/pages/login/index.jsx | 6 ++++-- .../template-retail-react-app/app/utils/auth-utils.js | 8 ++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index 843f624865..f817b3f70a 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -36,7 +36,8 @@ import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import { getAuthorizePasswordlessErrorMessage, getPasswordResetErrorMessage, - getLoginPasswordlessErrorMessage + getLoginPasswordlessErrorMessage, + getLoginWithPasskeyErrorMessage } from '@salesforce/retail-react-app/app/utils/auth-utils' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' @@ -240,7 +241,8 @@ export const AuthModal = ({ form.reset() // Prompt user to login without username (discoverable credentials) loginWithPasskey().catch((error) => { - // TODO W-21056536: Add error message handling + const message = formatMessage(getLoginWithPasskeyErrorMessage(error)) + form.setError('global', {type: 'manual', message}) }) } }, [isOpen]) diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index a45d5f84f0..eb1c037bc3 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -34,7 +34,8 @@ import {isServer, noop} from '@salesforce/retail-react-app/app/utils/utils' import { getAuthorizePasswordlessErrorMessage, getLoginPasswordlessErrorMessage, - getPasswordlessCallbackUrl + getPasswordlessCallbackUrl, + getLoginWithPasskeyErrorMessage } from '@salesforce/retail-react-app/app/utils/auth-utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' @@ -213,7 +214,8 @@ const Login = ({initialView = LOGIN_VIEW}) => { try { loginWithPasskey() } catch (error) { - // TODO W-21056536: Add error message handling + const message = formatMessage(getLoginWithPasskeyErrorMessage(error)) + form.setError('global', {type: 'manual', message}) } }, []) diff --git a/packages/template-retail-react-app/app/utils/auth-utils.js b/packages/template-retail-react-app/app/utils/auth-utils.js index 4b8772553c..819be3e9af 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.js @@ -110,3 +110,11 @@ export const getLoginPasswordlessErrorMessage = (errorMessage) => { } return API_ERROR_MESSAGE } + +export const getLoginWithPasskeyErrorMessage = (error) => { + // 400 Bad Request and 401 Unauthorized errors are related to incorrect configuration + if (error.response.status === 400 || error.response.status === 401) { + return FEATURE_UNAVAILABLE_ERROR_MESSAGE + } + return API_ERROR_MESSAGE +} From 8ec8140eb775a8b3f93c3ce9a7ad4c9ebb3b24d1 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 17 Feb 2026 13:43:16 -0500 Subject: [PATCH 02/28] Refactor passkey error handling in AuthModal and Login components; replace deprecated error message function with a new utility for improved clarity and add handling for too many registration attempts. --- .../app/hooks/use-auth-modal.js | 4 +-- .../app/pages/login/index.jsx | 4 +-- .../app/utils/auth-utils.js | 31 +++++++++++++++++-- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index f817b3f70a..e9337e80e5 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -37,7 +37,7 @@ import { getAuthorizePasswordlessErrorMessage, getPasswordResetErrorMessage, getLoginPasswordlessErrorMessage, - getLoginWithPasskeyErrorMessage + getPasskeyErrorMessage } from '@salesforce/retail-react-app/app/utils/auth-utils' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' @@ -241,7 +241,7 @@ export const AuthModal = ({ form.reset() // Prompt user to login without username (discoverable credentials) loginWithPasskey().catch((error) => { - const message = formatMessage(getLoginWithPasskeyErrorMessage(error)) + const message = formatMessage(getPasskeyErrorMessage(error)) form.setError('global', {type: 'manual', message}) }) } diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index eb1c037bc3..eba5a01fdb 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -35,7 +35,7 @@ import { getAuthorizePasswordlessErrorMessage, getLoginPasswordlessErrorMessage, getPasswordlessCallbackUrl, - getLoginWithPasskeyErrorMessage + getPasskeyErrorMessage } from '@salesforce/retail-react-app/app/utils/auth-utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' @@ -214,7 +214,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { try { loginWithPasskey() } catch (error) { - const message = formatMessage(getLoginWithPasskeyErrorMessage(error)) + const message = formatMessage(getPasskeyErrorMessage(error)) form.setError('global', {type: 'manual', message}) } }, []) diff --git a/packages/template-retail-react-app/app/utils/auth-utils.js b/packages/template-retail-react-app/app/utils/auth-utils.js index 819be3e9af..c0067795b8 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.js @@ -28,6 +28,12 @@ export const TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE = defineMessage({ id: 'global.error.too_many_password_reset_requests' }) +export const TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE = defineMessage({ + defaultMessage: + 'You reached the limit for passkey registration attempts. For your security, wait 10 minutes and try again.', + id: 'global.error.too_many_passkey_registration_attempts' +}) + // Shared error patterns for token-based auth features (passwordless login, password reset) const TOKEN_BASED_AUTH_FEATURE_UNAVAILABLE_ERRORS = [ /no callback_uri is registered/i, @@ -111,9 +117,28 @@ export const getLoginPasswordlessErrorMessage = (errorMessage) => { return API_ERROR_MESSAGE } -export const getLoginWithPasskeyErrorMessage = (error) => { - // 400 Bad Request and 401 Unauthorized errors are related to incorrect configuration - if (error.response.status === 400 || error.response.status === 401) { +/** + * Maps errors from SLAS WebAuthn APIs to user-friendly message descriptors. + * Used for both passkey login and registration auth helpers. + * + * Passkey Login: + * - StartWebauthnAuthentication + * - FinishWebauthnAuthentication + * + * Passkey Registration: + * - AuthorizeWebauthnRegistration + * - StartWebauthnUserRegistration + * - FinishWebauthnUserRegistration + * + * @param {Error} error - The error from the API + * @returns {Object} - The message descriptor (from defineMessage) for formatMessage + */ +export const getPasskeyErrorMessage = (error) => { + // Too many requests error is only returned by AuthorizeWebauthnRegistration + if (TOO_MANY_REQUESTS_ERROR.test(error.message)) { + return TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE + } + if (error.response?.status === 400 || error.response?.status === 401) { return FEATURE_UNAVAILABLE_ERROR_MESSAGE } return API_ERROR_MESSAGE From 98d8950f9945b17793aae4b1fb9a9d10097e819b Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 17 Feb 2026 14:59:04 -0500 Subject: [PATCH 03/28] Add unit tests for passkey error message handling in auth-utils; include mappings for various error responses and ensure correct message descriptors are returned. --- .../app/utils/auth-utils.test.js | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/utils/auth-utils.test.js b/packages/template-retail-react-app/app/utils/auth-utils.test.js index ccd7dbcf86..32f8c9bd5c 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.test.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.test.js @@ -14,8 +14,10 @@ import { getAuthorizePasswordlessErrorMessage, getPasswordResetErrorMessage, getLoginPasswordlessErrorMessage, + getPasskeyErrorMessage, TOO_MANY_LOGIN_ATTEMPTS_ERROR_MESSAGE, - TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE + TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE, + TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE } from '@salesforce/retail-react-app/app/utils/auth-utils' afterEach(() => { @@ -115,3 +117,20 @@ describe('getLoginPasswordlessErrorMessage', () => { } ) }) + +describe('getPasskeyErrorMessage', () => { + test.each([ + [ + {message: 'Too many passkey registration requests were made.', response: {status: 400}}, + TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE + ], + [{response: {status: 400}}, FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [{response: {status: 401}}, FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [{response: {status: 403}}, API_ERROR_MESSAGE], + [{response: {status: 412}}, API_ERROR_MESSAGE], + [{response: {status: 500}}, API_ERROR_MESSAGE], + [new Error('Network Error'), API_ERROR_MESSAGE] + ])('maps passkey error to the correct message descriptor', (error, expectedMessage) => { + expect(getPasskeyErrorMessage(error)).toBe(expectedMessage) + }) +}) From 315e3a11a88c8e2f7a399742dc415f872bbb2182 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 17 Feb 2026 14:59:27 -0500 Subject: [PATCH 04/28] fix unit tests by removing mocking AuthHelpers --- .../app/hooks/use-auth-modal.test.js | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index 6f6e47f29e..bcbef9e671 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -25,7 +25,6 @@ import Account from '@salesforce/retail-react-app/app/pages/account' import {rest} from 'msw' import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data' import * as ReactHookForm from 'react-hook-form' -import {AuthHelpers} from '@salesforce/commerce-sdk-react' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' @@ -60,23 +59,6 @@ const mockRegisteredCustomer = { login: 'customer@test.com' } -const mockAuthHelperFunctions = { - [AuthHelpers.AuthorizePasswordless]: {mutateAsync: jest.fn()}, - [AuthHelpers.Register]: {mutateAsync: jest.fn()}, - [AuthHelpers.LoginRegisteredUserB2C]: {mutateAsync: jest.fn()} -} - -jest.mock('@salesforce/commerce-sdk-react', () => { - const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') - return { - ...originalModule, - useAuthHelper: jest.fn().mockImplementation((helperType) => { - // Return the specific mock if defined, otherwise return a default mock - return mockAuthHelperFunctions[helperType] || {mutateAsync: jest.fn()} - }) - } -}) - let authModal = undefined const MockedComponent = (props) => { const {initialView, isPasswordlessEnabled = false} = props @@ -272,7 +254,6 @@ describe('Passwordless enabled', () => { } } }) - const {user} = renderWithProviders() const validEmail = 'test@salesforce.com' // open the modal From 1f45c068ec13baaf174f551d92ee82906f69b426 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Feb 2026 09:46:21 -0500 Subject: [PATCH 05/28] Refactor passkey login tests to improve clarity and functionality; enable passkey login tests, handle user cancellation errors, and ensure proper rendering of UI elements during authentication. --- .../app/hooks/use-auth-modal.test.js | 216 ++++-------------- 1 file changed, 50 insertions(+), 166 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index bcbef9e671..0b5ddb98ea 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -27,6 +27,7 @@ import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/m import * as ReactHookForm from 'react-hook-form' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ getConfig: jest.fn() @@ -622,7 +623,18 @@ describe('Passkey login', () => { beforeEach(() => { // Clear all mocks jest.clearAllMocks() - + + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: {enabled: true} + } + } + }) + // Mock WebAuthn API - default to never resolving (simulating no user action) mockCredentialsGet = jest.fn().mockImplementation(() => new Promise(() => {})) mockPublicKeyCredential = { @@ -675,18 +687,7 @@ describe('Passkey login', () => { delete global.window.PublicKeyCredential }) - // TODO: These passkey tests need refactoring to work properly with MSW handlers - // The passkey functionality is already well-tested in login/index.test.js - // eslint-disable-next-line jest/no-disabled-tests - test.skip('Triggers passkey login when modal opens with passkey enabled', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passkey: {enabled: true} - } - } - + test('Triggers passkey login when modal opens with passkey enabled', async () => { const mockPublicKeyOptions = { challenge: 'mock-challenge', allowCredentials: [] @@ -721,14 +722,8 @@ describe('Passkey login', () => { mockCredentialsGet.mockResolvedValue(mockCredential) - getConfig.mockReturnValue({ - ...mockConfig, - app: mockAppConfig - }) - const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -750,17 +745,7 @@ describe('Passkey login', () => { ) }) - // eslint-disable-next-line jest/no-disabled-tests - test.skip('Successfully logs in with passkey in passwordless mode', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passwordless: {enabled: true}, - passkey: {enabled: true} - } - } - + test('Falls back to other login methods when passkey login is cancelled', async () => { const mockPublicKeyOptions = { challenge: 'mock-challenge', allowCredentials: [] @@ -768,52 +753,13 @@ describe('Passkey login', () => { mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - const mockCredential = { - id: 'mock-credential-id', - rawId: new ArrayBuffer(32), - type: 'public-key', - response: { - authenticatorData: new ArrayBuffer(37), - clientDataJSON: new ArrayBuffer(128), - signature: new ArrayBuffer(64), - userHandle: new ArrayBuffer(16) - }, - getClientExtensionResults: jest.fn().mockReturnValue({}), - toJSON: jest.fn().mockReturnValue({ - id: 'mock-credential-id', - rawId: 'mock-raw-id', - type: 'public-key', - response: { - authenticatorData: 'mock-auth-data', - clientDataJSON: 'mock-client-data', - signature: 'mock-signature', - userHandle: 'mock-user-handle' - } - }) - } - - mockCredentialsGet.mockResolvedValue(mockCredential) - - // Mock customer as registered after passkey login - global.server.use( - rest.post('*/oauth2/token', (req, res, ctx) => - res( - ctx.delay(0), - ctx.json({ - customer_id: 'customerid_1', - access_token: registerUserToken, - refresh_token: 'testrefeshtoken_1', - usid: 'testusid_1', - enc_user_id: 'testEncUserId_1', - id_token: 'testIdToken_1' - }) - ) - ) - ) + // Simulate user cancelling passkey selection (NotAllowedError) + const notAllowedError = new Error('User cancelled') + notAllowedError.name = 'NotAllowedError' + mockCredentialsGet.mockRejectedValue(notAllowedError) const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -822,34 +768,17 @@ describe('Passkey login', () => { const trigger = screen.getByText(/open modal/i) await user.click(trigger) + // Should not show error for cancelled passkey await waitFor(() => { - expect(screen.getByText(/continue securely/i)).toBeInTheDocument() + expect(mockCredentialsGet).toHaveBeenCalled() + expect(screen.getByText(/welcome back/i)).toBeInTheDocument() + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + expect(screen.getByText(/continue/i)).toBeInTheDocument() + expect(screen.getByText(/password/i)).toBeInTheDocument() }) - - // Enter email and attempt passwordless login (which should try passkey first) - const validEmail = 'test@salesforce.com' - await user.type(screen.getByLabelText('Email'), validEmail) - await user.click(screen.getByText(/continue securely/i)) - - await waitFor( - () => { - expect(mockCredentialsGet).toHaveBeenCalled() - }, - {timeout: 5000} - ) }) - // eslint-disable-next-line jest/no-disabled-tests - test.skip('Falls back to passwordless when passkey login is cancelled', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passwordless: {enabled: true}, - passkey: {enabled: true} - } - } - + test('Shows error when passkey authentication fails with error from the browser', async () => { const mockPublicKeyOptions = { challenge: 'mock-challenge', allowCredentials: [] @@ -857,19 +786,11 @@ describe('Passkey login', () => { mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - // Simulate user cancelling passkey selection (NotAllowedError) - const notAllowedError = new Error('User cancelled') - notAllowedError.name = 'NotAllowedError' - mockCredentialsGet.mockRejectedValue(notAllowedError) - - getConfig.mockReturnValue({ - ...mockConfig, - app: mockAppConfig - }) + // Simulate error in loginWithPasskey hook + mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -878,50 +799,26 @@ describe('Passkey login', () => { const trigger = screen.getByText(/open modal/i) await user.click(trigger) - await waitFor(() => { - expect(screen.getByText(/continue securely/i)).toBeInTheDocument() - }) - - // Enter email and attempt passwordless login - const validEmail = 'test@salesforce.com' - await user.type(screen.getByLabelText('Email'), validEmail) - await user.click(screen.getByText(/continue securely/i)) - - // Should not show error for cancelled passkey + // Should show error - passkey error should be caught and handled await waitFor(() => { expect(mockCredentialsGet).toHaveBeenCalled() + expect(screen.getByText(/Something went wrong. Try again!/i)).toBeInTheDocument() }) }) - // eslint-disable-next-line jest/no-disabled-tests - test.skip('Shows error when passkey authentication fails', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passwordless: {enabled: true}, - passkey: {enabled: true} - } - } - - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - - // Simulate other error (not NotAllowedError) - mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) - - getConfig.mockReturnValue({ - ...mockConfig, - app: mockAppConfig - }) + test('Shows error when passkey authentication fails with error from the WebAuthn API', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(401), + ctx.json({message: 'Authentication failed'}) + ) + }) + ) const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -930,18 +827,11 @@ describe('Passkey login', () => { const trigger = screen.getByText(/open modal/i) await user.click(trigger) + // Should show error - 401 error from WebAuthn API should be caught and converted to user-friendly message await waitFor(() => { - expect(screen.getByText(/continue securely/i)).toBeInTheDocument() - }) - - // Enter email and attempt passwordless login - const validEmail = 'test@salesforce.com' - await user.type(screen.getByLabelText('Email'), validEmail) - await user.click(screen.getByText(/continue securely/i)) - - // Should show error - passkey error should be caught and handled - await waitFor(() => { - expect(mockCredentialsGet).toHaveBeenCalled() + expect( + screen.getByText(/This feature is not currently available./i) + ).toBeInTheDocument() }) }) @@ -979,17 +869,7 @@ describe('Passkey login', () => { expect(mockCredentialsGet).not.toHaveBeenCalled() }) - // eslint-disable-next-line jest/no-disabled-tests - test.skip('Successfully logs in with passkey', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passwordless: {enabled: true}, - passkey: {enabled: true} - } - } - + test('Successfully logs in with passkey', async () => { const mockPublicKeyOptions = { challenge: 'mock-challenge', allowCredentials: [] @@ -1042,7 +922,6 @@ describe('Passkey login', () => { const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -1058,6 +937,11 @@ describe('Passkey login', () => { }, {timeout: 5000} ) + + // login successfully and close the modal + await waitFor(() => { + expect(screen.queryByText(/Welcome back/i)).not.toBeInTheDocument() + }) }) }) From 54d60437ecdb7d0275bf6f51a48efa042f5c9c74 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Feb 2026 11:11:28 -0500 Subject: [PATCH 06/28] Enhance passkey login tests by refining error handling and improving test clarity; mock configurations for passkey enabled/disabled scenarios, and ensure proper UI rendering during authentication processes. --- .../app/hooks/use-auth-modal.test.js | 39 +--- .../app/pages/login/index.jsx | 6 +- .../app/pages/login/index.test.js | 192 +++++++++--------- 3 files changed, 110 insertions(+), 127 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index 0b5ddb98ea..08635004ac 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -624,6 +624,7 @@ describe('Passkey login', () => { // Clear all mocks jest.clearAllMocks() + // Override getConfig to return config with passkey enabled getConfig.mockReturnValue({ ...mockConfig, app: { @@ -649,6 +650,12 @@ describe('Passkey login', () => { get: mockCredentialsGet } + // Mock parseRequestOptionsFromJSON to return mock options + mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue({ + challenge: 'mock-challenge', + allowCredentials: [] + }) + // Setup MSW handlers for WebAuthn API endpoints global.server.use( rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { @@ -688,13 +695,6 @@ describe('Passkey login', () => { }) test('Triggers passkey login when modal opens with passkey enabled', async () => { - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - // Mock credential that will be returned from navigator.credentials.get const mockCredential = { id: 'mock-credential-id', @@ -745,14 +745,7 @@ describe('Passkey login', () => { ) }) - test('Falls back to other login methods when passkey login is cancelled', async () => { - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - + test('User can login with other method when passkey login is cancelled', async () => { // Simulate user cancelling passkey selection (NotAllowedError) const notAllowedError = new Error('User cancelled') notAllowedError.name = 'NotAllowedError' @@ -768,7 +761,7 @@ describe('Passkey login', () => { const trigger = screen.getByText(/open modal/i) await user.click(trigger) - // Should not show error for cancelled passkey + // Login form should be shown await waitFor(() => { expect(mockCredentialsGet).toHaveBeenCalled() expect(screen.getByText(/welcome back/i)).toBeInTheDocument() @@ -779,13 +772,6 @@ describe('Passkey login', () => { }) test('Shows error when passkey authentication fails with error from the browser', async () => { - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - // Simulate error in loginWithPasskey hook mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) @@ -870,13 +856,6 @@ describe('Passkey login', () => { }) test('Successfully logs in with passkey', async () => { - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - const mockCredential = { id: 'mock-credential-id', rawId: new ArrayBuffer(32), diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index eba5a01fdb..5443045965 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -211,12 +211,10 @@ const Login = ({initialView = LOGIN_VIEW}) => { }, [isRegistered, redirectPath]) useEffect(() => { - try { - loginWithPasskey() - } catch (error) { + loginWithPasskey().catch((error) => { const message = formatMessage(getPasskeyErrorMessage(error)) form.setError('global', {type: 'manual', message}) - } + }) }, []) /**************** Einstein ****************/ diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 28779c87c7..46397149d8 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -283,11 +283,26 @@ describe('Error while logging in', function () { describe('Passkey login', () => { let mockCredentialsGet let mockPublicKeyCredential + let mockAppConfig beforeEach(() => { // Clear all mocks jest.clearAllMocks() + // Override getConfig to return config with passkey enabled + mockAppConfig = { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: {enabled: true} + } + } + + getConfig.mockReturnValue({ + ...mockConfig, + app: mockAppConfig + }) + // Mock WebAuthn API - default to never resolving (simulating no user action) mockCredentialsGet = jest.fn().mockImplementation(() => new Promise(() => {})) mockPublicKeyCredential = { @@ -302,6 +317,12 @@ describe('Passkey login', () => { get: mockCredentialsGet } + // Mock parseRequestOptionsFromJSON to return mock options + mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue({ + challenge: 'mock-challenge', + allowCredentials: [] + }) + // Clear localStorage localStorage.clear() @@ -345,21 +366,6 @@ describe('Passkey login', () => { }) test('Sets up conditional mediation on page load when passkey enabled', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passkey: {enabled: true} - } - } - - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - // Mock that conditional mediation starts but user doesn't select mockCredentialsGet.mockImplementation( () => @@ -395,23 +401,42 @@ describe('Passkey login', () => { ) }) - test('Successfully logs in with passkey in passwordless mode on login page', async () => { + test('Does not trigger passkey when passkey is disabled', async () => { const mockAppConfig = { ...mockConfig.app, login: { ...mockConfig.app.login, - passwordless: {enabled: true}, - passkey: {enabled: true} + passkey: {enabled: false} } } - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } + // Override getConfig to return config with passkey disabled + getConfig.mockReturnValue({ + ...mockConfig, + app: mockAppConfig + }) - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) + renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: false + } + }) + + await waitFor(() => { + expect(screen.getByTestId('login-page')).toBeInTheDocument() + }) + + // Give it a moment for any async effects to run + await new Promise((resolve) => setTimeout(resolve, 100)) + // Should not call credentials API when passkey is disabled + expect(mockCredentialsGet).not.toHaveBeenCalled() + }) + + test('Successfully logs in with passkey', async () => { const mockCredential = { id: 'mock-credential-id', rawId: new ArrayBuffer(32), @@ -438,7 +463,7 @@ describe('Passkey login', () => { mockCredentialsGet.mockResolvedValue(mockCredential) - // Mock successful auth after passkey + // Mock customer as registered after passkey login global.server.use( rest.post('*/oauth2/token', (req, res, ctx) => res( @@ -456,7 +481,7 @@ describe('Passkey login', () => { ) ) - const {user} = renderWithProviders(, { + renderWithProviders(, { wrapperProps: { siteAlias: 'uk', locale: {id: 'en-GB'}, @@ -465,11 +490,7 @@ describe('Passkey login', () => { } }) - // Enter email (don't enter password for passwordless) - await user.type(screen.getByLabelText('Email'), 'test@salesforce.com') - await user.click(screen.getByRole('button', {name: /sign in/i})) - - // Should trigger passkey authentication with credentials.get + // Wait for passkey flow to be triggered when modal opens await waitFor( () => { expect(mockCredentialsGet).toHaveBeenCalled() @@ -477,29 +498,18 @@ describe('Passkey login', () => { {timeout: 5000} ) - // After successful passkey login, should redirect to account page - await waitFor( - () => { - expect(window.location.pathname).toBe('/uk/en-GB/account') - }, - {timeout: 5000} - ) + // login successfully and navigate to account page + await waitFor(() => { + expect(window.location.pathname).toBe('/uk/en-GB/account') + expect(screen.getByText(/My Profile/i)).toBeInTheDocument() + }) }) - test('Does not trigger passkey when passkey is disabled', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passkey: {enabled: false} - } - } - - // Override getConfig to return config with passkey disabled - getConfig.mockReturnValue({ - ...mockConfig, - app: mockAppConfig - }) + test('User can select other login method when passkey login is cancelled', async () => { + // User cancels passkey selection + const notAllowedError = new Error('User cancelled') + notAllowedError.name = 'NotAllowedError' + mockCredentialsGet.mockRejectedValue(notAllowedError) renderWithProviders(, { wrapperProps: { @@ -510,38 +520,50 @@ describe('Passkey login', () => { } }) + // Login form should be shown await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalled() + expect(screen.getByText(/welcome back/i)).toBeInTheDocument() + expect(screen.getByLabelText('Email')).toBeInTheDocument() + expect(screen.getByLabelText('Password')).toBeInTheDocument() + expect(screen.getByRole('button', {name: /sign in/i})).toBeInTheDocument() expect(screen.getByTestId('login-page')).toBeInTheDocument() }) - - // Give it a moment for any async effects to run - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Should not call credentials API when passkey is disabled - expect(mockCredentialsGet).not.toHaveBeenCalled() }) - test('Handles passkey login cancellation gracefully', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passwordless: {enabled: true}, - passkey: {enabled: true} + test('Shows error when passkey authentication fails with error from the browser', async () => { + // Simulate error in navigator.credentials.get hook + mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) + + renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: false } - } + }) - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue({ - challenge: 'mock-challenge', - allowCredentials: [] + // Should show error - passkey error should be caught and handled + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalled() + expect(screen.getByText(/Something went wrong. Try again!/i)).toBeInTheDocument() }) + }) - // User cancels passkey selection - const notAllowedError = new Error('User cancelled') - notAllowedError.name = 'NotAllowedError' - mockCredentialsGet.mockRejectedValue(notAllowedError) + test('Shows error when passkey authentication fails with error from the WebAuthn API', async () => { + // Simulate error in WebAuthn API + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(401), + ctx.json({message: 'Authentication failed'}) + ) + }) + ) - const {user} = renderWithProviders(, { + renderWithProviders(, { wrapperProps: { siteAlias: 'uk', locale: {id: 'en-GB'}, @@ -550,31 +572,15 @@ describe('Passkey login', () => { } }) - // Enter email without password for passwordless - await user.type(screen.getByLabelText('Email'), 'test@salesforce.com') - await user.click(screen.getByRole('button', {name: /sign in/i})) - - // Should not show error for cancelled passkey - // Page should remain on login page + // Should show error - 401 error from WebAuthn API should be caught and converted to user-friendly message await waitFor(() => { - expect(screen.getByTestId('login-page')).toBeInTheDocument() + expect( + screen.getByText(/This feature is not currently available./i) + ).toBeInTheDocument() }) }) test('Shows passkey registration prompt after successful login when passkey enabled and not registered', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passkey: {enabled: true} - } - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue({ - challenge: 'mock-challenge', - allowCredentials: [] - }) - const {user} = renderWithProviders(, { wrapperProps: { siteAlias: 'uk', From b16b75908e7abc54c99347b11bf1073338d55913 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Feb 2026 13:23:00 -0500 Subject: [PATCH 07/28] add error handling in passkey registration. add console error for debugging. --- .../passkey-registration-modal/index.jsx | 30 +++++++------------ .../app/hooks/use-auth-modal.js | 4 +-- .../app/pages/login/index.jsx | 5 ++-- .../app/utils/auth-utils.js | 18 +++++++++-- .../app/utils/auth-utils.test.js | 18 +++++++++-- 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx index a9d007182a..f6c0748bf0 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx @@ -33,6 +33,7 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur // Utils import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/utils' +import {getPasskeyRegistrationErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils' // SDK import {AuthHelpers, useAuthHelper} from '@salesforce/commerce-sdk-react' @@ -73,13 +74,10 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { onClose() setIsOtpAuthOpen(true) } catch (err) { - setError( - err.message || - formatMessage({ - id: 'passkey_registration.modal.error.authorize_failed', - defaultMessage: 'Failed to authorize passkey registration' - }) - ) + // Set error message for the passkey registration modal + setError(formatMessage(getPasskeyRegistrationErrorMessage(err))) + // Re-throw the error to be handled by the OTP auth modal + throw err } finally { setIsLoading(false) } @@ -105,7 +103,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { throw new Error('WebAuthn API not available in this browser') } - // navigator.credentials.create() will show a browser/system prompt + // Step 4: navigator.credentials.create() will show a browser/system prompt // This may appear to hang if the user doesn't interact with the prompt let credential try { @@ -124,7 +122,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { throw new Error('Failed to create credential: user cancelled or operation failed') } - // Step 4: Convert credential to JSON format before sending to SLAS + // Step 5: Convert credential to JSON format before sending to SLAS // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/toJSON let credentialJson try { @@ -147,30 +145,24 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { } } - // Step 5: Finish WebAuthn registration + // Step 6: Finish WebAuthn registration await finishWebauthnUserRegistration.mutateAsync({ username: customer.email, credential: credentialJson, pwd_action_token: code }) - // Step 6: Close OTP modal and main modal on success + // Step 7: Close OTP modal and main modal on success setIsOtpAuthOpen(false) onClose() return {success: true} } catch (err) { - const errorMessage = - err.message || - formatMessage({ - id: 'passkey_registration.modal.error.registration_failed', - defaultMessage: 'Failed to register passkey' - }) - + console.error('Error registering passkey:', err) // Return error result for OTP component to display return { success: false, - error: errorMessage + error: formatMessage(getPasskeyRegistrationErrorMessage(err)) } } finally { setIsLoading(false) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index e9337e80e5..d2c005d267 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -37,7 +37,7 @@ import { getAuthorizePasswordlessErrorMessage, getPasswordResetErrorMessage, getLoginPasswordlessErrorMessage, - getPasskeyErrorMessage + getPasskeyAuthenticateErrorMessage } from '@salesforce/retail-react-app/app/utils/auth-utils' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' @@ -241,7 +241,7 @@ export const AuthModal = ({ form.reset() // Prompt user to login without username (discoverable credentials) loginWithPasskey().catch((error) => { - const message = formatMessage(getPasskeyErrorMessage(error)) + const message = formatMessage(getPasskeyAuthenticateErrorMessage(error)) form.setError('global', {type: 'manual', message}) }) } diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index 5443045965..6176fdcd83 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -35,7 +35,7 @@ import { getAuthorizePasswordlessErrorMessage, getLoginPasswordlessErrorMessage, getPasswordlessCallbackUrl, - getPasskeyErrorMessage + getPasskeyAuthenticateErrorMessage } from '@salesforce/retail-react-app/app/utils/auth-utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' @@ -212,7 +212,8 @@ const Login = ({initialView = LOGIN_VIEW}) => { useEffect(() => { loginWithPasskey().catch((error) => { - const message = formatMessage(getPasskeyErrorMessage(error)) + console.error('Error authenticating passkey:', error) + const message = formatMessage(getPasskeyAuthenticateErrorMessage(error)) form.setError('global', {type: 'manual', message}) }) }, []) diff --git a/packages/template-retail-react-app/app/utils/auth-utils.js b/packages/template-retail-react-app/app/utils/auth-utils.js index c0067795b8..2d07feba55 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.js @@ -119,12 +119,26 @@ export const getLoginPasswordlessErrorMessage = (errorMessage) => { /** * Maps errors from SLAS WebAuthn APIs to user-friendly message descriptors. - * Used for both passkey login and registration auth helpers. + * Used for passkey login auth helpers. * * Passkey Login: * - StartWebauthnAuthentication * - FinishWebauthnAuthentication * + * @param {Error} error - The error from the API + * @returns {Object} - The message descriptor (from defineMessage) for formatMessage + */ +export const getPasskeyAuthenticateErrorMessage = (error) => { + if (error.response?.status === 400 || error.response?.status === 401) { + return FEATURE_UNAVAILABLE_ERROR_MESSAGE + } + return API_ERROR_MESSAGE +} + +/** + * Maps errors from SLAS WebAuthn APIs to user-friendly message descriptors. + * Used for passkey registration auth helpers. + * * Passkey Registration: * - AuthorizeWebauthnRegistration * - StartWebauthnUserRegistration @@ -133,7 +147,7 @@ export const getLoginPasswordlessErrorMessage = (errorMessage) => { * @param {Error} error - The error from the API * @returns {Object} - The message descriptor (from defineMessage) for formatMessage */ -export const getPasskeyErrorMessage = (error) => { +export const getPasskeyRegistrationErrorMessage = (error) => { // Too many requests error is only returned by AuthorizeWebauthnRegistration if (TOO_MANY_REQUESTS_ERROR.test(error.message)) { return TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE diff --git a/packages/template-retail-react-app/app/utils/auth-utils.test.js b/packages/template-retail-react-app/app/utils/auth-utils.test.js index 32f8c9bd5c..a2cdb02fdf 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.test.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.test.js @@ -14,7 +14,8 @@ import { getAuthorizePasswordlessErrorMessage, getPasswordResetErrorMessage, getLoginPasswordlessErrorMessage, - getPasskeyErrorMessage, + getPasskeyAuthenticateErrorMessage, + getPasskeyRegistrationErrorMessage, TOO_MANY_LOGIN_ATTEMPTS_ERROR_MESSAGE, TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE, TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE @@ -118,7 +119,18 @@ describe('getLoginPasswordlessErrorMessage', () => { ) }) -describe('getPasskeyErrorMessage', () => { +describe('getPasskeyAuthenticateErrorMessage', () => { + test.each([ + [{response: {status: 400}}, FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [{response: {status: 401}}, FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [{response: {status: 403}}, API_ERROR_MESSAGE], + [{response: {status: 412}}, API_ERROR_MESSAGE], + [{response: {status: 500}}, API_ERROR_MESSAGE], + [new Error('Network Error'), API_ERROR_MESSAGE] + ])('maps passkey error to the correct message descriptor', (error, expectedMessage) => { + expect(getPasskeyAuthenticateErrorMessage(error)).toBe(expectedMessage) + }) + test.each([ [ {message: 'Too many passkey registration requests were made.', response: {status: 400}}, @@ -131,6 +143,6 @@ describe('getPasskeyErrorMessage', () => { [{response: {status: 500}}, API_ERROR_MESSAGE], [new Error('Network Error'), API_ERROR_MESSAGE] ])('maps passkey error to the correct message descriptor', (error, expectedMessage) => { - expect(getPasskeyErrorMessage(error)).toBe(expectedMessage) + expect(getPasskeyRegistrationErrorMessage(error)).toBe(expectedMessage) }) }) From 767acfce5ed3e83f502cf34c20a1af4d38b609ca Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Feb 2026 15:02:17 -0500 Subject: [PATCH 08/28] Refactor passkey registration handling in AuthModal and related components; replace showToast with showRegisterPasskeyToast for improved clarity and functionality. Update tests to validate passkey registration toast display after successful login, ensuring proper configuration checks and UI rendering. --- .../app/hooks/use-auth-modal.js | 24 +-- .../app/hooks/use-auth-modal.test.js | 68 ++++++ .../app/hooks/use-passkey-login.js | 2 +- .../app/hooks/use-passkey-registration.js | 36 +++- .../hooks/use-passkey-registration.test.js | 195 +++++++++++++++--- .../app/pages/login/index.jsx | 27 +-- .../app/pages/login/index.test.js | 96 +++++---- 7 files changed, 334 insertions(+), 114 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index d2c005d267..14a6b65254 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -90,7 +90,6 @@ export const AuthModal = ({ const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) const {locale} = useMultiSite() - const config = getConfig() const {getPasswordResetToken} = usePasswordReset() const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) @@ -105,7 +104,7 @@ export const AuthModal = ({ ) const mergeBasket = useShopperBasketsMutation('mergeBasket') - const {showToast} = usePasskeyRegistration() + const {showRegisterPasskeyToast} = usePasskeyRegistration() const handlePasswordlessLogin = async (email) => { try { @@ -281,23 +280,8 @@ export const AuthModal = ({ onClose() setIsOtpAuthOpen(false) - if (config?.app?.login?.passkey?.enabled) { - // Show passkey registration modal only if Webauthn feature flag is enabled and compatible with the browser - if ( - window.PublicKeyCredential && - window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable && - window.PublicKeyCredential.isConditionalMediationAvailable - ) { - Promise.all([ - window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), - window.PublicKeyCredential.isConditionalMediationAvailable() - ]).then((results) => { - if (results.every((r) => r === true)) { - showToast() - } - }) - } - } + // Show passkey registration prompt if supported + showRegisterPasskeyToast() // Show a toast only for those registed users returning to the site. // Only show toast when customer data is available (user is logged in and data is loaded) @@ -431,7 +415,7 @@ AuthModal.propTypes = { */ export const useAuthModal = (initialView = LOGIN_VIEW) => { const {isOpen, onOpen, onClose} = useDisclosure() - const {passwordless = {}, social = {}, passkey = {}} = getConfig().app.login || {} + const {passwordless = {}, social = {}} = getConfig().app.login || {} return { initialView, diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index 08635004ac..c41d2ed20e 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -922,6 +922,74 @@ describe('Passkey login', () => { expect(screen.queryByText(/Welcome back/i)).not.toBeInTheDocument() }) }) + + describe('Passkey Registration', () => { + beforeEach(() => { + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: {enabled: true} + } + } + }) + + // Mock WebAuthn API + global.PublicKeyCredential = { + isUserVerifyingPlatformAuthenticatorAvailable: jest.fn().mockResolvedValue(true), + isConditionalMediationAvailable: jest.fn().mockResolvedValue(true) + } + global.window.PublicKeyCredential = global.PublicKeyCredential + + global.server.use( + rest.post('*/oauth2/token', (req, res, ctx) => + res( + ctx.delay(0), + ctx.json({ + customer_id: 'registeredCustomerId', + access_token: registerUserToken, + refresh_token: 'testrefeshtoken', + usid: 'testusid', + enc_user_id: 'testEncUserId', + id_token: 'testIdToken' + }) + ) + ) + ) + }) + + afterEach(() => { + delete global.PublicKeyCredential + delete global.window.PublicKeyCredential + }) + + test('shows passkey registration toast after login', async () => { + const {user} = renderWithProviders() + const validEmail = 'test@salesforce.com' + const validPassword = 'Password123!' + + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/Continue/i)).toBeInTheDocument() + }) + + await user.type(screen.getByLabelText('Email'), validEmail) + await user.click(screen.getByText(/password/i)) + await user.type(screen.getByLabelText('Password'), validPassword) + await user.keyboard('{Enter}') + + // Create passkey toast is shown after login + await waitFor(() => { + // 2 matches are found for the toast + const toasts = screen.getAllByText(/Create Passkey/i) + expect(toasts.length).toBeGreaterThanOrEqual(1) + }) + }) + }) }) describe('Reset password', function () { diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js index 51bc1f747f..d85e8560e7 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -33,7 +33,7 @@ export const usePasskeyLogin = () => { } // Check if conditional mediation is available. Conditional mediation is a feature of the WebAuthn API that allows passkeys to appear in the browser's standard autofill suggestions, alongside saved passwords. This allows users to sign in with a passkey using the standard username input field, rather than clicking a dedicated passkey login button. - // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/isConditionalMediationAvailable + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/isConditionalMediationAvailable_static const isCMA = await window.PublicKeyCredential.isConditionalMediationAvailable() if (!isCMA) { return diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-registration.js b/packages/template-retail-react-app/app/hooks/use-passkey-registration.js index 596d024fcb..26c8b8193d 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-registration.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-registration.js @@ -14,17 +14,47 @@ import { useToast } from '@salesforce/retail-react-app/app/components/shared/ui' import {usePasskeyRegistrationContext} from '@salesforce/retail-react-app/app/contexts/passkey-registration-provider' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' /** * Custom hook to manage passkey registration prompt (toast and modal) - * @returns {Object} Object containing showToast function and passkey modal state + * @returns {Object} Object containing showToast and passkey modal state */ export const usePasskeyRegistration = () => { const toast = useToast() const {passkeyModal} = usePasskeyRegistrationContext() const {formatMessage} = useIntl() - const showToast = () => { + /** + * Shows the passkey registration toast only if passkey is enabled and the browser + * supports WebAuthn (platform authenticator and conditional mediation). + * Returns a Promise that resolves when the check (and optional toast) is complete. + * @returns {Promise} + */ + const showRegisterPasskeyToast = async () => { + const config = getConfig() + + // Check if passkey is enabled in config + if (!config?.app?.login?.passkey?.enabled) return + + // Check if the browser supports user verifying platform authenticator and conditional mediation + // User verifying platform authenticator is a feature of the WebAuthn API that allows the browser to use a platform authenticator to verify the user's identity. + // Conditional mediation is a feature of the WebAuthn API that allows passkeys to appear in the browser's standard autofill suggestions, alongside saved passwords. This allows users to sign in with a passkey using the standard username input field, rather than clicking a dedicated passkey login button. + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/isUserVerifyingPlatformAuthenticatorAvailable_static + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/isConditionalMediationAvailable_static + if ( + !window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable || + !window.PublicKeyCredential?.isConditionalMediationAvailable + ) { + return + } + + const [platformAvailable, conditionalAvailable] = await Promise.all([ + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), + window.PublicKeyCredential.isConditionalMediationAvailable() + ]) + if (!platformAvailable || !conditionalAvailable) return + toast({ position: 'top-right', duration: 9000, @@ -75,7 +105,7 @@ export const usePasskeyRegistration = () => { } return { - showToast, + showRegisterPasskeyToast, passkeyModal } } diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-registration.test.js b/packages/template-retail-react-app/app/hooks/use-passkey-registration.test.js index cc245efaef..79aefd81f6 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-registration.test.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-registration.test.js @@ -11,6 +11,12 @@ import {screen, waitFor} from '@testing-library/react' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration' import {PasskeyRegistrationProvider} from '@salesforce/retail-react-app/app/contexts/passkey-registration-provider' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import mockConfig from '@salesforce/retail-react-app/config/mocks/default' + +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: jest.fn() +})) // Mock PasskeyRegistrationModal jest.mock('@salesforce/retail-react-app/app/components/passkey-registration-modal/index', () => { @@ -46,11 +52,11 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ( })) const TestComponent = () => { - const {showToast} = usePasskeyRegistration() + const {showRegisterPasskeyToast} = usePasskeyRegistration() return (
-
@@ -68,6 +74,7 @@ TestComponentWithProvider.propTypes = { describe('usePasskeyRegistration', () => { beforeEach(() => { jest.clearAllMocks() + getConfig.mockReturnValue(mockConfig) mockUseCurrentCustomer.mockReturnValue({ data: {email: 'test@example.com'} }) @@ -84,46 +91,44 @@ describe('usePasskeyRegistration', () => { }) describe('Hook Return Values', () => { - test('returns showToast function and passkeyModal state', () => { - let hookResult - const TestHook = () => { - hookResult = usePasskeyRegistration() - return null - } - + test('returns showRegisterPasskeyToast function and passkeyModal state', () => { renderWithProviders( - + ) - expect(hookResult).toBeDefined() - expect(typeof hookResult.showToast).toBe('function') - expect(hookResult.passkeyModal).toBeDefined() - expect(typeof hookResult.passkeyModal.isOpen).toBe('boolean') - expect(typeof hookResult.passkeyModal.onClose).toBe('function') - expect(typeof hookResult.passkeyModal.onOpen).toBe('function') + expect(screen.getByTestId('show-toast-button')).toBeInTheDocument() + expect(screen.queryByTestId('passkey-registration-modal')).not.toBeInTheDocument() }) test('initializes with modal closed', () => { - let hookResult - const TestHook = () => { - hookResult = usePasskeyRegistration() - return null - } - renderWithProviders( - + ) - expect(hookResult.passkeyModal.isOpen).toBe(false) + expect(screen.queryByTestId('passkey-registration-modal')).not.toBeInTheDocument() }) }) describe('Toast Functionality', () => { - test('displays toast when showToast is called', async () => { + beforeEach(() => { + getConfig.mockReturnValue(mockConfig) + global.PublicKeyCredential = { + isUserVerifyingPlatformAuthenticatorAvailable: jest.fn().mockResolvedValue(true), + isConditionalMediationAvailable: jest.fn().mockResolvedValue(true) + } + global.window.PublicKeyCredential = global.PublicKeyCredential + }) + + afterEach(() => { + delete global.PublicKeyCredential + delete global.window.PublicKeyCredential + }) + + test('displays toast when showRegisterPasskeyToast is called', async () => { const {user} = renderWithProviders( @@ -157,6 +162,20 @@ describe('usePasskeyRegistration', () => { }) describe('Modal Integration', () => { + beforeEach(() => { + getConfig.mockReturnValue(mockConfig) + global.PublicKeyCredential = { + isUserVerifyingPlatformAuthenticatorAvailable: jest.fn().mockResolvedValue(true), + isConditionalMediationAvailable: jest.fn().mockResolvedValue(true) + } + global.window.PublicKeyCredential = global.PublicKeyCredential + }) + + afterEach(() => { + delete global.PublicKeyCredential + delete global.window.PublicKeyCredential + }) + test('clicking Create Passkey button in toast opens modal', async () => { const {user} = renderWithProviders( @@ -213,4 +232,130 @@ describe('usePasskeyRegistration', () => { }) }) }) + + describe('Preconditions for showing the toast', () => { + let mockIsUserVerifying + let mockIsConditionalMediation + + beforeEach(() => { + mockIsUserVerifying = jest.fn().mockResolvedValue(true) + mockIsConditionalMediation = jest.fn().mockResolvedValue(true) + global.PublicKeyCredential = { + isUserVerifyingPlatformAuthenticatorAvailable: mockIsUserVerifying, + isConditionalMediationAvailable: mockIsConditionalMediation + } + global.window.PublicKeyCredential = global.PublicKeyCredential + }) + + afterEach(() => { + delete global.PublicKeyCredential + delete global.window.PublicKeyCredential + }) + + test('does not display toast when passkey is disabled in config', async () => { + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: {enabled: false} + } + } + }) + + const {user} = renderWithProviders( + + + + ) + + await user.click(screen.getByTestId('show-toast-button')) + await waitFor(() => { + expect(mockIsUserVerifying).not.toHaveBeenCalled() + }) + expect( + screen.queryByText('Create a passkey for a more secure and easier login') + ).not.toBeInTheDocument() + }) + + test('does not display toast when PublicKeyCredential is not available', async () => { + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: {enabled: true} + } + } + }) + delete global.PublicKeyCredential + delete global.window.PublicKeyCredential + + const {user} = renderWithProviders( + + + + ) + + await user.click(screen.getByTestId('show-toast-button')) + + expect( + screen.queryByText('Create a passkey for a more secure and easier login') + ).not.toBeInTheDocument() + }) + + test('does not display toast when isUserVerifyingPlatformAuthenticatorAvailable returns false', async () => { + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: {enabled: true} + } + } + }) + mockIsUserVerifying.mockResolvedValue(false) + + const {user} = renderWithProviders( + + + + ) + + await user.click(screen.getByTestId('show-toast-button')) + + expect( + screen.queryByText('Create a passkey for a more secure and easier login') + ).not.toBeInTheDocument() + }) + + test('does not display toast when isConditionalMediationAvailable returns false', async () => { + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: {enabled: true} + } + } + }) + mockIsConditionalMediation.mockResolvedValue(false) + + const {user} = renderWithProviders( + + + + ) + + await user.click(screen.getByTestId('show-toast-button')) + + expect( + screen.queryByText('Create a passkey for a more secure and easier login') + ).not.toBeInTheDocument() + }) + }) }) diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index 6176fdcd83..d5cd77aeb9 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -63,7 +63,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser) const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const config = getConfig() - const {passwordless = {}, social = {}, passkey = {}} = config.app.login || {} + const {passwordless = {}, social = {}} = config.app.login || {} const isPasswordlessEnabled = !!passwordless?.enabled const passwordlessMode = passwordless?.mode const passwordlessLoginLandingPath = passwordless?.landingPath @@ -81,7 +81,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { ) const mergeBasket = useShopperBasketsMutation('mergeBasket') const [redirectPath, setRedirectPath] = useState('') - const {showToast} = usePasskeyRegistration() + const {showRegisterPasskeyToast} = usePasskeyRegistration() const {loginWithPasskey} = usePasskeyLogin() const [isOtpAuthOpen, setIsOtpAuthOpen] = useState(false) @@ -185,28 +185,9 @@ const Login = ({initialView = LOGIN_VIEW}) => { handleMergeBasket() const redirectTo = redirectPath ? redirectPath : '/account' - if (passkey?.enabled) { - // Show passkey registration modal only if Webauthn feature flag is enabled and compatible with the browser - if ( - window.PublicKeyCredential && - window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable && - window.PublicKeyCredential.isConditionalMediationAvailable - ) { - Promise.all([ - window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), - window.PublicKeyCredential.isConditionalMediationAvailable() - ]).then((results) => { - if (results.every((r) => r === true)) { - showToast() - } - // Navigate after passkey check completes (whether toast is shown or not) - navigate(redirectTo) - }) - return - } - } + // Show passkey registration prompt if supported + showRegisterPasskeyToast() - // Navigate immediately if passkey is not enabled or not available navigate(redirectTo) }, [isRegistered, redirectPath]) diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 46397149d8..9ac068e073 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -531,6 +531,60 @@ describe('Passkey login', () => { }) }) + describe('Passkey Registration', () => { + test('Displays Create passkey toast after successful login when passkey is enabled', async () => { + // Successful email/password login + global.server.use( + rest.post('*/oauth2/token', (req, res, ctx) => + res( + ctx.delay(0), + ctx.json({ + customer_id: 'customerid_1', + access_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', + refresh_token: 'testrefeshtoken_1', + usid: 'testusid_1', + enc_user_id: 'testEncUserId_1', + id_token: 'testIdToken_1' + }) + ) + ), + rest.post('*/baskets/actions/merge', (req, res, ctx) => + res(ctx.delay(0), ctx.json(mockMergedBasket)) + ) + ) + + const {user} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: false + } + }) + + // Wait for login form after passkey is cancelled + await waitFor(() => { + expect(screen.getByLabelText('Email')).toBeInTheDocument() + expect(screen.getByLabelText('Password')).toBeInTheDocument() + }) + + await user.type(screen.getByLabelText('Email'), 'customer@test.com') + await user.type(screen.getByLabelText('Password'), 'Password!1') + await user.click(screen.getByRole('button', {name: /sign in/i})) + + // Create passkey toast is shown after successful login when passkey is enabled and WebAuthn is supported + await waitFor( + () => { + expect( + screen.getByRole('button', {name: /Create Passkey/i}) + ).toBeInTheDocument() + }, + {timeout: 3000} + ) + }) + }) + test('Shows error when passkey authentication fails with error from the browser', async () => { // Simulate error in navigator.credentials.get hook mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) @@ -579,48 +633,6 @@ describe('Passkey login', () => { ).toBeInTheDocument() }) }) - - test('Shows passkey registration prompt after successful login when passkey enabled and not registered', async () => { - const {user} = renderWithProviders(, { - wrapperProps: { - siteAlias: 'uk', - locale: {id: 'en-GB'}, - appConfig: mockAppConfig, - bypassAuth: false - } - }) - - // Login with regular credentials - await user.type(screen.getByLabelText('Email'), 'customer@test.com') - await user.type(screen.getByLabelText('Password'), 'Password!1') - - global.server.use( - rest.post('*/oauth2/token', (req, res, ctx) => - res( - ctx.delay(0), - ctx.json({ - customer_id: 'customerid_1', - access_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', - refresh_token: 'testrefeshtoken_1', - usid: 'testusid_1', - enc_user_id: 'testEncUserId_1', - id_token: 'testIdToken_1' - }) - ) - ) - ) - - await user.click(screen.getByRole('button', {name: /sign in/i})) - - // After successful login, should navigate to account page - await waitFor( - () => { - expect(window.location.pathname).toBe('/uk/en-GB/account') - }, - {timeout: 5000} - ) - }) }) describe('Navigate away from login page tests', function () { From 1199fc65a0134ad4a57283a5b01d513668e1b38a Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Feb 2026 15:59:26 -0500 Subject: [PATCH 09/28] Enhance passkey error handling in auth-utils by introducing new error messages for unavailable features and authentication API errors. Update related tests to ensure correct message mappings for various error responses. --- .../app/utils/auth-utils.js | 21 +++++++++-------- .../app/utils/auth-utils.test.js | 23 ++++++++----------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/template-retail-react-app/app/utils/auth-utils.js b/packages/template-retail-react-app/app/utils/auth-utils.js index 2d07feba55..bc7f47c224 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.js @@ -28,10 +28,15 @@ export const TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE = defineMessage({ id: 'global.error.too_many_password_reset_requests' }) -export const TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE = defineMessage({ +// Constants for Passkey authentication and registration +export const PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE = defineMessage({ + id: 'global.error.passkey_feature_unavailable', + defaultMessage: 'The passkey feature is currently unavailable' +}) +export const PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE = defineMessage({ + id: 'global.error.passkey_api_error', defaultMessage: - 'You reached the limit for passkey registration attempts. For your security, wait 10 minutes and try again.', - id: 'global.error.too_many_passkey_registration_attempts' + 'Something went wrong while authenticating your passkey. Try again or use a different login method.' }) // Shared error patterns for token-based auth features (passwordless login, password reset) @@ -130,9 +135,9 @@ export const getLoginPasswordlessErrorMessage = (errorMessage) => { */ export const getPasskeyAuthenticateErrorMessage = (error) => { if (error.response?.status === 400 || error.response?.status === 401) { - return FEATURE_UNAVAILABLE_ERROR_MESSAGE + return PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE } - return API_ERROR_MESSAGE + return PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE } /** @@ -148,12 +153,8 @@ export const getPasskeyAuthenticateErrorMessage = (error) => { * @returns {Object} - The message descriptor (from defineMessage) for formatMessage */ export const getPasskeyRegistrationErrorMessage = (error) => { - // Too many requests error is only returned by AuthorizeWebauthnRegistration - if (TOO_MANY_REQUESTS_ERROR.test(error.message)) { - return TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE - } if (error.response?.status === 400 || error.response?.status === 401) { - return FEATURE_UNAVAILABLE_ERROR_MESSAGE + return PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE } return API_ERROR_MESSAGE } diff --git a/packages/template-retail-react-app/app/utils/auth-utils.test.js b/packages/template-retail-react-app/app/utils/auth-utils.test.js index a2cdb02fdf..c8bf035fb5 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.test.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.test.js @@ -18,7 +18,8 @@ import { getPasskeyRegistrationErrorMessage, TOO_MANY_LOGIN_ATTEMPTS_ERROR_MESSAGE, TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE, - TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE + PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE, + PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE } from '@salesforce/retail-react-app/app/utils/auth-utils' afterEach(() => { @@ -121,23 +122,19 @@ describe('getLoginPasswordlessErrorMessage', () => { describe('getPasskeyAuthenticateErrorMessage', () => { test.each([ - [{response: {status: 400}}, FEATURE_UNAVAILABLE_ERROR_MESSAGE], - [{response: {status: 401}}, FEATURE_UNAVAILABLE_ERROR_MESSAGE], - [{response: {status: 403}}, API_ERROR_MESSAGE], - [{response: {status: 412}}, API_ERROR_MESSAGE], - [{response: {status: 500}}, API_ERROR_MESSAGE], - [new Error('Network Error'), API_ERROR_MESSAGE] + [{response: {status: 400}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [{response: {status: 401}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [{response: {status: 403}}, PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE], + [{response: {status: 412}}, PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE], + [{response: {status: 500}}, PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE], + [new Error('Network Error'), PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE] ])('maps passkey error to the correct message descriptor', (error, expectedMessage) => { expect(getPasskeyAuthenticateErrorMessage(error)).toBe(expectedMessage) }) test.each([ - [ - {message: 'Too many passkey registration requests were made.', response: {status: 400}}, - TOO_MANY_PASSKEY_REGISTRATION_ATTEMPTS_ERROR_MESSAGE - ], - [{response: {status: 400}}, FEATURE_UNAVAILABLE_ERROR_MESSAGE], - [{response: {status: 401}}, FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [{response: {status: 400}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [{response: {status: 401}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], [{response: {status: 403}}, API_ERROR_MESSAGE], [{response: {status: 412}}, API_ERROR_MESSAGE], [{response: {status: 500}}, API_ERROR_MESSAGE], From 977e9eabde2b1773802f66d8b521255092394a1e Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Feb 2026 16:06:54 -0500 Subject: [PATCH 10/28] use preview version for commerce-sdk-react --- packages/commerce-sdk-react/package-lock.json | 5 +---- packages/commerce-sdk-react/package.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index 9357dd94e1..ee38b2363b 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -9,7 +9,7 @@ "version": "5.0.0-dev", "license": "See license in LICENSE", "dependencies": { - "commerce-sdk-isomorphic": "5.0.0-unstable-20260202081607", + "commerce-sdk-isomorphic": "preview", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, @@ -2843,7 +2843,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2856,7 +2855,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3543,7 +3541,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index 9bfbf8a397..e58bde75df 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -40,7 +40,7 @@ "version": "node ./scripts/version.js" }, "dependencies": { - "commerce-sdk-isomorphic": "5.0.0-unstable-20260202081607", + "commerce-sdk-isomorphic": "preview", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, From bbc4071e2c743619779f1754672a00627552a8e5 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Feb 2026 16:25:13 -0500 Subject: [PATCH 11/28] Revert "use preview version for commerce-sdk-react" This reverts commit 977e9eabde2b1773802f66d8b521255092394a1e. --- packages/commerce-sdk-react/package-lock.json | 5 ++++- packages/commerce-sdk-react/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index ee38b2363b..9357dd94e1 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -9,7 +9,7 @@ "version": "5.0.0-dev", "license": "See license in LICENSE", "dependencies": { - "commerce-sdk-isomorphic": "preview", + "commerce-sdk-isomorphic": "5.0.0-unstable-20260202081607", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, @@ -2843,6 +2843,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2855,6 +2856,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3541,6 +3543,7 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index e58bde75df..9bfbf8a397 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -40,7 +40,7 @@ "version": "node ./scripts/version.js" }, "dependencies": { - "commerce-sdk-isomorphic": "preview", + "commerce-sdk-isomorphic": "5.0.0-unstable-20260202081607", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, From 7c727b2914ff005c6281720916c6026580924cc0 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 19 Feb 2026 10:48:43 -0500 Subject: [PATCH 12/28] Enhance usePasskeyLogin hook to handle specific error responses gracefully, including early returns for 412 status. Improve passkey authentication error handling by adding console error logging in AuthModal and Login components. --- .../app/hooks/use-auth-modal.js | 1 + .../app/hooks/use-passkey-login.js | 14 ++- .../app/hooks/use-passkey-login.test.js | 99 ++++++++++++++++--- .../app/pages/login/index.jsx | 2 +- .../static/translations/compiled/en-GB.json | 18 ++-- .../static/translations/compiled/en-US.json | 18 ++-- .../static/translations/compiled/en-XA.json | 42 +++----- .../app/utils/auth-utils.js | 7 +- .../app/utils/auth-utils.test.js | 11 +-- .../translations/en-GB.json | 9 +- .../translations/en-US.json | 9 +- 11 files changed, 137 insertions(+), 93 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index 14a6b65254..b6c5de1893 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -242,6 +242,7 @@ export const AuthModal = ({ loginWithPasskey().catch((error) => { const message = formatMessage(getPasskeyAuthenticateErrorMessage(error)) form.setError('global', {type: 'manual', message}) + console.error('Error authenticating passkey:', error) }) } }, [isOpen]) diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js index d85e8560e7..eb256dff09 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -39,9 +39,17 @@ export const usePasskeyLogin = () => { return } - const startWebauthnAuthenticationResponse = await startWebauthnAuthentication.mutateAsync( - {} - ) + let startWebauthnAuthenticationResponse + try { + startWebauthnAuthenticationResponse = await startWebauthnAuthentication.mutateAsync({}) + } catch (error) { + // 412 is returned when user attempts to authenticate within 1 minute of a previous attempt + // We return early in this case to avoid showing an error to the user + if (error.response?.status === 412) { + return + } + throw error + } // Transform response for WebAuthn API to send to navigator.credentials.get() // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/parseRequestOptionsFromJSON_static diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js index 3b12bba031..fe4cb03b68 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js @@ -7,11 +7,13 @@ import React from 'react' import {rest} from 'msw' import {fireEvent, screen, waitFor} from '@testing-library/react' -import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import { + renderWithProviders, + registerUserToken +} from '@salesforce/retail-react-app/app/utils/test-utils' import {usePasskeyLogin} from '@salesforce/retail-react-app/app/hooks/use-passkey-login' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' -import {registerUserToken} from '@salesforce/retail-react-app/app/utils/test-utils' const mockCredential = { id: 'test-credential-id', @@ -78,9 +80,16 @@ const mockParseRequestOptionsFromJSON = jest.fn() const MockComponent = () => { const {loginWithPasskey} = usePasskeyLogin() + const [result, setResult] = React.useState(null) + const handleClick = () => { + loginWithPasskey() + .then(() => setResult('resolved')) + .catch(() => setResult('rejected')) + } return (
-
) } @@ -261,26 +270,90 @@ describe('usePasskeyLogin', () => { }) test('returns early without error when NotAllowedError is thrown from navigator.credentials.get', async () => { - // Create a NotAllowedError (typically thrown when user cancels passkey login) + // Create a NotAllowedError (thrown when user cancels passkey login) const notAllowedError = new Error('User cancelled') notAllowedError.name = 'NotAllowedError' - - // Mock navigator.credentials.get to throw NotAllowedError mockGetCredentials.mockRejectedValue(notAllowedError) renderWithProviders() const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled()) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') + ) + }) + + test('returns early without error when 412 is returned from startWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(412), + ctx.json({message: 'Authenticate not started for: user@example.com"'}) + ) + }) + ) - // Click the button - should not throw an error even though NotAllowedError is thrown + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') fireEvent.click(trigger) - // Wait for navigator.credentials.get to be called - await waitFor(() => { - expect(mockGetCredentials).toHaveBeenCalled() - }) + expect(mockGetCredentials).not.toHaveBeenCalled() + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') + ) + }) + + test('throws error when other error is returned from startWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) + }) + ) - // Verify that no error message is displayed - expect(screen.queryByText('Something went wrong. Try again!')).not.toBeInTheDocument() + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) + }) + + test('throws error when other error is returned from finishWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/finish', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) + }) + ) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) + }) + + test('throws error when other error is returned from navigator.credentials.get', async () => { + const networkError = new Error('NetworkError') + networkError.name = 'NetworkError' + mockGetCredentials.mockRejectedValue(networkError) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) }) }) diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index d5cd77aeb9..331d3b29f6 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -193,9 +193,9 @@ const Login = ({initialView = LOGIN_VIEW}) => { useEffect(() => { loginWithPasskey().catch((error) => { - console.error('Error authenticating passkey:', error) const message = formatMessage(getPasskeyAuthenticateErrorMessage(error)) form.setError('global', {type: 'manual', message}) + console.error('Error authenticating passkey:', error) }) }, []) diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index a8a6c53967..9a70ae371a 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -2091,6 +2091,12 @@ "value": "Invalid token, please try again." } ], + "global.error.passkey_feature_unavailable": [ + { + "type": 0, + "value": "The passkey feature is currently unavailable" + } + ], "global.error.something_went_wrong": [ { "type": 0, @@ -3255,18 +3261,6 @@ "value": "Register Passkey" } ], - "passkey_registration.modal.error.authorize_failed": [ - { - "type": 0, - "value": "Failed to authorize passkey registration" - } - ], - "passkey_registration.modal.error.registration_failed": [ - { - "type": 0, - "value": "Failed to register passkey" - } - ], "passkey_registration.modal.label.nickname": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index a8a6c53967..9a70ae371a 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -2091,6 +2091,12 @@ "value": "Invalid token, please try again." } ], + "global.error.passkey_feature_unavailable": [ + { + "type": 0, + "value": "The passkey feature is currently unavailable" + } + ], "global.error.something_went_wrong": [ { "type": 0, @@ -3255,18 +3261,6 @@ "value": "Register Passkey" } ], - "passkey_registration.modal.error.authorize_failed": [ - { - "type": 0, - "value": "Failed to authorize passkey registration" - } - ], - "passkey_registration.modal.error.registration_failed": [ - { - "type": 0, - "value": "Failed to register passkey" - } - ], "passkey_registration.modal.label.nickname": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 6a2faf5122..363dd68c7b 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -4347,6 +4347,20 @@ "value": "]" } ], + "global.error.passkey_feature_unavailable": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŧħḗḗ ƥȧȧşşķḗḗẏ ƒḗḗȧȧŧŭŭřḗḗ īş ƈŭŭřřḗḗƞŧŀẏ ŭŭƞȧȧṽȧȧīŀȧȧƀŀḗḗ" + }, + { + "type": 0, + "value": "]" + } + ], "global.error.something_went_wrong": [ { "type": 0, @@ -6847,34 +6861,6 @@ "value": "]" } ], - "passkey_registration.modal.error.authorize_failed": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƒȧȧīŀḗḗḓ ŧǿǿ ȧȧŭŭŧħǿǿřīẑḗḗ ƥȧȧşşķḗḗẏ řḗḗɠīşŧřȧȧŧīǿǿƞ" - }, - { - "type": 0, - "value": "]" - } - ], - "passkey_registration.modal.error.registration_failed": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƒȧȧīŀḗḗḓ ŧǿǿ řḗḗɠīşŧḗḗř ƥȧȧşşķḗḗẏ" - }, - { - "type": 0, - "value": "]" - } - ], "passkey_registration.modal.label.nickname": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/utils/auth-utils.js b/packages/template-retail-react-app/app/utils/auth-utils.js index bc7f47c224..7aacffc4dd 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.js @@ -33,11 +33,6 @@ export const PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE = defineMessage({ id: 'global.error.passkey_feature_unavailable', defaultMessage: 'The passkey feature is currently unavailable' }) -export const PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE = defineMessage({ - id: 'global.error.passkey_api_error', - defaultMessage: - 'Something went wrong while authenticating your passkey. Try again or use a different login method.' -}) // Shared error patterns for token-based auth features (passwordless login, password reset) const TOKEN_BASED_AUTH_FEATURE_UNAVAILABLE_ERRORS = [ @@ -137,7 +132,7 @@ export const getPasskeyAuthenticateErrorMessage = (error) => { if (error.response?.status === 400 || error.response?.status === 401) { return PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE } - return PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE + return API_ERROR_MESSAGE } /** diff --git a/packages/template-retail-react-app/app/utils/auth-utils.test.js b/packages/template-retail-react-app/app/utils/auth-utils.test.js index c8bf035fb5..eca9591566 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.test.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.test.js @@ -18,8 +18,7 @@ import { getPasskeyRegistrationErrorMessage, TOO_MANY_LOGIN_ATTEMPTS_ERROR_MESSAGE, TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE, - PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE, - PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE + PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE } from '@salesforce/retail-react-app/app/utils/auth-utils' afterEach(() => { @@ -124,10 +123,10 @@ describe('getPasskeyAuthenticateErrorMessage', () => { test.each([ [{response: {status: 400}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], [{response: {status: 401}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], - [{response: {status: 403}}, PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE], - [{response: {status: 412}}, PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE], - [{response: {status: 500}}, PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE], - [new Error('Network Error'), PASSKEY_AUTHENTICATION_API_ERROR_MESSAGE] + [{response: {status: 403}}, API_ERROR_MESSAGE], + [{response: {status: 412}}, API_ERROR_MESSAGE], + [{response: {status: 500}}, API_ERROR_MESSAGE], + [new Error('Network Error'), API_ERROR_MESSAGE] ])('maps passkey error to the correct message descriptor', (error, expectedMessage) => { expect(getPasskeyAuthenticateErrorMessage(error)).toBe(expectedMessage) }) diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 22465a07a0..91b0a5bbae 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -849,6 +849,9 @@ "global.error.invalid_token": { "defaultMessage": "Invalid token, please try again." }, + "global.error.passkey_feature_unavailable": { + "defaultMessage": "The passkey feature is currently unavailable" + }, "global.error.something_went_wrong": { "defaultMessage": "Something went wrong. Try again!" }, @@ -1354,12 +1357,6 @@ "passkey_registration.modal.button.register": { "defaultMessage": "Register Passkey" }, - "passkey_registration.modal.error.authorize_failed": { - "defaultMessage": "Failed to authorize passkey registration" - }, - "passkey_registration.modal.error.registration_failed": { - "defaultMessage": "Failed to register passkey" - }, "passkey_registration.modal.label.nickname": { "defaultMessage": "Passkey Nickname (optional)" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 22465a07a0..91b0a5bbae 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -849,6 +849,9 @@ "global.error.invalid_token": { "defaultMessage": "Invalid token, please try again." }, + "global.error.passkey_feature_unavailable": { + "defaultMessage": "The passkey feature is currently unavailable" + }, "global.error.something_went_wrong": { "defaultMessage": "Something went wrong. Try again!" }, @@ -1354,12 +1357,6 @@ "passkey_registration.modal.button.register": { "defaultMessage": "Register Passkey" }, - "passkey_registration.modal.error.authorize_failed": { - "defaultMessage": "Failed to authorize passkey registration" - }, - "passkey_registration.modal.error.registration_failed": { - "defaultMessage": "Failed to register passkey" - }, "passkey_registration.modal.label.nickname": { "defaultMessage": "Passkey Nickname (optional)" }, From 2e64f70160256379a1e29ce2251aa8464d9262fc Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 19 Feb 2026 15:35:26 -0500 Subject: [PATCH 13/28] Refactor passkey registration and authentication error handling across components to standardize user feedback. Replace specific error messages with a generic "Something went wrong. Try again!" message. Update tests to reflect these changes and ensure consistent error handling in the UI. --- .../passkey-registration-modal/index.jsx | 26 ++++------- .../passkey-registration-modal/index.test.js | 41 +++++++++++++++--- .../app/hooks/use-auth-modal.js | 6 +-- .../app/hooks/use-auth-modal.test.js | 2 +- .../app/hooks/use-passkey-login.js | 1 + .../app/pages/login/index.jsx | 9 ++-- .../app/pages/login/index.test.js | 2 +- .../static/translations/compiled/en-GB.json | 6 --- .../static/translations/compiled/en-US.json | 6 --- .../static/translations/compiled/en-XA.json | 14 ------ .../app/utils/auth-utils.js | 43 ------------------- .../app/utils/auth-utils.test.js | 29 +------------ .../translations/en-GB.json | 3 -- .../translations/en-US.json | 3 -- 14 files changed, 52 insertions(+), 139 deletions(-) diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx index f6c0748bf0..7b65aef3c5 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx @@ -33,11 +33,13 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur // Utils import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/utils' -import {getPasskeyRegistrationErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils' // SDK import {AuthHelpers, useAuthHelper} from '@salesforce/commerce-sdk-react' +// Constants +import {API_ERROR_MESSAGE, INVALID_TOKEN_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' + /** * Modal for registering a new passkey with a nickname */ @@ -75,9 +77,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { setIsOtpAuthOpen(true) } catch (err) { // Set error message for the passkey registration modal - setError(formatMessage(getPasskeyRegistrationErrorMessage(err))) - // Re-throw the error to be handled by the OTP auth modal - throw err + setError(formatMessage(API_ERROR_MESSAGE)) } finally { setIsLoading(false) } @@ -105,18 +105,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { // Step 4: navigator.credentials.create() will show a browser/system prompt // This may appear to hang if the user doesn't interact with the prompt - let credential - try { - credential = await navigator.credentials.create({ - publicKey - }) - } catch (createError) { - // Handle user cancellation or other errors from the WebAuthn API - if (createError.name === 'NotAllowedError' || createError.name === 'AbortError') { - throw new Error('Passkey registration was cancelled or timed out') - } - throw createError - } + const credential = await navigator.credentials.create({publicKey}) if (!credential) { throw new Error('Failed to create credential: user cancelled or operation failed') @@ -159,10 +148,13 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { return {success: true} } catch (err) { console.error('Error registering passkey:', err) + const message = /Unauthorized/i.test(err.message) + ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) + : formatMessage(API_ERROR_MESSAGE) // Return error result for OTP component to display return { success: false, - error: formatMessage(getPasskeyRegistrationErrorMessage(err)) + error: message } } finally { setIsLoading(false) diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js index 2e644a3e2a..b1ecb40ec4 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js @@ -223,7 +223,7 @@ describe('PasskeyRegistrationModal', () => { await user.click(registerButton) await waitFor(() => { - expect(screen.getByText(errorMessage)).toBeInTheDocument() + expect(screen.getByText('Something went wrong. Try again!')).toBeInTheDocument() }) }) @@ -457,7 +457,7 @@ describe('PasskeyRegistrationModal', () => { expect(result).toEqual({ success: false, - error: errorMessage + error: 'Something went wrong. Try again!' }) // Verify modals are not closed on error @@ -498,7 +498,7 @@ describe('PasskeyRegistrationModal', () => { expect(result).toEqual({ success: false, - error: 'WebAuthn API not available in this browser' + error: 'Something went wrong. Try again!' }) }) @@ -537,7 +537,7 @@ describe('PasskeyRegistrationModal', () => { expect(result).toEqual({ success: false, - error: 'Passkey registration was cancelled or timed out' + error: 'Something went wrong. Try again!' }) }) @@ -573,7 +573,7 @@ describe('PasskeyRegistrationModal', () => { expect(result).toEqual({ success: false, - error: 'Failed to create credential: user cancelled or operation failed' + error: 'Something went wrong. Try again!' }) }) @@ -622,7 +622,7 @@ describe('PasskeyRegistrationModal', () => { expect(result).toEqual({ success: false, - error: errorMessage + error: 'Something went wrong. Try again!' }) }) @@ -661,7 +661,34 @@ describe('PasskeyRegistrationModal', () => { expect(result).toEqual({ success: false, - error: 'Passkey registration was cancelled or timed out' + error: 'Something went wrong. Try again!' + }) + }) + + test('returns INVALID_TOKEN_ERROR_MESSAGE when startWebauthnUserRegistration fails with Unauthorized', async () => { + const otpCode = '12345678' + + mockStartWebauthnRegistration.mockRejectedValue(new Error('Unauthorized')) + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + const result = await otpVerificationHandler(otpCode) + + expect(result).toEqual({ + success: false, + error: 'Invalid token, please try again.' }) }) }) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index b6c5de1893..7dac6dafc6 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -239,10 +239,8 @@ export const AuthModal = ({ setCurrentView(initialView) form.reset() // Prompt user to login without username (discoverable credentials) - loginWithPasskey().catch((error) => { - const message = formatMessage(getPasskeyAuthenticateErrorMessage(error)) - form.setError('global', {type: 'manual', message}) - console.error('Error authenticating passkey:', error) + loginWithPasskey().catch(() => { + form.setError('global', {type: 'manual', message:formatMessage(API_ERROR_MESSAGE)}) }) } }, [isOpen]) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index c41d2ed20e..19a41eeaf4 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -816,7 +816,7 @@ describe('Passkey login', () => { // Should show error - 401 error from WebAuthn API should be caught and converted to user-friendly message await waitFor(() => { expect( - screen.getByText(/This feature is not currently available./i) + screen.getByText(/Something went wrong. Try again!/i) ).toBeInTheDocument() }) }) diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js index eb256dff09..497c874638 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -71,6 +71,7 @@ export const usePasskeyLogin = () => { if (error.name == 'NotAllowedError') { return } + console.error('Error getting passkey credential from browser:', error) throw error } diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index 331d3b29f6..a83fcc4362 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -34,8 +34,7 @@ import {isServer, noop} from '@salesforce/retail-react-app/app/utils/utils' import { getAuthorizePasswordlessErrorMessage, getLoginPasswordlessErrorMessage, - getPasswordlessCallbackUrl, - getPasskeyAuthenticateErrorMessage + getPasswordlessCallbackUrl } from '@salesforce/retail-react-app/app/utils/auth-utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' @@ -192,10 +191,8 @@ const Login = ({initialView = LOGIN_VIEW}) => { }, [isRegistered, redirectPath]) useEffect(() => { - loginWithPasskey().catch((error) => { - const message = formatMessage(getPasskeyAuthenticateErrorMessage(error)) - form.setError('global', {type: 'manual', message}) - console.error('Error authenticating passkey:', error) + loginWithPasskey().catch(() => { + form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) }) }, []) diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 9ac068e073..c74ae71ad5 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -629,7 +629,7 @@ describe('Passkey login', () => { // Should show error - 401 error from WebAuthn API should be caught and converted to user-friendly message await waitFor(() => { expect( - screen.getByText(/This feature is not currently available./i) + screen.getByText(/Something went wrong. Try again!/i) ).toBeInTheDocument() }) }) diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index 9a70ae371a..e3bf3e6ae7 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -2091,12 +2091,6 @@ "value": "Invalid token, please try again." } ], - "global.error.passkey_feature_unavailable": [ - { - "type": 0, - "value": "The passkey feature is currently unavailable" - } - ], "global.error.something_went_wrong": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index 9a70ae371a..e3bf3e6ae7 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -2091,12 +2091,6 @@ "value": "Invalid token, please try again." } ], - "global.error.passkey_feature_unavailable": [ - { - "type": 0, - "value": "The passkey feature is currently unavailable" - } - ], "global.error.something_went_wrong": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 363dd68c7b..900b6c95b8 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -4347,20 +4347,6 @@ "value": "]" } ], - "global.error.passkey_feature_unavailable": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ŧħḗḗ ƥȧȧşşķḗḗẏ ƒḗḗȧȧŧŭŭřḗḗ īş ƈŭŭřřḗḗƞŧŀẏ ŭŭƞȧȧṽȧȧīŀȧȧƀŀḗḗ" - }, - { - "type": 0, - "value": "]" - } - ], "global.error.something_went_wrong": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/utils/auth-utils.js b/packages/template-retail-react-app/app/utils/auth-utils.js index 7aacffc4dd..4b8772553c 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.js @@ -28,12 +28,6 @@ export const TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE = defineMessage({ id: 'global.error.too_many_password_reset_requests' }) -// Constants for Passkey authentication and registration -export const PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE = defineMessage({ - id: 'global.error.passkey_feature_unavailable', - defaultMessage: 'The passkey feature is currently unavailable' -}) - // Shared error patterns for token-based auth features (passwordless login, password reset) const TOKEN_BASED_AUTH_FEATURE_UNAVAILABLE_ERRORS = [ /no callback_uri is registered/i, @@ -116,40 +110,3 @@ export const getLoginPasswordlessErrorMessage = (errorMessage) => { } return API_ERROR_MESSAGE } - -/** - * Maps errors from SLAS WebAuthn APIs to user-friendly message descriptors. - * Used for passkey login auth helpers. - * - * Passkey Login: - * - StartWebauthnAuthentication - * - FinishWebauthnAuthentication - * - * @param {Error} error - The error from the API - * @returns {Object} - The message descriptor (from defineMessage) for formatMessage - */ -export const getPasskeyAuthenticateErrorMessage = (error) => { - if (error.response?.status === 400 || error.response?.status === 401) { - return PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE - } - return API_ERROR_MESSAGE -} - -/** - * Maps errors from SLAS WebAuthn APIs to user-friendly message descriptors. - * Used for passkey registration auth helpers. - * - * Passkey Registration: - * - AuthorizeWebauthnRegistration - * - StartWebauthnUserRegistration - * - FinishWebauthnUserRegistration - * - * @param {Error} error - The error from the API - * @returns {Object} - The message descriptor (from defineMessage) for formatMessage - */ -export const getPasskeyRegistrationErrorMessage = (error) => { - if (error.response?.status === 400 || error.response?.status === 401) { - return PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE - } - return API_ERROR_MESSAGE -} diff --git a/packages/template-retail-react-app/app/utils/auth-utils.test.js b/packages/template-retail-react-app/app/utils/auth-utils.test.js index eca9591566..ccd7dbcf86 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.test.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.test.js @@ -14,11 +14,8 @@ import { getAuthorizePasswordlessErrorMessage, getPasswordResetErrorMessage, getLoginPasswordlessErrorMessage, - getPasskeyAuthenticateErrorMessage, - getPasskeyRegistrationErrorMessage, TOO_MANY_LOGIN_ATTEMPTS_ERROR_MESSAGE, - TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE, - PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE + TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE } from '@salesforce/retail-react-app/app/utils/auth-utils' afterEach(() => { @@ -118,27 +115,3 @@ describe('getLoginPasswordlessErrorMessage', () => { } ) }) - -describe('getPasskeyAuthenticateErrorMessage', () => { - test.each([ - [{response: {status: 400}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], - [{response: {status: 401}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], - [{response: {status: 403}}, API_ERROR_MESSAGE], - [{response: {status: 412}}, API_ERROR_MESSAGE], - [{response: {status: 500}}, API_ERROR_MESSAGE], - [new Error('Network Error'), API_ERROR_MESSAGE] - ])('maps passkey error to the correct message descriptor', (error, expectedMessage) => { - expect(getPasskeyAuthenticateErrorMessage(error)).toBe(expectedMessage) - }) - - test.each([ - [{response: {status: 400}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], - [{response: {status: 401}}, PASSKEY_FEATURE_UNAVAILABLE_ERROR_MESSAGE], - [{response: {status: 403}}, API_ERROR_MESSAGE], - [{response: {status: 412}}, API_ERROR_MESSAGE], - [{response: {status: 500}}, API_ERROR_MESSAGE], - [new Error('Network Error'), API_ERROR_MESSAGE] - ])('maps passkey error to the correct message descriptor', (error, expectedMessage) => { - expect(getPasskeyRegistrationErrorMessage(error)).toBe(expectedMessage) - }) -}) diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 91b0a5bbae..f0c8657d49 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -849,9 +849,6 @@ "global.error.invalid_token": { "defaultMessage": "Invalid token, please try again." }, - "global.error.passkey_feature_unavailable": { - "defaultMessage": "The passkey feature is currently unavailable" - }, "global.error.something_went_wrong": { "defaultMessage": "Something went wrong. Try again!" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 91b0a5bbae..f0c8657d49 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -849,9 +849,6 @@ "global.error.invalid_token": { "defaultMessage": "Invalid token, please try again." }, - "global.error.passkey_feature_unavailable": { - "defaultMessage": "The passkey feature is currently unavailable" - }, "global.error.something_went_wrong": { "defaultMessage": "Something went wrong. Try again!" }, From cc79b7488fa603168cfcf6b4d0fb47fc62c4d02d Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 19 Feb 2026 16:10:36 -0500 Subject: [PATCH 14/28] Update comments in PasskeyRegistrationModal to correct step numbering for clarity in the WebAuthn registration process. --- .../app/components/passkey-registration-modal/index.jsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx index 7b65aef3c5..686c869903 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx @@ -103,7 +103,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { throw new Error('WebAuthn API not available in this browser') } - // Step 4: navigator.credentials.create() will show a browser/system prompt + // navigator.credentials.create() will show a browser/system prompt // This may appear to hang if the user doesn't interact with the prompt const credential = await navigator.credentials.create({publicKey}) @@ -111,7 +111,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { throw new Error('Failed to create credential: user cancelled or operation failed') } - // Step 5: Convert credential to JSON format before sending to SLAS + // Step 4: Convert credential to JSON format before sending to SLAS // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/toJSON let credentialJson try { @@ -134,14 +134,14 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { } } - // Step 6: Finish WebAuthn registration + // Step 5: Finish WebAuthn registration await finishWebauthnUserRegistration.mutateAsync({ username: customer.email, credential: credentialJson, pwd_action_token: code }) - // Step 7: Close OTP modal and main modal on success + // Step 6: Close OTP modal and main modal on success setIsOtpAuthOpen(false) onClose() @@ -151,6 +151,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { const message = /Unauthorized/i.test(err.message) ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) : formatMessage(API_ERROR_MESSAGE) + // Return error result for OTP component to display return { success: false, From b6f7f4f440f0f4b9ae06e3f479b5efae480890e5 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 20 Feb 2026 11:10:29 -0500 Subject: [PATCH 15/28] lint --- .../app/components/passkey-registration-modal/index.jsx | 7 +++++-- .../template-retail-react-app/app/hooks/use-auth-modal.js | 5 ++--- .../app/hooks/use-auth-modal.test.js | 5 +---- .../app/pages/checkout/partials/contact-info.jsx | 5 +---- .../app/pages/login/index.test.js | 4 +--- 5 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx index 686c869903..1345be5d4c 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx @@ -38,7 +38,10 @@ import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/uti import {AuthHelpers, useAuthHelper} from '@salesforce/commerce-sdk-react' // Constants -import {API_ERROR_MESSAGE, INVALID_TOKEN_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' +import { + API_ERROR_MESSAGE, + INVALID_TOKEN_ERROR_MESSAGE +} from '@salesforce/retail-react-app/app/constants' /** * Modal for registering a new passkey with a nickname @@ -148,7 +151,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { return {success: true} } catch (err) { console.error('Error registering passkey:', err) - const message = /Unauthorized/i.test(err.message) + const message = /Unauthorized/i.test(err.message) ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) : formatMessage(API_ERROR_MESSAGE) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index 7dac6dafc6..a536faebb1 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -36,8 +36,7 @@ import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import { getAuthorizePasswordlessErrorMessage, getPasswordResetErrorMessage, - getLoginPasswordlessErrorMessage, - getPasskeyAuthenticateErrorMessage + getLoginPasswordlessErrorMessage } from '@salesforce/retail-react-app/app/utils/auth-utils' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' @@ -240,7 +239,7 @@ export const AuthModal = ({ form.reset() // Prompt user to login without username (discoverable credentials) loginWithPasskey().catch(() => { - form.setError('global', {type: 'manual', message:formatMessage(API_ERROR_MESSAGE)}) + form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) }) } }, [isOpen]) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index 19a41eeaf4..3e52b8073f 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -27,7 +27,6 @@ import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/m import * as ReactHookForm from 'react-hook-form' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' -import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ getConfig: jest.fn() @@ -815,9 +814,7 @@ describe('Passkey login', () => { // Should show error - 401 error from WebAuthn API should be caught and converted to user-friendly message await waitFor(() => { - expect( - screen.getByText(/Something went wrong. Try again!/i) - ).toBeInTheDocument() + expect(screen.getByText(/Something went wrong. Try again!/i)).toBeInTheDocument() }) }) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index 711ca8b3d4..81fdb4c5e0 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -49,10 +49,7 @@ import { getAuthorizePasswordlessErrorMessage } from '@salesforce/retail-react-app/app/utils/auth-utils' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' -import {getPasswordlessErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils' -import { - API_ERROR_MESSAGE -} from '@salesforce/retail-react-app/app/constants' +import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, idps = []}) => { const {formatMessage} = useIntl() diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index c74ae71ad5..4c170c08e6 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -628,9 +628,7 @@ describe('Passkey login', () => { // Should show error - 401 error from WebAuthn API should be caught and converted to user-friendly message await waitFor(() => { - expect( - screen.getByText(/Something went wrong. Try again!/i) - ).toBeInTheDocument() + expect(screen.getByText(/Something went wrong. Try again!/i)).toBeInTheDocument() }) }) }) From 529b70380a4a8a808a7ff7244c3ed127ef034350 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 20 Feb 2026 11:32:29 -0500 Subject: [PATCH 16/28] increase bundle size --- packages/template-retail-react-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 4a7f4616c6..825da5229e 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -101,7 +101,7 @@ "bundlesize": [ { "path": "build/main.js", - "maxSize": "90 kB" + "maxSize": "92 kB" }, { "path": "build/vendor.js", From 1bf60756dc8b0338913c37c9f02a7423991ec1ae Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 20 Feb 2026 12:38:08 -0500 Subject: [PATCH 17/28] fix commerce-sdk-react unit tests --- .../src/hooks/ShopperLogin/index.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts index cf1ce4712d..65dd7b8967 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts @@ -20,12 +20,7 @@ describe('Shopper Login hooks', () => { // These endpoints all return data in the response headers, rather than body, so they // don't work well with the current implementation of mutation hooks. 'authenticateCustomer', - 'authorizeWebauthnRegistration', - 'finishWebauthnAuthentication', - 'finishWebauthnUserRegistration', - 'getTrustedAgentAuthorizationToken', - 'startWebauthnAuthentication', - 'startWebauthnUserRegistration' + 'getTrustedAgentAuthorizationToken' ]) }) test('all mutations have cache update logic', () => { From b4170ad968b4b4180294df014ec8f2796aa014f3 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Feb 2026 10:56:55 -0500 Subject: [PATCH 18/28] Add abort functionality to passkey login hook - Introduced `abortPasskeyLogin` method to allow users to cancel ongoing passkey login requests. - Integrated `AbortController` to manage cancellation of the passkey prompt when switching login methods. - Updated tests to verify the new abort functionality and ensure correct parameters are passed to `navigator.credentials.get`. --- .../app/hooks/use-passkey-login.js | 30 +- .../app/hooks/use-passkey-login.test.js | 387 ++++++++++-------- 2 files changed, 246 insertions(+), 171 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js index 497c874638..c9cb5f5b67 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -4,6 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import {useRef} from 'react' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useAuthHelper, AuthHelpers, useUsid} from '@salesforce/commerce-sdk-react' import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/utils' @@ -15,6 +16,19 @@ export const usePasskeyLogin = () => { const startWebauthnAuthentication = useAuthHelper(AuthHelpers.StartWebauthnAuthentication) const finishWebauthnAuthentication = useAuthHelper(AuthHelpers.FinishWebauthnAuthentication) const {usid} = useUsid() + const abortControllerRef = useRef(null) + + /** + * Aborts any pending passkey login request. + * This is useful when the user logs in with a different method (e.g., password) + * while a passkey prompt (e.g., 1Password) is still open. + */ + const abortPasskeyLogin = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + } const loginWithPasskey = async () => { const config = getConfig() @@ -57,22 +71,30 @@ export const usePasskeyLogin = () => { startWebauthnAuthenticationResponse.publicKey ) + // Create an AbortController to allow cancelling the passkey prompt + // This is needed when the user logs in with a different method while the passkey prompt is open + abortControllerRef.current = new AbortController() + // Get passkey credential from browser // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get let credential try { credential = await navigator.credentials.get({ publicKey: options, - mediation: 'conditional' + mediation: 'conditional', + signal: abortControllerRef.current.signal }) } catch (error) { // NotAllowedError is thrown when the user cancels the passkey login - // We return early in this case to avoid showing an error to the user - if (error.name == 'NotAllowedError') { + // AbortError is thrown when the passkey login is aborted programmatically (e.g., user logged in with password) + // We return early in these cases to avoid showing an error to the user + if (error.name === 'NotAllowedError' || error.name === 'AbortError') { return } console.error('Error getting passkey credential from browser:', error) throw error + } finally { + abortControllerRef.current = null } // Encode credential before sending to SLAS @@ -107,5 +129,5 @@ export const usePasskeyLogin = () => { return } - return {loginWithPasskey} + return {loginWithPasskey, abortPasskeyLogin} } diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js index fe4cb03b68..b0201d9316 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js @@ -79,16 +79,21 @@ const mockIsConditionalMediationAvailable = jest.fn() const mockParseRequestOptionsFromJSON = jest.fn() const MockComponent = () => { - const {loginWithPasskey} = usePasskeyLogin() + const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin() const [result, setResult] = React.useState(null) - const handleClick = () => { + + const handleLogin = () => { loginWithPasskey() .then(() => setResult('resolved')) .catch(() => setResult('rejected')) } + const handleAbort = () => { + abortPasskeyLogin() + } return (
-
) @@ -143,217 +148,265 @@ describe('usePasskeyLogin', () => { mockGetCredentials.mockResolvedValue(mockCredential) }) - test('calls navigator.credentials.get with the correct parameters when all conditions are met', async () => { - renderWithProviders() - - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) - - // Check that credentials.get is called with the correct parameters - await waitFor(() => { - expect(mockGetCredentials).toHaveBeenCalledWith({ - publicKey: expect.objectContaining({ - challenge: expect.any(String), - timeout: expect.any(Number), - rpId: expect.any(String) - }), - mediation: 'conditional' + describe('loginWithPasskey', () => { + test('calls navigator.credentials.get with the correct parameters when all conditions are met', async () => { + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + // Check that credentials.get is called with the correct parameters + await waitFor(() => { + expect(mockGetCredentials).toHaveBeenCalledWith({ + publicKey: expect.objectContaining({ + challenge: expect.any(String), + timeout: expect.any(Number), + rpId: expect.any(String) + }), + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) }) }) - }) - test('does not call navigator.credentials.get when passkey is not enabled', async () => { - getConfig.mockReturnValue({ - ...mockConfig, - app: { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passkey: { - enabled: false + test('does not call navigator.credentials.get when passkey is not enabled', async () => { + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: { + enabled: false + } } } - } - }) + }) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - expect(mockGetCredentials).not.toHaveBeenCalled() - }) + expect(mockGetCredentials).not.toHaveBeenCalled() + }) - test('does not start passkey login when PublicKeyCredential is not available', async () => { - delete global.window.PublicKeyCredential + test('does not start passkey login when PublicKeyCredential is not available', async () => { + delete global.window.PublicKeyCredential - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - expect(mockGetCredentials).not.toHaveBeenCalled() - }) + expect(mockGetCredentials).not.toHaveBeenCalled() + }) - test('does not start passkey login when conditional mediation is not available', async () => { - mockIsConditionalMediationAvailable.mockResolvedValue(false) + test('does not start passkey login when conditional mediation is not available', async () => { + mockIsConditionalMediationAvailable.mockResolvedValue(false) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - await waitFor(() => { - expect(mockIsConditionalMediationAvailable).toHaveBeenCalled() + await waitFor(() => { + expect(mockIsConditionalMediationAvailable).toHaveBeenCalled() + }) + + expect(mockGetCredentials).not.toHaveBeenCalled() }) - expect(mockGetCredentials).not.toHaveBeenCalled() - }) + test('falls back to manual encoding when toJSON() is not supported', async () => { + // Create a credential mock where toJSON() throws an error (e.g., 1Password) + const credentialWithoutToJSON = { + ...mockCredential, + toJSON: jest.fn(() => { + throw new Error('toJSON is not supported') + }) + } + + // Reset and set the mock for this specific test to ensure it returns the credential + mockGetCredentials.mockResolvedValue(credentialWithoutToJSON) - test('falls back to manual encoding when toJSON() is not supported', async () => { - // Create a credential mock where toJSON() throws an error (e.g., 1Password) - const credentialWithoutToJSON = { - ...mockCredential, - toJSON: jest.fn(() => { - throw new Error('toJSON is not supported') + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json(mockStartWebauthnAuthenticationResponse) + ) + }), + rest.post('*/oauth2/webauthn/authenticate/finish', async (req, res, ctx) => { + const body = await req.json() + // Assert: credential is still manually encoded when toJSON() is not supported + expect(body).toEqual( + expect.objectContaining({ + credential: expect.objectContaining({ + id: 'test-credential-id', + rawId: expect.any(String), + type: 'public-key', + clientExtensionResults: {}, + response: expect.objectContaining({ + authenticatorData: expect.any(String), + clientDataJSON: expect.any(String), + signature: expect.any(String), + userHandle: expect.any(String) + }) + }), + // usid used in the test environment + usid: '8e883973-68eb-41fe-a3c5-756232652ff5' + }) + ) + return res( + ctx.delay(0), + ctx.status(200), + ctx.json(mockFinishWebauthnAuthenticationResponse) + ) + }) + ) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => { + expect(mockGetCredentials).toHaveBeenCalled() }) - } + }) - // Reset and set the mock for this specific test to ensure it returns the credential - mockGetCredentials.mockResolvedValue(credentialWithoutToJSON) + test.each(['NotAllowedError', 'AbortError'])( + 'returns early without error when %s is thrown from navigator.credentials.get', + async (errorName) => { + const error = new Error() + error.name = errorName + mockGetCredentials.mockRejectedValue(error) - global.server.use( - rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { - return res( - ctx.delay(0), - ctx.status(200), - ctx.json(mockStartWebauthnAuthenticationResponse) - ) - }), - rest.post('*/oauth2/webauthn/authenticate/finish', async (req, res, ctx) => { - const body = await req.json() - // Assert: credential is still manually encoded when toJSON() is not supported - expect(body).toEqual( - expect.objectContaining({ - credential: expect.objectContaining({ - id: 'test-credential-id', - rawId: expect.any(String), - type: 'public-key', - clientExtensionResults: {}, - response: expect.objectContaining({ - authenticatorData: expect.any(String), - clientDataJSON: expect.any(String), - signature: expect.any(String), - userHandle: expect.any(String) - }) - }), - // usid used in the test environment - usid: '8e883973-68eb-41fe-a3c5-756232652ff5' - }) - ) - return res( - ctx.delay(0), - ctx.status(200), - ctx.json(mockFinishWebauthnAuthenticationResponse) - ) - }) - ) + renderWithProviders() - renderWithProviders() + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled()) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') + ) + } + ) - await waitFor(() => { - expect(mockGetCredentials).toHaveBeenCalled() + test('returns early without error when 412 is returned from startWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(412), + ctx.json({message: 'Authenticate not started for: user@example.com"'}) + ) + }) + ) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + expect(mockGetCredentials).not.toHaveBeenCalled() + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') + ) }) - }) - test('returns early without error when NotAllowedError is thrown from navigator.credentials.get', async () => { - // Create a NotAllowedError (thrown when user cancels passkey login) - const notAllowedError = new Error('User cancelled') - notAllowedError.name = 'NotAllowedError' - mockGetCredentials.mockRejectedValue(notAllowedError) + test('throws error when other error is returned from startWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) + }) + ) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled()) - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') - ) - }) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) + }) - test('returns early without error when 412 is returned from startWebauthnAuthentication', async () => { - global.server.use( - rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { - return res( - ctx.delay(0), - ctx.status(412), - ctx.json({message: 'Authenticate not started for: user@example.com"'}) - ) - }) - ) + test('throws error when other error is returned from finishWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/finish', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) + }) + ) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - expect(mockGetCredentials).not.toHaveBeenCalled() - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') - ) - }) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) + }) - test('throws error when other error is returned from startWebauthnAuthentication', async () => { - global.server.use( - rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) - }) - ) + test('throws error when other error is returned from navigator.credentials.get', async () => { + const networkError = new Error('NetworkError') + networkError.name = 'NetworkError' + mockGetCredentials.mockRejectedValue(networkError) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') - ) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) + }) }) - test('throws error when other error is returned from finishWebauthnAuthentication', async () => { - global.server.use( - rest.post('*/oauth2/webauthn/authenticate/finish', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) + describe('abortPasskeyLogin', () => { + test('aborts the pending passkey request', async () => { + // Return a promise that rejects with AbortError when the abort signal fires + mockGetCredentials.mockImplementation(({signal}) => { + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + const abortError = new Error('Aborted') + abortError.name = 'AbortError' + reject(abortError) + }) + }) }) - ) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const loginTrigger = screen.getByTestId('login-with-passkey') + fireEvent.click(loginTrigger) - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') - ) - }) + // Wait for credentials.get to be called (passkey prompt is "waiting") + await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled()) - test('throws error when other error is returned from navigator.credentials.get', async () => { - const networkError = new Error('NetworkError') - networkError.name = 'NetworkError' - mockGetCredentials.mockRejectedValue(networkError) + // Abort while navigator.credentials.get is pending + const abortTrigger = screen.getByTestId('abort-passkey-login') + fireEvent.click(abortTrigger) - renderWithProviders() + // Hook catches AbortError and returns, so loginWithPasskey resolves + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') + ) + }) - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + test('abortPasskeyLogin does nothing when no passkey request is pending', async () => { + renderWithProviders() - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') - ) + // Click abort without starting passkey login - should not throw + const abortTrigger = screen.getByTestId('abort-passkey-login') + fireEvent.click(abortTrigger) + + // No errors should occur + expect(mockGetCredentials).not.toHaveBeenCalled() + }) }) }) From c0dd5e9b574c508f08902f74251e7563d0eab00e Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Feb 2026 11:01:58 -0500 Subject: [PATCH 19/28] Enhance passkey login handling with abort functionality in pages/login/index.jsx - Added `abortPasskeyLogin` to the login component to cleanly abort ongoing passkey login requests when navigating away. - Updated tests to ensure the abort signal is correctly passed and handled during login attempts with different methods. - Verified that the passkey prompt is aborted when the user logs in with a password or navigates away from the login page. --- .../app/pages/login/index.jsx | 7 +- .../app/pages/login/index.test.js | 120 ++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index a83fcc4362..ebaa130b28 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -81,7 +81,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const mergeBasket = useShopperBasketsMutation('mergeBasket') const [redirectPath, setRedirectPath] = useState('') const {showRegisterPasskeyToast} = usePasskeyRegistration() - const {loginWithPasskey} = usePasskeyLogin() + const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin() const [isOtpAuthOpen, setIsOtpAuthOpen] = useState(false) const handleMergeBasket = () => { @@ -194,6 +194,11 @@ const Login = ({initialView = LOGIN_VIEW}) => { loginWithPasskey().catch(() => { form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) }) + + // Cleanup: abort passkey login when navigating away from login page + return () => { + abortPasskeyLogin() + } }, []) /**************** Einstein ****************/ diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 4c170c08e6..bc6d22bfe4 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -631,6 +631,126 @@ describe('Passkey login', () => { expect(screen.getByText(/Something went wrong. Try again!/i)).toBeInTheDocument() }) }) + + test('Passkey prompt is aborted when user logs in with password', async () => { + // This test verifies that navigator.credentials.get receives an AbortSignal + // which allows the passkey prompt to be aborted when user logs in via another method + // + // Note: The actual abort behavior is tested in use-passkey-login.test.js. + // This integration test verifies the signal is properly passed through. + + // Mock credentials.get to capture and verify the signal is passed + mockCredentialsGet.mockImplementation(({signal}) => { + // Verify that an AbortSignal is passed to navigator.credentials.get + expect(signal).toBeInstanceOf(AbortSignal) + return new Promise(() => { + // Never resolve - simulates passkey prompt staying open + }) + }) + + // Successful email/password login + global.server.use( + rest.post('*/oauth2/token', (req, res, ctx) => + res( + ctx.delay(0), + ctx.json({ + customer_id: 'customerid_1', + access_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', + refresh_token: 'testrefeshtoken_1', + usid: 'testusid_1', + enc_user_id: 'testEncUserId_1', + id_token: 'testIdToken_1' + }) + ) + ), + rest.post('*/baskets/actions/merge', (req, res, ctx) => + res(ctx.delay(0), ctx.json(mockMergedBasket)) + ) + ) + + const {user} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: false + } + }) + + // Wait for passkey conditional mediation to start and verify signal is passed + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) + ) + }) + + // User logs in with password while passkey prompt is still open + await user.type(screen.getByLabelText('Email'), 'customer@test.com') + await user.type(screen.getByLabelText('Password'), 'Password!1') + await user.click(screen.getByRole('button', {name: /sign in/i})) + + // Wait for successful login and navigation to account page + await waitFor( + () => { + expect(window.location.pathname).toBe('/uk/en-GB/account') + expect(screen.getByText(/My Profile/i)).toBeInTheDocument() + }, + {timeout: 3000} + ) + + // The test verifies: + // 1. AbortSignal is passed to navigator.credentials.get (via the mock above) + // 2. User can successfully login with password while passkey prompt is pending + // 3. Login completes and user is redirected to the account page + // + // The actual abort mechanism is verified in use-passkey-login.test.js + }) + + test('Passkey prompt is aborted when navigating away from login page', async () => { + // Capture the abort signal passed to credentials.get + let capturedSignal = null + + // Mock credentials.get to capture the abort signal and stay pending + mockCredentialsGet.mockImplementation(({signal}) => { + capturedSignal = signal + return new Promise(() => { + // Never resolve - simulates passkey prompt staying open + }) + }) + + const {unmount} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: false + } + }) + + // Wait for passkey conditional mediation to start and capture the signal + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) + ) + expect(capturedSignal).not.toBeNull() + }) + + // Verify signal is not yet aborted + expect(capturedSignal.aborted).toBe(false) + + // Simulate navigating away from the login page by unmounting + unmount() + + // Verify the signal was aborted when component unmounted + expect(capturedSignal.aborted).toBe(true) + }) }) describe('Navigate away from login page tests', function () { From 0d384691d01c8c8bdc927057e8308adfe2524b61 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Feb 2026 11:06:40 -0500 Subject: [PATCH 20/28] Implement abort functionality for passkey login in AuthModal - Added `abortPasskeyLogin` to the AuthModal hook to ensure ongoing passkey login requests are aborted when the modal closes or the component unmounts. - Enhanced tests to verify that the passkey prompt is correctly aborted when the modal is closed or when a user logs in with a password while the passkey prompt is active. --- .../app/hooks/use-auth-modal.js | 7 +- .../app/hooks/use-auth-modal.test.js | 113 ++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index a536faebb1..03471085bb 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -75,7 +75,7 @@ export const AuthModal = ({ const customerId = useCustomerId() const {isRegistered, customerType} = useCustomerType() const prevAuthType = usePrevious(customerType) - const {loginWithPasskey} = usePasskeyLogin() + const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin() const customer = useCustomer( {parameters: {customerId}}, {enabled: !!customerId && isRegistered} @@ -242,6 +242,11 @@ export const AuthModal = ({ form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) }) } + + // Cleanup: abort passkey login when modal closes or component unmounts + return () => { + abortPasskeyLogin() + } }, [isOpen]) // Auto-focus the first field in each form view diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index 3e52b8073f..fff372238d 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -818,6 +818,119 @@ describe('Passkey login', () => { }) }) + test('Passkey prompt is aborted when modal is closed', async () => { + // Capture the abort signal passed to credentials.get + let capturedSignal = null + + // Mock credentials.get to capture the abort signal and stay pending + mockCredentialsGet.mockImplementation(({signal}) => { + capturedSignal = signal + return new Promise(() => { + // Never resolve - simulates passkey prompt staying open + }) + }) + + const {user} = renderWithProviders(, { + wrapperProps: { + bypassAuth: false + } + }) + + // Open the modal + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + // Wait for passkey conditional mediation to start and capture the signal + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) + ) + expect(capturedSignal).not.toBeNull() + }) + + // Verify signal is not yet aborted + expect(capturedSignal.aborted).toBe(false) + + // Close the modal by clicking the close button + const closeButton = screen.getByLabelText(/close login form/i) + await user.click(closeButton) + + // Verify the signal was aborted when modal closed + expect(capturedSignal.aborted).toBe(true) + }) + + test('Passkey prompt is aborted when user logs in', async () => { + // This test verifies that when the user logs in while the passkey + // prompt is pending, the modal closes (login succeeds) and the passkey flow is + // aborted via the cleanup that runs when the modal closes. + let capturedSignal = null + + mockCredentialsGet.mockImplementation(({signal}) => { + expect(signal).toBeInstanceOf(AbortSignal) + capturedSignal = signal + return new Promise(() => { + // Never resolve - simulates passkey prompt staying open + }) + }) + + // Successful email/password login + global.server.use( + rest.post('*/oauth2/token', (req, res, ctx) => + res( + ctx.delay(0), + ctx.json({ + customer_id: 'customerid_1', + access_token: registerUserToken, + refresh_token: 'testrefeshtoken_1', + usid: 'testusid_1', + enc_user_id: 'testEncUserId_1', + id_token: 'testIdToken_1' + }) + ) + ), + rest.post('*/baskets/actions/merge', (req, res, ctx) => { + return res(ctx.delay(0), ctx.json(mockMergedBasket)) + }) + ) + + const {user} = renderWithProviders(, { + wrapperProps: { + bypassAuth: false + } + }) + + // Open the modal - passkey flow starts and stays pending + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) + ) + expect(capturedSignal).not.toBeNull() + }) + + // User logs in with password while passkey prompt is still open + await user.type(screen.getByLabelText(/email/i), 'customer@test.com') + await user.type(screen.getByLabelText(/^password$/i), 'Password!1') + await user.click(screen.getByRole('button', {name: /sign in/i})) + + // Wait for login to succeed and modal to close (cleanup runs and aborts passkey) + await waitFor( + () => { + expect(screen.queryByText(/welcome back/i)).not.toBeInTheDocument() + expect(capturedSignal.aborted).toBe(true) + }, + {timeout: 3000} + ) + }) + test('Does not trigger passkey when not enabled', async () => { const mockAppConfig = { ...mockConfig.app, From c4d08112116199161fe8d12c8a7ac77c4b83762c Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Feb 2026 17:13:46 -0500 Subject: [PATCH 21/28] Refine passkey login behavior to prevent unnecessary prompts - Updated the login component to only prompt for passkey login when the user is not already signed in, improving user experience. - Adjusted the dependency array in the `useEffect` hook to include `isRegistered`, ensuring the effect runs correctly based on the user's authentication state. - Enhanced tests to verify that the passkey prompt does not trigger when the user is already registered. --- .../app/pages/login/index.jsx | 11 +++-- .../app/pages/login/index.test.js | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index ebaa130b28..9473441e94 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -191,15 +191,18 @@ const Login = ({initialView = LOGIN_VIEW}) => { }, [isRegistered, redirectPath]) useEffect(() => { - loginWithPasskey().catch(() => { - form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) - }) + // Only prompt for passkey when the user is not already signed in + if (!isRegistered) { + loginWithPasskey().catch(() => { + form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) + }) + } // Cleanup: abort passkey login when navigating away from login page return () => { abortPasskeyLogin() } - }, []) + }, [isRegistered]) /**************** Einstein ****************/ useEffect(() => { diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index bc6d22bfe4..1e7c527780 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -20,12 +20,22 @@ import ResetPassword from '@salesforce/retail-react-app/app/pages/reset-password import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {useCustomerType} from '@salesforce/commerce-sdk-react' // Mock getConfig for passkey tests jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ getConfig: jest.fn() })) +// Allow overriding useCustomerType for tests that need a specific auth state (e.g. "already signed in") +jest.mock('@salesforce/commerce-sdk-react', () => { + const actual = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...actual, + useCustomerType: jest.fn(actual.useCustomerType) + } +}) + const mockMergedBasket = { basketId: 'a10ff320829cb0eef93ca5310a', currency: 'USD', @@ -436,6 +446,41 @@ describe('Passkey login', () => { expect(mockCredentialsGet).not.toHaveBeenCalled() }) + test('Does not trigger passkey when user is already signed in', async () => { + // Use the same pattern as account/drawer-menu: mock useCustomerType so first render sees isRegistered: true. + // No need to mock /customers/:customerId — the passkey effect only checks useCustomerType(), not the API. + const realUseCustomerType = useCustomerType.getMockImplementation() + useCustomerType.mockReturnValue({ + isRegistered: true, + customerType: 'registered', + isGuest: false, + isExternal: false + }) + try { + renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: true, + isGuest: false + } + }) + + await waitFor(() => { + expect(screen.getByTestId('login-page')).toBeInTheDocument() + }) + + // Give it a moment for any async effects to run + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Rendering the login page should not trigger navigator.credentials.get when user is already registered + expect(mockCredentialsGet).not.toHaveBeenCalled() + } finally { + useCustomerType.mockImplementation(realUseCustomerType) + } + }) + test('Successfully logs in with passkey', async () => { const mockCredential = { id: 'mock-credential-id', From 3254cf4ce8d99af7f018fc7d41a2facc30317255 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Feb 2026 17:40:00 -0500 Subject: [PATCH 22/28] Enhance passkey login handling in ContactInfo component - Added `abortPasskeyLogin` to cleanly abort ongoing passkey login requests when the component unmounts or when the user logs in with a password. - Updated the logic to only prompt for passkey login when the user is a guest, improving user experience. - Enhanced tests to verify the correct behavior of the passkey login flow, including cleanup on unmount and handling user login transitions. --- .../pages/checkout/partials/contact-info.jsx | 12 ++- .../checkout/partials/contact-info.test.js | 76 ++++++++++++++++++- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index 81fdb4c5e0..1661a72daa 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -62,7 +62,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket') const mergeBasket = useShopperBasketsMutation('mergeBasket') - const {loginWithPasskey} = usePasskeyLogin() + const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin() const {step, STEPS, goToStep, goToNextStep} = useCheckout() @@ -169,10 +169,16 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } } - if (!customer.isRegistered) { + // Only prompt for passkey when we know the user is a guest (not loading, not registered) + if (customer && !customer.isRegistered) { handlePasskeyLogin() } - }, [customer.isRegistered]) + + // Cleanup: abort passkey login when navigating away from checkout + return () => { + abortPasskeyLogin() + } + }, [customer?.isRegistered]) const onPasswordlessLoginClick = async (e) => { const isValid = await form.trigger('email') diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js index 215c77aca8..10b4757dd1 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js @@ -72,12 +72,14 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => { }) const mockLoginWithPasskey = jest.fn().mockResolvedValue(undefined) +const mockAbortPasskeyLogin = jest.fn() jest.mock('@salesforce/retail-react-app/app/hooks/use-passkey-login', () => { return { __esModule: true, usePasskeyLogin: jest.fn(() => ({ - loginWithPasskey: mockLoginWithPasskey + loginWithPasskey: mockLoginWithPasskey, + abortPasskeyLogin: mockAbortPasskeyLogin })) } }) @@ -455,8 +457,8 @@ describe('passkey login', () => { }) }) - test('does not call loginWithPasskey when customer is registered', async () => { - // Mock registered customer + test('does not prompt for passkey when user is already logged in', async () => { + // When customer is registered, we must not trigger passkey (no prompt) mockUseCurrentCustomer.mockReturnValue({ data: { isRegistered: true, @@ -466,9 +468,75 @@ describe('passkey login', () => { renderWithProviders() - // Wait a bit to ensure useEffect has run await waitFor(() => { expect(mockLoginWithPasskey).not.toHaveBeenCalled() }) }) + + test('calls abortPasskeyLogin when component unmounts', async () => { + const {unmount} = renderWithProviders() + + // Wait for passkey login to be triggered + await waitFor(() => { + expect(mockLoginWithPasskey).toHaveBeenCalled() + }) + + // Verify abort hasn't been called yet + expect(mockAbortPasskeyLogin).not.toHaveBeenCalled() + + // Unmount the component (simulates navigating away) + unmount() + + // Verify abort was called during cleanup + expect(mockAbortPasskeyLogin).toHaveBeenCalled() + }) + + test('Passkey prompt is aborted when user logs in with password', async () => { + // This test verifies that when the user logs in with password while the passkey + // flow is pending, the useEffect cleanup runs (customer becomes registered) and + // abortPasskeyLogin is called. + // + // The actual abort behavior is tested in use-passkey-login.test.js. + + let customerRegistered = false + mockUseCurrentCustomer.mockImplementation(() => ({ + data: {isRegistered: customerRegistered} + })) + + // When login succeeds, mark customer as registered so the next render triggers + // the useEffect cleanup (abortPasskeyLogin) + global.server.use( + rest.post('*/oauth2/login', (req, res, ctx) => { + customerRegistered = true + return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer)) + }) + ) + + const {user} = renderWithProviders() + + // Wait for passkey login to be triggered (passkey prompt is "pending") + await waitFor(() => { + expect(mockLoginWithPasskey).toHaveBeenCalled() + }) + expect(mockAbortPasskeyLogin).not.toHaveBeenCalled() + + // User switches to login and logs in with password + const trigger = screen.getByText(/Already have an account\? Log in/i) + await user.click(trigger) + + await user.type(screen.getByLabelText('Email'), validEmail) + await user.type(screen.getByLabelText('Password'), password) + + const loginButton = screen.getByText('Log In') + await user.click(loginButton) + + // Login succeeds; component re-renders with isRegistered: true; cleanup runs + await waitFor( + () => { + expect(mockAbortPasskeyLogin).toHaveBeenCalled() + expect(mockGoToNextStep).toHaveBeenCalled() + }, + {timeout: 3000} + ) + }) }) From 41c6abdfafd5db4d92da8c9211c8e0823e0af262 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 24 Feb 2026 11:51:34 -0500 Subject: [PATCH 23/28] Update login component to handle guest user state for passkey login - Modified the logic to prompt for passkey login based on the `isGuest` state instead of `isRegistered`, enhancing user experience for guest users. - Updated the dependency array in the `useEffect` hook to reflect the change in state management. - Improved test isolation by clearing authentication state cookies after each test. --- .../app/pages/login/index.jsx | 7 +- .../app/pages/login/index.test.js | 137 ++++++++++-------- 2 files changed, 78 insertions(+), 66 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index 9473441e94..9d51dcf41c 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -56,7 +56,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const {path} = useRouteMatch() const einstein = useEinstein() const dataCloud = useDataCloud() - const {isRegistered, customerType} = useCustomerType() + const {isRegistered, customerType, isGuest} = useCustomerType() const {locale} = useMultiSite() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser) @@ -191,8 +191,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { }, [isRegistered, redirectPath]) useEffect(() => { - // Only prompt for passkey when the user is not already signed in - if (!isRegistered) { + if (isGuest) { loginWithPasskey().catch(() => { form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) }) @@ -202,7 +201,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { return () => { abortPasskeyLogin() } - }, [isRegistered]) + }, [isGuest]) /**************** Einstein ****************/ useEffect(() => { diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 1e7c527780..4a2e39b10b 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -10,7 +10,8 @@ import {rest} from 'msw' import { renderWithProviders, createPathWithDefaults, - guestToken + guestToken, + clearAllCookies } from '@salesforce/retail-react-app/app/utils/test-utils' import Login from '.' import {BrowserRouter as Router, Route} from 'react-router-dom' @@ -98,6 +99,9 @@ beforeEach(() => { afterEach(() => { jest.resetModules() localStorage.clear() + // Clear auth state cookies to ensure test isolation + // Without this, authenticated state from previous tests can leak into subsequent tests + clearAllCookies() }) describe('Logging in tests', function () { @@ -290,6 +294,7 @@ describe('Error while logging in', function () { ).toBeInTheDocument() }) }) + describe('Passkey login', () => { let mockCredentialsGet let mockPublicKeyCredential @@ -576,60 +581,6 @@ describe('Passkey login', () => { }) }) - describe('Passkey Registration', () => { - test('Displays Create passkey toast after successful login when passkey is enabled', async () => { - // Successful email/password login - global.server.use( - rest.post('*/oauth2/token', (req, res, ctx) => - res( - ctx.delay(0), - ctx.json({ - customer_id: 'customerid_1', - access_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', - refresh_token: 'testrefeshtoken_1', - usid: 'testusid_1', - enc_user_id: 'testEncUserId_1', - id_token: 'testIdToken_1' - }) - ) - ), - rest.post('*/baskets/actions/merge', (req, res, ctx) => - res(ctx.delay(0), ctx.json(mockMergedBasket)) - ) - ) - - const {user} = renderWithProviders(, { - wrapperProps: { - siteAlias: 'uk', - locale: {id: 'en-GB'}, - appConfig: mockAppConfig, - bypassAuth: false - } - }) - - // Wait for login form after passkey is cancelled - await waitFor(() => { - expect(screen.getByLabelText('Email')).toBeInTheDocument() - expect(screen.getByLabelText('Password')).toBeInTheDocument() - }) - - await user.type(screen.getByLabelText('Email'), 'customer@test.com') - await user.type(screen.getByLabelText('Password'), 'Password!1') - await user.click(screen.getByRole('button', {name: /sign in/i})) - - // Create passkey toast is shown after successful login when passkey is enabled and WebAuthn is supported - await waitFor( - () => { - expect( - screen.getByRole('button', {name: /Create Passkey/i}) - ).toBeInTheDocument() - }, - {timeout: 3000} - ) - }) - }) - test('Shows error when passkey authentication fails with error from the browser', async () => { // Simulate error in navigator.credentials.get hook mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) @@ -746,13 +697,6 @@ describe('Passkey login', () => { }, {timeout: 3000} ) - - // The test verifies: - // 1. AbortSignal is passed to navigator.credentials.get (via the mock above) - // 2. User can successfully login with password while passkey prompt is pending - // 3. Login completes and user is redirected to the account page - // - // The actual abort mechanism is verified in use-passkey-login.test.js }) test('Passkey prompt is aborted when navigating away from login page', async () => { @@ -798,6 +742,75 @@ describe('Passkey login', () => { }) }) +describe('Passkey Registration', () => { + let mockPublicKeyCredential + + beforeEach(() => { + mockPublicKeyCredential = { + parseRequestOptionsFromJSON: jest.fn(), + isConditionalMediationAvailable: jest.fn().mockResolvedValue(true), + isUserVerifyingPlatformAuthenticatorAvailable: jest.fn().mockResolvedValue(true) + } + + global.PublicKeyCredential = mockPublicKeyCredential + global.window.PublicKeyCredential = mockPublicKeyCredential + }) + + afterEach(() => { + delete global.PublicKeyCredential + delete global.window.PublicKeyCredential + }) + + test('Displays Create passkey toast after successful login when passkey is enabled', async () => { + // Successful email/password login + global.server.use( + rest.post('*/oauth2/token', (req, res, ctx) => + res( + ctx.delay(0), + ctx.json({ + customer_id: 'customerid_1', + access_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', + refresh_token: 'testrefeshtoken_1', + usid: 'testusid_1', + enc_user_id: 'testEncUserId_1', + id_token: 'testIdToken_1' + }) + ) + ), + rest.post('*/baskets/actions/merge', (req, res, ctx) => + res(ctx.delay(0), ctx.json(mockMergedBasket)) + ) + ) + + const {user} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + bypassAuth: false + } + }) + + // Wait for login form after passkey is cancelled + await waitFor(() => { + expect(screen.getByLabelText('Email')).toBeInTheDocument() + expect(screen.getByLabelText('Password')).toBeInTheDocument() + }) + + await user.type(screen.getByLabelText('Email'), 'customer@test.com') + await user.type(screen.getByLabelText('Password'), 'Password!1') + await user.click(screen.getByRole('button', {name: /sign in/i})) + + // Create passkey toast is shown after successful login when passkey is enabled and WebAuthn is supported + await waitFor( + () => { + expect(screen.getByRole('button', {name: /Create Passkey/i})).toBeInTheDocument() + }, + {timeout: 3000} + ) + }) +}) + describe('Navigate away from login page tests', function () { test('should navigate to sign up page when the user clicks Create Account', async () => { const {user} = renderWithProviders(, { From 044b8369eae3ba274aff684ce4c13720f916af49 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 24 Feb 2026 13:05:51 -0500 Subject: [PATCH 24/28] revert contact-info changes that are unnecessary --- .../app/pages/checkout/partials/contact-info.jsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index 1661a72daa..6e33d8c99d 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -169,8 +169,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } } - // Only prompt for passkey when we know the user is a guest (not loading, not registered) - if (customer && !customer.isRegistered) { + if (!customer.isRegistered) { handlePasskeyLogin() } @@ -178,7 +177,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id return () => { abortPasskeyLogin() } - }, [customer?.isRegistered]) + }, [customer.isRegistered]) const onPasswordlessLoginClick = async (e) => { const isValid = await form.trigger('email') From 016aefff6a48c789ac0b997e3d5685e383846daa Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 24 Feb 2026 14:19:26 -0500 Subject: [PATCH 25/28] Refactor passkey login logic in ContactInfo component - Updated the logic to prompt for passkey login only when the user is on the contact info step, enhancing the user experience. - Adjusted the dependency array in the `useEffect` hook to track the current step instead of the registration status. - Enhanced tests to ensure correct behavior of passkey login prompts based on the current step in the checkout process. --- .../pages/checkout/partials/contact-info.jsx | 5 +- .../checkout/partials/contact-info.test.js | 92 ++++++++++++------- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index 6e33d8c99d..b4979b8d98 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -169,7 +169,8 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } } - if (!customer.isRegistered) { + // Only prompt for passkey when the user has not entered their contact info yet + if (step === STEPS.CONTACT_INFO) { handlePasskeyLogin() } @@ -177,7 +178,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id return () => { abortPasskeyLogin() } - }, [customer.isRegistered]) + }, [step]) const onPasswordlessLoginClick = async (e) => { const isValid = await form.trigger('email') diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js index 10b4757dd1..893cc90f45 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js @@ -16,7 +16,8 @@ import { import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import { mockGoToStep, - mockGoToNextStep + mockGoToNextStep, + useCheckout } from '@salesforce/retail-react-app/app/pages/checkout/util/checkout-context' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' @@ -430,8 +431,34 @@ describe('navigation based on shipment context', () => { }) describe('passkey login', () => { + let currentBasket = JSON.parse(JSON.stringify(scapiBasketWithItem)) + const MOCK_STEPS = {CONTACT_INFO: 0, PAYMENT: 2} + beforeEach(() => { jest.clearAllMocks() + // Reset useCheckout mock to default values + useCheckout.mockReturnValue({ + customer: null, + basket: {}, + isGuestCheckout: true, + setIsGuestCheckout: jest.fn(), + step: MOCK_STEPS.CONTACT_INFO, + login: null, + STEPS: MOCK_STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep + }) + global.server.use( + rest.put('*/baskets/:basketId/customer', (req, res, ctx) => { + currentBasket.customerInfo.email = validEmail + return res(ctx.json(currentBasket)) + }) + ) + // Provide basket with basketId and items for tests in this suite + useCurrentBasket.mockReturnValue({ + data: currentBasket, + derivedData: {totalItems: currentBasket.productItems?.length || 0} + }) // Default to guest user (not registered) mockUseCurrentCustomer.mockReturnValue({ data: { @@ -457,13 +484,18 @@ describe('passkey login', () => { }) }) - test('does not prompt for passkey when user is already logged in', async () => { - // When customer is registered, we must not trigger passkey (no prompt) - mockUseCurrentCustomer.mockReturnValue({ - data: { - isRegistered: true, - email: 'test@example.com' - } + test('does not prompt for passkey when not on contact info step', async () => { + // When step is not CONTACT_INFO, we must not trigger passkey (no prompt) + useCheckout.mockReturnValue({ + customer: null, + basket: {}, + isGuestCheckout: true, + setIsGuestCheckout: jest.fn(), + step: MOCK_STEPS.PAYMENT, + login: null, + STEPS: MOCK_STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep }) renderWithProviders() @@ -493,25 +525,24 @@ describe('passkey login', () => { test('Passkey prompt is aborted when user logs in with password', async () => { // This test verifies that when the user logs in with password while the passkey - // flow is pending, the useEffect cleanup runs (customer becomes registered) and + // flow is pending, the useEffect cleanup runs (step changes) and // abortPasskeyLogin is called. - // - // The actual abort behavior is tested in use-passkey-login.test.js. - - let customerRegistered = false - mockUseCurrentCustomer.mockImplementation(() => ({ - data: {isRegistered: customerRegistered} + useCheckout.mockImplementation(() => ({ + customer: null, + basket: {}, + isGuestCheckout: true, + setIsGuestCheckout: jest.fn(), + step: + // Make step "change" after goToNextStep is called so the effect cleanup runs. + mockGoToNextStep.mock.calls.length > 0 + ? MOCK_STEPS.PAYMENT + : MOCK_STEPS.CONTACT_INFO, + login: null, + STEPS: MOCK_STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep })) - // When login succeeds, mark customer as registered so the next render triggers - // the useEffect cleanup (abortPasskeyLogin) - global.server.use( - rest.post('*/oauth2/login', (req, res, ctx) => { - customerRegistered = true - return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer)) - }) - ) - const {user} = renderWithProviders() // Wait for passkey login to be triggered (passkey prompt is "pending") @@ -530,13 +561,10 @@ describe('passkey login', () => { const loginButton = screen.getByText('Log In') await user.click(loginButton) - // Login succeeds; component re-renders with isRegistered: true; cleanup runs - await waitFor( - () => { - expect(mockAbortPasskeyLogin).toHaveBeenCalled() - expect(mockGoToNextStep).toHaveBeenCalled() - }, - {timeout: 3000} - ) + // Login succeeds; goToNextStep is called. Next render will see step change + await waitFor(() => { + expect(mockGoToNextStep).toHaveBeenCalled() + expect(mockAbortPasskeyLogin).toHaveBeenCalled() + }) }) }) From a33031086dff5cedde00fc66e754d020ade9eb60 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 24 Feb 2026 14:46:00 -0500 Subject: [PATCH 26/28] cleanup comments and tests --- .../app/hooks/use-auth-modal.test.js | 1 - .../app/hooks/use-passkey-login.js | 5 ++- .../app/hooks/use-passkey-login.test.js | 11 ------ .../pages/checkout/partials/contact-info.jsx | 1 - .../app/pages/login/index.test.js | 37 ++++++++++--------- 5 files changed, 22 insertions(+), 33 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index 85e74864fa..b9d396db04 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -907,7 +907,6 @@ describe('Passkey login', () => { const trigger = screen.getByText(/open modal/i) await user.click(trigger) - // Should show error - 401 error from WebAuthn API should be caught and converted to user-friendly message await waitFor(() => { expect(mockCredentialsGet).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js index c9cb5f5b67..9cff1a6771 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -21,7 +21,7 @@ export const usePasskeyLogin = () => { /** * Aborts any pending passkey login request. * This is useful when the user logs in with a different method (e.g., password) - * while a passkey prompt (e.g., 1Password) is still open. + * while a passkey prompt (e.g., Touch ID, Face ID, etc.) is still open. */ const abortPasskeyLogin = () => { if (abortControllerRef.current) { @@ -72,7 +72,8 @@ export const usePasskeyLogin = () => { ) // Create an AbortController to allow cancelling the passkey prompt - // This is needed when the user logs in with a different method while the passkey prompt is open + // This is needed when the user logs in with a different method or + // navigates away from the page while the passkey prompt is open abortControllerRef.current = new AbortController() // Get passkey credential from browser diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js index b0201d9316..04c1fffe02 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js @@ -397,16 +397,5 @@ describe('usePasskeyLogin', () => { expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') ) }) - - test('abortPasskeyLogin does nothing when no passkey request is pending', async () => { - renderWithProviders() - - // Click abort without starting passkey login - should not throw - const abortTrigger = screen.getByTestId('abort-passkey-login') - fireEvent.click(abortTrigger) - - // No errors should occur - expect(mockGetCredentials).not.toHaveBeenCalled() - }) }) }) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index b4979b8d98..edac001d69 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -169,7 +169,6 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } } - // Only prompt for passkey when the user has not entered their contact info yet if (step === STEPS.CONTACT_INFO) { handlePasskeyLogin() } diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 4a2e39b10b..4ab6599c86 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -11,6 +11,7 @@ import { renderWithProviders, createPathWithDefaults, guestToken, + registerUserToken, clearAllCookies } from '@salesforce/retail-react-app/app/utils/test-utils' import Login from '.' @@ -28,7 +29,8 @@ jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ getConfig: jest.fn() })) -// Allow overriding useCustomerType for tests that need a specific auth state (e.g. "already signed in") +// Allows overriding useCustomerType for tests that need a specific auth +// state (e.g. simulate a user being already authenticated on page load. jest.mock('@salesforce/commerce-sdk-react', () => { const actual = jest.requireActual('@salesforce/commerce-sdk-react') return { @@ -99,8 +101,7 @@ beforeEach(() => { afterEach(() => { jest.resetModules() localStorage.clear() - // Clear auth state cookies to ensure test isolation - // Without this, authenticated state from previous tests can leak into subsequent tests + // Ensures authenticated state from previous tests don't leak into subsequent tests clearAllCookies() }) @@ -452,8 +453,7 @@ describe('Passkey login', () => { }) test('Does not trigger passkey when user is already signed in', async () => { - // Use the same pattern as account/drawer-menu: mock useCustomerType so first render sees isRegistered: true. - // No need to mock /customers/:customerId — the passkey effect only checks useCustomerType(), not the API. + // Simulates a user being already authenticated on page load const realUseCustomerType = useCustomerType.getMockImplementation() useCustomerType.mockReturnValue({ isRegistered: true, @@ -629,16 +629,12 @@ describe('Passkey login', () => { }) test('Passkey prompt is aborted when user logs in with password', async () => { - // This test verifies that navigator.credentials.get receives an AbortSignal - // which allows the passkey prompt to be aborted when user logs in via another method - // - // Note: The actual abort behavior is tested in use-passkey-login.test.js. - // This integration test verifies the signal is properly passed through. + // Capture the abort signal passed to credentials.get + let capturedSignal = null - // Mock credentials.get to capture and verify the signal is passed + // Mock credentials.get to capture the abort signal and stay pending mockCredentialsGet.mockImplementation(({signal}) => { - // Verify that an AbortSignal is passed to navigator.credentials.get - expect(signal).toBeInstanceOf(AbortSignal) + capturedSignal = signal return new Promise(() => { // Never resolve - simulates passkey prompt staying open }) @@ -674,7 +670,7 @@ describe('Passkey login', () => { } }) - // Wait for passkey conditional mediation to start and verify signal is passed + // Wait for passkey conditional mediation to start and capture the signal await waitFor(() => { expect(mockCredentialsGet).toHaveBeenCalledWith( expect.objectContaining({ @@ -682,8 +678,12 @@ describe('Passkey login', () => { signal: expect.any(AbortSignal) }) ) + expect(capturedSignal).not.toBeNull() }) + // Verify signal is not yet aborted + expect(capturedSignal.aborted).toBe(false) + // User logs in with password while passkey prompt is still open await user.type(screen.getByLabelText('Email'), 'customer@test.com') await user.type(screen.getByLabelText('Password'), 'Password!1') @@ -697,6 +697,9 @@ describe('Passkey login', () => { }, {timeout: 3000} ) + + // Verify the signal was aborted when user logs in with password + expect(capturedSignal.aborted).toBe(true) }) test('Passkey prompt is aborted when navigating away from login page', async () => { @@ -769,8 +772,7 @@ describe('Passkey Registration', () => { ctx.delay(0), ctx.json({ customer_id: 'customerid_1', - access_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', + access_token: registerUserToken, refresh_token: 'testrefeshtoken_1', usid: 'testusid_1', enc_user_id: 'testEncUserId_1', @@ -871,8 +873,7 @@ describe('Passwordless login tests', () => { ctx.status(200), ctx.json({ customer_id: 'customerid_1', - access_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', + access_token: registerUserToken, refresh_token: 'testrefeshtoken_1', usid: 'testusid_1', enc_user_id: 'testEncUserId_1', From 5714c0b44f872e2a29994ea42052bdf075c18cbf Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 24 Feb 2026 14:48:20 -0500 Subject: [PATCH 27/28] use registerUserToken instead of hardcoded access token in tests --- .../template-retail-react-app/app/pages/login/index.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 4ab6599c86..cb79a8b82e 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -647,8 +647,7 @@ describe('Passkey login', () => { ctx.delay(0), ctx.json({ customer_id: 'customerid_1', - access_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', + access_token: registerUserToken, refresh_token: 'testrefeshtoken_1', usid: 'testusid_1', enc_user_id: 'testEncUserId_1', From 75e59c86fc71da5562d58e2a68e76f78a25ee9bb Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 24 Feb 2026 16:47:59 -0500 Subject: [PATCH 28/28] Refactor AuthModal logic and improve tests - Simplified the condition for rendering the AuthModal by removing unnecessary checks for customer data. - Updated the welcome message to default to 'back' if the customer's first name is not available. - Adjusted the dependency array in the `useEffect` hook to only track `isRegistered`. - Enhanced tests to verify modal behavior during passkey login, ensuring proper assertions for modal visibility and user sign-in confirmation. --- .../app/hooks/use-auth-modal.js | 7 +++---- .../app/hooks/use-auth-modal.test.js | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index 3744b0a195..d1b0c555a1 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -275,8 +275,7 @@ export const AuthModal = ({ const isNowRegistered = (isOpen || isOtpAuthOpen) && isRegistered && (loggingIn || registering) // If the customer changed, but it's not because they logged in or registered. Do nothing. - // Also ensure that the customer data is loaded. - if (!isNowRegistered || !customer.data) { + if (!isNowRegistered) { return } @@ -297,7 +296,7 @@ export const AuthModal = ({ id: 'auth_modal.info.welcome_user' }, { - name: customer.data?.firstName || '' + name: customer.data?.firstName || 'back' } )}`, description: `${formatMessage({ @@ -318,7 +317,7 @@ export const AuthModal = ({ // Execute action to be performed on successful registration onRegistrationSuccess() } - }, [isRegistered, customer.data]) + }, [isRegistered]) const onBackToSignInClick = () => initialView === PASSWORD_VIEW ? onClose() : setCurrentView(LOGIN_VIEW) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index b9d396db04..be48968b9d 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -922,11 +922,14 @@ describe('Passkey login', () => { await user.type(screen.getByLabelText(/^password$/i), 'Password!1') await user.click(screen.getByRole('button', {name: /sign in/i})) - // Wait for login to succeed and modal to close (cleanup runs and aborts passkey) await waitFor( () => { - expect(screen.queryByText(/welcome back/i)).not.toBeInTheDocument() + // Verify the passkey prompt was aborted expect(capturedSignal.aborted).toBe(true) + // Verify the modal was closed + expect(screen.queryByRole('button', {name: /sign in/i})).not.toBeInTheDocument() + // Verify the user was signed in + expect(screen.getByText(/You're now signed in./i)).toBeInTheDocument() }, {timeout: 3000} ) @@ -958,6 +961,7 @@ describe('Passkey login', () => { const trigger = screen.getByText(/open modal/i) await user.click(trigger) + // Wait for modal to open await waitFor(() => { expect(screen.getByText(/welcome back/i)).toBeInTheDocument() }) @@ -1030,7 +1034,8 @@ describe('Passkey login', () => { // login successfully and close the modal await waitFor(() => { - expect(screen.queryByText(/Welcome back/i)).not.toBeInTheDocument() + expect(screen.queryByRole('button', {name: /Sign in/i})).not.toBeInTheDocument() + expect(screen.getByText(/You're now signed in./i)).toBeInTheDocument() }) }) }) @@ -1062,7 +1067,11 @@ describe('Passkey Registration', () => { }) test('shows passkey registration toast after login', async () => { - const {user} = renderWithProviders() + const {user} = renderWithProviders(, { + wrapperProps: { + bypassAuth: false + } + }) const validEmail = 'test@salesforce.com' const validPassword = 'Password123!'