Skip to content

Commit ea529fd

Browse files
hajinsuha1syadupathi-sf
authored andcommitted
@W-20448811 Shopper can manually enter OTP in login flows (#3554)
- introduce a new `login.tokenLength` configuration in default.js that determines the number of input fields to show in the `OtpAuthModal` - update `useAuthModal` and login page to open the `OtpAuthModal` after user clicks "Continue Securely" - update `OtpAuthModal` to include a prop for hiding the `Checkout as Guest` button - update `PasswordlessLogin` component to not hide elements after form has been successfully submitted
1 parent a76270d commit ea529fd

File tree

26 files changed

+718
-263
lines changed

26 files changed

+718
-263
lines changed

e2e/tests/desktop/extra-features.spec.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,49 @@ test('Verify passwordless login request', async ({page}) => {
3838
'**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login'
3939
)
4040

41+
// Verify the passwordless login request
4142
expect(interceptedRequest).toBeTruthy()
4243
expect(interceptedRequest.method()).toBe('POST')
4344

44-
const postData = interceptedRequest.postData()
45+
let postData = interceptedRequest.postData()
4546
expect(postData).toBeTruthy()
4647

47-
const params = new URLSearchParams(postData)
48+
let params = new URLSearchParams(postData)
4849

4950
expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL)
5051
expect(params.get('mode')).toBe('email')
5152
expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE)
53+
54+
await page.route(
55+
'**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token',
56+
(route) => {
57+
interceptedRequest = route.request()
58+
route.continue()
59+
}
60+
)
61+
62+
// Wait for OTP input fields to appear and fill the 8-digit code
63+
const otpCode = '12345678' // Replace with actual OTP code
64+
const otpInputs = page.locator('input[inputmode="numeric"][maxlength="1"]')
65+
await otpInputs.first().waitFor()
66+
67+
// Fill each input field with one digit
68+
for (let i = 0; i < 8; i++) {
69+
await otpInputs.nth(i).fill(otpCode[i])
70+
}
71+
72+
await page.waitForResponse(
73+
'**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token'
74+
)
75+
76+
// Verify the passwordless login token request
77+
expect(interceptedRequest).toBeTruthy()
78+
expect(interceptedRequest.method()).toBe('POST')
79+
postData = interceptedRequest.postData()
80+
expect(postData).toBeTruthy()
81+
params = new URLSearchParams(postData)
82+
expect(params.get('pwdless_login_token')).toBe(otpCode)
83+
expect(params.get('hint')).toBe('pwdless_login')
5284
})
5385

5486
test('Verify password reset request', async ({page}) => {

e2e/tests/mobile/extra-features.spec.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,49 @@ test('Verify passwordless login request on mobile', async ({page}) => {
4040
'**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login'
4141
)
4242

43+
// Verify the passwordless login request
4344
expect(interceptedRequest).toBeTruthy()
4445
expect(interceptedRequest.method()).toBe('POST')
4546

46-
const postData = interceptedRequest.postData()
47+
let postData = interceptedRequest.postData()
4748
expect(postData).toBeTruthy()
4849

49-
const params = new URLSearchParams(postData)
50+
let params = new URLSearchParams(postData)
5051

5152
expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL)
5253
expect(params.get('mode')).toBe('email')
5354
expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE)
55+
56+
await page.route(
57+
'**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token',
58+
(route) => {
59+
interceptedRequest = route.request()
60+
route.continue()
61+
}
62+
)
63+
64+
// Wait for OTP input fields to appear and fill the 8-digit code
65+
const otpCode = '12345678' // Replace with actual OTP code
66+
const otpInputs = page.locator('input[inputmode="numeric"][maxlength="1"]')
67+
await otpInputs.first().waitFor()
68+
69+
// Fill each input field with one digit
70+
for (let i = 0; i < 8; i++) {
71+
await otpInputs.nth(i).fill(otpCode[i])
72+
}
73+
74+
await page.waitForResponse(
75+
'**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token'
76+
)
77+
78+
// Verify the passwordless login token request
79+
expect(interceptedRequest).toBeTruthy()
80+
expect(interceptedRequest.method()).toBe('POST')
81+
postData = interceptedRequest.postData()
82+
expect(postData).toBeTruthy()
83+
params = new URLSearchParams(postData)
84+
expect(params.get('pwdless_login_token')).toBe(otpCode)
85+
expect(params.get('hint')).toBe('pwdless_login')
5486
})
5587

