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 b2946cdcd3..4d09cf4946 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 @@ -45,10 +45,10 @@ import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-passw import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' -const LOGIN_VIEW = 'login' -const REGISTER_VIEW = 'register' -const PASSWORD_VIEW = 'password' -const EMAIL_VIEW = 'email' +export const LOGIN_VIEW = 'login' +export const REGISTER_VIEW = 'register' +export const PASSWORD_VIEW = 'password' +export const EMAIL_VIEW = 'email' const LOGIN_ERROR = defineMessage({ defaultMessage: "Something's not right with your email or password. Try again.", @@ -57,6 +57,7 @@ const LOGIN_ERROR = defineMessage({ export const AuthModal = ({ initialView = LOGIN_VIEW, + initialEmail = '', onLoginSuccess = noop, onRegistrationSuccess = noop, isOpen, @@ -85,7 +86,7 @@ export const AuthModal = ({ const register = useAuthHelper(AuthHelpers.Register) const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) - const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') + const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState(initialEmail) const {getPasswordResetToken} = usePasswordReset() const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) @@ -216,6 +217,10 @@ export const AuthModal = ({ form.reset() }, [currentView]) + useEffect(() => { + setPasswordlessLoginEmail(initialEmail) + }, [initialEmail]) + useEffect(() => { // Lets determine if the user has either logged in, or registed. const loggingIn = currentView === LOGIN_VIEW @@ -327,6 +332,7 @@ export const AuthModal = ({ AuthModal.propTypes = { initialView: PropTypes.oneOf([LOGIN_VIEW, REGISTER_VIEW, PASSWORD_VIEW, EMAIL_VIEW]), + initialEmail: PropTypes.string, isOpen: PropTypes.bool.isRequired, onOpen: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, 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 2576696ae9..749542293c 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 @@ -32,20 +32,32 @@ import { } from '@salesforce/retail-react-app/app/components/toggle-card' import Field from '@salesforce/retail-react-app/app/components/field' import LoginState from '@salesforce/retail-react-app/app/pages/checkout/partials/login-state' -import {AuthModal, useAuthModal} from '@salesforce/retail-react-app/app/hooks/use-auth-modal' +import { + AuthModal, + EMAIL_VIEW, + PASSWORD_VIEW, + useAuthModal +} from '@salesforce/retail-react-app/app/hooks/use-auth-modal' 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 {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import { + API_ERROR_MESSAGE, + FEATURE_UNAVAILABLE_ERROR_MESSAGE, + CREATE_ACCOUNT_FIRST_ERROR_MESSAGE, + PASSWORDLESS_ERROR_MESSAGES, + USER_NOT_FOUND_ERROR +} from '@salesforce/retail-react-app/app/constants' const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, idps = []}) => { const {formatMessage} = useIntl() - const authModal = useAuthModal('password') const navigate = useNavigation() const {data: customer} = useCurrentCustomer() const {data: basket} = useCurrentBasket() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const logout = useAuthHelper(AuthHelpers.Logout) + const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket') const mergeBasket = useShopperBasketsMutation('mergeBasket') @@ -62,8 +74,32 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const [showPasswordField, setShowPasswordField] = useState(false) const [signOutConfirmDialogIsOpen, setSignOutConfirmDialogIsOpen] = useState(false) + const [authModalView, setAuthModalView] = useState(PASSWORD_VIEW) + const authModal = useAuthModal(authModalView) + const [isPasswordlessLoginClicked, setIsPasswordlessLoginClicked] = useState(false) + + const handlePasswordlessLogin = async (email) => { + try { + await authorizePasswordlessLogin.mutateAsync({userid: email}) + setAuthModalView(EMAIL_VIEW) + authModal.onOpen() + } catch (error) { + const message = USER_NOT_FOUND_ERROR.test(error.message) + ? formatMessage(CREATE_ACCOUNT_FIRST_ERROR_MESSAGE) + : PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message)) + ? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE) + : formatMessage(API_ERROR_MESSAGE) + setError(message) + } + } + const submitForm = async (data) => { setError(null) + if (isPasswordlessLoginClicked) { + handlePasswordlessLogin(data.email) + setIsPasswordlessLoginClicked(false) + return + } try { if (!data.password) { await updateCustomerForBasket.mutateAsync({ @@ -108,6 +144,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } const onForgotPasswordClick = () => { + setAuthModalView(PASSWORD_VIEW) authModal.onOpen() } @@ -117,6 +154,10 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } }, [showPasswordField]) + const onPasswordlessLoginClick = async () => { + setIsPasswordlessLoginClicked(true) + } + return ( - + {basket?.customerInfo?.email || customer?.email} 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 d5ce0a696d..aac8c5933d 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 @@ -5,9 +5,30 @@ * 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 {screen, within} from '@testing-library/react' +import {screen, waitFor, within} from '@testing-library/react' import ContactInfo from '@salesforce/retail-react-app/app/pages/checkout/partials/contact-info' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import {rest} from 'msw' +import {scapiBasketWithItem} from '@salesforce/retail-react-app/app/mocks/mock-data' +import {AuthHelpers} from '@salesforce/commerce-sdk-react' + +const invalidEmail = 'invalidEmail' +const validEmail = 'test@salesforce.com' +const password = 'abc123' +const mockAuthHelperFunctions = { + [AuthHelpers.LoginRegisteredUserB2C]: {mutateAsync: jest.fn()}, + [AuthHelpers.AuthorizePasswordless]: {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]) + } +}) jest.mock('../util/checkout-context', () => { return { @@ -20,36 +41,203 @@ jest.mock('../util/checkout-context', () => { login: null, STEPS: {CONTACT_INFO: 0}, goToStep: null, - goToNextStep: null + goToNextStep: jest.fn() }) } }) -test('renders component', async () => { - const {user} = renderWithProviders( - - ) +afterEach(() => { + jest.resetModules() +}) + +describe('passwordless and social disabled', () => { + test('renders component', async () => { + const {user} = renderWithProviders( + + ) + + // switch to login + const trigger = screen.getByText(/Already have an account\? Log in/i) + await user.click(trigger) - // switch to login - const trigger = screen.getByText(/Already have an account\? Log in/i) - await user.click(trigger) + // open forgot password modal + const withinCard = within(screen.getByTestId('sf-toggle-card-step-0')) + const openModal = withinCard.getByText(/Forgot password\?/i) + await user.click(openModal) - // open forgot password modal - const withinCard = within(screen.getByTestId('sf-toggle-card-step-0')) - const openModal = withinCard.getByText(/Forgot password\?/i) - await user.click(openModal) + // check that forgot password modal is open + const withinForm = within(screen.getByTestId('sf-auth-modal-form')) + expect(withinForm.getByText(/Reset Password/i)).toBeInTheDocument() + }) - // check that forgot password modal is open - const withinForm = within(screen.getByTestId('sf-auth-modal-form')) - expect(withinForm.getByText(/Reset Password/i)).toBeInTheDocument() + test('does not allow login if email or password is missing', async () => { + const {user} = renderWithProviders() + + // switch to login + const trigger = screen.getByText(/Already have an account\? Log in/i) + await user.click(trigger) + + // attempt to login + const loginButton = screen.getByText('Log In') + await user.click(loginButton) + expect(screen.getByText('Please enter your email address.')).toBeInTheDocument() + expect(screen.getByText('Please enter your password.')).toBeInTheDocument() + }) + + test('allows login', async () => { + const {user} = renderWithProviders() + + // switch to login + const trigger = screen.getByText(/Already have an account\? Log in/i) + await user.click(trigger) + + // enter email address and password + await user.type(screen.getByLabelText('Email'), validEmail) + await user.type(screen.getByLabelText('Password'), password) + + const loginButton = screen.getByText('Log In') + await user.click(loginButton) + expect( + mockAuthHelperFunctions[AuthHelpers.LoginRegisteredUserB2C].mutateAsync + ).toHaveBeenCalledWith({username: validEmail, password: password}) + }) }) -test('Shows passwordless login button if enabled', async () => { - renderWithProviders() - expect(screen.getByText('Secure Link')).toBeInTheDocument() +describe('passwordless enabled', () => { + let currentBasket = JSON.parse(JSON.stringify(scapiBasketWithItem)) + beforeEach(() => { + global.server.use( + rest.put('*/baskets/:basketId/customer', (req, res, ctx) => { + currentBasket.customerInfo.email = validEmail + return res(ctx.json(currentBasket)) + }) + ) + }) + + test('renders component', async () => { + const {getByRole} = renderWithProviders() + expect(getByRole('button', {name: 'Checkout as Guest'})).toBeInTheDocument() + expect(getByRole('button', {name: 'Secure Link'})).toBeInTheDocument() + expect(getByRole('button', {name: 'Password'})).toBeInTheDocument() + }) + + test('does not allow login if email is missing', async () => { + const {user} = renderWithProviders() + + // Click passwordless login button + const passwordlessLoginButton = screen.getByText('Secure Link') + await user.click(passwordlessLoginButton) + expect(screen.getByText('Please enter your email address.')).toBeInTheDocument() + + // Click password login button + const passwordLoginButton = screen.getByText('Password') + await user.click(passwordLoginButton) + expect(screen.getByText('Please enter your email address.')).toBeInTheDocument() + }) + + test('does not allow passwordless login if email is invalid', async () => { + const {user} = renderWithProviders() + + // enter an invalid email address + await user.type(screen.getByLabelText('Email'), invalidEmail) + + const passwordlessLoginButton = screen.getByText('Secure Link') + await user.click(passwordlessLoginButton) + expect(screen.queryByTestId('sf-form-resend-passwordless-email')).not.toBeInTheDocument() + }) + + test('allows passwordless login', async () => { + const {user} = renderWithProviders() + + // enter a valid email address + await user.type(screen.getByLabelText('Email'), validEmail) + + // initiate passwordless login + const passwordlessLoginButton = screen.getByText('Secure Link') + // 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}) + + // 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}) + }) + + test('allows login using password', async () => { + const {user} = renderWithProviders() + + // enter a valid email address + await user.type(screen.getByLabelText('Email'), validEmail) + + // initiate login using password + const passwordButton = screen.getByText('Password') + await user.click(passwordButton) + + // enter a password + await user.type(screen.getByLabelText('Password'), password) + + const loginButton = screen.getByText('Log In') + await user.click(loginButton) + expect( + mockAuthHelperFunctions[AuthHelpers.LoginRegisteredUserB2C].mutateAsync + ).toHaveBeenCalledWith({username: validEmail, password: password}) + }) + + test.each([ + [ + 'User not found', + 'This feature is not currently available. You must create an account to access this feature.' + ], + [ + "callback_uri doesn't match the registered callbacks", + 'This feature is not currently available.' + ], + [ + 'PasswordLess Permissions Error for clientId:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'This feature is not currently available.' + ], + ['client secret is not provided', 'This feature is not currently available.'], + ['unexpected error message', 'Something went wrong. Try again!'] + ])( + 'maps API error "%s" to the displayed error message"%s"', + async (apiErrorMessage, expectedMessage) => { + mockAuthHelperFunctions[ + AuthHelpers.AuthorizePasswordless + ].mutateAsync.mockImplementation(() => { + throw new Error(apiErrorMessage) + }) + const {user} = renderWithProviders() + await user.type(screen.getByLabelText('Email'), validEmail) + const passwordlessLoginButton = screen.getByText('Secure Link') + // Click the button twice as the isPasswordlessLoginClicked state doesn't change after the first click + await user.click(passwordlessLoginButton) + await user.click(passwordlessLoginButton) + await waitFor(() => { + expect(screen.getByText(expectedMessage)).toBeInTheDocument() + }) + } + ) }) -test('Shows Google login button if configured', async () => { - renderWithProviders() - expect(screen.getByText('Google')).toBeInTheDocument() +describe('social login enabled', () => { + test('renders component', async () => { + const {getByRole} = renderWithProviders( + + ) + expect(getByRole('button', {name: 'Checkout as Guest'})).toBeInTheDocument() + expect(getByRole('button', {name: 'Password'})).toBeInTheDocument() + expect(getByRole('button', {name: /Google/i})).toBeInTheDocument() + }) }) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/login-state.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/login-state.jsx index e5fe705b56..e4fb5d88fd 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/login-state.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/login-state.jsx @@ -12,6 +12,7 @@ import SocialLogin from '@salesforce/retail-react-app/app/components/social-logi const LoginState = ({ form, + handlePasswordlessLoginClick, isSocialEnabled, isPasswordlessEnabled, idps, @@ -38,7 +39,7 @@ const LoginState = ({ borderColor="gray.500" type="submit" onClick={() => { - form.clearErrors('global') + handlePasswordlessLoginClick() }} isLoading={form.formState.isSubmitting} > @@ -78,8 +79,8 @@ const LoginState = ({ }} > ) @@ -104,6 +105,7 @@ const LoginState = ({ LoginState.propTypes = { form: PropTypes.object, + handlePasswordlessLoginClick: PropTypes.func, isSocialEnabled: PropTypes.bool, isPasswordlessEnabled: PropTypes.bool, idps: PropTypes.arrayOf(PropTypes.string), diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/login-state.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/login-state.test.js new file mode 100644 index 0000000000..266908bbd7 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/partials/login-state.test.js @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025, Salesforce, 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 LoginState from '@salesforce/retail-react-app/../../app/pages/checkout/partials/login-state' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import {useForm} from 'react-hook-form' + +const mockTogglePasswordField = jest.fn() +const idps = ['apple', 'google'] + +const WrapperComponent = ({...props}) => { + const form = useForm() + return +} + +describe('LoginState', () => { + test('shows login button when showPasswordField is false', async () => { + const {getByRole, user} = renderWithProviders() + const trigger = getByRole('button', {name: /Already have an account\? Log in/i}) + await user.click(trigger) + expect(mockTogglePasswordField).toHaveBeenCalled() + }) + + test('shows checkout as guest button when showPasswordField is true', async () => { + const {getByRole, user} = renderWithProviders() + const trigger = getByRole('button', {name: /Checkout as Guest/i}) + await user.click(trigger) + expect(mockTogglePasswordField).toHaveBeenCalled() + }) + + test('shows passwordless login button if enabled', async () => { + const {getByRole, getByText, user} = renderWithProviders( + + ) + expect(getByText('Or Login With')).toBeInTheDocument() + expect(getByRole('button', {name: 'Secure Link'})).toBeInTheDocument() + const trigger = getByRole('button', {name: 'Password'}) + await user.click(trigger) + expect(mockTogglePasswordField).toHaveBeenCalled() + expect(getByRole('button', {name: 'Back to Sign In Options'})).toBeInTheDocument() + }) + + test('does not show passwordless login button if disabled', () => { + const {queryByRole, queryByText} = renderWithProviders( + + ) + expect(queryByText('Or Login With')).not.toBeInTheDocument() + expect(queryByRole('button', {name: 'Secure Link'})).not.toBeInTheDocument() + }) + + test('shows social login buttons if enabled', async () => { + const {getByRole, getByText, user} = renderWithProviders( + + ) + expect(getByText('Or Login With')).toBeInTheDocument() + expect(getByRole('button', {name: /Google/i})).toBeInTheDocument() + expect(getByRole('button', {name: /Apple/i})).toBeInTheDocument() + const trigger = getByRole('button', {name: 'Password'}) + await user.click(trigger) + expect(mockTogglePasswordField).toHaveBeenCalled() + expect(getByRole('button', {name: 'Back to Sign In Options'})).toBeInTheDocument() + }) + + test('does not show social login buttons if disabled', () => { + const {queryByRole, queryByText} = renderWithProviders( + + ) + expect(queryByText('Or Login With')).not.toBeInTheDocument() + expect(queryByRole('button', {name: /Google/i})).not.toBeInTheDocument() + expect(queryByRole('button', {name: /Apple/i})).not.toBeInTheDocument() + }) +}) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index f17efe80e6..80ce88673a 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -75,7 +75,7 @@ const passwordlessLoginCallback = config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' // Reusable function to handle sending a magic link email. -// By default, this imp[lmenetation uses Marketing Cloud. +// By default, this implementation uses Marketing Cloud. async function sendMagicLinkEmail(req, res, landingPath, emailTemplate) { // Extract the base URL from the request const base = req.protocol + '://' + req.get('host') diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index 28cbebae38..0142425eab 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -1055,6 +1055,12 @@ "value": "Already have an account? Log in" } ], + "contact_info.button.back_to_sign_in_options": [ + { + "type": 0, + "value": "Back to Sign In Options" + } + ], "contact_info.button.checkout_as_guest": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index 28cbebae38..0142425eab 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -1055,6 +1055,12 @@ "value": "Already have an account? Log in" } ], + "contact_info.button.back_to_sign_in_options": [ + { + "type": 0, + "value": "Back to Sign In Options" + } + ], "contact_info.button.checkout_as_guest": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 75158e93dd..1a3e194232 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -2167,6 +2167,20 @@ "value": "]" } ], + "contact_info.button.back_to_sign_in_options": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ɓȧȧƈķ ŧǿǿ Şīɠƞ Īƞ Ǿƥŧīǿǿƞş" + }, + { + "type": 0, + "value": "]" + } + ], "contact_info.button.checkout_as_guest": [ { "type": 0, diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 5a2ff3096c..0cdb95ae46 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -418,6 +418,9 @@ "contact_info.button.already_have_account": { "defaultMessage": "Already have an account? Log in" }, + "contact_info.button.back_to_sign_in_options": { + "defaultMessage": "Back to Sign In Options" + }, "contact_info.button.checkout_as_guest": { "defaultMessage": "Checkout as Guest" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 5a2ff3096c..0cdb95ae46 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -418,6 +418,9 @@ "contact_info.button.already_have_account": { "defaultMessage": "Already have an account? Log in" }, + "contact_info.button.back_to_sign_in_options": { + "defaultMessage": "Back to Sign In Options" + }, "contact_info.button.checkout_as_guest": { "defaultMessage": "Checkout as Guest" },