diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index ad58c918fd..e6bb618451 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -4,6 +4,7 @@ - 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) - 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) - 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) +- 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) ## v4.3.0 (Dec 17, 2025) diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts index 038ba645c2..32aba6aad3 100644 --- a/packages/commerce-sdk-react/src/auth/index.test.ts +++ b/packages/commerce-sdk-react/src/auth/index.test.ts @@ -1066,7 +1066,11 @@ describe('Auth', () => { const getPasswordResetTokenSpy = jest.spyOn((auth as any).client, 'getPasswordResetToken') getPasswordResetTokenSpy.mockResolvedValueOnce(mockResponse) - await auth.getPasswordResetToken({user_id: 'user@example.com'} as any) + await auth.getPasswordResetToken({ + user_id: 'user@example.com', + mode: 'email', + channel_id: 'channel_id' + }) expect(getPasswordResetTokenSpy).toHaveBeenCalled() const callArgs = getPasswordResetTokenSpy.mock.calls[0][0] as any @@ -1074,6 +1078,21 @@ describe('Auth', () => { expect(callArgs.headers.Authorization).toContain('Basic ') }) + test('getPasswordResetToken throws error on non-200 response', async () => { + const auth = new Auth(configSLASPrivate) + + const mockErrorResponse = { + status: 400, + json: jest.fn().mockResolvedValue({message: 'Invalid request'}) + } + const getPasswordResetTokenSpy = jest.spyOn((auth as any).client, 'getPasswordResetToken') + getPasswordResetTokenSpy.mockReturnValueOnce(mockErrorResponse) + + await expect( + auth.getPasswordResetToken({user_id: 'userid', mode: 'email', channel_id: 'channel_id'}) + ).rejects.toThrow('400 Invalid request') + }) + test.each([ [ 'with all parameters specified', diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index 8f8e30599a..a604166c1a 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1352,7 +1352,11 @@ class Auth { )}` } - const res = await slasClient.getPasswordResetToken(options) + const res = await slasClient.getPasswordResetToken(options, true) + if (res && res.status !== 200) { + const errorData = await res.json() + throw new Error(`${res.status} ${String(errorData.message)}`) + } return res } diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 9e88a80dc0..3cf4708bca 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,7 +1,7 @@ ## v8.4.0-dev (Dec 17, 2025) - [Feature] Add `fuzzyPathMatching` to reduce computational overhead of route generation at time of application load [#3530](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3530) - [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) -- 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) +- 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) - Update "Continue Securely" button text to "Continue" for passwordless login [#3556](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3556) ## v8.3.0 (Dec 17, 2025) diff --git a/packages/template-retail-react-app/app/components/email-confirmation/index.jsx b/packages/template-retail-react-app/app/components/email-confirmation/index.jsx index 91f65abddd..dde4cba95f 100644 --- a/packages/template-retail-react-app/app/components/email-confirmation/index.jsx +++ b/packages/template-retail-react-app/app/components/email-confirmation/index.jsx @@ -8,7 +8,13 @@ import React from 'react' import PropTypes from 'prop-types' import {FormattedMessage} from 'react-intl' -import {Button, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' +import { + Button, + Stack, + Text, + Alert, + AlertIcon +} from '@salesforce/retail-react-app/app/components/shared/ui' import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' const PasswordlessEmailConfirmation = ({form, submitForm, email = ''}) => { @@ -40,6 +46,14 @@ const PasswordlessEmailConfirmation = ({form, submitForm, email = ''}) => { id="auth_modal.check_email.title.check_your_email" /> + {form.formState.errors?.global && ( + + + + {form.formState.errors.global.message} + + + )} renderWithProviders() expect(screen.getByText(email)).toBeInTheDocument() }) + +test('displays error message when form has global error', () => { + const WrapperWithError = () => { + const form = useForm() + const formWithError = { + ...form, + formState: { + ...form.formState, + errors: { + global: {message: 'test error'} + } + } + } + return + } + renderWithProviders() + expect(screen.getByText('test error')).toBeInTheDocument() +}) diff --git a/packages/template-retail-react-app/app/constants.js b/packages/template-retail-react-app/app/constants.js index f5045382c6..4765905116 100644 --- a/packages/template-retail-react-app/app/constants.js +++ b/packages/template-retail-react-app/app/constants.js @@ -251,12 +251,6 @@ export const LOGIN_TYPES = { SOCIAL: 'social' } -export const PASSWORDLESS_ERROR_MESSAGES = [ - /callback_uri doesn't match/i, - /passwordless permissions error/i, - /client secret is not provided/i -] - export const INVALID_TOKEN_ERROR = /invalid token/i /** 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 6d828669ad..00672a1522 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 @@ -32,10 +32,10 @@ import RegisterForm from '@salesforce/retail-react-app/app/components/register' import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' import {noop} from '@salesforce/retail-react-app/app/utils/utils' import { - API_ERROR_MESSAGE, - FEATURE_UNAVAILABLE_ERROR_MESSAGE, - PASSWORDLESS_ERROR_MESSAGES -} from '@salesforce/retail-react-app/app/constants' + getPasswordlessErrorMessage, + getPasswordResetErrorMessage +} from '@salesforce/retail-react-app/app/utils/auth-utils' +import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset' @@ -108,9 +108,7 @@ export const AuthModal = ({ }) setCurrentView(EMAIL_VIEW) } catch (error) { - const message = PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message)) - ? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE) - : formatMessage(API_ERROR_MESSAGE) + const message = formatMessage(getPasswordlessErrorMessage(error.message)) form.setError('global', {type: 'manual', message}) } } @@ -185,10 +183,7 @@ export const AuthModal = ({ try { await getPasswordResetToken(data.email) } catch (e) { - const message = - e.response?.status === 400 - ? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE) - : formatMessage(API_ERROR_MESSAGE) + const message = formatMessage(getPasswordResetErrorMessage(e.message)) form.setError('global', {type: 'manual', message}) } }, 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 6325045d0b..a2cc2cd6aa 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 @@ -70,6 +70,19 @@ jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ getConfig: jest.fn() })) +const mockGetPasswordResetToken = jest.fn() +jest.mock('@salesforce/retail-react-app/app/hooks/use-password-reset', () => { + const originalModule = jest.requireActual( + '@salesforce/retail-react-app/app/hooks/use-password-reset' + ) + return { + usePasswordReset: jest.fn(() => ({ + ...originalModule, + getPasswordResetToken: mockGetPasswordResetToken + })) + } +}) + let authModal = undefined const MockedComponent = (props) => { const {initialView, isPasswordlessEnabled = false} = props @@ -357,6 +370,45 @@ describe('Passwordless enabled', () => { callbackURI: 'https://callback.com/passwordless?redirectUrl=/' }) }) + + test.each([ + ['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.' + ], + ['unexpected error message', 'Something went wrong. Try again!'] + ])( + 'displays correct error message when passwordless login fails with "%s"', + async (apiErrorMessage, expectedMessage) => { + const {user} = renderWithProviders() + const validEmail = 'test@salesforce.com' + + // Mock the error + mockAuthHelperFunctions[ + AuthHelpers.AuthorizePasswordless + ].mutateAsync.mockImplementation(() => { + throw new Error(apiErrorMessage) + }) + + // open the modal + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/Continue/i)).toBeInTheDocument() + }) + + // enter email and submit + await user.type(screen.getByLabelText('Email'), validEmail) + await user.click(screen.getByText(/Continue/i)) + + // Verify error message is displayed + await waitFor(() => { + expect(screen.getByText(expectedMessage)).toBeInTheDocument() + }) + } + ) }) // TODO: Fix flaky/broken test @@ -592,4 +644,43 @@ describe('Reset password', function () { // check that the modal is closed expect(authModal.isOpen).toBe(false) }) + + test.each([ + ['no callback_uri is registered for client', 'This feature is not currently available.'], + [ + 'Too many password reset requests were made. Please try again later.', + 'Too many requests. For your security, please wait 10 minutes before trying again.' + ], + ['unexpected error message', 'Something went wrong. Try again!'] + ])( + 'displays correct error message when password reset fails with "%s"', + async (apiErrorMessage, expectedMessage) => { + // Mock getPasswordResetToken to throw error + mockGetPasswordResetToken.mockRejectedValue(new Error(apiErrorMessage)) + + const {user} = renderWithProviders(, { + wrapperProps: { + bypassAuth: false + } + }) + + // open the modal + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + // Wait for password reset form + let resetPwForm = await screen.findByTestId('sf-auth-modal-form') + expect(resetPwForm).toBeInTheDocument() + const withinForm = within(resetPwForm) + + // Enter email and submit + await user.type(withinForm.getByLabelText('Email'), 'foo@test.com') + await user.click(withinForm.getByText(/reset password/i)) + + // Verify error message is displayed + await waitFor(() => { + expect(withinForm.getByText(expectedMessage)).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 c49ad3743b..16f4d21d5a 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 @@ -45,11 +45,7 @@ import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origi import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {buildAbsoluteUrl} from '@salesforce/retail-react-app/app/utils/url' -import { - API_ERROR_MESSAGE, - FEATURE_UNAVAILABLE_ERROR_MESSAGE, - PASSWORDLESS_ERROR_MESSAGES -} from '@salesforce/retail-react-app/app/constants' +import {getPasswordlessErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils' const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, idps = []}) => { const {formatMessage} = useIntl() @@ -94,9 +90,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id setAuthModalView(EMAIL_VIEW) authModal.onOpen() } catch (error) { - const message = PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message)) - ? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE) - : formatMessage(API_ERROR_MESSAGE) + const message = formatMessage(getPasswordlessErrorMessage(error.message)) setError(message) } } 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 f000ce5c6b..2330366bfe 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -29,12 +29,11 @@ import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/comp import { API_ERROR_MESSAGE, INVALID_TOKEN_ERROR, - INVALID_TOKEN_ERROR_MESSAGE, - FEATURE_UNAVAILABLE_ERROR_MESSAGE, - PASSWORDLESS_ERROR_MESSAGES + INVALID_TOKEN_ERROR_MESSAGE } from '@salesforce/retail-react-app/app/constants' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' import {isServer, noop} from '@salesforce/retail-react-app/app/utils/utils' +import {getPasswordlessErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' const LOGIN_ERROR_MESSAGE = defineMessage({ @@ -113,9 +112,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { setPasswordlessLoginEmail(email) setCurrentView(EMAIL_VIEW) } catch (error) { - const message = PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message)) - ? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE) - : formatMessage(API_ERROR_MESSAGE) + const message = formatMessage(getPasswordlessErrorMessage(error.message)) form.setError('global', {type: 'manual', message}) } } 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 1578a485a0..f9c7ac1c37 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 @@ -334,9 +334,6 @@ describe('Passwordless login tests', () => { beforeEach(() => { // Clear the mock before each test mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockClear() - }) - - test('allows passwordless login', async () => { getConfig.mockReturnValue({ app: { ...mockConfig.app, @@ -348,6 +345,9 @@ describe('Passwordless login tests', () => { } } }) + }) + + test('allows passwordless login', async () => { const {user} = renderWithProviders(, { wrapperProps: { siteAlias: 'uk', @@ -391,4 +391,37 @@ describe('Passwordless login tests', () => { mode: 'email' }) }) + + test.each([ + [ + "callback_uri doesn't match the registered callbacks", + 'This feature is not currently available.' + ], + [ + 'PasswordLess Permissions Error for clientId:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'This feature is not currently available.' + ], + ['client secret is not provided', 'This feature is not currently available.'], + ['unexpected error message', 'Something went wrong. Try again!'] + ])( + 'displays correct error message when passwordless login fails with "%s"', + async (apiErrorMessage, expectedMessage) => { + mockAuthHelperFunctions[ + AuthHelpers.AuthorizePasswordless + ].mutateAsync.mockImplementation(() => { + throw new Error(apiErrorMessage) + }) + const {user} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockConfig.app, + bypassAuth: false + } + }) + await user.type(screen.getByLabelText('Email'), 'customer@test.com') + await user.click(screen.getByRole('button', {name: /Continue/i})) + expect(screen.getByText(expectedMessage)).toBeInTheDocument() + } + ) }) diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.jsx index e467b7c3af..d44410b804 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.jsx @@ -19,10 +19,7 @@ import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import {useLocation} from 'react-router-dom' import {useRouteMatch} from 'react-router' import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset' -import { - API_ERROR_MESSAGE, - FEATURE_UNAVAILABLE_ERROR_MESSAGE -} from '@salesforce/retail-react-app/app/constants' +import {getPasswordResetErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils' const ResetPassword = () => { const {formatMessage} = useIntl() @@ -38,10 +35,7 @@ const ResetPassword = () => { try { await getPasswordResetToken(email) } catch (e) { - const message = - e.response?.status === 400 - ? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE) - : formatMessage(API_ERROR_MESSAGE) + const message = formatMessage(getPasswordResetErrorMessage(e.message)) form.setError('global', {type: 'manual', message}) } } diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx index eaf2d316aa..9ff499fa84 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx @@ -89,6 +89,38 @@ test('Allows customer to generate password token', async () => { }) }) +test.each([ + ['no callback_uri is registered for client', 'This feature is not currently available.'], + [ + 'Too many password reset requests were made. Please try again later.', + 'Too many requests. For your security, please wait 10 minutes before trying again.' + ], + ['unexpected error message', 'Something went wrong. Try again!'] +])( + 'displays correct error message when password reset fails with "%s"', + async (apiErrorMessage, expectedMessage) => { + global.server.use( + rest.post('*/oauth2/password/reset', (req, res, ctx) => + res(ctx.delay(0), ctx.status(400), ctx.json({message: apiErrorMessage})) + ) + ) + // render our test component + const {user} = renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) + + // enter credentials and submit + await user.type(await screen.findByLabelText('Email'), 'foo@test.com') + await user.click( + within(await screen.findByTestId('sf-auth-modal-form')).getByText(/reset password/i) + ) + + await waitFor(() => { + expect(screen.getByText(expectedMessage)).toBeInTheDocument() + }) + } +) + test.each([ ['base path', '/reset-password-landing'], ['path with site and locale', '/uk/en-GB/reset-password-landing'] 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 b87abefb3e..52df882aac 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 @@ -1881,6 +1881,12 @@ "value": "Something went wrong. Try again!" } ], + "global.error.too_many_requests": [ + { + "type": 0, + "value": "Too many requests. For your security, please wait 10 minutes before trying again." + } + ], "global.info.added_to_wishlist": [ { "type": 1, 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 b87abefb3e..52df882aac 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 @@ -1881,6 +1881,12 @@ "value": "Something went wrong. Try again!" } ], + "global.error.too_many_requests": [ + { + "type": 0, + "value": "Too many requests. For your security, please wait 10 minutes before trying again." + } + ], "global.info.added_to_wishlist": [ { "type": 1, 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 41031efa05..54587b6448 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 @@ -3857,6 +3857,20 @@ "value": "]" } ], + "global.error.too_many_requests": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŧǿǿǿǿ ḿȧȧƞẏ řḗḗɋŭŭḗḗşŧş. Ƒǿǿř ẏǿǿŭŭř şḗḗƈŭŭřīŧẏ, ƥŀḗḗȧȧşḗḗ ẇȧȧīŧ 10 ḿīƞŭŭŧḗḗş ƀḗḗƒǿǿřḗḗ ŧřẏīƞɠ ȧȧɠȧȧīƞ." + }, + { + "type": 0, + "value": "]" + } + ], "global.info.added_to_wishlist": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/utils/auth-utils.js b/packages/template-retail-react-app/app/utils/auth-utils.js new file mode 100644 index 0000000000..3deb9b8922 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/auth-utils.js @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * 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 {defineMessage} from 'react-intl' +import { + API_ERROR_MESSAGE, + FEATURE_UNAVAILABLE_ERROR_MESSAGE +} from '@salesforce/retail-react-app/app/constants' + +export const TOO_MANY_REQUESTS_ERROR_MESSAGE = defineMessage({ + defaultMessage: + 'Too many requests. For your security, please wait 10 minutes before trying again.', + id: 'global.error.too_many_requests' +}) + +// Shared error patterns for token-based auth features (passwordless login, password reset) +const TOKEN_BASED_AUTH_FEATURE_UNAVAILABLE_ERRORS = [ + /no callback_uri is registered/i, + /callback_uri doesn't match/i, + /monthly quota/i +] + +const PASSWORDLESS_FEATURE_UNAVAILABLE_ERRORS = [ + ...TOKEN_BASED_AUTH_FEATURE_UNAVAILABLE_ERRORS, + /passwordless permissions error/i, + /client secret is not provided/i +] + +const PASSWORD_RESET_FEATURE_UNAVAILABLE_ERRORS = TOKEN_BASED_AUTH_FEATURE_UNAVAILABLE_ERRORS + +const TOO_MANY_REQUESTS_ERROR = /too many .* requests/i + +/** + * Maps an error message to the appropriate user-friendly error message descriptor + * for passwordless login feature errors. + * + * @param {string} errorMessage - The error message from the API + * @returns {Object} - The message descriptor object (from defineMessage) that can be passed to formatMessage + */ +export const getPasswordlessErrorMessage = (errorMessage) => { + if (PASSWORDLESS_FEATURE_UNAVAILABLE_ERRORS.some((msg) => msg.test(errorMessage))) { + return FEATURE_UNAVAILABLE_ERROR_MESSAGE + } + if (TOO_MANY_REQUESTS_ERROR.test(errorMessage)) { + return TOO_MANY_REQUESTS_ERROR_MESSAGE + } + return API_ERROR_MESSAGE +} + +/** + * Maps an error message to the appropriate user-friendly error message descriptor + * for password reset feature errors. + * + * @param {string} errorMessage - The error message from the API + * @returns {Object} - The message descriptor object (from defineMessage) that can be passed to formatMessage + */ +export const getPasswordResetErrorMessage = (errorMessage) => { + if (PASSWORD_RESET_FEATURE_UNAVAILABLE_ERRORS.some((msg) => msg.test(errorMessage))) { + return FEATURE_UNAVAILABLE_ERROR_MESSAGE + } + if (TOO_MANY_REQUESTS_ERROR.test(errorMessage)) { + return TOO_MANY_REQUESTS_ERROR_MESSAGE + } + return API_ERROR_MESSAGE +} diff --git a/packages/template-retail-react-app/app/utils/auth-utils.test.js b/packages/template-retail-react-app/app/utils/auth-utils.test.js new file mode 100644 index 0000000000..2841957bb8 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/auth-utils.test.js @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * 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 { + API_ERROR_MESSAGE, + FEATURE_UNAVAILABLE_ERROR_MESSAGE +} from '@salesforce/retail-react-app/app/constants' +import { + getPasswordlessErrorMessage, + getPasswordResetErrorMessage, + TOO_MANY_REQUESTS_ERROR_MESSAGE +} from '@salesforce/retail-react-app/app/utils/auth-utils' + +describe('getPasswordlessErrorMessage', () => { + test.each([ + ['no callback_uri is registered for client', FEATURE_UNAVAILABLE_ERROR_MESSAGE], + ["callback_uri doesn't match the registered callbacks", FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [ + 'Monthly quota for passwordless login mode email has been exceeded', + FEATURE_UNAVAILABLE_ERROR_MESSAGE + ], + [ + 'PasswordLess Permissions Error for clientId:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + FEATURE_UNAVAILABLE_ERROR_MESSAGE + ], + ['client secret is not provided', FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [ + 'Too many login requests were made. Please try again later.', + TOO_MANY_REQUESTS_ERROR_MESSAGE + ], + ['unexpected error message', API_ERROR_MESSAGE], + [null, API_ERROR_MESSAGE] + ])( + 'maps passwordless error "%s" to the correct message descriptor', + (errorMessage, expectedMessage) => { + expect(getPasswordlessErrorMessage(errorMessage)).toBe(expectedMessage) + } + ) +}) + +describe('getPasswordResetErrorMessage', () => { + test.each([ + ['no callback_uri is registered for client', FEATURE_UNAVAILABLE_ERROR_MESSAGE], + ["callback_uri doesn't match the registered callbacks", FEATURE_UNAVAILABLE_ERROR_MESSAGE], + [ + 'Monthly quota for passwordless login mode email has been exceeded', + FEATURE_UNAVAILABLE_ERROR_MESSAGE + ], + [ + 'Too many password reset requests were made. Please try again later.', + TOO_MANY_REQUESTS_ERROR_MESSAGE + ], + ['unexpected error message', API_ERROR_MESSAGE], + [null, API_ERROR_MESSAGE] + ])( + 'maps password reset error "%s" to the correct message descriptor', + (errorMessage, expectedMessage) => { + expect(getPasswordResetErrorMessage(errorMessage)).toBe(expectedMessage) + } + ) +}) diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 4adcc0f0c5..edaa4427e2 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -744,6 +744,9 @@ "global.error.something_went_wrong": { "defaultMessage": "Something went wrong. Try again!" }, + "global.error.too_many_requests": { + "defaultMessage": "Too many requests. For your security, please wait 10 minutes before trying again." + }, "global.info.added_to_wishlist": { "defaultMessage": "{quantity} {quantity, plural, one {item} other {items}} added to wishlist" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 4adcc0f0c5..edaa4427e2 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -744,6 +744,9 @@ "global.error.something_went_wrong": { "defaultMessage": "Something went wrong. Try again!" }, + "global.error.too_many_requests": { + "defaultMessage": "Too many requests. For your security, please wait 10 minutes before trying again." + }, "global.info.added_to_wishlist": { "defaultMessage": "{quantity} {quantity, plural, one {item} other {items}} added to wishlist" },