Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3791b65
Implement error handling for login with passkey in AuthModal and Logi…
hajinsuha1 Feb 17, 2026
8ec8140
Refactor passkey error handling in AuthModal and Login components; re…
hajinsuha1 Feb 17, 2026
98d8950
Add unit tests for passkey error message handling in auth-utils; incl…
hajinsuha1 Feb 17, 2026
315e3a1
fix unit tests by removing mocking AuthHelpers
hajinsuha1 Feb 17, 2026
1f45c06
Refactor passkey login tests to improve clarity and functionality; en…
hajinsuha1 Feb 18, 2026
54d6043
Enhance passkey login tests by refining error handling and improving …
hajinsuha1 Feb 18, 2026
b16b759
add error handling in passkey registration. add console error for deb…
hajinsuha1 Feb 18, 2026
767acfc
Refactor passkey registration handling in AuthModal and related compo…
hajinsuha1 Feb 18, 2026
1199fc6
Enhance passkey error handling in auth-utils by introducing new error…
hajinsuha1 Feb 18, 2026
977e9ea
use preview version for commerce-sdk-react
hajinsuha1 Feb 18, 2026
bbc4071
Revert "use preview version for commerce-sdk-react"
hajinsuha1 Feb 18, 2026
7c727b2
Enhance usePasskeyLogin hook to handle specific error responses grace…
hajinsuha1 Feb 19, 2026
a9bda52
Merge branch 'feature/webauthn-login' into W-21056536-error-handling-…
hajinsuha1 Feb 19, 2026
2e64f70
Refactor passkey registration and authentication error handling acros…
hajinsuha1 Feb 19, 2026
ca9c030
Merge branch 'W-21056536-error-handling-passkey-registration-and-logi…
hajinsuha1 Feb 19, 2026
cc79b74
Update comments in PasskeyRegistrationModal to correct step numbering…
hajinsuha1 Feb 19, 2026
303ed13
Merge branch 'feature/webauthn-login' into W-21056536-error-handling-…
hajinsuha1 Feb 19, 2026
b6f7f4f
lint
hajinsuha1 Feb 20, 2026
529b703
increase bundle size
hajinsuha1 Feb 20, 2026
1bf6075
fix commerce-sdk-react unit tests
hajinsuha1 Feb 20, 2026
f80c954
Merge branch 'feature/webauthn-login' into W-21056536-error-handling-…
hajinsuha1 Feb 20, 2026
b4170ad
Add abort functionality to passkey login hook
hajinsuha1 Feb 23, 2026
c0dd5e9
Enhance passkey login handling with abort functionality in pages/logi…
hajinsuha1 Feb 23, 2026
0d38469
Implement abort functionality for passkey login in AuthModal
hajinsuha1 Feb 23, 2026
c4d0811
Refine passkey login behavior to prevent unnecessary prompts
hajinsuha1 Feb 23, 2026
3583b34
Merge branch 'feature/webauthn-login' into W-21253277-passkey-login-c…
hajinsuha1 Feb 23, 2026
9c8717a
Merge branch 'W-21253277-passkey-login-cancelled-after-user-logs-in' …
hajinsuha1 Feb 23, 2026
3254cf4
Enhance passkey login handling in ContactInfo component
hajinsuha1 Feb 23, 2026
41c6abd
Update login component to handle guest user state for passkey login
hajinsuha1 Feb 24, 2026
044b836
revert contact-info changes that are unnecessary
hajinsuha1 Feb 24, 2026
016aeff
Refactor passkey login logic in ContactInfo component
hajinsuha1 Feb 24, 2026
a330310
cleanup comments and tests
hajinsuha1 Feb 24, 2026
5714c0b
use registerUserToken instead of hardcoded access token in tests
hajinsuha1 Feb 24, 2026
75e59c8
Refactor AuthModal logic and improve tests
hajinsuha1 Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions packages/template-retail-react-app/app/hooks/use-auth-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Comment on lines -273 to -274
Copy link
Collaborator Author

@hajinsuha1 hajinsuha1 Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was merged in the previous PR but I am reverting it as it introduces a bug where the auth modal will be emtpy for a moment while it waits for the customer data to load.

bug.mp4

if (!isNowRegistered) {
return
}

Expand All @@ -292,7 +296,7 @@ export const AuthModal = ({
id: 'auth_modal.info.welcome_user'
},
{
name: customer.data?.firstName || ''
name: customer.data?.firstName || 'back'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small enhancement in case the customer is not loaded in time, to display "Welcome back," instead of "Welcome ,"

}
)}`,
description: `${formatMessage({
Expand All @@ -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)
Expand Down
126 changes: 124 additions & 2 deletions packages/template-retail-react-app/app/hooks/use-auth-modal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MockedComponent />, {
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(<MockedComponent />, {
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,
Expand All @@ -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()
})
Expand Down Expand Up @@ -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()
})
})
})
Expand Down Expand Up @@ -949,7 +1067,11 @@ describe('Passkey Registration', () => {
})

test('shows passkey registration toast after login', async () => {
const {user} = renderWithProviders(<MockedComponent isPasswordlessEnabled={true} />)
const {user} = renderWithProviders(<MockedComponent isPasswordlessEnabled={true} />, {
wrapperProps: {
bypassAuth: false
}
})
const validEmail = 'test@salesforce.com'
const validPassword = 'Password123!'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -107,5 +130,5 @@ export const usePasskeyLogin = () => {
return
}

return {loginWithPasskey}
return {loginWithPasskey, abortPasskeyLogin}
}
Loading
Loading