diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index 5c4d654e54..69fa794d3d 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -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) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs index a3a8ae7cae..0592183a90 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs @@ -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' @@ -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, @@ -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 ( { 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}} diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs index 7a8935117a..bebebc4f56 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs @@ -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' @@ -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, @@ -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 ( { 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}} diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 60e7f0f2c4..3d38363b25 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -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) diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index 4d49802d74..7880c28341 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -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' @@ -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, @@ -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 ( { locale={locals.locale?.id} currency={locals.locale?.preferredCurrency} redirectURI={redirectURI} - passwordlessLoginCallbackURI={passwordlessLoginCallbackURI} + passwordlessLoginCallbackURI={passwordlessCallbackURI} proxy={proxy} headers={headers} defaultDnt={DEFAULT_DNT_STATE} diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index 315be310f6..d55d408425 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -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' @@ -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}}, diff --git a/packages/template-retail-react-app/app/hooks/use-password-reset.js b/packages/template-retail-react-app/app/hooks/use-password-reset.js index d61fbc172b..3ce8a6dd83 100644 --- a/packages/template-retail-react-app/app/hooks/use-password-reset.js +++ b/packages/template-retail-react-app/app/hooks/use-password-reset.js @@ -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. @@ -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) diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx index b9daf566c6..a31f4e309a 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx @@ -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() @@ -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() diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index 7773ab937a..c001dee17a 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -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 = []}) => { @@ -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 { diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index 1dd7d049e7..fd44f2322b 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -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.', @@ -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 diff --git a/packages/template-retail-react-app/app/utils/auth-utils.js b/packages/template-retail-react-app/app/utils/auth-utils.js index e7500fa454..199725cb6b 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.js @@ -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: @@ -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. diff --git a/packages/template-retail-react-app/app/utils/auth-utils.test.js b/packages/template-retail-react-app/app/utils/auth-utils.test.js index 1e719c9665..e2e4eb5096 100644 --- a/packages/template-retail-react-app/app/utils/auth-utils.test.js +++ b/packages/template-retail-react-app/app/utils/auth-utils.test.js @@ -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], diff --git a/packages/template-retail-react-app/app/utils/url.js b/packages/template-retail-react-app/app/utils/url.js index 9c3d5b24f2..b9fbe03dc6 100644 --- a/packages/template-retail-react-app/app/utils/url.js +++ b/packages/template-retail-react-app/app/utils/url.js @@ -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. @@ -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() } /**