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 (
-
+
Show Toast
@@ -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)"
},