Skip to content

Commit 50f2629

Browse files
authored
@W-21056536 Error handling Passkey Registration and Login (#3672)
display user-friendly error message for errors during passkey login and registration
1 parent 1939e36 commit 50f2629

File tree

18 files changed

+619
-548
lines changed

18 files changed

+619
-548
lines changed

packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,7 @@ describe('Shopper Login hooks', () => {
2020
// These endpoints all return data in the response headers, rather than body, so they
2121
// don't work well with the current implementation of mutation hooks.
2222
'authenticateCustomer',
23-
'authorizeWebauthnRegistration',
24-
'finishWebauthnAuthentication',
25-
'finishWebauthnUserRegistration',
26-
'getTrustedAgentAuthorizationToken',
27-
'startWebauthnAuthentication',
28-
'startWebauthnUserRegistration'
23+
'getTrustedAgentAuthorizationToken'
2924
])
3025
})
3126
test('all mutations have cache update logic', () => {

packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/uti
3737
// SDK
3838
import {AuthHelpers, useAuthHelper} from '@salesforce/commerce-sdk-react'
3939

40+
// Constants
41+
import {
42+
API_ERROR_MESSAGE,
43+
INVALID_TOKEN_ERROR_MESSAGE
44+
} from '@salesforce/retail-react-app/app/constants'
45+
4046
/**
4147
* Modal for registering a new passkey with a nickname
4248
*/
@@ -73,13 +79,8 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => {
7379
onClose()
7480
setIsOtpAuthOpen(true)
7581
} catch (err) {
76-
setError(
77-
err.message ||
78-
formatMessage({
79-
id: 'passkey_registration.modal.error.authorize_failed',
80-
defaultMessage: 'Failed to authorize passkey registration'
81-
})
82-
)
82+
// Set error message for the passkey registration modal
83+
setError(formatMessage(API_ERROR_MESSAGE))
8384
} finally {
8485
setIsLoading(false)
8586
}
@@ -107,18 +108,7 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => {
107108

108109
// navigator.credentials.create() will show a browser/system prompt
109110
// This may appear to hang if the user doesn't interact with the prompt
110-
let credential
111-
try {
112-
credential = await navigator.credentials.create({
113-
publicKey
114-
})
115-
} catch (createError) {
116-
// Handle user cancellation or other errors from the WebAuthn API
117-
if (createError.name === 'NotAllowedError' || createError.name === 'AbortError') {
118-
throw new Error('Passkey registration was cancelled or timed out')
119-
}
120-
throw createError
121-
}
111+
const credential = await navigator.credentials.create({publicKey})
122112

123113
if (!credential) {
124114
throw new Error('Failed to create credential: user cancelled or operation failed')
@@ -160,17 +150,15 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => {
160150

161151
return {success: true}
162152
} catch (err) {
163-
const errorMessage =
164-
err.message ||
165-
formatMessage({
166-
id: 'passkey_registration.modal.error.registration_failed',
167-
defaultMessage: 'Failed to register passkey'
168-
})
153+
console.error('Error registering passkey:', err)
154+
const message = /401/.test(err.message)
155+
? formatMessage(INVALID_TOKEN_ERROR_MESSAGE)
156+
: formatMessage(API_ERROR_MESSAGE)
169157

170158
// Return error result for OTP component to display
171159
return {
172160
success: false,
173-
error: errorMessage
161+
error: message
174162
}
175163
} finally {
176164
setIsLoading(false)

packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ describe('PasskeyRegistrationModal', () => {
223223
await user.click(registerButton)
224224

225225
await waitFor(() => {
226-
expect(screen.getByText(errorMessage)).toBeInTheDocument()
226+
expect(screen.getByText('Something went wrong. Try again!')).toBeInTheDocument()
227227
})
228228
})
229229

@@ -457,7 +457,7 @@ describe('PasskeyRegistrationModal', () => {
457457

458458
expect(result).toEqual({
459459
success: false,
460-
error: errorMessage
460+
error: 'Something went wrong. Try again!'
461461
})
462462

463463
// Verify modals are not closed on error
@@ -498,7 +498,7 @@ describe('PasskeyRegistrationModal', () => {
498498

499499
expect(result).toEqual({
500500
success: false,
501-
error: 'WebAuthn API not available in this browser'
501+
error: 'Something went wrong. Try again!'
502502
})
503503
})
504504

@@ -537,7 +537,7 @@ describe('PasskeyRegistrationModal', () => {
537537

538538
expect(result).toEqual({
539539
success: false,
540-
error: 'Passkey registration was cancelled or timed out'
540+
error: 'Something went wrong. Try again!'
541541
})
542542
})
543543

@@ -573,7 +573,7 @@ describe('PasskeyRegistrationModal', () => {
573573

574574
expect(result).toEqual({
575575
success: false,
576-
error: 'Failed to create credential: user cancelled or operation failed'
576+
error: 'Something went wrong. Try again!'
577577
})
578578
})
579579

@@ -622,7 +622,7 @@ describe('PasskeyRegistrationModal', () => {
622622

623623
expect(result).toEqual({
624624
success: false,
625-
error: errorMessage
625+
error: 'Something went wrong. Try again!'
626626
})
627627
})
628628

@@ -661,7 +661,34 @@ describe('PasskeyRegistrationModal', () => {
661661

662662
expect(result).toEqual({
663663
success: false,
664-
error: 'Passkey registration was cancelled or timed out'
664+
error: 'Something went wrong. Try again!'
665+
})
666+
})
667+
668+
test('returns INVALID_TOKEN_ERROR_MESSAGE when startWebauthnUserRegistration fails with 401', async () => {
669+
const otpCode = '12345678'
670+
671+
mockStartWebauthnRegistration.mockRejectedValue(new Error('401'))
672+
673+
const {user} = renderWithProviders(
674+
<PasskeyRegistrationModal isOpen={true} onClose={mockOnClose} />,
675+
{
676+
wrapperProps: {appConfig: mockConfig.app}
677+
}
678+
)
679+
680+
const registerButton = screen.getByText('Register Passkey')
681+
await user.click(registerButton)
682+
683+
await waitFor(() => {
684+
expect(otpVerificationHandler).toBeTruthy()
685+
})
686+
687+
const result = await otpVerificationHandler(otpCode)
688+
689+
expect(result).toEqual({
690+
success: false,
691+
error: 'Invalid token, please try again.'
665692
})
666693
})
667694
})

packages/template-retail-react-app/app/hooks/use-auth-modal.js

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ export const AuthModal = ({
8989
const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C)
9090
const register = useAuthHelper(AuthHelpers.Register)
9191
const {locale} = useMultiSite()
92-
const config = getConfig()
9392

9493
const {getPasswordResetToken} = usePasswordReset()
9594
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
@@ -104,7 +103,7 @@ export const AuthModal = ({
104103
)
105104
const mergeBasket = useShopperBasketsMutation('mergeBasket')
106105

107-
const {showToast} = usePasskeyRegistration()
106+
const {showRegisterPasskeyToast} = usePasskeyRegistration()
108107

109108
const handlePasswordlessLogin = async (email) => {
110109
try {
@@ -239,8 +238,8 @@ export const AuthModal = ({
239238
setCurrentView(initialView)
240239
form.reset()
241240
// Prompt user to login without username (discoverable credentials)
242-
loginWithPasskey().catch((error) => {
243-
// TODO W-21056536: Add error message handling
241+
loginWithPasskey().catch(() => {
242+
form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)})
244243
})
245244
}
246245
}, [isOpen])
@@ -271,35 +270,20 @@ export const AuthModal = ({
271270
const isNowRegistered =
272271
(isOpen || isOtpAuthOpen) && isRegistered && (loggingIn || registering)
273272
// If the customer changed, but it's not because they logged in or registered. Do nothing.
274-
if (!isNowRegistered) {
273+
// Also ensure that the customer data is loaded.
274+
if (!isNowRegistered || !customer.data) {
275275
return
276276
}
277277

278278
// We are done with the modal. Close any modals that are open.
279279
onClose()
280280
setIsOtpAuthOpen(false)
281281

282-
if (config?.app?.login?.passkey?.enabled) {
283-
// Show passkey registration modal only if Webauthn feature flag is enabled and compatible with the browser
284-
if (
285-
window.PublicKeyCredential &&
286-
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
287-
window.PublicKeyCredential.isConditionalMediationAvailable
288-
) {
289-
Promise.all([
290-
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
291-
window.PublicKeyCredential.isConditionalMediationAvailable()
292-
]).then((results) => {
293-
if (results.every((r) => r === true)) {
294-
showToast()
295-
}
296-
})
297-
}
298-
}
282+
// Show passkey registration prompt if supported
283+
showRegisterPasskeyToast()
299284

300285
// Show a toast only for those registed users returning to the site.
301-
// Only show toast when customer data is available (user is logged in and data is loaded)
302-
if (loggingIn && customer.data) {
286+
if (loggingIn) {
303287
toast({
304288
variant: 'subtle',
305289
title: `${formatMessage(
@@ -429,7 +413,7 @@ AuthModal.propTypes = {
429413
*/
430414
export const useAuthModal = (initialView = LOGIN_VIEW) => {
431415
const {isOpen, onOpen, onClose} = useDisclosure()
432-
const {passwordless = {}, social = {}, passkey = {}} = getConfig().app.login || {}
416+
const {passwordless = {}, social = {}} = getConfig().app.login || {}
433417

434418
return {
435419
initialView,

0 commit comments

Comments
 (0)