Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 1 addition & 2 deletions packages/pwa-kit-create-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
## v3.16.0-dev (Dec 17, 2025)
- Add new One-Click Checkout configuration [#3609](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3609)

## v3.15.0-dev (Nov 05, 2025)
- 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)

## 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 @@ -22,8 +22,9 @@ import {
getEnvBasePath,
slasPrivateProxyPath
} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
import {absoluteUrl, createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import createLogger from '@salesforce/pwa-kit-runtime/utils/logger-factory'
import {getPasswordlessCallbackUrl} from '@salesforce/retail-react-app/app/utils/auth-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 @@ -62,8 +63,9 @@ const AppConfig = ({children, locals = {}}) => {
const commerceApiConfig = locals.appConfig.commerceAPI

const appOrigin = getAppOrigin()

const passwordlessCallback = locals.appConfig.login?.passwordless?.callbackURI
const passwordlessCallbackURI = getPasswordlessCallbackUrl(
locals.appConfig.login?.passwordless?.callbackURI
)

const storeLocatorConfig = {
radius: STORE_LOCATOR_RADIUS,
Expand All @@ -79,7 +81,6 @@ const AppConfig = ({children, locals = {}}) => {
const redirectURI = `${appOrigin}${getEnvBasePath()}/callback`
const proxy = `${appOrigin}${getEnvBasePath()}${commerceApiConfig.proxyPath}`
const slasPrivateClientProxyEndpoint = `${appOrigin}${getEnvBasePath()}${slasPrivateProxyPath}`
const passwordlessLoginCallbackURI = absoluteUrl(passwordlessCallback, appOrigin)

return (
<CommerceApiProvider
Expand All @@ -94,7 +95,7 @@ const AppConfig = ({children, locals = {}}) => {
headers={headers}
defaultDnt={DEFAULT_DNT_STATE}
logger={createLogger({packageName: 'commerce-sdk-react'})}
passwordlessLoginCallbackURI={passwordlessLoginCallbackURI}
passwordlessLoginCallbackURI={passwordlessCallbackURI}
// Set 'enablePWAKitPrivateClient' to true to use SLAS private client login flows.
// Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting.
{{#if answers.project.commerce.isSlasPrivate}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import {
getEnvBasePath,
slasPrivateProxyPath
} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
import {absoluteUrl, createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import createLogger from '@salesforce/pwa-kit-runtime/utils/logger-factory'
import {getPasswordlessCallbackUrl} from '@salesforce/retail-react-app/app/utils/auth-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 @@ -63,7 +64,9 @@ const AppConfig = ({children, locals = {}}) => {

const appOrigin = useAppOrigin()

const passwordlessCallback = locals.appConfig.login?.passwordless?.callbackURI
const passwordlessCallbackURI = getPasswordlessCallbackUrl(
locals.appConfig.login?.passwordless?.callbackURI
)

const storeLocatorConfig = {
radius: STORE_LOCATOR_RADIUS,
Expand All @@ -79,7 +82,6 @@ const AppConfig = ({children, locals = {}}) => {
const redirectURI = `${appOrigin}${getEnvBasePath()}/callback`
const proxy = `${appOrigin}${getEnvBasePath()}${commerceApiConfig.proxyPath}`
const slasPrivateClientProxyEndpoint = `${appOrigin}${getEnvBasePath()}${slasPrivateProxyPath}`
const passwordlessLoginCallbackURI = absoluteUrl(passwordlessCallback, appOrigin)

return (
<CommerceApiProvider
Expand All @@ -94,7 +96,7 @@ const AppConfig = ({children, locals = {}}) => {
headers={headers}
defaultDnt={DEFAULT_DNT_STATE}
logger={createLogger({packageName: 'commerce-sdk-react'})}
passwordlessLoginCallbackURI={passwordlessLoginCallbackURI}
passwordlessLoginCallbackURI={passwordlessCallbackURI}
// Set 'enablePWAKitPrivateClient' to true to use SLAS private client login flows.
// Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting.
{{#if answers.project.commerce.isSlasPrivate}}
Expand Down
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- BOPIS multishipment with OMS [#3613] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3613)
- [Feature] 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)
- Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630)

## 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ import {
getEnvBasePath,
slasPrivateProxyPath
} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
import {absoluteUrl, createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import createLogger from '@salesforce/pwa-kit-runtime/utils/logger-factory'
import {getPasswordlessCallbackUrl} from '@salesforce/retail-react-app/app/utils/auth-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 @@ -72,7 +73,9 @@ const AppConfig = ({children, locals = {}}) => {

const appOrigin = useAppOrigin()

const passwordlessCallback = locals.appConfig.login?.passwordless?.callbackURI
const passwordlessCallbackURI = getPasswordlessCallbackUrl(
locals.appConfig.login?.passwordless?.callbackURI
)

const storeLocatorConfig = {
radius: STORE_LOCATOR_RADIUS,
Expand All @@ -88,7 +91,6 @@ const AppConfig = ({children, locals = {}}) => {
const redirectURI = `${appOrigin}${getEnvBasePath()}/callback`
const proxy = `${appOrigin}${getEnvBasePath()}${commerceApiConfig.proxyPath}`
const slasPrivateClientProxyEndpoint = `${appOrigin}${getEnvBasePath()}${slasPrivateProxyPath}`
const passwordlessLoginCallbackURI = absoluteUrl(passwordlessCallback, appOrigin)

return (
<CommerceApiProvider
Expand All @@ -99,7 +101,7 @@ const AppConfig = ({children, locals = {}}) => {
locale={locals.locale?.id}
currency={locals.locale?.preferredCurrency}
redirectURI={redirectURI}
passwordlessLoginCallbackURI={passwordlessLoginCallbackURI}
passwordlessLoginCallbackURI={passwordlessCallbackURI}
proxy={proxy}
headers={headers}
defaultDnt={DEFAULT_DNT_STATE}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous'
import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset'
import {isServer} from '@salesforce/retail-react-app/app/utils/utils'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url'
import {getPasswordlessCallbackUrl} from '@salesforce/retail-react-app/app/utils/auth-utils'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'

export const LOGIN_VIEW = 'login'
Expand Down Expand Up @@ -88,9 +88,8 @@ export const AuthModal = ({
const {getPasswordResetToken} = usePasswordReset()
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
const passwordlessConfig = getConfig().app.login?.passwordless
const passwordlessConfigCallback = passwordlessConfig?.callbackURI
const passwordlessMode = passwordlessConfig?.mode
const callbackURL = absoluteUrl(passwordlessConfigCallback)
const callbackURL = getPasswordlessCallbackUrl(passwordlessConfig?.callbackURI)

const {data: baskets} = useCustomerBaskets(
{parameters: {customerId}},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
import {useIntl} from 'react-intl'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url'
import {getPasswordlessCallbackUrl} from '@salesforce/retail-react-app/app/utils/auth-utils'

/**
* This hook provides commerce-react-sdk hooks to simplify the reset password flow.
Expand All @@ -19,8 +19,8 @@ export const usePasswordReset = () => {
const {formatMessage} = useIntl()
const {locale} = useMultiSite()
const config = getConfig().app.login?.resetPassword
const callbackURI = absoluteUrl(config?.callbackURI)
const resetPasswordLandingPath = config?.landingPath
const callbackURI = getPasswordlessCallbackUrl(config?.callbackURI)

const getPasswordResetTokenMutation = useAuthHelper(AuthHelpers.GetPasswordResetToken)
const resetPasswordMutation = useAuthHelper(AuthHelpers.ResetPassword)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ import {formatPhoneNumber} from '@salesforce/retail-react-app/app/utils/phone-ut
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url'
import {useLocation} from 'react-router-dom'
import {getPasswordlessCallbackUrl} from '@salesforce/retail-react-app/app/utils/auth-utils'

const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseGuest}) => {
const {formatMessage} = useIntl()
Expand All @@ -75,9 +75,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG
const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser)
const {locale} = useMultiSite()
const passwordlessConfig = getConfig().app.login?.passwordless
const callbackURL = passwordlessConfig?.callbackURI
? absoluteUrl(passwordlessConfig.callbackURI)
: ''
const callbackURL = getPasswordlessCallbackUrl(passwordlessConfig?.callbackURI)
const redirectPath = location.pathname + location.search

const {step, STEPS, goToStep, goToNextStep, setContactPhone} = useCheckout()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url'
import {getPasswordlessErrorMessage} from '@salesforce/retail-react-app/app/utils/auth-utils'
import {
getPasswordlessCallbackUrl,
getPasswordlessErrorMessage
} from '@salesforce/retail-react-app/app/utils/auth-utils'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'

const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, idps = []}) => {
Expand Down Expand Up @@ -76,8 +78,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id
const authModal = useAuthModal(authModalView)
const passwordlessConfig = getConfig().app.login?.passwordless
const passwordlessConfigMode = passwordlessConfig?.mode
const passwordlessConfigCallback = passwordlessConfig?.callbackURI
const callbackURL = absoluteUrl(passwordlessConfigCallback)
const callbackURL = getPasswordlessCallbackUrl(passwordlessConfig?.callbackURI)

const handlePasswordlessLogin = async (email) => {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import {
} 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 {
getPasswordlessCallbackUrl,
getPasswordlessErrorMessage
} from '@salesforce/retail-react-app/app/utils/auth-utils'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url'

const LOGIN_ERROR_MESSAGE = defineMessage({
defaultMessage: 'Incorrect username or password, please try again.',
Expand Down Expand Up @@ -64,7 +66,8 @@ const Login = ({initialView = LOGIN_VIEW}) => {
const isPasswordlessEnabled = !!passwordless?.enabled
const passwordlessMode = passwordless?.mode
const passwordlessLoginLandingPath = passwordless?.landingPath
const passwordlessConfigCallback = absoluteUrl(passwordless?.callbackURI)

const passwordlessConfigCallback = getPasswordlessCallbackUrl(passwordless?.callbackURI)

const isSocialEnabled = !!social?.enabled
const idps = social?.idps
Expand Down
21 changes: 21 additions & 0 deletions packages/template-retail-react-app/app/utils/auth-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
*/

import {defineMessage} from 'react-intl'
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
import {
API_ERROR_MESSAGE,
FEATURE_UNAVAILABLE_ERROR_MESSAGE
} from '@salesforce/retail-react-app/app/constants'
import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'

export const TOO_MANY_LOGIN_ATTEMPTS_ERROR_MESSAGE = defineMessage({
defaultMessage:
Expand Down Expand Up @@ -40,6 +43,24 @@ const PASSWORD_RESET_FEATURE_UNAVAILABLE_ERRORS = TOKEN_BASED_AUTH_FEATURE_UNAVA

const TOO_MANY_REQUESTS_ERROR = /too many .* requests/i

/**
* Returns the absolute URL for the passwordless login callback.
* If the callback URI is already absolute, it is returned as-is; otherwise it is
* resolved against the app origin and env base path.
*
* @param {string} [callbackURI] - The callback URI from config (relative or absolute)
* @returns {string|undefined} - The full callback URL, or undefined if callbackURI is falsy
*/
export const getPasswordlessCallbackUrl = (callbackURI) => {
if (!callbackURI) {
return undefined
}
if (isAbsoluteURL(callbackURI)) {
return callbackURI
}
return `${getAppOrigin()}${getEnvBasePath()}${callbackURI}`
}

/**
* Maps an error message to the appropriate user-friendly error message descriptor
* for passwordless login feature errors.
Expand Down
37 changes: 37 additions & 0 deletions packages/template-retail-react-app/app/utils/auth-utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,49 @@ import {
FEATURE_UNAVAILABLE_ERROR_MESSAGE
} from '@salesforce/retail-react-app/app/constants'
import {
getPasswordlessCallbackUrl,
getPasswordlessErrorMessage,
getPasswordResetErrorMessage,
TOO_MANY_LOGIN_ATTEMPTS_ERROR_MESSAGE,
TOO_MANY_PASSWORD_RESET_ATTEMPTS_ERROR_MESSAGE
} from '@salesforce/retail-react-app/app/utils/auth-utils'

afterEach(() => {
jest.clearAllMocks()
})

jest.mock('@salesforce/pwa-kit-react-sdk/utils/url', () => {
const original = jest.requireActual('@salesforce/pwa-kit-react-sdk/utils/url')
return {
...original,
getAppOrigin: jest.fn(() => 'https://www.example.com')
}
})
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths', () => {
const original = jest.requireActual('@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths')
return {
...original,
getEnvBasePath: jest.fn(() => '/test')
}
})

describe('getPasswordlessCallbackUrl', function () {
test.each([undefined, null, ''])('return undefined when callbackURI is %s', (callbackURI) => {
expect(getPasswordlessCallbackUrl(callbackURI)).toBeUndefined()
})

test('return callbackURI as-is when it is an absolute URL', () => {
const absoluteUrl = 'https://callback.example.com/passwordless'
expect(getPasswordlessCallbackUrl(absoluteUrl)).toBe(absoluteUrl)
})

test('return full URL with origin and base path when callbackURI is relative', () => {
expect(getPasswordlessCallbackUrl('/passwordless-login-callback')).toBe(
'https://www.example.com/test/passwordless-login-callback'
)
})
})

describe('getPasswordlessErrorMessage', () => {
test.each([
['no callback_uri is registered for client', FEATURE_UNAVAILABLE_ERROR_MESSAGE],
Expand Down
8 changes: 3 additions & 5 deletions packages/template-retail-react-app/app/utils/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import {
getSiteByReference
} from '@salesforce/retail-react-app/app/utils/site-utils'
import {HOME_HREF, urlPartPositions} from '@salesforce/retail-react-app/app/constants'
import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'

/**
* Constructs an absolute URL from a given path and an optional application origin.
* TODO: How should this work with base path?
*
* @param {string} path - The relative URL path or absolute URL to be resolved.
* @param {string} path - The relative URL path to be appended to the origin.
* @param {string} [appOrigin] - The optional application origin (e.g., "https://example.com").
* If not provided, the function will call `getAppOrigin()`.
* @returns {string} - The fully qualified URL as a string.
Expand All @@ -30,10 +30,8 @@ export const absoluteUrl = (path, appOrigin) => {
return path
}

// Construct the full path with envBasePath between origin and path
const fullPath = `${getEnvBasePath()}${path}`
// absoluteUrl is not a react hook so we cannot use the useAppOrigin hook here
return new URL(fullPath, appOrigin || getAppOrigin()).toString()
return new URL(path, appOrigin || getAppOrigin()).toString()
}

/**
Expand Down
Loading