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', () => { 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..f48190fcf9 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 @@ -37,6 +37,12 @@ import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/uti // 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 */ @@ -73,13 +79,8 @@ 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(API_ERROR_MESSAGE)) } finally { setIsLoading(false) } @@ -107,18 +108,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { // 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') @@ -160,17 +150,15 @@ const PasskeyRegistrationModal = ({isOpen, 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) + const message = /401/.test(err.message) + ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) + : formatMessage(API_ERROR_MESSAGE) // Return error result for OTP component to display return { success: false, - error: errorMessage + 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..f3f97feeb1 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 401', async () => { + const otpCode = '12345678' + + mockStartWebauthnRegistration.mockRejectedValue(new Error('401')) + + 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 843f624865..f73d856bc9 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 @@ -89,7 +89,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) @@ -104,7 +103,7 @@ export const AuthModal = ({ ) const mergeBasket = useShopperBasketsMutation('mergeBasket') - const {showToast} = usePasskeyRegistration() + const {showRegisterPasskeyToast} = usePasskeyRegistration() const handlePasswordlessLogin = async (email) => { try { @@ -239,8 +238,8 @@ export const AuthModal = ({ setCurrentView(initialView) form.reset() // Prompt user to login without username (discoverable credentials) - loginWithPasskey().catch((error) => { - // TODO W-21056536: Add error message handling + loginWithPasskey().catch(() => { + form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) }) } }, [isOpen]) @@ -271,7 +270,8 @@ 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. - if (!isNowRegistered) { + // Also ensure that the customer data is loaded. + if (!isNowRegistered || !customer.data) { return } @@ -279,27 +279,11 @@ 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) - if (loggingIn && customer.data) { + if (loggingIn) { toast({ variant: 'subtle', title: `${formatMessage( @@ -429,7 +413,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 6f6e47f29e..f6629156b1 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 @@ -256,11 +238,6 @@ describe('Passwordless enabled', () => { }) test('Allows passwordless login', async () => { - const {user} = renderWithProviders(, { - wrapperProps: { - bypassAuth: false - } - }) // Disable passkey to test passwordless in isolation getConfig.mockReturnValue({ ...mockConfig, @@ -272,7 +249,12 @@ describe('Passwordless enabled', () => { } } }) - const {user} = renderWithProviders() + const {user} = renderWithProviders(, { + wrapperProps: { + bypassAuth: false + } + }) + const validEmail = 'test@salesforce.com' // open the modal @@ -641,7 +623,19 @@ describe('Passkey login', () => { beforeEach(() => { // Clear all mocks jest.clearAllMocks() - + + // Override getConfig to return config with passkey enabled + 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 = { @@ -656,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) => { @@ -694,25 +694,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} - } - } - - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - + test('Triggers passkey login when modal opens with passkey enabled', async () => { // Mock credential that will be returned from navigator.credentials.get const mockCredential = { id: 'mock-credential-id', @@ -740,14 +722,8 @@ describe('Passkey login', () => { mockCredentialsGet.mockResolvedValue(mockCredential) - getConfig.mockReturnValue({ - ...mockConfig, - app: mockAppConfig - }) - const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -769,70 +745,14 @@ 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} - } - } - - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - 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' - }) - ) - ) - ) + 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' + mockCredentialsGet.mockRejectedValue(notAllowedError) const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -841,54 +761,22 @@ describe('Passkey login', () => { const trigger = screen.getByText(/open modal/i) await user.click(trigger) + // Login form should be shown 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} - } - } - - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - 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 - }) + test('Shows error when passkey authentication fails with error from the browser', async () => { + // Simulate error in loginWithPasskey hook + mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -897,50 +785,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 } }) @@ -949,18 +813,9 @@ 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(/Something went wrong. Try again!/i)).toBeInTheDocument() }) }) @@ -998,24 +853,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} - } - } - - const mockPublicKeyOptions = { - challenge: 'mock-challenge', - allowCredentials: [] - } - - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue(mockPublicKeyOptions) - + test('Successfully logs in with passkey', async () => { const mockCredential = { id: 'mock-credential-id', rawId: new ArrayBuffer(32), @@ -1061,7 +899,6 @@ describe('Passkey login', () => { const {user} = renderWithProviders(, { wrapperProps: { - appConfig: mockAppConfig, bypassAuth: false } }) @@ -1077,6 +914,61 @@ describe('Passkey login', () => { }, {timeout: 5000} ) + + // login successfully and close the modal + await waitFor(() => { + 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 + }) + + 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(/Welcome Back/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(() => { + expect(screen.getByText(/Create Passkey/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 51bc1f747f..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 @@ -33,15 +33,23 @@ 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 } - 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 @@ -63,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/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/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/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.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index a45d5f84f0..a83fcc4362 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -62,7 +62,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 @@ -80,7 +80,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) @@ -184,37 +184,16 @@ 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]) useEffect(() => { - try { - loginWithPasskey() - } catch (error) { - // TODO W-21056536: Add error message handling - } + loginWithPasskey().catch(() => { + form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_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..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 @@ -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,76 @@ 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)) + 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)) + ) + ) - // Should not call credentials API when passkey is disabled - expect(mockCredentialsGet).not.toHaveBeenCalled() - }) + const {user} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: false + } + }) - test('Handles passkey login cancellation gracefully', async () => { - const mockAppConfig = { - ...mockConfig.app, - login: { - ...mockConfig.app.login, - passwordless: {enabled: true}, - passkey: {enabled: true} - } - } + // Wait for login form after passkey is cancelled + await waitFor(() => { + expect(screen.getByLabelText('Email')).toBeInTheDocument() + expect(screen.getByLabelText('Password')).toBeInTheDocument() + }) - mockPublicKeyCredential.parseRequestOptionsFromJSON.mockReturnValue({ - challenge: 'mock-challenge', - allowCredentials: [] + 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} + ) }) + }) - // 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 browser', async () => { + // Simulate error in navigator.credentials.get hook + mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) - const {user} = renderWithProviders(, { + renderWithProviders(, { wrapperProps: { siteAlias: 'uk', locale: {id: 'en-GB'}, @@ -550,32 +598,26 @@ 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 - passkey error should be caught and handled await waitFor(() => { - expect(screen.getByTestId('login-page')).toBeInTheDocument() + expect(mockCredentialsGet).toHaveBeenCalled() + expect(screen.getByText(/Something went wrong. Try again!/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: [] - }) + 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'}, @@ -584,36 +626,10 @@ describe('Passkey login', () => { } }) - // 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} - ) + // 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() + }) }) }) 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 fcad18a5ea..2817c52044 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 @@ -3255,18 +3255,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 fcad18a5ea..2817c52044 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 @@ -3255,18 +3255,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 97ead20c30..51e7a95470 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 @@ -6847,34 +6847,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/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", diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 7a3bdcd83b..b91e918f7a 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1354,12 +1354,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 7a3bdcd83b..b91e918f7a 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1354,12 +1354,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)" },