diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index a37229c58e..1428f7055d 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1128,7 +1128,7 @@ class Auth { */ async authorizePasswordless(parameters: AuthorizePasswordlessParams) { const userid = parameters.userid - const callbackURI = this.passwordlessLoginCallbackURI + const callbackURI = parameters.callbackURI || this.passwordlessLoginCallbackURI const usid = this.get('usid') const mode = callbackURI ? 'callback' : 'sms' 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 4d09cf4946..77a9ccc569 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 @@ -44,6 +44,8 @@ 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 {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils' +import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin' export const LOGIN_VIEW = 'login' export const REGISTER_VIEW = 'register' @@ -84,11 +86,16 @@ export const AuthModal = ({ const toast = useToast() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) + const appOrigin = useAppOrigin() const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState(initialEmail) const {getPasswordResetToken} = usePasswordReset() const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) + const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI + const callbackURL = isAbsoluteURL(passwordlessConfigCallback) + ? passwordlessConfigCallback + : `${appOrigin}${passwordlessConfigCallback}` const {data: baskets} = useCustomerBaskets( {parameters: {customerId}}, @@ -105,7 +112,11 @@ export const AuthModal = ({ const handlePasswordlessLogin = async (email) => { try { - await authorizePasswordlessLogin.mutateAsync({userid: email}) + const redirectPath = window.location.pathname + window.location.search + await authorizePasswordlessLogin.mutateAsync({ + userid: email, + callbackURI: `${callbackURL}?redirectUrl=${redirectPath}` + }) setCurrentView(EMAIL_VIEW) } catch (error) { const message = USER_NOT_FOUND_ERROR.test(error.message) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index a13580c2d4..8c5a8775e4 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -19,6 +19,7 @@ import Account from '@salesforce/retail-react-app/app/pages/account' import {rest} from 'msw' import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data' import * as ReactHookForm from 'react-hook-form' +import {AuthHelpers} from '@salesforce/commerce-sdk-react' jest.setTimeout(60000) @@ -47,6 +48,21 @@ const mockRegisteredCustomer = { login: 'customer@test.com' } +const mockAuthHelperFunctions = { + [AuthHelpers.AuthorizePasswordless]: {mutateAsync: jest.fn()}, + [AuthHelpers.Register]: {mutateAsync: jest.fn()} +} + +jest.mock('@salesforce/commerce-sdk-react', () => { + const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...originalModule, + useAuthHelper: jest + .fn() + .mockImplementation((helperType) => mockAuthHelperFunctions[helperType]) + } +}) + let authModal = undefined const MockedComponent = (props) => { const {initialView, isPasswordlessEnabled = false} = props @@ -155,17 +171,63 @@ test('Renders check email modal on email mode', async () => { mockUseForm.mockRestore() }) -test('Renders passwordless login when enabled', async () => { - const user = userEvent.setup() +describe('Passwordless enabled', () => { + test('Renders passwordless login when enabled', async () => { + const user = userEvent.setup() - renderWithProviders() + renderWithProviders() - // open the modal - const trigger = screen.getByText(/open modal/i) - await user.click(trigger) + // open the modal + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) - await waitFor(() => { - expect(screen.getByText(/continue securely/i)).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText(/continue securely/i)).toBeInTheDocument() + }) + }) + + test('Allows passwordless login', async () => { + const {user} = renderWithProviders() + const validEmail = 'test@salesforce.com' + + // open the modal + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/continue securely/i)).toBeInTheDocument() + }) + + // enter a valid email address + await user.type(screen.getByLabelText('Email'), validEmail) + + // initiate passwordless login + const passwordlessLoginButton = screen.getByText(/continue securely/i) + // Click the button twice as the isPasswordlessLoginClicked state doesn't change after the first click + await user.click(passwordlessLoginButton) + await user.click(passwordlessLoginButton) + expect( + mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync + ).toHaveBeenCalledWith({ + userid: validEmail, + callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/' + }) + + // check that check email modal is open + await waitFor(() => { + const withinForm = within(screen.getByTestId('sf-form-resend-passwordless-email')) + expect(withinForm.getByText(/Check Your Email/i)).toBeInTheDocument() + expect(withinForm.getByText(validEmail)).toBeInTheDocument() + }) + + // resend the email + user.click(screen.getByText(/Resend Link/i)) + expect( + mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync + ).toHaveBeenCalledWith({ + userid: validEmail, + callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/' + }) }) }) 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 749542293c..ef0cdc8fd1 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 @@ -41,7 +41,10 @@ import { import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' +import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils' +import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin' import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import { API_ERROR_MESSAGE, FEATURE_UNAVAILABLE_ERROR_MESSAGE, @@ -55,6 +58,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const navigate = useNavigation() const {data: customer} = useCurrentCustomer() const {data: basket} = useCurrentBasket() + const appOrigin = useAppOrigin() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const logout = useAuthHelper(AuthHelpers.Logout) const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) @@ -77,10 +81,18 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const [authModalView, setAuthModalView] = useState(PASSWORD_VIEW) const authModal = useAuthModal(authModalView) const [isPasswordlessLoginClicked, setIsPasswordlessLoginClicked] = useState(false) + const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI + const callbackURL = isAbsoluteURL(passwordlessConfigCallback) + ? passwordlessConfigCallback + : `${appOrigin}${passwordlessConfigCallback}` const handlePasswordlessLogin = async (email) => { try { - await authorizePasswordlessLogin.mutateAsync({userid: email}) + const redirectPath = window.location.pathname + window.location.search + await authorizePasswordlessLogin.mutateAsync({ + userid: email, + callbackURI: `${callbackURL}?redirectUrl=${redirectPath}` + }) setAuthModalView(EMAIL_VIEW) authModal.onOpen() } catch (error) { diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js index aac8c5933d..34333b73b5 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js @@ -105,6 +105,7 @@ describe('passwordless and social disabled', () => { describe('passwordless enabled', () => { let currentBasket = JSON.parse(JSON.stringify(scapiBasketWithItem)) + beforeEach(() => { global.server.use( rest.put('*/baskets/:basketId/customer', (req, res, ctx) => { @@ -147,6 +148,9 @@ describe('passwordless enabled', () => { }) test('allows passwordless login', async () => { + jest.spyOn(window, 'location', 'get').mockReturnValue({ + pathname: '/checkout' + }) const {user} = renderWithProviders() // enter a valid email address @@ -159,7 +163,11 @@ describe('passwordless enabled', () => { await user.click(passwordlessLoginButton) expect( mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync - ).toHaveBeenCalledWith({userid: validEmail}) + ).toHaveBeenCalledWith({ + userid: validEmail, + callbackURI: + 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/checkout' + }) // check that check email modal is open await waitFor(() => { @@ -172,7 +180,11 @@ describe('passwordless enabled', () => { user.click(screen.getByText(/Resend Link/i)) expect( mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync - ).toHaveBeenCalledWith({userid: validEmail}) + ).toHaveBeenCalledWith({ + userid: validEmail, + callbackURI: + 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/checkout' + }) }) test('allows login using password', async () => { 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 f32d27247a..e2c3b4a103 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -75,6 +75,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const [currentView, setCurrentView] = useState(initialView) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) + const [redirectPath, setRedirectPath] = useState('') const handleMergeBasket = () => { const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0 @@ -149,7 +150,12 @@ const Login = ({initialView = LOGIN_VIEW}) => { // customer baskets to be loaded to guarantee proper basket merging. useEffect(() => { if (path === PASSWORDLESS_LOGIN_LANDING_PATH && isSuccessCustomerBaskets) { - const token = queryParams.get('token') + const token = decodeURIComponent(queryParams.get('token')) + if (queryParams.get('redirect_url')) { + setRedirectPath(decodeURIComponent(queryParams.get('redirect_url'))) + } else { + setRedirectPath('') + } const passwordlessLogin = async () => { try { @@ -170,13 +176,10 @@ const Login = ({initialView = LOGIN_VIEW}) => { useEffect(() => { if (isRegistered) { handleMergeBasket() - if (location?.state?.directedFrom) { - navigate(location.state.directedFrom) - } else { - navigate('/account') - } + const redirectTo = redirectPath ? redirectPath : '/account' + navigate(redirectTo) } - }, [isRegistered]) + }, [isRegistered, redirectPath]) /**************** Einstein ****************/ useEffect(() => { diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 66268ef08d..8a5ea7e269 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -19,6 +19,7 @@ import Registration from '@salesforce/retail-react-app/app/pages/registration' import ResetPassword from '@salesforce/retail-react-app/app/pages/reset-password' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data' + const mockMergedBasket = { basketId: 'a10ff320829cb0eef93ca5310a', currency: 'USD', @@ -97,6 +98,7 @@ describe('Logging in tests', function () { }) ) }) + test('Allows customer to sign in to their account', async () => { const {user} = renderWithProviders(, { wrapperProps: { diff --git a/packages/template-retail-react-app/app/pages/login/passwordless-landing.test.js b/packages/template-retail-react-app/app/pages/login/passwordless-landing.test.js new file mode 100644 index 0000000000..34867fafa3 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/login/passwordless-landing.test.js @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import {waitFor} from '@testing-library/react' +import {rest} from 'msw' +import { + renderWithProviders, + createPathWithDefaults +} from '@salesforce/retail-react-app/app/utils/test-utils' +import Login from '.' +import {BrowserRouter as Router, Route} from 'react-router-dom' +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' + +const mockMergedBasket = { + basketId: 'a10ff320829cb0eef93ca5310a', + currency: 'USD', + customerInfo: { + customerId: 'registeredCustomerId', + email: 'customer@test.com' + } +} + +const mockAuthHelperFunctions = { + [AuthHelpers.LoginPasswordlessUser]: {mutateAsync: jest.fn()} +} + +const MockedComponent = () => { + const match = { + params: {pageName: 'profile'} + } + return ( + + + + + + + ) +} + +jest.mock('react-router', () => { + return { + ...jest.requireActual('react-router'), + useRouteMatch: () => { + return {path: '/passwordless-login-landing'} + } + } +}) + +jest.mock('@salesforce/commerce-sdk-react', () => { + const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...originalModule, + useAuthHelper: jest + .fn() + .mockImplementation((helperType) => mockAuthHelperFunctions[helperType]), + useCustomerBaskets: () => { + return {data: mockMergedBasket, isSuccess: true} + }, + useCustomerType: jest.fn(() => { + return {isRegistered: true, customerType: 'guest'} + }) + } +}) + +// Set up and clean up +beforeEach(() => { + global.server.use( + rest.post('*/customers', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer)) + }), + rest.get('*/customers/:customerId', (req, res, ctx) => { + const {customerId} = req.params + if (customerId === 'customerId') { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + authType: 'guest', + customerId: 'customerid' + }) + ) + } + return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer)) + }) + ) +}) +afterEach(() => { + jest.resetModules() +}) + +describe('Passwordless landing tests', function () { + test('redirects to account page when redirect url is not passed', async () => { + const token = '12345678' + window.history.pushState( + {}, + 'Passwordless Login Landing', + createPathWithDefaults(`/passwordless-login-landing?token=${token}`) + ) + renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockConfig.app + } + }) + + expect( + mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync + ).toHaveBeenCalledWith({ + pwdlessLoginToken: token + }) + + await waitFor(() => { + expect(window.location.pathname).toBe('/uk/en-GB/account') + }) + }) + + test('redirects to redirectUrl when passed as param', async () => { + const token = '12345678' + const redirectUrl = '/womens-tops' + window.history.pushState( + {}, + 'Passwordless Login Landing', + createPathWithDefaults( + `/passwordless-login-landing?token=${token}&redirect_url=${redirectUrl}` + ) + ) + renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockConfig.app + } + }) + + expect( + mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync + ).toHaveBeenCalledWith({ + pwdlessLoginToken: token + }) + + await waitFor(() => { + expect(window.location.pathname).toBe('/uk/en-GB/womens-tops') + }) + }) +}) diff --git a/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx index 97b8a35361..956bb3a1e2 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx @@ -35,8 +35,8 @@ const ResetPasswordLanding = () => { const {search} = useLocation() const navigate = useNavigation() const queryParams = new URLSearchParams(search) - const email = queryParams.get('email') - const token = queryParams.get('token') + const email = decodeURIComponent(queryParams.get('email')) + const token = decodeURIComponent(queryParams.get('token')) const fields = useUpdatePasswordFields({form}) const password = form.watch('password') const {resetPassword} = usePasswordReset() diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index f152b457ac..88e39f6580 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -80,7 +80,7 @@ const passwordlessLoginCallback = // Reusable function to handle sending a magic link email. // By default, this implementation uses Marketing Cloud. -async function sendMagicLinkEmail(req, res, landingPath, emailTemplate) { +async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirectUrl) { // Extract the base URL from the request const base = req.protocol + '://' + req.get('host') @@ -88,10 +88,13 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate) { const {email_id, token} = req.body // Construct the magic link URL - let magicLink = `${base}${landingPath}?token=${token}` + let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}` if (landingPath === RESET_PASSWORD_LANDING_PATH) { // Add email query parameter for reset password flow - magicLink += `&email=${email_id}` + magicLink += `&email=${encodeURIComponent(email_id)}` + } + if (landingPath === PASSWORDLESS_LOGIN_LANDING_PATH && redirectUrl) { + magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}` } // Call the emailLink function to send an email with the magic link using Marketing Cloud @@ -147,12 +150,14 @@ const {handler} = runtime.createHandler(options, (app) => { // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-passwordless-login.html#receive-the-callback app.post(passwordlessLoginCallback, (req, res) => { const slasCallbackToken = req.headers['x-slas-callback-token'] + const redirectUrl = req.query.redirectUrl validateSlasCallbackToken(slasCallbackToken).then(() => { sendMagicLinkEmail( req, res, PASSWORDLESS_LOGIN_LANDING_PATH, - process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE + process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, + redirectUrl ) }) }) diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js index 2f73d5c99a..b57cc8eb5e 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -26,7 +26,9 @@ export const createRemoteJWKSet = (tenantId) => { const shortCode = appConfig.commerceAPI.parameters.shortCode const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '') if (tenantId !== configTenantId) { - throw new Error(`The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`) + throw new Error( + `The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").` + ) } const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks` return joseCreateRemoteJWKSet(new URL(JWKS_URI)) @@ -77,7 +79,7 @@ export async function jwksCaching(req, res, options) { // JWKS rotate every 30 days. For now, cache response for 14 days so that // fetches only need to happen twice a month - res.set('Cache-Control', 'public, max-age=1209600') + res.set('Cache-Control', 'public, max-age=1209600, stale-while-revalidate=86400') return res.json(await response.json()) } catch (error) {