Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,7 +22,7 @@ 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 {CommerceApiProvider} from '@salesforce/commerce-sdk-react'
Expand Down Expand Up @@ -62,8 +62,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 +80,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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ 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 {CommerceApiProvider} from '@salesforce/commerce-sdk-react'
Expand Down Expand Up @@ -63,7 +63,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 +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 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