Skip to content

Commit 3d79932

Browse files
authored
@W-20890250 Handle request limit and monthly quota error states for passwordless and reset password (#3574)
* Enhance error handling for password reset functionality and add new error message for "too many requests" errors * display errors in Email Confirmation page * added new auth-utils.js that contains utility methods for mapping passwordless and reset password API error messages to user-friendly error messages * added mapping of the following API error messages: "no callback_uri is registered for client" -> "This feature is not currently available", "Too many login requests were made. Please try again later." -> Too many requests. For your security, please wait 10 ** minutes before trying again., "Monthly quota for passwordless login mode email has been exceeded" -> "This feature is not currently available" * changelog updates and translations
1 parent c02836d commit 3d79932

File tree

21 files changed

+397
-46
lines changed

21 files changed

+397
-46
lines changed

packages/commerce-sdk-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- Update `authorizePasswordless` to pass locale and simplify mode selection to respect user's explicit mode choice while still defaulting to callback mode for backward compatibility [#3492](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3492)
55
- Update `getPasswordResetToken` to default locale to the one in CommerceApiProvider and pass callback_uri and idp_name only when they are defined [#3547](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3547)
66
- Update `resetPassword` to default hint to `cross_device` and pass code_verifier only when it is defined [#3547](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3547)
7+
- Update `getPasswordResetToken` to return raw response and throw an error with the error message if the status code is not 200 [#3574](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3574)
78

89
## v4.3.0 (Dec 17, 2025)
910

packages/commerce-sdk-react/src/auth/index.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1066,14 +1066,33 @@ describe('Auth', () => {
10661066
const getPasswordResetTokenSpy = jest.spyOn((auth as any).client, 'getPasswordResetToken')
10671067
getPasswordResetTokenSpy.mockResolvedValueOnce(mockResponse)
10681068

1069-
await auth.getPasswordResetToken({user_id: 'user@example.com'} as any)
1069+
await auth.getPasswordResetToken({
1070+
user_id: 'user@example.com',
1071+
mode: 'email',
1072+
channel_id: 'channel_id'
1073+
})
10701074

10711075
expect(getPasswordResetTokenSpy).toHaveBeenCalled()
10721076
const callArgs = getPasswordResetTokenSpy.mock.calls[0][0] as any
10731077
expect(callArgs.headers.Authorization).toBeTruthy()
10741078
expect(callArgs.headers.Authorization).toContain('Basic ')
10751079
})
10761080

1081+
test('getPasswordResetToken throws error on non-200 response', async () => {
1082+
const auth = new Auth(configSLASPrivate)
1083+
1084+
const mockErrorResponse = {
1085+
status: 400,
1086+
json: jest.fn().mockResolvedValue({message: 'Invalid request'})
1087+
}
1088+
const getPasswordResetTokenSpy = jest.spyOn((auth as any).client, 'getPasswordResetToken')
1089+
getPasswordResetTokenSpy.mockReturnValueOnce(mockErrorResponse)
1090+
1091+
await expect(
1092+
auth.getPasswordResetToken({user_id: 'userid', mode: 'email', channel_id: 'channel_id'})
1093+
).rejects.toThrow('400 Invalid request')
1094+
})
1095+
10771096
test.each([
10781097
[
10791098
'with all parameters specified',

packages/commerce-sdk-react/src/auth/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1352,7 +1352,11 @@ class Auth {
13521352
)}`
13531353
}
13541354

1355-
const res = await slasClient.getPasswordResetToken(options)
1355+
const res = await slasClient.getPasswordResetToken(options, true)
1356+
if (res && res.status !== 200) {
1357+
const errorData = await res.json()
1358+
throw new Error(`${res.status} ${String(errorData.message)}`)
1359+
}
13561360
return res
13571361
}
13581362

packages/template-retail-react-app/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
## v8.4.0-dev (Dec 17, 2025)
22
- [Feature] Add `fuzzyPathMatching` to reduce computational overhead of route generation at time of application load [#3530](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3530)
33
- [Bugfix] Fix Passwordless Login landingPath, Reset Password landingPath, and Social Login redirectUri value in config not being used [#3560](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3560)
4-
- Update passwordless login and password reset to use email mode by default. The mode can now be configured across the login page, auth modal, and checkout page [#3492](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3492) [#3547](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3547)
4+
- Update passwordless login and password reset to use email mode by default. The mode can now be configured across the login page, auth modal, and checkout page [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525)
55
- Update "Continue Securely" button text to "Continue" for passwordless login [#3556](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3556)
66

77
## v8.3.0 (Dec 17, 2025)

packages/template-retail-react-app/app/components/email-confirmation/index.jsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
import React from 'react'
99
import PropTypes from 'prop-types'
1010
import {FormattedMessage} from 'react-intl'
11-
import {Button, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui'
11+
import {
12+
Button,
13+
Stack,
14+
Text,
15+
Alert,
16+
AlertIcon
17+
} from '@salesforce/retail-react-app/app/components/shared/ui'
1218
import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons'
1319

1420
const PasswordlessEmailConfirmation = ({form, submitForm, email = ''}) => {
@@ -40,6 +46,14 @@ const PasswordlessEmailConfirmation = ({form, submitForm, email = ''}) => {
4046
id="auth_modal.check_email.title.check_your_email"
4147
/>
4248
</Text>
49+
{form.formState.errors?.global && (
50+
<Alert status="error">
51+
<AlertIcon color="red.500" boxSize={4} />
52+
<Text fontSize="sm" ml={3}>
53+
{form.formState.errors.global.message}
54+
</Text>
55+
</Alert>
56+
)}
4357
<Stack spacing={10}>
4458
<Text align="center" fontSize="md" id="email-confirmation-desc">
4559
<FormattedMessage

packages/template-retail-react-app/app/components/email-confirmation/index.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,21 @@ test('renders PasswordlessEmailConfirmation component with passed email', () =>
2020
renderWithProviders(<WrapperComponent email={email} />)
2121
expect(screen.getByText(email)).toBeInTheDocument()
2222
})
23+
24+
test('displays error message when form has global error', () => {
25+
const WrapperWithError = () => {
26+
const form = useForm()
27+
const formWithError = {
28+
...form,
29+
formState: {
30+
...form.formState,
31+
errors: {
32+
global: {message: 'test error'}
33+
}
34+
}
35+
}
36+
return <PasswordlessEmailConfirmation form={formWithError} />
37+
}
38+
renderWithProviders(<WrapperWithError />)
39+
expect(screen.getByText('test error')).toBeInTheDocument()
40+
})

packages/template-retail-react-app/app/constants.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,6 @@ export const LOGIN_TYPES = {
251251
SOCIAL: 'social'
252252
}
253253

254-
export const PASSWORDLESS_ERROR_MESSAGES = [
255-
/callback_uri doesn't match/i,
256-
/passwordless permissions error/i,
257-
/client secret is not provided/i
258-
]
259-
260254
export const INVALID_TOKEN_ERROR = /invalid token/i
261255

262256
/**

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

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ import RegisterForm from '@salesforce/retail-react-app/app/components/register'
3232
import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index'
3333
import {noop} from '@salesforce/retail-react-app/app/utils/utils'
3434
import {
35-
API_ERROR_MESSAGE,
36-
FEATURE_UNAVAILABLE_ERROR_MESSAGE,
37-
PASSWORDLESS_ERROR_MESSAGES
38-
} from '@salesforce/retail-react-app/app/constants'
35+
getPasswordlessErrorMessage,
36+
getPasswordResetErrorMessage
37+
} from '@salesforce/retail-react-app/app/utils/auth-utils'
38+
import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants'
3939
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
4040
import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous'
4141
import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset'
@@ -108,9 +108,7 @@ export const AuthModal = ({
108108
})
109109
setCurrentView(EMAIL_VIEW)
110110
} catch (error) {
111-
const message = PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message))
112-
? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE)
113-
: formatMessage(API_ERROR_MESSAGE)
111+
const message = formatMessage(getPasswordlessErrorMessage(error.message))
114112
form.setError('global', {type: 'manual', message})
115113
}
116114
}
@@ -185,10 +183,7 @@ export const AuthModal = ({
185183
try {
186184
await getPasswordResetToken(data.email)
187185
} catch (e) {
188-
const message =
189-
e.response?.status === 400
190-
? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE)
191-
: formatMessage(API_ERROR_MESSAGE)
186+
const message = formatMessage(getPasswordResetErrorMessage(e.message))
192187
form.setError('global', {type: 'manual', message})
193188
}
194189
},

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({
7070
getConfig: jest.fn()
7171
}))
7272

73+
const mockGetPasswordResetToken = jest.fn()
74+
jest.mock('@salesforce/retail-react-app/app/hooks/use-password-reset', () => {
75+
const originalModule = jest.requireActual(
76+
'@salesforce/retail-react-app/app/hooks/use-password-reset'
77+
)
78+
return {
79+
usePasswordReset: jest.fn(() => ({
80+
...originalModule,
81+
getPasswordResetToken: mockGetPasswordResetToken
82+
}))
83+
}
84+
})
85+
7386
let authModal = undefined
7487
const MockedComponent = (props) => {
7588
const {initialView, isPasswordlessEnabled = false} = props
@@ -357,6 +370,45 @@ describe('Passwordless enabled', () => {
357370
callbackURI: 'https://callback.com/passwordless?redirectUrl=/'
358371
})
359372
})
373+
374+
test.each([
375+
['no callback_uri is registered for client', 'This feature is not currently available.'],
376+
[
377+
'Too many login requests were made. Please try again later.',
378+
'Too many requests. For your security, please wait 10 minutes before trying again.'
379+
],
380+
['unexpected error message', 'Something went wrong. Try again!']
381+
])(
382+
'displays correct error message when passwordless login fails with "%s"',
383+
async (apiErrorMessage, expectedMessage) => {
384+
const {user} = renderWithProviders(<MockedComponent isPasswordlessEnabled={true} />)
385+
const validEmail = 'test@salesforce.com'
386+
387+
// Mock the error
388+
mockAuthHelperFunctions[
389+
AuthHelpers.AuthorizePasswordless
390+
].mutateAsync.mockImplementation(() => {
391+
throw new Error(apiErrorMessage)
392+
})
393+
394+
// open the modal
395+
const trigger = screen.getByText(/open modal/i)
396+
await user.click(trigger)
397+
398+
await waitFor(() => {
399+
expect(screen.getByText(/Continue/i)).toBeInTheDocument()
400+
})
401+
402+
// enter email and submit
403+
await user.type(screen.getByLabelText('Email'), validEmail)
404+
await user.click(screen.getByText(/Continue/i))
405+
406+
// Verify error message is displayed
407+
await waitFor(() => {
408+
expect(screen.getByText(expectedMessage)).toBeInTheDocument()
409+
})
410+
}
411+
)
360412
})
361413

362414
// TODO: Fix flaky/broken test
@@ -592,4 +644,43 @@ describe('Reset password', function () {
592644
// check that the modal is closed
593645
expect(authModal.isOpen).toBe(false)
594646
})
647+
648+
test.each([
649+
['no callback_uri is registered for client', 'This feature is not currently available.'],
650+
[
651+
'Too many password reset requests were made. Please try again later.',
652+
'Too many requests. For your security, please wait 10 minutes before trying again.'
653+
],
654+
['unexpected error message', 'Something went wrong. Try again!']
655+
])(
656+
'displays correct error message when password reset fails with "%s"',
657+
async (apiErrorMessage, expectedMessage) => {
658+
// Mock getPasswordResetToken to throw error
659+
mockGetPasswordResetToken.mockRejectedValue(new Error(apiErrorMessage))
660+
661+
const {user} = renderWithProviders(<MockedComponent initialView="password" />, {
662+
wrapperProps: {
663+
bypassAuth: false
664+
}
665+
})
666+
667+
// open the modal
668+
const trigger = screen.getByText(/open modal/i)
669+
await user.click(trigger)
670+
671+
// Wait for password reset form
672+
let resetPwForm = await screen.findByTestId('sf-auth-modal-form')
673+
expect(resetPwForm).toBeInTheDocument()
674+
const withinForm = within(resetPwForm)
675+
676+
// Enter email and submit
677+
await user.type(withinForm.getByLabelText('Email'), 'foo@test.com')
678+
await user.click(withinForm.getByText(/reset password/i))
679+
680+
// Verify error message is displayed
681+
await waitFor(() => {
682+
expect(withinForm.getByText(expectedMessage)).toBeInTheDocument()
683+
})
684+
}
685+
)
595686
})

packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,7 @@ import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origi
4545
import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react'
4646
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
4747
import {buildAbsoluteUrl} from '@salesforce/retail-react-app/app/utils/url'
48-
import {
49-
API_ERROR_MESSAGE,
50-
FEATURE_UNAVAILABLE_ERROR_MESSAGE,
51-
PASSWORDLESS_ERROR_MESSAGES
52-
} from '@salesforce/retail-react-app/app/constants'
48+
import {getPasswordlessErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils'
5349

5450
const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, idps = []}) => {
5551
const {formatMessage} = useIntl()
@@ -94,9 +90,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id
9490
setAuthModalView(EMAIL_VIEW)
9591
authModal.onOpen()
9692
} catch (error) {
97-
const message = PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message))
98-
? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE)
99-
: formatMessage(API_ERROR_MESSAGE)
93+
const message = formatMessage(getPasswordlessErrorMessage(error.message))
10094
setError(message)
10195
}
10296
}

0 commit comments

Comments
 (0)