diff --git a/e2e/tests/desktop/extra-features.spec.js b/e2e/tests/desktop/extra-features.spec.js index b323e53b2f..b6f7b1e993 100644 --- a/e2e/tests/desktop/extra-features.spec.js +++ b/e2e/tests/desktop/extra-features.spec.js @@ -38,17 +38,49 @@ test('Verify passwordless login request', async ({page}) => { '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login' ) + // Verify the passwordless login request expect(interceptedRequest).toBeTruthy() expect(interceptedRequest.method()).toBe('POST') - const postData = interceptedRequest.postData() + let postData = interceptedRequest.postData() expect(postData).toBeTruthy() - const params = new URLSearchParams(postData) + let params = new URLSearchParams(postData) expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) expect(params.get('mode')).toBe('email') expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) + + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + // Wait for OTP input fields to appear and fill the 8-digit code + const otpCode = '12345678' // Replace with actual OTP code + const otpInputs = page.locator('input[inputmode="numeric"][maxlength="1"]') + await otpInputs.first().waitFor() + + // Fill each input field with one digit + for (let i = 0; i < 8; i++) { + await otpInputs.nth(i).fill(otpCode[i]) + } + + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token' + ) + + // Verify the passwordless login token request + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + params = new URLSearchParams(postData) + expect(params.get('pwdless_login_token')).toBe(otpCode) + expect(params.get('hint')).toBe('pwdless_login') }) test('Verify password reset request', async ({page}) => { diff --git a/e2e/tests/mobile/extra-features.spec.js b/e2e/tests/mobile/extra-features.spec.js index 403ab81e7d..e521f9bd4d 100644 --- a/e2e/tests/mobile/extra-features.spec.js +++ b/e2e/tests/mobile/extra-features.spec.js @@ -40,17 +40,49 @@ test('Verify passwordless login request on mobile', async ({page}) => { '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/login' ) + // Verify the passwordless login request expect(interceptedRequest).toBeTruthy() expect(interceptedRequest.method()).toBe('POST') - const postData = interceptedRequest.postData() + let postData = interceptedRequest.postData() expect(postData).toBeTruthy() - const params = new URLSearchParams(postData) + let params = new URLSearchParams(postData) expect(params.get('user_id')).toBe(config.PWA_E2E_USER_EMAIL) expect(params.get('mode')).toBe('email') expect(params.get('channel_id')).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE) + + await page.route( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token', + (route) => { + interceptedRequest = route.request() + route.continue() + } + ) + + // Wait for OTP input fields to appear and fill the 8-digit code + const otpCode = '12345678' // Replace with actual OTP code + const otpInputs = page.locator('input[inputmode="numeric"][maxlength="1"]') + await otpInputs.first().waitFor() + + // Fill each input field with one digit + for (let i = 0; i < 8; i++) { + await otpInputs.nth(i).fill(otpCode[i]) + } + + await page.waitForResponse( + '**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/passwordless/token' + ) + + // Verify the passwordless login token request + expect(interceptedRequest).toBeTruthy() + expect(interceptedRequest.method()).toBe('POST') + postData = interceptedRequest.postData() + expect(postData).toBeTruthy() + params = new URLSearchParams(postData) + expect(params.get('pwdless_login_token')).toBe(otpCode) + expect(params.get('hint')).toBe('pwdless_login') }) test('Verify password reset request on mobile (extra features enabled)', async ({page}) => { diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index 69fa794d3d..7c9187eb72 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -2,6 +2,7 @@ - Add new One-Click Checkout configuration [#3609](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3609) - Support email mode by default for passwordless login and password reset in a generated app. [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525) - Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630) +- Add `tokenLength` to login configuration [#3554](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3554) ## v3.15.0 (Dec 17, 2025) - Add new Google Cloud API configuration and Bonus Product configuration [#3523](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3523) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index 4fd8c42ff8..d76b6c4c88 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -6,7 +6,7 @@ */ /* eslint-disable @typescript-eslint/no-var-requires */ const sites = require('./sites.js') -const {parseSettings} = require('./utils.js') +const {parseSettings, validateOtpTokenLength} = require('./utils.js') module.exports = { app: { @@ -57,6 +57,9 @@ module.exports = { interpretPlusSignAsSpace: false }, login: { + // The length of the token for OTP authentication. Used by passwordless login and reset password. + // If the env var `OTP_TOKEN_LENGTH` is set, it will override the config value. Valid values are 6 or 8. Defaults to: 8 + tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH), passwordless: { // Enables or disables passwordless login for the site. Defaults to: false {{#if answers.project.demo.enableDemoSettings}} diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js b/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js index cc1b17e5c7..2aad9cfd7a 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/utils.js @@ -5,6 +5,42 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +/** + * Valid OTP token lengths supported by the authentication system. + * These values are enforced to ensure compatibility with the OTP verification flow. + */ +const VALID_OTP_TOKEN_LENGTHS = [6, 8] +const DEFAULT_OTP_TOKEN_LENGTH = 8 + +/** + * Validates and normalizes the OTP token length configuration. + * Throws an error if the token length is invalid. + * + * @param {string|number|undefined} tokenLength - The token length from config or env var + * @returns {number} Validated token length (6 or 8) + * @throws {Error} If tokenLength is invalid (not 6 or 8) + */ +function validateOtpTokenLength(tokenLength) { + // If undefined, return default + if (tokenLength === undefined) { + return DEFAULT_OTP_TOKEN_LENGTH + } + + // Parse to number (handles string numbers like "6" or "8") + const parsedLength = Number(tokenLength) + + // Check if it's one of the allowed values (includes() will return false for NaN or invalid numbers) + if (!VALID_OTP_TOKEN_LENGTHS.includes(parsedLength)) { + throw new Error( + `Invalid OTP token length: ${tokenLength}. Valid values are ${VALID_OTP_TOKEN_LENGTHS.join( + ' or ' + )}. ` + ) + } + + return parsedLength +} + /** * Safely parses settings from either a JSON string or object * @param {string|object} settings - The settings @@ -30,5 +66,8 @@ function parseSettings(settings) { } module.exports = { - parseSettings + parseSettings, + validateOtpTokenLength, + DEFAULT_OTP_TOKEN_LENGTH, + VALID_OTP_TOKEN_LENGTHS } diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 177377458e..e4c13b3d41 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -6,7 +6,7 @@ */ /* eslint-disable @typescript-eslint/no-var-requires */ const sites = require('./sites.js') -const {parseSettings} = require('./utils.js') +const {parseSettings, validateOtpTokenLength} = require('./utils.js') module.exports = { app: { @@ -57,6 +57,9 @@ module.exports = { interpretPlusSignAsSpace: false }, login: { + // The length of the token for OTP authentication. Used by passwordless login and reset password. + // If the env var `OTP_TOKEN_LENGTH` is set, it will override the config value. Valid values are 6 or 8. Defaults to: 8 + tokenLength: validateOtpTokenLength(process.env.OTP_TOKEN_LENGTH), passwordless: { // Enables or disables passwordless login for the site. Defaults to: false {{#if answers.project.demo.enableDemoSettings}} diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js index cc1b17e5c7..2aad9cfd7a 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/utils.js @@ -5,6 +5,42 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +/** + * Valid OTP token lengths supported by the authentication system. + * These values are enforced to ensure compatibility with the OTP verification flow. + */ +const VALID_OTP_TOKEN_LENGTHS = [6, 8] +const DEFAULT_OTP_TOKEN_LENGTH = 8 + +/** + * Validates and normalizes the OTP token length configuration. + * Throws an error if the token length is invalid. + * + * @param {string|number|undefined} tokenLength - The token length from config or env var + * @returns {number} Validated token length (6 or 8) + * @throws {Error} If tokenLength is invalid (not 6 or 8) + */ +function validateOtpTokenLength(tokenLength) { + // If undefined, return default + if (tokenLength === undefined) { + return DEFAULT_OTP_TOKEN_LENGTH + } + + // Parse to number (handles string numbers like "6" or "8") + const parsedLength = Number(tokenLength) + + // Check if it's one of the allowed values (includes() will return false for NaN or invalid numbers) + if (!VALID_OTP_TOKEN_LENGTHS.includes(parsedLength)) { + throw new Error( + `Invalid OTP token length: ${tokenLength}. Valid values are ${VALID_OTP_TOKEN_LENGTHS.join( + ' or ' + )}. ` + ) + } + + return parsedLength +} + /** * Safely parses settings from either a JSON string or object * @param {string|object} settings - The settings @@ -30,5 +66,8 @@ function parseSettings(settings) { } module.exports = { - parseSettings + parseSettings, + validateOtpTokenLength, + DEFAULT_OTP_TOKEN_LENGTH, + VALID_OTP_TOKEN_LENGTHS } diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 171f860f9a..b446d6905d 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -11,6 +11,7 @@ - Update "Continue Securely" button text to "Continue" for passwordless login [#3556](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3556) - Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630) - [BREAKING] Remove unused absoluteUrl util from retail react app [#3633](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3633) +- Allow shopper to manually input OTP during passwordless login [#3554](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3554) ## v8.3.0 (Dec 17, 2025) - [Bugfix] Fix Forgot Password link not working from Account Profile password update form [#3493](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3493) diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.jsx b/packages/template-retail-react-app/app/components/otp-auth/index.jsx index e159fc53a2..ed38132b6f 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.jsx +++ b/packages/template-retail-react-app/app/components/otp-auth/index.jsx @@ -27,6 +27,7 @@ import {useUsid, useCustomerType, useDNT} from '@salesforce/commerce-sdk-react' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useOtpInputs} from '@salesforce/retail-react-app/app/hooks/use-otp-inputs' import {useCountdown} from '@salesforce/retail-react-app/app/hooks/use-countdown' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' const OtpAuth = ({ isOpen, @@ -35,9 +36,20 @@ const OtpAuth = ({ handleSendEmailOtp, handleOtpVerification, onCheckoutAsGuest, - isGuestRegistration = false + isGuestRegistration = false, + hideCheckoutAsGuestButton = false }) => { - const OTP_LENGTH = 8 + const {tokenLength} = getConfig().app.login + const parsedLength = Number(tokenLength) + const isValidOtpLength = parsedLength === 6 || parsedLength === 8 + const OTP_LENGTH = isValidOtpLength ? parsedLength : 8 + + if (!isValidOtpLength) { + console.warn( + `Invalid OTP token length: ${tokenLength}. Expected 6 or 8. Defaulting to ${OTP_LENGTH}.` + ) + } + const [isVerifying, setIsVerifying] = useState(false) const [error, setError] = useState('') const [resendTimer, setResendTimer] = useCountdown(0) @@ -269,35 +281,37 @@ const OtpAuth = ({ {/* Buttons */} - + {!hideCheckoutAsGuestButton && ( + + )}