Skip to content
Merged
21 changes: 20 additions & 1 deletion packages/commerce-sdk-react/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,14 +1066,33 @@ 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
expect(callArgs.headers.Authorization).toBeTruthy()
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',
Expand Down
6 changes: 5 additions & 1 deletion packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`)
}
Comment on lines +1355 to +1359
Copy link
Collaborator Author

@hajinsuha1 hajinsuha1 Jan 14, 2026

Choose a reason for hiding this comment

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

calling getPasswordResetToken(options, true) returns the rawResponse which was need to pass the error message to retail-react-app for determining which error message to display

return res
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''}) => {
Expand Down Expand Up @@ -40,6 +46,14 @@ const PasswordlessEmailConfirmation = ({form, submitForm, email = ''}) => {
id="auth_modal.check_email.title.check_your_email"
/>
</Text>
{form.formState.errors?.global && (
<Alert status="error">
<AlertIcon color="red.500" boxSize={4} />
<Text fontSize="sm" ml={3}>
{form.formState.errors.global.message}
</Text>
</Alert>
)}
Comment on lines +49 to +56
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This has been added to the email confirmation page now that we need to display a "Too many requests" error

Image

<Stack spacing={10}>
<Text align="center" fontSize="md" id="email-confirmation-desc">
<FormattedMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,21 @@ test('renders PasswordlessEmailConfirmation component with passed email', () =>
renderWithProviders(<WrapperComponent email={email} />)
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 <PasswordlessEmailConfirmation form={formWithError} />
}
renderWithProviders(<WrapperWithError />)
expect(screen.getByText('test error')).toBeInTheDocument()
})
6 changes: 0 additions & 6 deletions packages/template-retail-react-app/app/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,6 @@ export const LOGIN_TYPES = {
SOCIAL: 'social'
}

export const PASSWORDLESS_ERROR_MESSAGES = [
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this has been moved to auth-utils.js above and renamed to PASSWORDLESS_FEATURE_UNAVAILABLE_ERRORS as it is only used there

/callback_uri doesn't match/i,
/passwordless permissions error/i,
/client secret is not provided/i
]

export const INVALID_TOKEN_ERROR = /invalid token/i

/**
Expand Down
17 changes: 6 additions & 11 deletions packages/template-retail-react-app/app/hooks/use-auth-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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})
}
}
Expand Down Expand Up @@ -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})
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(<MockedComponent isPasswordlessEnabled={true} />)
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
Expand Down Expand Up @@ -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(<MockedComponent initialView="password" />, {
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()
})
}
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
Expand Down
9 changes: 3 additions & 6 deletions packages/template-retail-react-app/app/pages/login/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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})
}
}
Expand Down
39 changes: 36 additions & 3 deletions packages/template-retail-react-app/app/pages/login/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -348,6 +345,9 @@ describe('Passwordless login tests', () => {
}
}
})
})

test('allows passwordless login', async () => {
const {user} = renderWithProviders(<MockedComponent />, {
wrapperProps: {
siteAlias: 'uk',
Expand Down Expand Up @@ -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(<MockedComponent />, {
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()
}
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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})
}
}
Expand Down
Loading
Loading