5688
test('Verify password reset request on mobile (extra features enabled)', async ({page}) => {

packages/pwa-kit-create-app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
- Add new One-Click Checkout configuration [#3609](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3609)
33
- Support email mode by default for passwordless login and password reset in a generated app. [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525)
44
- Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630)
5+
- Add `tokenLength` to login configuration [#3554](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3554)
56

67
## v3.15.0 (Dec 17, 2025)
78
- Add new Google Cloud API configuration and Bonus Product configuration [#3523](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3523)

packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
/* eslint-disable @typescript-eslint/no-var-requires */
88
const sites = require('./sites.js')
9-
const {parseSettings} = require('./utils.js')
9+
const {parseSettings, validateOtpTokenLength} = require('./utils.js')
1010

1111
module.exports = {
1212
app: {
@@ -57,6 +57,9 @@ module.exports = {
5757
interpretPlusSignAsSpace: false
5858
},
5959
login: {
60+
// The length of the token for OTP authentication. Used by passwordless login and reset password.
61+
// If the env var `OTP_TOKEN_LENGTH` is set, it will override the config value. Valid values are 6 or 8. Defaults to: 8
62+
tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH),
6063
passwordless: {
6164
// Enables or disables passwordless login for the site. Defaults to: false
6265
{{#if answers.project.demo.enableDemoSettings}}

packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8+
/**
9+
* Valid OTP token lengths supported by the authentication system.
10+
* These values are enforced to ensure compatibility with the OTP verification flow.
11+
*/
12+
const VALID_OTP_TOKEN_LENGTHS = [6, 8]
13+
const DEFAULT_OTP_TOKEN_LENGTH = 8
14+
15+
/**
16+
* Validates and normalizes the OTP token length configuration.
17+
* Throws an error if the token length is invalid.
18+
*
19+
* @param {string|number|undefined} tokenLength - The token length from config or env var
20+
* @returns {number} Validated token length (6 or 8)
21+
* @throws {Error} If tokenLength is invalid (not 6 or 8)
22+
*/
23+
function validateOtpTokenLength(tokenLength) {
24+
// If undefined, return default
25+
if (tokenLength === undefined) {
26+
return DEFAULT_OTP_TOKEN_LENGTH
27+
}
28+
29+
// Parse to number (handles string numbers like "6" or "8")
30+
const parsedLength = Number(tokenLength)
31+
32+
// Check if it's one of the allowed values (includes() will return false for NaN or invalid numbers)
33+
if (!VALID_OTP_TOKEN_LENGTHS.includes(parsedLength)) {
34+
throw new Error(
35+
`Invalid OTP token length: ${tokenLength}. Valid values are ${VALID_OTP_TOKEN_LENGTHS.join(
36+
' or '
37+
)}. `
38+
)
39+
}
40+
41+
return parsedLength
42+
}
43+
844
/**
945
* Safely parses settings from either a JSON string or object
1046
* @param {string|object} settings - The settings
@@ -30,5 +66,8 @@ function parseSettings(settings) {
3066
}
3167

3268
module.exports = {
33-
parseSettings
69+
parseSettings,
70+
validateOtpTokenLength,
71+
DEFAULT_OTP_TOKEN_LENGTH,
72+
VALID_OTP_TOKEN_LENGTHS
3473
}

packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
/* eslint-disable @typescript-eslint/no-var-requires */
88
const sites = require('./sites.js')
9-
const {parseSettings} = require('./utils.js')
9+
const {parseSettings, validateOtpTokenLength} = require('./utils.js')
1010

1111
module.exports = {
1212
app: {
@@ -57,6 +57,9 @@ module.exports = {
5757
interpretPlusSignAsSpace: false
5858
},
5959
login: {
60+
// The length of the token for OTP authentication. Used by passwordless login and reset password.
61+
// If the env var `OTP_TOKEN_LENGTH` is set, it will override the config value. Valid values are 6 or 8. Defaults to: 8
62+
tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH),
6063
passwordless: {
6164
// Enables or disables passwordless login for the site. Defaults to: false
6265
{{#if answers.project.demo.enableDemoSettings}}

packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8+
/**
9+
* Valid OTP token lengths supported by the authentication system.
10+
* These values are enforced to ensure compatibility with the OTP verification flow.
11+
*/
12+
const VALID_OTP_TOKEN_LENGTHS = [6, 8]
13+
const DEFAULT_OTP_TOKEN_LENGTH = 8
14+
15+
/**
16+
* Validates and normalizes the OTP token length configuration.
17+
* Throws an error if the token length is invalid.
18+
*
19+
* @param {string|number|undefined} tokenLength - The token length from config or env var
20+
* @returns {number} Validated token length (6 or 8)
21+
* @throws {Error} If tokenLength is invalid (not 6 or 8)
22+
*/
23+
function validateOtpTokenLength(tokenLength) {
24+
// If undefined, return default
25+
if (tokenLength === undefined) {
26+
return DEFAULT_OTP_TOKEN_LENGTH
27+
}
28+
29+
// Parse to number (handles string numbers like "6" or "8")
30+
const parsedLength = Number(tokenLength)
31+
32+
// Check if it's one of the allowed values (includes() will return false for NaN or invalid numbers)
33+
if (!VALID_OTP_TOKEN_LENGTHS.includes(parsedLength)) {
34+
throw new Error(
35+
`Invalid OTP token length: ${tokenLength}. Valid values are ${VALID_OTP_TOKEN_LENGTHS.join(
36+
' or '
37+
)}. `
38+
)
39+
}
40+
41+
return parsedLength
42+
}
43+
844
/**
945
* Safely parses settings from either a JSON string or object
1046
* @param {string|object} settings - The settings
@@ -30,5 +66,8 @@ function parseSettings(settings) {
3066
}
3167

3268
module.exports = {
33-
parseSettings
69+
parseSettings,
70+
validateOtpTokenLength,
71+
DEFAULT_OTP_TOKEN_LENGTH,
72+
VALID_OTP_TOKEN_LENGTHS
3473
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- Update "Continue Securely" button text to "Continue" for passwordless login [#3556](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3556)
1212
- Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630)
1313
- [BREAKING] Remove unused absoluteUrl util from retail react app [#3633](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3633)
14+
- Allow shopper to manually input OTP during passwordless login [#3554](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3554)
1415

1516
## v8.3.0 (Dec 17, 2025)
1617
- [Bugfix] Fix Forgot Password link not working from Account Profile password update form [#3493](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3493)

packages/template-retail-react-app/app/components/otp-auth/index.jsx

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {useUsid, useCustomerType, useDNT} from '@salesforce/commerce-sdk-react'
2727
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
2828
import {useOtpInputs} from '@salesforce/retail-react-app/app/hooks/use-otp-inputs'
2929
import {useCountdown} from '@salesforce/retail-react-app/app/hooks/use-countdown'
30+
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
3031

3132
const OtpAuth = ({
3233
isOpen,
@@ -36,9 +37,20 @@ const OtpAuth = ({
3637
handleOtpVerification,
3738
onCheckoutAsGuest,
3839
isGuestRegistration = false,
40+
hideCheckoutAsGuestButton = false,
3941
resendCooldownDuration = 30
4042
}) => {
41-
const OTP_LENGTH = 8
43+
const {tokenLength} = getConfig().app.login
44+
const parsedLength = Number(tokenLength)
45+
const isValidOtpLength = parsedLength === 6 || parsedLength === 8
46+
const OTP_LENGTH = isValidOtpLength ? parsedLength : 8
47+
48+
if (!isValidOtpLength) {
49+
console.warn(
50+
`Invalid OTP token length: ${tokenLength}. Expected 6 or 8. Defaulting to ${OTP_LENGTH}.`
51+
)
52+
}
53+
4254
const [isVerifying, setIsVerifying] = useState(false)
4355
const [error, setError] = useState('')
4456
const [resendTimer, setResendTimer] = useCountdown(0)
@@ -279,34 +291,36 @@ const OtpAuth = ({
279291

280292
{/* Buttons */}
281293
<HStack spacing={4} width="100%" justifyContent="flex-end">
282-
<Button
283-
onClick={handleCheckoutAsGuest}
284-
variant="solid"
285-
size="lg"
286-
minWidth={40}
287-
bg="gray.50"
288-
color="gray.800"
289-
fontWeight="bold"
290-
border="none"
291-
_hover={{
292-
bg: 'gray.100'
293-
}}
294-
_active={{
295-
bg: 'gray.200'
296-
}}
297-
>
298-
{isGuestRegistration ? (
299-
<FormattedMessage
300-
defaultMessage="Cancel"
301-
id="otp.button.cancel_guest_registration"
302-
/>
303-
) : (
304-
<FormattedMessage
305-
defaultMessage="Checkout as a Guest"
306-
id="otp.button.checkout_as_guest"
307-
/>
308-
)}
309-
</Button>
294+
{!hideCheckoutAsGuestButton && (
295+
<Button
296+
onClick={handleCheckoutAsGuest}
297+
variant="solid"
298+
size="lg"
299+
minWidth={40}
300+
bg="gray.50"
301+
color="gray.800"
302+
fontWeight="bold"
303+
border="none"
304+
_hover={{
305+
bg: 'gray.100'
306+
}}
307+
_active={{
308+
bg: 'gray.200'
309+
}}
310+
>
311+
{isGuestRegistration ? (
312+
<FormattedMessage
313+
defaultMessage="Cancel"
314+
id="otp.button.cancel_guest_registration"
315+
/>
316+
) : (
317+
<FormattedMessage
318+
defaultMessage="Checkout as a Guest"
319+
id="otp.button.checkout_as_guest"
320+
/>
321+
)}
322+
</Button>
323+
)}
310324

311325
<Button
312326
onClick={handleResend}
@@ -339,6 +353,7 @@ OtpAuth.propTypes = {
339353
handleOtpVerification: PropTypes.func.isRequired,
340354
onCheckoutAsGuest: PropTypes.func,
341355
isGuestRegistration: PropTypes.bool,
356+
hideCheckoutAsGuestButton: PropTypes.bool,
342357
/** Resend cooldown (in seconds). Default 30. */
343358
resendCooldownDuration: PropTypes.number
344359
}

0 commit comments

Comments
 (0)