Skip to content
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## v8.4.0-dev (Dec 17, 2025)
- [Feature] Add `fuzzyPathMatching` to reduce computational overhead of route generation at time of application load [#3530](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3530)
- [Bugfix] Fix Passwordless Login landingPath, Reset Password landingPath, and Social Login redirectUri value in config not being used [#3560](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3560)

## 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
6 changes: 0 additions & 6 deletions packages/template-retail-react-app/app/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,6 @@ export const LOGIN_TYPES = {
SOCIAL: 'social'
}

// Constants for Password Reset
export const RESET_PASSWORD_LANDING_PATH = '/reset-password-landing'

// Constants for Passwordless Login
export const PASSWORDLESS_LOGIN_LANDING_PATH = '/passwordless-login-landing'

export const PASSWORDLESS_ERROR_MESSAGES = [
/callback_uri doesn't match/i,
/passwordless permissions error/i,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const usePasswordReset = () => {
const callbackURI = isAbsoluteURL(resetPasswordCallback)
? resetPasswordCallback
: `${appOrigin}${getEnvBasePath()}${resetPasswordCallback}`
const resetPasswordLandingPath = config.app.login?.resetPassword?.landingPath

const getPasswordResetTokenMutation = useAuthHelper(AuthHelpers.GetPasswordResetToken)
const resetPasswordMutation = useAuthHelper(AuthHelpers.ResetPassword)
Expand Down Expand Up @@ -54,5 +55,5 @@ export const usePasswordReset = () => {
)
}

return {getPasswordResetToken, resetPassword}
return {getPasswordResetToken, resetPassword, resetPasswordLandingPath}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import {fireEvent, screen, waitFor} from '@testing-library/react'
import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react'
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset'
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'

const mockEmail = '[email protected]'
const mockToken = '123456'
const mockNewPassword = 'new-password'

const MockComponent = () => {
const {getPasswordResetToken, resetPassword} = usePasswordReset()

const {getPasswordResetToken, resetPassword, resetPasswordLandingPath} = usePasswordReset()
return (
<div>
<button
Expand All @@ -33,6 +33,8 @@ const MockComponent = () => {
})
}
/>

<div data-testid="reset-password-landing-path">{resetPasswordLandingPath}</div>
</div>
)
}
Expand Down Expand Up @@ -91,4 +93,11 @@ describe('usePasswordReset', () => {
)
})
})

