Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7877887
@W-20279798 support setting passwordless login mode in config (#3492)
hajinsuha1 Dec 10, 2025
22b6a36
Merge branch 'develop' into feature/email-otp
hajinsuha1 Dec 17, 2025
c646e44
@W-20443849 Set default passwordless mode to 'email' for apps created…
hajinsuha1 Dec 22, 2025
bd6d1d0
Merge branch 'develop' into feature/email-otp
hajinsuha1 Dec 29, 2025
11be87e
@W-20342599 Password Reset uses `email` mode (#3547)
hajinsuha1 Jan 2, 2026
d9ed864
@W-20279800 Update Passwordless E2E tests and change "Continue Secure…
hajinsuha1 Jan 8, 2026
07802f3
Merge branch 'develop' into feature/email-otp
hajinsuha1 Jan 9, 2026
c0aedcb
lint
hajinsuha1 Jan 9, 2026
3d27d0b
fix incorrect resetPasswordLandingPath in usePasswordReset after merg…
hajinsuha1 Jan 12, 2026
e49c9be
Merge branch 'develop' into feature/email-otp
hajinsuha1 Jan 12, 2026
3cfece1
fix failing unit test after merge from develop
hajinsuha1 Jan 12, 2026
c02836d
Merge branch 'develop' into feature/email-otp
hajinsuha1 Jan 13, 2026
3d79932
@W-20890250 Handle request limit and monthly quota error states for p…
hajinsuha1 Jan 16, 2026
892ef8c
Merge branch 'develop' into feature/email-otp
hajinsuha1 Jan 16, 2026
076b1ac
refactor auth so locale is sent via parameters instead of in the Auth…
hajinsuha1 Jan 21, 2026
b0ca814
Refactor URL handling by replacing buildAbsoluteUrl with absoluteUrl …
hajinsuha1 Jan 21, 2026
3c05026
Merge branch 'develop' into feature/email-otp
hajinsuha1 Jan 22, 2026
36e1b20
Update error message when shopper exceedes request limit for password…
hajinsuha1 Jan 22, 2026
3e9e385
Merge branch 'feature/email-otp' of https://github.com/SalesforceComm…
hajinsuha1 Jan 22, 2026
abff98d
Apply suggestion from @alexvuong
hajinsuha1 Jan 22, 2026
fe0a623
address feedback:
hajinsuha1 Jan 23, 2026
5f31e30
Merge branch 'feature/email-otp' of https://github.com/SalesforceComm…
hajinsuha1 Jan 23, 2026
bc6f621
lint
hajinsuha1 Jan 23, 2026
c766b59
Merge branch 'develop' into feature/email-otp
hajinsuha1 Jan 26, 2026
f4852df
update copyright date for new files
hajinsuha1 Jan 26, 2026
12a235d
Merge branch 'develop' into feature/email-otp
hajinsuha1 Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## v4.4.0-dev (Dec 17, 2025)

- 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)

## v4.3.0 (Dec 17, 2025)

- Upgrade to commerce-sdk-isomorphic v4.2.0 and introduce Shopper Configurations SCAPI integration [#3071](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3071)
Expand Down
110 changes: 79 additions & 31 deletions packages/commerce-sdk-react/src/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,15 @@
import Auth, {AuthData} from './'
import {waitFor} from '@testing-library/react'
import jwt from 'jsonwebtoken'
import {
helpers,
ShopperCustomersTypes,
ShopperCustomers,
ShopperLogin
} from 'commerce-sdk-isomorphic'
import {helpers, ShopperCustomersTypes, ShopperCustomers} from 'commerce-sdk-isomorphic'
import * as utils from '../utils'
import {SLAS_SECRET_PLACEHOLDER} from '../constant'
import {ShopperLoginTypes} from 'commerce-sdk-isomorphic'
import {
DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL,
DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL
} from './index'
import {ApiClientConfigParams, RequireKeys} from '../hooks/types'
import {RequireKeys} from '../hooks/types'

const baseCustomer: RequireKeys<ShopperCustomersTypes.Customer, 'login'> = {
customerId: 'customerId',
Expand Down Expand Up @@ -100,7 +95,8 @@ const config = {
proxy: 'proxy',
redirectURI: 'redirectURI',
logger: console,
passwordlessLoginCallbackURI: 'passwordlessLoginCallbackURI'
passwordlessLoginCallbackURI: 'passwordlessLoginCallbackURI',
locale: 'en-US'
}

const configSLASPrivate = {
Expand All @@ -124,16 +120,6 @@ const JWTExpired = jwt.sign(
'secret'
)

const configPasswordlessSms = {
clientId: 'clientId',
organizationId: 'organizationId',
shortCode: 'shortCode',
siteId: 'siteId',
proxy: 'proxy',
redirectURI: 'redirectURI',
logger: console
}

const FAKE_SLAS_EXPIRY = DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL - 1

const TOKEN_RESPONSE: ShopperLoginTypes.TokenResponse = {
Expand Down Expand Up @@ -921,32 +907,94 @@ describe('Auth', () => {
expect(result).toHaveProperty('codeVerifier')
})

test('authorizePasswordless calls isomorphic authorizePasswordless', async () => {
const auth = new Auth(config)
await auth.authorizePasswordless({
callbackURI: 'callbackURI',
userid: 'userid',
mode: 'callback'
})
test.each([
[
'with all parameters specified',
{callbackURI: 'callbackURI', userid: 'userid', mode: 'callback'},
{
callbackURI: 'callbackURI',
userid: 'userid',
mode: 'callback',
locale: configSLASPrivate.locale
}
],
[
'defaults mode to callback when not specified',
{userid: 'userid'},
{userid: 'userid', mode: 'callback'}
],
[
'defaults callbackURI to passwordlessLoginCallbackURI when not specified',
{userid: 'userid'},
{
userid: 'userid',
mode: 'callback',
callbackURI: configSLASPrivate.passwordlessLoginCallbackURI
}
],
['with mode email', {userid: 'userid', mode: 'email'}, {userid: 'userid', mode: 'email'}]
])('authorizePasswordless %s', async (_, input: any, expectedParams: any) => {
const auth = new Auth(configSLASPrivate)
// @ts-expect-error private method
auth.set('usid', 'test-usid-value')

await auth.authorizePasswordless(input)
expect(helpers.authorizePasswordless).toHaveBeenCalled()
const functionArg = (helpers.authorizePasswordless as jest.Mock).mock.calls[0][0]
expect(functionArg).toMatchObject({
credentials: {
clientSecret: SLAS_SECRET_PLACEHOLDER
},
parameters: {
callbackURI: 'callbackURI',
userid: 'userid',
mode: 'callback'
...expectedParams,
usid: 'test-usid-value'
}
})
})

test('authorizePasswordless sets mode to sms as configured', async () => {
const auth = new Auth(configPasswordlessSms)
test('authorizePasswordless without usid', async () => {
const auth = new Auth(configSLASPrivate)

await auth.authorizePasswordless({userid: 'userid'})
expect(helpers.authorizePasswordless).toHaveBeenCalled()
const functionArg = (helpers.authorizePasswordless as jest.Mock).mock.calls[0][0]
expect(functionArg).toMatchObject({
parameters: {userid: 'userid', mode: 'sms'}
parameters: {
userid: 'userid',
mode: 'callback',
callbackURI: configSLASPrivate.passwordlessLoginCallbackURI
}
})
// Verify usid is not in parameters when not set
expect(functionArg.parameters.usid).toBeUndefined()
})

test('authorizePasswordless without passwordlessLoginCallbackURI in config', async () => {
const configWithoutCallback = {
...configSLASPrivate,
passwordlessLoginCallbackURI: undefined
}
const auth = new Auth(configWithoutCallback)

await auth.authorizePasswordless({userid: 'userid'})
expect(helpers.authorizePasswordless).toHaveBeenCalled()
const functionArg = (helpers.authorizePasswordless as jest.Mock).mock.calls[0][0]
// callbackURI should not be in parameters when not configured
expect(functionArg.parameters.callbackURI).toBeUndefined()
})

test('authorizePasswordless throws error on non-200 response', async () => {
const auth = new Auth(configSLASPrivate)

const mockErrorResponse = {
status: 400,
json: jest.fn().mockResolvedValue({message: 'Invalid request'})
}
;(helpers.authorizePasswordless as jest.Mock).mockResolvedValueOnce(mockErrorResponse)

await expect(auth.authorizePasswordless({userid: 'userid'})).rejects.toThrow(
'400 Invalid request'
)
})

test('getPasswordLessAccessToken calls isomorphic getPasswordLessAccessToken', async () => {
Expand Down
20 changes: 13 additions & 7 deletions packages/commerce-sdk-react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
isOriginTrusted,
onClient,
getDefaultCookieAttributes,
isAbsoluteUrl,
stringToBase64,
extractCustomParameters
} from '../utils'
Expand Down Expand Up @@ -55,6 +54,7 @@ interface AuthConfig extends ApiClientConfigParams {
refreshTokenRegisteredCookieTTL?: number
refreshTokenGuestCookieTTL?: number
hybridAuthEnabled?: boolean
locale: string
}

interface JWTHeaders {
Expand Down Expand Up @@ -95,7 +95,7 @@ type AuthorizeIDPParams = {
type AuthorizePasswordlessParams = {
callbackURI?: string
userid: string
mode?: string
mode?: 'email' | 'callback'
}

type GetPasswordLessAccessTokenParams = {
Expand Down Expand Up @@ -273,6 +273,7 @@ class Auth {
| undefined

private hybridAuthEnabled: boolean
private locale: string

constructor(config: AuthConfig) {
// Special proxy endpoint for injecting SLAS private client secret.
Expand Down Expand Up @@ -366,10 +367,11 @@ class Auth {

this.isPrivate = !!this.clientSecret

const passwordlessLoginCallbackURI = config.passwordlessLoginCallbackURI
this.passwordlessLoginCallbackURI = passwordlessLoginCallbackURI || ''
this.passwordlessLoginCallbackURI = config.passwordlessLoginCallbackURI || ''

this.hybridAuthEnabled = config.hybridAuthEnabled || false

this.locale = config.locale
}

get(name: AuthDataKeys) {
Expand Down Expand Up @@ -1269,19 +1271,23 @@ class Auth {
*/
async authorizePasswordless(parameters: AuthorizePasswordlessParams) {
const usid = this.get('usid')
// Default to 'callback' mode for backward compatibility as older versions of the template-retail-react-app
// do not pass the mode parameter. Newer versions should explicitly pass the mode.
const mode = parameters.mode || 'callback'
Copy link
Contributor

Choose a reason for hiding this comment

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

The upcoming release is gonna be breaking change version. If you want to change the default, now is the time

Copy link
Contributor

Choose a reason for hiding this comment

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

Make sure you document this behavior somewhere to make it clear

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

will make sure it's documented in the dev docs

const callbackURI = parameters.callbackURI || this.passwordlessLoginCallbackURI
const finalMode = callbackURI ? 'callback' : parameters.mode || 'sms'
const locale = this.locale

const res = await helpers.authorizePasswordless({
slasClient: this.client,
credentials: {
clientSecret: this.clientSecret
},
parameters: {
...(callbackURI && {callbackURI: callbackURI}),
...(callbackURI && {callbackURI}),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we omit this if mode === email ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I chose to let the API handle the scenario when mode = email and callbackURI is set.
The API will ignore the callback URI when mode is set to email
Screenshot 2026-01-26 at 3 34 47 PM

...(usid && {usid}),
...(locale && {locale}),
Copy link
Contributor

Choose a reason for hiding this comment

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

The locale of the template. Not needed for the callback mode
from https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-login?meta=authorizePasswordlessCustomer

Just to confirm if the api will throw error if we happen to pass locale in callback mode?

Copy link
Collaborator Author

@hajinsuha1 hajinsuha1 Jan 21, 2026

Choose a reason for hiding this comment

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

No locale is optional for callback mode. I'll update the wording in the OAS to be more clear. Thanks for catching that!
Image

Image

userid: parameters.userid,
mode: finalMode
mode
}
})
if (res && res.status !== 200) {
Expand Down
2 changes: 2 additions & 0 deletions packages/commerce-sdk-react/src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
organizationId,
shortCode,
siteId,
locale,
proxy,
redirectURI,
headers,
Expand All @@ -178,6 +179,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
organizationId,
shortCode,
siteId,
locale,
proxy,
redirectURI,
headers,
Expand Down
2 changes: 2 additions & 0 deletions packages/pwa-kit-create-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## v3.16.0-dev (Dec 17, 2025)
- Update `default.js` and `/_app-config/index.jsx` template to use email mode by default for passwordless login. [#3526] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3526)

## 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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ module.exports = {
{{else}}
enabled: false,
{{/if}}
// The mode of passwordless login. Valid values include 'email|callback'. Defaults to: 'callback'
mode: 'email',
// The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer.
// Required in 'callback' mode; if missing, passwordless login defaults to 'sms' mode, which requires Marketing Cloud configuration.
// If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value.
callbackURI:
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
// Required in 'callback' mode. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value.
// callbackURI:
// process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
// The landing path for passwordless login
landingPath: '/passwordless-login-landing'
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ import {
getEnvBasePath,
slasPrivateProxyPath
} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import {buildAbsoluteUrl, createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import createLogger from '@salesforce/pwa-kit-runtime/utils/logger-factory'
import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'

import {CommerceApiProvider} from '@salesforce/commerce-sdk-react'
import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/components/with-react-query'
Expand Down Expand Up @@ -80,9 +79,7 @@ const AppConfig = ({children, locals = {}}) => {
const redirectURI = `${appOrigin}${getEnvBasePath()}/callback`
const proxy = `${appOrigin}${getEnvBasePath()}${commerceApiConfig.proxyPath}`
const slasPrivateClientProxyEndpoint = `${appOrigin}${getEnvBasePath()}${slasPrivateProxyPath}`
const passwordlessLoginCallbackURI = isAbsoluteURL(passwordlessCallback)
? passwordlessCallback
: `${appOrigin}${getEnvBasePath()}${passwordlessCallback}`
const passwordlessLoginCallbackURI = buildAbsoluteUrl(appOrigin, passwordlessCallback)

return (
<CommerceApiProvider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ import {
getEnvBasePath,
slasPrivateProxyPath
} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import {buildAbsoluteUrl, createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import createLogger from '@salesforce/pwa-kit-runtime/utils/logger-factory'
import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'

import {CommerceApiProvider} from '@salesforce/commerce-sdk-react'
import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/components/with-react-query'
Expand Down Expand Up @@ -80,9 +79,7 @@ const AppConfig = ({children, locals = {}}) => {
const redirectURI = `${appOrigin}${getEnvBasePath()}/callback`
const proxy = `${appOrigin}${getEnvBasePath()}${commerceApiConfig.proxyPath}`
const slasPrivateClientProxyEndpoint = `${appOrigin}${getEnvBasePath()}${slasPrivateProxyPath}`
const passwordlessLoginCallbackURI = isAbsoluteURL(passwordlessCallback)
? passwordlessCallback
: `${appOrigin}${getEnvBasePath()}${passwordlessCallback}`
const passwordlessLoginCallbackURI = buildAbsoluteUrl(appOrigin, passwordlessCallback)

return (
<CommerceApiProvider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ module.exports = {
{{else}}
enabled: false,
{{/if}}
// The mode of passwordless login. Valid values include 'email|callback'. Defaults to: 'callback'
mode: 'email',
// The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer.
// Required in 'callback' mode; if missing, passwordless login defaults to 'sms' mode, which requires Marketing Cloud configuration.
// If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value.
callbackURI:
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
// Required in 'callback' mode. If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value.
// callbackURI:
// process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
// The landing path for passwordless login
landingPath: '/passwordless-login-landing'
},
Expand Down
2 changes: 2 additions & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- [Bugfix] Fix Forgot Password link not working from Account Profile password update form [#3493](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3493)
- Introduce Address Autocompletion feature in the checkout flow, powered by Google Maps Platform [#3071](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3071)

- Update passwordless login to use email mode by default. The passwordless login mode can now be configured across the login page, auth modal, and checkout page [#3492](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3492)

## v8.2.0 (Nov 04, 2025)
- Add support for Rule Based Promotions for Choice of Bonus Products. We are currently supporting only one product level rule based promotion per product [#3418](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3418)
- Add support for Rule Based Promotions for Choice of Bonus Products. We are currently supporting only one product level rule based promotion per product [#3418](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3418)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ import {
getEnvBasePath,
slasPrivateProxyPath
} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import {buildAbsoluteUrl, createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import createLogger from '@salesforce/pwa-kit-runtime/utils/logger-factory'
import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'

import {CommerceApiProvider} from '@salesforce/commerce-sdk-react'
import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/components/with-react-query'
Expand Down Expand Up @@ -89,9 +88,7 @@ const AppConfig = ({children, locals = {}}) => {
const redirectURI = `${appOrigin}${getEnvBasePath()}/callback`
const proxy = `${appOrigin}${getEnvBasePath()}${commerceApiConfig.proxyPath}`
const slasPrivateClientProxyEndpoint = `${appOrigin}${getEnvBasePath()}${slasPrivateProxyPath}`
const passwordlessLoginCallbackURI = isAbsoluteURL(passwordlessCallback)
? passwordlessCallback
: `${appOrigin}${getEnvBasePath()}${passwordlessCallback}`
const passwordlessLoginCallbackURI = buildAbsoluteUrl(appOrigin, passwordlessCallback)

return (
<CommerceApiProvider
Expand Down
Loading
Loading