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 f73d856bc9..d1b0c555a1 100644
--- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js
+++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js
@@ -75,7 +75,7 @@ export const AuthModal = ({
const customerId = useCustomerId()
const {isRegistered, customerType} = useCustomerType()
const prevAuthType = usePrevious(customerType)
- const {loginWithPasskey} = usePasskeyLogin()
+ const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin()
const customer = useCustomer(
{parameters: {customerId}},
{enabled: !!customerId && isRegistered}
@@ -242,6 +242,11 @@ export const AuthModal = ({
form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)})
})
}
+
+ // Cleanup: abort passkey login when modal closes or component unmounts
+ return () => {
+ abortPasskeyLogin()
+ }
}, [isOpen])
// Auto-focus the first field in each form view
@@ -270,8 +275,7 @@ export const AuthModal = ({
const isNowRegistered =
(isOpen || isOtpAuthOpen) && isRegistered && (loggingIn || registering)
// If the customer changed, but it's not because they logged in or registered. Do nothing.
- // Also ensure that the customer data is loaded.
- if (!isNowRegistered || !customer.data) {
+ if (!isNowRegistered) {
return
}
@@ -292,7 +296,7 @@ export const AuthModal = ({
id: 'auth_modal.info.welcome_user'
},
{
- name: customer.data?.firstName || ''
+ name: customer.data?.firstName || 'back'
}
)}`,
description: `${formatMessage({
@@ -313,7 +317,7 @@ export const AuthModal = ({
// Execute action to be performed on successful registration
onRegistrationSuccess()
}
- }, [isRegistered, customer.data])
+ }, [isRegistered])
const onBackToSignInClick = () =>
initialView === PASSWORD_VIEW ? onClose() : setCurrentView(LOGIN_VIEW)
diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js
index f6629156b1..be48968b9d 100644
--- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js
+++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js
@@ -819,6 +819,122 @@ describe('Passkey login', () => {
})
})
+ test('Passkey prompt is aborted when modal is closed', async () => {
+ // Capture the abort signal passed to credentials.get
+ let capturedSignal = null
+
+ // Mock credentials.get to capture the abort signal and stay pending
+ mockCredentialsGet.mockImplementation(({signal}) => {
+ capturedSignal = signal
+ return new Promise(() => {
+ // Never resolve - simulates passkey prompt staying open
+ })
+ })
+
+ const {user} = renderWithProviders(, {
+ wrapperProps: {
+ bypassAuth: false
+ }
+ })
+
+ // Open the modal
+ const trigger = screen.getByText(/open modal/i)
+ await user.click(trigger)
+
+ // Wait for passkey conditional mediation to start and capture the signal
+ await waitFor(() => {
+ expect(mockCredentialsGet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mediation: 'conditional',
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(capturedSignal).not.toBeNull()
+ })
+
+ // Verify signal is not yet aborted
+ expect(capturedSignal.aborted).toBe(false)
+
+ // Close the modal by clicking the close button
+ const closeButton = screen.getByLabelText(/close login form/i)
+ await user.click(closeButton)
+
+ // Verify the signal was aborted when modal closed
+ expect(capturedSignal.aborted).toBe(true)
+ })
+
+ test('Passkey prompt is aborted when user logs in', async () => {
+ // This test verifies that when the user logs in while the passkey
+ // prompt is pending, the modal closes (login succeeds) and the passkey flow is
+ // aborted via the cleanup that runs when the modal closes.
+ let capturedSignal = null
+
+ mockCredentialsGet.mockImplementation(({signal}) => {
+ expect(signal).toBeInstanceOf(AbortSignal)
+ capturedSignal = signal
+ return new Promise(() => {
+ // Never resolve - simulates passkey prompt staying open
+ })
+ })
+
+ // Successful email/password login
+ global.server.use(
+ rest.post('*/oauth2/token', (req, res, ctx) =>
+ res(
+ ctx.delay(0),
+ ctx.json({
+ customer_id: 'customerid_1',
+ access_token: registerUserToken,
+ refresh_token: 'testrefeshtoken_1',
+ usid: 'testusid_1',
+ enc_user_id: 'testEncUserId_1',
+ id_token: 'testIdToken_1'
+ })
+ )
+ ),
+ rest.post('*/baskets/actions/merge', (req, res, ctx) => {
+ return res(ctx.delay(0), ctx.json(mockMergedBasket))
+ })
+ )
+
+ const {user} = renderWithProviders(, {
+ wrapperProps: {
+ bypassAuth: false
+ }
+ })
+
+ // Open the modal - passkey flow starts and stays pending
+ const trigger = screen.getByText(/open modal/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(mockCredentialsGet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mediation: 'conditional',
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(capturedSignal).not.toBeNull()
+ })
+
+ // User logs in with password while passkey prompt is still open
+ await user.type(screen.getByLabelText(/email/i), 'customer@test.com')
+ await user.type(screen.getByLabelText(/^password$/i), 'Password!1')
+ await user.click(screen.getByRole('button', {name: /sign in/i}))
+
+ await waitFor(
+ () => {
+ // Verify the passkey prompt was aborted
+ expect(capturedSignal.aborted).toBe(true)
+ // Verify the modal was closed
+ expect(screen.queryByRole('button', {name: /sign in/i})).not.toBeInTheDocument()
+ // Verify the user was signed in
+ expect(screen.getByText(/You're now signed in./i)).toBeInTheDocument()
+ },
+ {timeout: 3000}
+ )
+ })
+
test('Does not trigger passkey when not enabled', async () => {
const mockAppConfig = {
...mockConfig.app,
@@ -845,6 +961,7 @@ describe('Passkey login', () => {
const trigger = screen.getByText(/open modal/i)
await user.click(trigger)
+ // Wait for modal to open
await waitFor(() => {
expect(screen.getByText(/welcome back/i)).toBeInTheDocument()
})
@@ -917,7 +1034,8 @@ describe('Passkey login', () => {
// login successfully and close the modal
await waitFor(() => {
- expect(screen.queryByText(/Welcome back/i)).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', {name: /Sign in/i})).not.toBeInTheDocument()
+ expect(screen.getByText(/You're now signed in./i)).toBeInTheDocument()
})
})
})
@@ -949,7 +1067,11 @@ describe('Passkey Registration', () => {
})
test('shows passkey registration toast after login', async () => {
- const {user} = renderWithProviders()
+ const {user} = renderWithProviders(, {
+ wrapperProps: {
+ bypassAuth: false
+ }
+ })
const validEmail = 'test@salesforce.com'
const validPassword = 'Password123!'
diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js
index 497c874638..9cff1a6771 100644
--- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js
+++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
+import {useRef} from 'react'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {useAuthHelper, AuthHelpers, useUsid} from '@salesforce/commerce-sdk-react'
import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/utils'
@@ -15,6 +16,19 @@ export const usePasskeyLogin = () => {
const startWebauthnAuthentication = useAuthHelper(AuthHelpers.StartWebauthnAuthentication)
const finishWebauthnAuthentication = useAuthHelper(AuthHelpers.FinishWebauthnAuthentication)
const {usid} = useUsid()
+ const abortControllerRef = useRef(null)
+
+ /**
+ * Aborts any pending passkey login request.
+ * This is useful when the user logs in with a different method (e.g., password)
+ * while a passkey prompt (e.g., Touch ID, Face ID, etc.) is still open.
+ */
+ const abortPasskeyLogin = () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort()
+ abortControllerRef.current = null
+ }
+ }
const loginWithPasskey = async () => {
const config = getConfig()
@@ -57,22 +71,31 @@ export const usePasskeyLogin = () => {
startWebauthnAuthenticationResponse.publicKey
)
+ // Create an AbortController to allow cancelling the passkey prompt
+ // This is needed when the user logs in with a different method or
+ // navigates away from the page while the passkey prompt is open
+ abortControllerRef.current = new AbortController()
+
// Get passkey credential from browser
// https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get
let credential
try {
credential = await navigator.credentials.get({
publicKey: options,
- mediation: 'conditional'
+ mediation: 'conditional',
+ signal: abortControllerRef.current.signal
})
} catch (error) {
// NotAllowedError is thrown when the user cancels the passkey login
- // We return early in this case to avoid showing an error to the user
- if (error.name == 'NotAllowedError') {
+ // AbortError is thrown when the passkey login is aborted programmatically (e.g., user logged in with password)
+ // We return early in these cases to avoid showing an error to the user
+ if (error.name === 'NotAllowedError' || error.name === 'AbortError') {
return
}
console.error('Error getting passkey credential from browser:', error)
throw error
+ } finally {
+ abortControllerRef.current = null
}
// Encode credential before sending to SLAS
@@ -107,5 +130,5 @@ export const usePasskeyLogin = () => {
return
}
- return {loginWithPasskey}
+ return {loginWithPasskey, abortPasskeyLogin}
}
diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js
index fe4cb03b68..04c1fffe02 100644
--- a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js
+++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js
@@ -79,16 +79,21 @@ const mockIsConditionalMediationAvailable = jest.fn()
const mockParseRequestOptionsFromJSON = jest.fn()
const MockComponent = () => {
- const {loginWithPasskey} = usePasskeyLogin()
+ const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin()
const [result, setResult] = React.useState(null)
- const handleClick = () => {
+
+ const handleLogin = () => {
loginWithPasskey()
.then(() => setResult('resolved'))
.catch(() => setResult('rejected'))
}
+ const handleAbort = () => {
+ abortPasskeyLogin()
+ }
return (
-
+
+
{result && {result}}
)
@@ -143,217 +148,254 @@ describe('usePasskeyLogin', () => {
mockGetCredentials.mockResolvedValue(mockCredential)
})
- test('calls navigator.credentials.get with the correct parameters when all conditions are met', async () => {
- renderWithProviders()
-
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
-
- // Check that credentials.get is called with the correct parameters
- await waitFor(() => {
- expect(mockGetCredentials).toHaveBeenCalledWith({
- publicKey: expect.objectContaining({
- challenge: expect.any(String),
- timeout: expect.any(Number),
- rpId: expect.any(String)
- }),
- mediation: 'conditional'
+ describe('loginWithPasskey', () => {
+ test('calls navigator.credentials.get with the correct parameters when all conditions are met', async () => {
+ renderWithProviders()
+
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
+
+ // Check that credentials.get is called with the correct parameters
+ await waitFor(() => {
+ expect(mockGetCredentials).toHaveBeenCalledWith({
+ publicKey: expect.objectContaining({
+ challenge: expect.any(String),
+ timeout: expect.any(Number),
+ rpId: expect.any(String)
+ }),
+ mediation: 'conditional',
+ signal: expect.any(AbortSignal)
+ })
})
})
- })
- test('does not call navigator.credentials.get when passkey is not enabled', async () => {
- getConfig.mockReturnValue({
- ...mockConfig,
- app: {
- ...mockConfig.app,
- login: {
- ...mockConfig.app.login,
- passkey: {
- enabled: false
+ test('does not call navigator.credentials.get when passkey is not enabled', async () => {
+ getConfig.mockReturnValue({
+ ...mockConfig,
+ app: {
+ ...mockConfig.app,
+ login: {
+ ...mockConfig.app.login,
+ passkey: {
+ enabled: false
+ }
}
}
- }
- })
+ })
- renderWithProviders()
+ renderWithProviders()
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
- expect(mockGetCredentials).not.toHaveBeenCalled()
- })
+ expect(mockGetCredentials).not.toHaveBeenCalled()
+ })
- test('does not start passkey login when PublicKeyCredential is not available', async () => {
- delete global.window.PublicKeyCredential
+ test('does not start passkey login when PublicKeyCredential is not available', async () => {
+ delete global.window.PublicKeyCredential
- renderWithProviders()
+ renderWithProviders()
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
- expect(mockGetCredentials).not.toHaveBeenCalled()
- })
+ expect(mockGetCredentials).not.toHaveBeenCalled()
+ })
- test('does not start passkey login when conditional mediation is not available', async () => {
- mockIsConditionalMediationAvailable.mockResolvedValue(false)
+ test('does not start passkey login when conditional mediation is not available', async () => {
+ mockIsConditionalMediationAvailable.mockResolvedValue(false)
- renderWithProviders()
+ renderWithProviders()
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
- await waitFor(() => {
- expect(mockIsConditionalMediationAvailable).toHaveBeenCalled()
+ await waitFor(() => {
+ expect(mockIsConditionalMediationAvailable).toHaveBeenCalled()
+ })
+
+ expect(mockGetCredentials).not.toHaveBeenCalled()
})
- expect(mockGetCredentials).not.toHaveBeenCalled()
- })
+ test('falls back to manual encoding when toJSON() is not supported', async () => {
+ // Create a credential mock where toJSON() throws an error (e.g., 1Password)
+ const credentialWithoutToJSON = {
+ ...mockCredential,
+ toJSON: jest.fn(() => {
+ throw new Error('toJSON is not supported')
+ })
+ }
+
+ // Reset and set the mock for this specific test to ensure it returns the credential
+ mockGetCredentials.mockResolvedValue(credentialWithoutToJSON)
- test('falls back to manual encoding when toJSON() is not supported', async () => {
- // Create a credential mock where toJSON() throws an error (e.g., 1Password)
- const credentialWithoutToJSON = {
- ...mockCredential,
- toJSON: jest.fn(() => {
- throw new Error('toJSON is not supported')
+ global.server.use(
+ rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => {
+ return res(
+ ctx.delay(0),
+ ctx.status(200),
+ ctx.json(mockStartWebauthnAuthenticationResponse)
+ )
+ }),
+ rest.post('*/oauth2/webauthn/authenticate/finish', async (req, res, ctx) => {
+ const body = await req.json()
+ // Assert: credential is still manually encoded when toJSON() is not supported
+ expect(body).toEqual(
+ expect.objectContaining({
+ credential: expect.objectContaining({
+ id: 'test-credential-id',
+ rawId: expect.any(String),
+ type: 'public-key',
+ clientExtensionResults: {},
+ response: expect.objectContaining({
+ authenticatorData: expect.any(String),
+ clientDataJSON: expect.any(String),
+ signature: expect.any(String),
+ userHandle: expect.any(String)
+ })
+ }),
+ // usid used in the test environment
+ usid: '8e883973-68eb-41fe-a3c5-756232652ff5'
+ })
+ )
+ return res(
+ ctx.delay(0),
+ ctx.status(200),
+ ctx.json(mockFinishWebauthnAuthenticationResponse)
+ )
+ })
+ )
+
+ renderWithProviders()
+
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ expect(mockGetCredentials).toHaveBeenCalled()
})
- }
+ })
- // Reset and set the mock for this specific test to ensure it returns the credential
- mockGetCredentials.mockResolvedValue(credentialWithoutToJSON)
+ test.each(['NotAllowedError', 'AbortError'])(
+ 'returns early without error when %s is thrown from navigator.credentials.get',
+ async (errorName) => {
+ const error = new Error()
+ error.name = errorName
+ mockGetCredentials.mockRejectedValue(error)
- global.server.use(
- rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => {
- return res(
- ctx.delay(0),
- ctx.status(200),
- ctx.json(mockStartWebauthnAuthenticationResponse)
- )
- }),
- rest.post('*/oauth2/webauthn/authenticate/finish', async (req, res, ctx) => {
- const body = await req.json()
- // Assert: credential is still manually encoded when toJSON() is not supported
- expect(body).toEqual(
- expect.objectContaining({
- credential: expect.objectContaining({
- id: 'test-credential-id',
- rawId: expect.any(String),
- type: 'public-key',
- clientExtensionResults: {},
- response: expect.objectContaining({
- authenticatorData: expect.any(String),
- clientDataJSON: expect.any(String),
- signature: expect.any(String),
- userHandle: expect.any(String)
- })
- }),
- // usid used in the test environment
- usid: '8e883973-68eb-41fe-a3c5-756232652ff5'
- })
- )
- return res(
- ctx.delay(0),
- ctx.status(200),
- ctx.json(mockFinishWebauthnAuthenticationResponse)
- )
- })
- )
+ renderWithProviders()
- renderWithProviders()
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
+ await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled())
+ await waitFor(() =>
+ expect(screen.getByTestId('login-result')).toHaveTextContent('resolved')
+ )
+ }
+ )
- await waitFor(() => {
- expect(mockGetCredentials).toHaveBeenCalled()
+ test('returns early without error when 412 is returned from startWebauthnAuthentication', async () => {
+ global.server.use(
+ rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => {
+ return res(
+ ctx.delay(0),
+ ctx.status(412),
+ ctx.json({message: 'Authenticate not started for: user@example.com"'})
+ )
+ })
+ )
+
+ renderWithProviders()
+
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
+
+ expect(mockGetCredentials).not.toHaveBeenCalled()
+ await waitFor(() =>
+ expect(screen.getByTestId('login-result')).toHaveTextContent('resolved')
+ )
})
- })
- test('returns early without error when NotAllowedError is thrown from navigator.credentials.get', async () => {
- // Create a NotAllowedError (thrown when user cancels passkey login)
- const notAllowedError = new Error('User cancelled')
- notAllowedError.name = 'NotAllowedError'
- mockGetCredentials.mockRejectedValue(notAllowedError)
+ test('throws error when other error is returned from startWebauthnAuthentication', async () => {
+ global.server.use(
+ rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => {
+ return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'}))
+ })
+ )
- renderWithProviders()
+ renderWithProviders()
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
- await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled())
- await waitFor(() =>
- expect(screen.getByTestId('login-result')).toHaveTextContent('resolved')
- )
- })
+ await waitFor(() =>
+ expect(screen.getByTestId('login-result')).toHaveTextContent('rejected')
+ )
+ })
- test('returns early without error when 412 is returned from startWebauthnAuthentication', async () => {
- global.server.use(
- rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => {
- return res(
- ctx.delay(0),
- ctx.status(412),
- ctx.json({message: 'Authenticate not started for: user@example.com"'})
- )
- })
- )
+ test('throws error when other error is returned from finishWebauthnAuthentication', async () => {
+ global.server.use(
+ rest.post('*/oauth2/webauthn/authenticate/finish', (req, res, ctx) => {
+ return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'}))
+ })
+ )
- renderWithProviders()
+ renderWithProviders()
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
- expect(mockGetCredentials).not.toHaveBeenCalled()
- await waitFor(() =>
- expect(screen.getByTestId('login-result')).toHaveTextContent('resolved')
- )
- })
+ await waitFor(() =>
+ expect(screen.getByTestId('login-result')).toHaveTextContent('rejected')
+ )
+ })
- test('throws error when other error is returned from startWebauthnAuthentication', async () => {
- global.server.use(
- rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => {
- return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'}))
- })
- )
+ test('throws error when other error is returned from navigator.credentials.get', async () => {
+ const networkError = new Error('NetworkError')
+ networkError.name = 'NetworkError'
+ mockGetCredentials.mockRejectedValue(networkError)
- renderWithProviders()
+ renderWithProviders()
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
+ const trigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(trigger)
- await waitFor(() =>
- expect(screen.getByTestId('login-result')).toHaveTextContent('rejected')
- )
+ await waitFor(() =>
+ expect(screen.getByTestId('login-result')).toHaveTextContent('rejected')
+ )
+ })
})
- test('throws error when other error is returned from finishWebauthnAuthentication', async () => {
- global.server.use(
- rest.post('*/oauth2/webauthn/authenticate/finish', (req, res, ctx) => {
- return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'}))
+ describe('abortPasskeyLogin', () => {
+ test('aborts the pending passkey request', async () => {
+ // Return a promise that rejects with AbortError when the abort signal fires
+ mockGetCredentials.mockImplementation(({signal}) => {
+ return new Promise((resolve, reject) => {
+ signal.addEventListener('abort', () => {
+ const abortError = new Error('Aborted')
+ abortError.name = 'AbortError'
+ reject(abortError)
+ })
+ })
})
- )
-
- renderWithProviders()
-
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
- await waitFor(() =>
- expect(screen.getByTestId('login-result')).toHaveTextContent('rejected')
- )
- })
+ renderWithProviders()
- test('throws error when other error is returned from navigator.credentials.get', async () => {
- const networkError = new Error('NetworkError')
- networkError.name = 'NetworkError'
- mockGetCredentials.mockRejectedValue(networkError)
+ const loginTrigger = screen.getByTestId('login-with-passkey')
+ fireEvent.click(loginTrigger)
- renderWithProviders()
+ // Wait for credentials.get to be called (passkey prompt is "waiting")
+ await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled())
- const trigger = screen.getByTestId('login-with-passkey')
- fireEvent.click(trigger)
+ // Abort while navigator.credentials.get is pending
+ const abortTrigger = screen.getByTestId('abort-passkey-login')
+ fireEvent.click(abortTrigger)
- await waitFor(() =>
- expect(screen.getByTestId('login-result')).toHaveTextContent('rejected')
- )
+ // Hook catches AbortError and returns, so loginWithPasskey resolves
+ await waitFor(() =>
+ expect(screen.getByTestId('login-result')).toHaveTextContent('resolved')
+ )
+ })
})
})
diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx
index 81fdb4c5e0..edac001d69 100644
--- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx
+++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx
@@ -62,7 +62,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket')
const mergeBasket = useShopperBasketsMutation('mergeBasket')
- const {loginWithPasskey} = usePasskeyLogin()
+ const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin()
const {step, STEPS, goToStep, goToNextStep} = useCheckout()
@@ -169,10 +169,15 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id
}
}
- if (!customer.isRegistered) {
+ if (step === STEPS.CONTACT_INFO) {
handlePasskeyLogin()
}
- }, [customer.isRegistered])
+
+ // Cleanup: abort passkey login when navigating away from checkout
+ return () => {
+ abortPasskeyLogin()
+ }
+ }, [step])
const onPasswordlessLoginClick = async (e) => {
const isValid = await form.trigger('email')
diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js
index 215c77aca8..893cc90f45 100644
--- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js
+++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js
@@ -16,7 +16,8 @@ import {
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
import {
mockGoToStep,
- mockGoToNextStep
+ mockGoToNextStep,
+ useCheckout
} from '@salesforce/retail-react-app/app/pages/checkout/util/checkout-context'
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
@@ -72,12 +73,14 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => {
})
const mockLoginWithPasskey = jest.fn().mockResolvedValue(undefined)
+const mockAbortPasskeyLogin = jest.fn()
jest.mock('@salesforce/retail-react-app/app/hooks/use-passkey-login', () => {
return {
__esModule: true,
usePasskeyLogin: jest.fn(() => ({
- loginWithPasskey: mockLoginWithPasskey
+ loginWithPasskey: mockLoginWithPasskey,
+ abortPasskeyLogin: mockAbortPasskeyLogin
}))
}
})
@@ -428,8 +431,34 @@ describe('navigation based on shipment context', () => {
})
describe('passkey login', () => {
+ let currentBasket = JSON.parse(JSON.stringify(scapiBasketWithItem))
+ const MOCK_STEPS = {CONTACT_INFO: 0, PAYMENT: 2}
+
beforeEach(() => {
jest.clearAllMocks()
+ // Reset useCheckout mock to default values
+ useCheckout.mockReturnValue({
+ customer: null,
+ basket: {},
+ isGuestCheckout: true,
+ setIsGuestCheckout: jest.fn(),
+ step: MOCK_STEPS.CONTACT_INFO,
+ login: null,
+ STEPS: MOCK_STEPS,
+ goToStep: mockGoToStep,
+ goToNextStep: mockGoToNextStep
+ })
+ global.server.use(
+ rest.put('*/baskets/:basketId/customer', (req, res, ctx) => {
+ currentBasket.customerInfo.email = validEmail
+ return res(ctx.json(currentBasket))
+ })
+ )
+ // Provide basket with basketId and items for tests in this suite
+ useCurrentBasket.mockReturnValue({
+ data: currentBasket,
+ derivedData: {totalItems: currentBasket.productItems?.length || 0}
+ })
// Default to guest user (not registered)
mockUseCurrentCustomer.mockReturnValue({
data: {
@@ -455,20 +484,87 @@ describe('passkey login', () => {
})
})
- test('does not call loginWithPasskey when customer is registered', async () => {
- // Mock registered customer
- mockUseCurrentCustomer.mockReturnValue({
- data: {
- isRegistered: true,
- email: 'test@example.com'
- }
+ test('does not prompt for passkey when not on contact info step', async () => {
+ // When step is not CONTACT_INFO, we must not trigger passkey (no prompt)
+ useCheckout.mockReturnValue({
+ customer: null,
+ basket: {},
+ isGuestCheckout: true,
+ setIsGuestCheckout: jest.fn(),
+ step: MOCK_STEPS.PAYMENT,
+ login: null,
+ STEPS: MOCK_STEPS,
+ goToStep: mockGoToStep,
+ goToNextStep: mockGoToNextStep
})
renderWithProviders()
- // Wait a bit to ensure useEffect has run
await waitFor(() => {
expect(mockLoginWithPasskey).not.toHaveBeenCalled()
})
})
+
+ test('calls abortPasskeyLogin when component unmounts', async () => {
+ const {unmount} = renderWithProviders()
+
+ // Wait for passkey login to be triggered
+ await waitFor(() => {
+ expect(mockLoginWithPasskey).toHaveBeenCalled()
+ })
+
+ // Verify abort hasn't been called yet
+ expect(mockAbortPasskeyLogin).not.toHaveBeenCalled()
+
+ // Unmount the component (simulates navigating away)
+ unmount()
+
+ // Verify abort was called during cleanup
+ expect(mockAbortPasskeyLogin).toHaveBeenCalled()
+ })
+
+ test('Passkey prompt is aborted when user logs in with password', async () => {
+ // This test verifies that when the user logs in with password while the passkey
+ // flow is pending, the useEffect cleanup runs (step changes) and
+ // abortPasskeyLogin is called.
+ useCheckout.mockImplementation(() => ({
+ customer: null,
+ basket: {},
+ isGuestCheckout: true,
+ setIsGuestCheckout: jest.fn(),
+ step:
+ // Make step "change" after goToNextStep is called so the effect cleanup runs.
+ mockGoToNextStep.mock.calls.length > 0
+ ? MOCK_STEPS.PAYMENT
+ : MOCK_STEPS.CONTACT_INFO,
+ login: null,
+ STEPS: MOCK_STEPS,
+ goToStep: mockGoToStep,
+ goToNextStep: mockGoToNextStep
+ }))
+
+ const {user} = renderWithProviders()
+
+ // Wait for passkey login to be triggered (passkey prompt is "pending")
+ await waitFor(() => {
+ expect(mockLoginWithPasskey).toHaveBeenCalled()
+ })
+ expect(mockAbortPasskeyLogin).not.toHaveBeenCalled()
+
+ // User switches to login and logs in with password
+ const trigger = screen.getByText(/Already have an account\? Log in/i)
+ await user.click(trigger)
+
+ await user.type(screen.getByLabelText('Email'), validEmail)
+ await user.type(screen.getByLabelText('Password'), password)
+
+ const loginButton = screen.getByText('Log In')
+ await user.click(loginButton)
+
+ // Login succeeds; goToNextStep is called. Next render will see step change
+ await waitFor(() => {
+ expect(mockGoToNextStep).toHaveBeenCalled()
+ expect(mockAbortPasskeyLogin).toHaveBeenCalled()
+ })
+ })
})
diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx
index a83fcc4362..9d51dcf41c 100644
--- a/packages/template-retail-react-app/app/pages/login/index.jsx
+++ b/packages/template-retail-react-app/app/pages/login/index.jsx
@@ -56,7 +56,7 @@ const Login = ({initialView = LOGIN_VIEW}) => {
const {path} = useRouteMatch()
const einstein = useEinstein()
const dataCloud = useDataCloud()
- const {isRegistered, customerType} = useCustomerType()
+ const {isRegistered, customerType, isGuest} = useCustomerType()
const {locale} = useMultiSite()
const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C)
const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser)
@@ -81,7 +81,7 @@ const Login = ({initialView = LOGIN_VIEW}) => {
const mergeBasket = useShopperBasketsMutation('mergeBasket')
const [redirectPath, setRedirectPath] = useState('')
const {showRegisterPasskeyToast} = usePasskeyRegistration()
- const {loginWithPasskey} = usePasskeyLogin()
+ const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin()
const [isOtpAuthOpen, setIsOtpAuthOpen] = useState(false)
const handleMergeBasket = () => {
@@ -191,10 +191,17 @@ const Login = ({initialView = LOGIN_VIEW}) => {
}, [isRegistered, redirectPath])
useEffect(() => {
- loginWithPasskey().catch(() => {
- form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)})
- })
- }, [])
+ if (isGuest) {
+ loginWithPasskey().catch(() => {
+ form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)})
+ })
+ }
+
+ // Cleanup: abort passkey login when navigating away from login page
+ return () => {
+ abortPasskeyLogin()
+ }
+ }, [isGuest])
/**************** Einstein ****************/
useEffect(() => {
diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js
index 4c170c08e6..cb79a8b82e 100644
--- a/packages/template-retail-react-app/app/pages/login/index.test.js
+++ b/packages/template-retail-react-app/app/pages/login/index.test.js
@@ -10,7 +10,9 @@ import {rest} from 'msw'
import {
renderWithProviders,
createPathWithDefaults,
- guestToken
+ guestToken,
+ registerUserToken,
+ clearAllCookies
} from '@salesforce/retail-react-app/app/utils/test-utils'
import Login from '.'
import {BrowserRouter as Router, Route} from 'react-router-dom'
@@ -20,12 +22,23 @@ import ResetPassword from '@salesforce/retail-react-app/app/pages/reset-password
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
+import {useCustomerType} from '@salesforce/commerce-sdk-react'
// Mock getConfig for passkey tests
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({
getConfig: jest.fn()
}))
+// Allows overriding useCustomerType for tests that need a specific auth
+// state (e.g. simulate a user being already authenticated on page load.
+jest.mock('@salesforce/commerce-sdk-react', () => {
+ const actual = jest.requireActual('@salesforce/commerce-sdk-react')
+ return {
+ ...actual,
+ useCustomerType: jest.fn(actual.useCustomerType)
+ }
+})
+
const mockMergedBasket = {
basketId: 'a10ff320829cb0eef93ca5310a',
currency: 'USD',
@@ -88,6 +101,8 @@ beforeEach(() => {
afterEach(() => {
jest.resetModules()
localStorage.clear()
+ // Ensures authenticated state from previous tests don't leak into subsequent tests
+ clearAllCookies()
})
describe('Logging in tests', function () {
@@ -280,6 +295,7 @@ describe('Error while logging in', function () {
).toBeInTheDocument()
})
})
+
describe('Passkey login', () => {
let mockCredentialsGet
let mockPublicKeyCredential
@@ -436,6 +452,40 @@ describe('Passkey login', () => {
expect(mockCredentialsGet).not.toHaveBeenCalled()
})
+ test('Does not trigger passkey when user is already signed in', async () => {
+ // Simulates a user being already authenticated on page load
+ const realUseCustomerType = useCustomerType.getMockImplementation()
+ useCustomerType.mockReturnValue({
+ isRegistered: true,
+ customerType: 'registered',
+ isGuest: false,
+ isExternal: false
+ })
+ try {
+ renderWithProviders(, {
+ wrapperProps: {
+ siteAlias: 'uk',
+ locale: {id: 'en-GB'},
+ appConfig: mockAppConfig,
+ bypassAuth: true,
+ isGuest: false
+ }
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('login-page')).toBeInTheDocument()
+ })
+
+ // Give it a moment for any async effects to run
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Rendering the login page should not trigger navigator.credentials.get when user is already registered
+ expect(mockCredentialsGet).not.toHaveBeenCalled()
+ } finally {
+ useCustomerType.mockImplementation(realUseCustomerType)
+ }
+ })
+
test('Successfully logs in with passkey', async () => {
const mockCredential = {
id: 'mock-credential-id',
@@ -531,60 +581,6 @@ describe('Passkey login', () => {
})
})
- describe('Passkey Registration', () => {
- test('Displays Create passkey toast after successful login when passkey is enabled', async () => {
- // Successful email/password login
- global.server.use(
- rest.post('*/oauth2/token', (req, res, ctx) =>
- res(
- ctx.delay(0),
- ctx.json({
- customer_id: 'customerid_1',
- access_token:
- 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494',
- refresh_token: 'testrefeshtoken_1',
- usid: 'testusid_1',
- enc_user_id: 'testEncUserId_1',
- id_token: 'testIdToken_1'
- })
- )
- ),
- rest.post('*/baskets/actions/merge', (req, res, ctx) =>
- res(ctx.delay(0), ctx.json(mockMergedBasket))
- )
- )
-
- const {user} = renderWithProviders(, {
- wrapperProps: {
- siteAlias: 'uk',
- locale: {id: 'en-GB'},
- appConfig: mockAppConfig,
- bypassAuth: false
- }
- })
-
- // Wait for login form after passkey is cancelled
- await waitFor(() => {
- expect(screen.getByLabelText('Email')).toBeInTheDocument()
- expect(screen.getByLabelText('Password')).toBeInTheDocument()
- })
-
- await user.type(screen.getByLabelText('Email'), 'customer@test.com')
- await user.type(screen.getByLabelText('Password'), 'Password!1')
- await user.click(screen.getByRole('button', {name: /sign in/i}))
-
- // Create passkey toast is shown after successful login when passkey is enabled and WebAuthn is supported
- await waitFor(
- () => {
- expect(
- screen.getByRole('button', {name: /Create Passkey/i})
- ).toBeInTheDocument()
- },
- {timeout: 3000}
- )
- })
- })
-
test('Shows error when passkey authentication fails with error from the browser', async () => {
// Simulate error in navigator.credentials.get hook
mockCredentialsGet.mockRejectedValue(new Error('Authentication failed'))
@@ -631,6 +627,189 @@ describe('Passkey login', () => {
expect(screen.getByText(/Something went wrong. Try again!/i)).toBeInTheDocument()
})
})
+
+ test('Passkey prompt is aborted when user logs in with password', async () => {
+ // Capture the abort signal passed to credentials.get
+ let capturedSignal = null
+
+ // Mock credentials.get to capture the abort signal and stay pending
+ mockCredentialsGet.mockImplementation(({signal}) => {
+ capturedSignal = signal
+ return new Promise(() => {
+ // Never resolve - simulates passkey prompt staying open
+ })
+ })
+
+ // Successful email/password login
+ global.server.use(
+ rest.post('*/oauth2/token', (req, res, ctx) =>
+ res(
+ ctx.delay(0),
+ ctx.json({
+ customer_id: 'customerid_1',
+ access_token: registerUserToken,
+ refresh_token: 'testrefeshtoken_1',
+ usid: 'testusid_1',
+ enc_user_id: 'testEncUserId_1',
+ id_token: 'testIdToken_1'
+ })
+ )
+ ),
+ rest.post('*/baskets/actions/merge', (req, res, ctx) =>
+ res(ctx.delay(0), ctx.json(mockMergedBasket))
+ )
+ )
+
+ const {user} = renderWithProviders(, {
+ wrapperProps: {
+ siteAlias: 'uk',
+ locale: {id: 'en-GB'},
+ appConfig: mockAppConfig,
+ bypassAuth: false
+ }
+ })
+
+ // Wait for passkey conditional mediation to start and capture the signal
+ await waitFor(() => {
+ expect(mockCredentialsGet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mediation: 'conditional',
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(capturedSignal).not.toBeNull()
+ })
+
+ // Verify signal is not yet aborted
+ expect(capturedSignal.aborted).toBe(false)
+
+ // User logs in with password while passkey prompt is still open
+ await user.type(screen.getByLabelText('Email'), 'customer@test.com')
+ await user.type(screen.getByLabelText('Password'), 'Password!1')
+ await user.click(screen.getByRole('button', {name: /sign in/i}))
+
+ // Wait for successful login and navigation to account page
+ await waitFor(
+ () => {
+ expect(window.location.pathname).toBe('/uk/en-GB/account')
+ expect(screen.getByText(/My Profile/i)).toBeInTheDocument()
+ },
+ {timeout: 3000}
+ )
+
+ // Verify the signal was aborted when user logs in with password
+ expect(capturedSignal.aborted).toBe(true)
+ })
+
+ test('Passkey prompt is aborted when navigating away from login page', async () => {
+ // Capture the abort signal passed to credentials.get
+ let capturedSignal = null
+
+ // Mock credentials.get to capture the abort signal and stay pending
+ mockCredentialsGet.mockImplementation(({signal}) => {
+ capturedSignal = signal
+ return new Promise(() => {
+ // Never resolve - simulates passkey prompt staying open
+ })
+ })
+
+ const {unmount} = renderWithProviders(, {
+ wrapperProps: {
+ siteAlias: 'uk',
+ locale: {id: 'en-GB'},
+ appConfig: mockAppConfig,
+ bypassAuth: false
+ }
+ })
+
+ // Wait for passkey conditional mediation to start and capture the signal
+ await waitFor(() => {
+ expect(mockCredentialsGet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mediation: 'conditional',
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(capturedSignal).not.toBeNull()
+ })
+
+ // Verify signal is not yet aborted
+ expect(capturedSignal.aborted).toBe(false)
+
+ // Simulate navigating away from the login page by unmounting
+ unmount()
+
+ // Verify the signal was aborted when component unmounted
+ expect(capturedSignal.aborted).toBe(true)
+ })
+})
+
+describe('Passkey Registration', () => {
+ let mockPublicKeyCredential
+
+ beforeEach(() => {
+ mockPublicKeyCredential = {
+ parseRequestOptionsFromJSON: jest.fn(),
+ isConditionalMediationAvailable: jest.fn().mockResolvedValue(true),
+ isUserVerifyingPlatformAuthenticatorAvailable: jest.fn().mockResolvedValue(true)
+ }
+
+ global.PublicKeyCredential = mockPublicKeyCredential
+ global.window.PublicKeyCredential = mockPublicKeyCredential
+ })
+
+ afterEach(() => {
+ delete global.PublicKeyCredential
+ delete global.window.PublicKeyCredential
+ })
+
+ test('Displays Create passkey toast after successful login when passkey is enabled', async () => {
+ // Successful email/password login
+ global.server.use(
+ rest.post('*/oauth2/token', (req, res, ctx) =>
+ res(
+ ctx.delay(0),
+ ctx.json({
+ customer_id: 'customerid_1',
+ access_token: registerUserToken,
+ refresh_token: 'testrefeshtoken_1',
+ usid: 'testusid_1',
+ enc_user_id: 'testEncUserId_1',
+ id_token: 'testIdToken_1'
+ })
+ )
+ ),
+ rest.post('*/baskets/actions/merge', (req, res, ctx) =>
+ res(ctx.delay(0), ctx.json(mockMergedBasket))
+ )
+ )
+
+ const {user} = renderWithProviders(, {
+ wrapperProps: {
+ siteAlias: 'uk',
+ locale: {id: 'en-GB'},
+ bypassAuth: false
+ }
+ })
+
+ // Wait for login form after passkey is cancelled
+ await waitFor(() => {
+ expect(screen.getByLabelText('Email')).toBeInTheDocument()
+ expect(screen.getByLabelText('Password')).toBeInTheDocument()
+ })
+
+ await user.type(screen.getByLabelText('Email'), 'customer@test.com')
+ await user.type(screen.getByLabelText('Password'), 'Password!1')
+ await user.click(screen.getByRole('button', {name: /sign in/i}))
+
+ // Create passkey toast is shown after successful login when passkey is enabled and WebAuthn is supported
+ await waitFor(
+ () => {
+ expect(screen.getByRole('button', {name: /Create Passkey/i})).toBeInTheDocument()
+ },
+ {timeout: 3000}
+ )
+ })
})
describe('Navigate away from login page tests', function () {
@@ -693,8 +872,7 @@ describe('Passwordless login tests', () => {
ctx.status(200),
ctx.json({
customer_id: 'customerid_1',
- access_token:
- 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494',
+ access_token: registerUserToken,
refresh_token: 'testrefeshtoken_1',
usid: 'testusid_1',
enc_user_id: 'testEncUserId_1',