test('resetPasswordLandingPath is returned', () => {
renderWithProviders(<MockComponent />)
expect(screen.getByTestId('reset-password-landing-path')).toHaveTextContent(
mockConfig.app.login.resetPassword.landingPath
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
INVALID_TOKEN_ERROR,
INVALID_TOKEN_ERROR_MESSAGE,
FEATURE_UNAVAILABLE_ERROR_MESSAGE,
PASSWORDLESS_LOGIN_LANDING_PATH,
PASSWORDLESS_ERROR_MESSAGES
} from '@salesforce/retail-react-app/app/constants'
import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous'
Expand Down Expand Up @@ -61,6 +60,7 @@ const Login = ({initialView = LOGIN_VIEW}) => {
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
const {passwordless = {}, social = {}} = getConfig().app.login || {}
const isPasswordlessEnabled = !!passwordless?.enabled
const passwordlessLoginLandingPath = passwordless?.landingPath
const isSocialEnabled = !!social?.enabled
const idps = social?.idps

Expand Down Expand Up @@ -147,7 +147,7 @@ const Login = ({initialView = LOGIN_VIEW}) => {
// executing a passwordless login attempt using the token. The process waits for the
// customer baskets to be loaded to guarantee proper basket merging.
useEffect(() => {
if (path === PASSWORDLESS_LOGIN_LANDING_PATH && isSuccessCustomerBaskets) {
if (path.endsWith(passwordlessLoginLandingPath) && isSuccessCustomerBaskets) {
const token = decodeURIComponent(queryParams.get('token'))
if (queryParams.get('redirect_url')) {
setRedirectPath(decodeURIComponent(queryParams.get('redirect_url')))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import Account from '@salesforce/retail-react-app/app/pages/account'
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data'
import {AuthHelpers} from '@salesforce/commerce-sdk-react'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'

jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({
getConfig: jest.fn(() => mockConfig)
}))

const mockMergedBasket = {
basketId: 'a10ff320829cb0eef93ca5310a',
Expand Down Expand Up @@ -45,12 +50,13 @@ const MockedComponent = () => {
)
}

const mockUseRouteMatch = jest.fn(() => ({path: '/'}))

jest.mock('react-router', () => {
const original = jest.requireActual('react-router')
return {
...jest.requireActual('react-router'),
useRouteMatch: () => {
return {path: '/passwordless-login-landing'}
}
...original,
useRouteMatch: () => mockUseRouteMatch()
}
})

Expand All @@ -72,6 +78,14 @@ jest.mock('@salesforce/commerce-sdk-react', () => {

// Set up and clean up
beforeEach(() => {
jest.clearAllMocks()
getConfig.mockReturnValue(mockConfig)

// Reset useRouteMatch mock to return path based on window.location.pathname
mockUseRouteMatch.mockImplementation(() => ({
path: typeof window !== 'undefined' && window.location ? window.location.pathname : '/'
}))

global.server.use(
rest.post('*/customers', (req, res, ctx) => {
return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer))
Expand All @@ -92,11 +106,32 @@ beforeEach(() => {
})
)
})
afterEach(() => {
jest.resetModules()
})

describe('Passwordless landing tests', function () {
test('does not run passwordless login when landing path does not match', async () => {
const token = '11111111'
const invalidLoginPath = '/invalid-passwordless-login-landing'

window.history.pushState(
{},
'Passwordless Login Landing',
createPathWithDefaults(`${invalidLoginPath}?token=${token}`)
)
renderWithProviders(<MockedComponent />, {
wrapperProps: {
siteAlias: 'uk',
locale: {id: 'en-GB'},
appConfig: mockConfig.app
}
})

await waitFor(() => {
expect(
mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
).not.toHaveBeenCalled()
})
})

test('redirects to account page when redirect url is not passed', async () => {
const token = '12345678'
window.history.pushState(
Expand Down Expand Up @@ -151,4 +186,76 @@ describe('Passwordless landing tests', function () {
expect(window.location.pathname).toBe('/uk/en-GB/womens-tops')
})
})

test('detects landing path when at the end of path', async () => {
const token = '33333333'
const loginPath = '/global/en-GB/passwordless-login-landing'
// mockRouteMatch.mockReturnValue({path: loginPath})
window.history.pushState(
{},
'Passwordless Login Landing',
createPathWithDefaults(`${loginPath}?token=${token}`)
)
renderWithProviders(<MockedComponent />, {
wrapperProps: {
siteAlias: 'global',
locale: {id: 'en-GB'},
appConfig: mockConfig.app
}
})

await waitFor(() => {
expect(
mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
).toHaveBeenCalledWith({
pwdlessLoginToken: token
})
})

expect(
mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
).toHaveBeenCalledWith({
pwdlessLoginToken: token
})
})

test('landing path changes based on config', async () => {
const token = '44444444'
const customLandingPath = '/custom-passwordless-login-landing'
const mockConfigWithCustomLandingPath = {
...mockConfig,
app: {
...mockConfig.app,
login: {
...mockConfig.app.login,
passwordless: {
...mockConfig.app.login.passwordless,
enabled: true,
landingPath: customLandingPath
}
}
}
}

getConfig.mockReturnValue(mockConfigWithCustomLandingPath)

window.history.pushState(
{},
'Passwordless Login Landing',
createPathWithDefaults(`${customLandingPath}?token=${token}`)
)
renderWithProviders(<MockedComponent />, {
wrapperProps: {
siteAlias: 'uk',
locale: {id: 'en-GB'},
appConfig: mockConfigWithCustomLandingPath.app
}
})

expect(
mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
).toHaveBeenCalledWith({
pwdlessLoginToken: token
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {useLocation} from 'react-router-dom'
import {useRouteMatch} from 'react-router'
import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset'
import {
RESET_PASSWORD_LANDING_PATH,
API_ERROR_MESSAGE,
FEATURE_UNAVAILABLE_ERROR_MESSAGE
} from '@salesforce/retail-react-app/app/constants'
Expand All @@ -33,7 +32,7 @@ const ResetPassword = () => {
const dataCloud = useDataCloud()
const {pathname} = useLocation()
const {path} = useRouteMatch()
const {getPasswordResetToken} = usePasswordReset()
const {getPasswordResetToken, resetPasswordLandingPath} = usePasswordReset()

const submitForm = async ({email}) => {
try {
Expand Down Expand Up @@ -71,7 +70,7 @@ const ResetPassword = () => {
marginBottom={8}
borderRadius="base"
>
{path === RESET_PASSWORD_LANDING_PATH ? (
{path.endsWith(resetPasswordLandingPath) ? (
<ResetPasswordLanding />
) : (
<ResetPasswordForm
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ import {
import ResetPassword from '.'
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'

const mockUseRouteMatch = jest.fn(() => ({path: '/'}))

jest.mock('react-router', () => {
const original = jest.requireActual('react-router')
return {
...original,
useRouteMatch: () => mockUseRouteMatch()
}
})

const MockedComponent = () => {
return (
<div>
Expand All @@ -24,7 +34,10 @@ const MockedComponent = () => {

// Set up and clean up
beforeEach(() => {
jest.resetModules()
// Reset useRouteMatch mock to return path based on window.location.pathname
mockUseRouteMatch.mockImplementation(() => ({
path: typeof window !== 'undefined' && window.location ? window.location.pathname : '/'
}))
window.history.pushState({}, 'Reset Password', createPathWithDefaults('/reset-password'))
})
afterEach(() => {
Expand Down Expand Up @@ -75,3 +88,20 @@ test('Allows customer to generate password token', async () => {
expect(window.location.pathname).toBe('/uk/en-GB/login')
})
})

test.each([
['base path', '/reset-password-landing'],
['path with site and locale', '/uk/en-GB/reset-password-landing']
])('renders reset password landing page when using %s', async (_, landingPath) => {
window.history.pushState({}, 'Reset Password', createPathWithDefaults(landingPath))

// render our test component
renderWithProviders(<MockedComponent />, {
wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app}
})

// check if the landing page is rendered
await waitFor(() => {
expect(screen.getByText(/confirm new password/i)).toBeInTheDocument()
})
})
Loading
Loading