From 7f8e46f46f933ee2004d27d8b013a84c3e7d5cd6 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 31 Oct 2024 14:47:52 -0400 Subject: [PATCH 001/100] add new check email page --- .../app/components/check-email/index.jsx | 59 +++++++++++++++++++ .../app/components/login/index.jsx | 12 +++- .../app/hooks/use-auth-modal.js | 6 ++ .../app/pages/check-email/index.jsx | 36 +++++++++++ .../app/pages/login/index.jsx | 1 + .../app/pages/social-login-redirect/index.jsx | 17 +++--- .../template-retail-react-app/app/routes.jsx | 6 ++ 7 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 packages/template-retail-react-app/app/components/check-email/index.jsx create mode 100644 packages/template-retail-react-app/app/pages/check-email/index.jsx diff --git a/packages/template-retail-react-app/app/components/check-email/index.jsx b/packages/template-retail-react-app/app/components/check-email/index.jsx new file mode 100644 index 0000000000..49df01bb1f --- /dev/null +++ b/packages/template-retail-react-app/app/components/check-email/index.jsx @@ -0,0 +1,59 @@ +/* + * 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 PropTypes from 'prop-types' +import {FormattedMessage, useIntl} from 'react-intl' +import {Button, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' +import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' + +const CheckEmail = ({email}) => { + return ( + + + + + + + {chunks} + }} + /> + + + + + + + + + + ) +} + +CheckEmail.propTypes = { + email: PropTypes.string +} + +export default CheckEmail diff --git a/packages/template-retail-react-app/app/components/login/index.jsx b/packages/template-retail-react-app/app/components/login/index.jsx index 8cea656196..9713855260 100644 --- a/packages/template-retail-react-app/app/components/login/index.jsx +++ b/packages/template-retail-react-app/app/components/login/index.jsx @@ -21,7 +21,13 @@ import SocialLogin from '@salesforce/retail-react-app/app/components/social-logi import {noop} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' -const LoginForm = ({submitForm, clickForgotPassword = noop, clickCreateAccount = noop, form}) => { +const LoginForm = ({ + submitForm, + clickForgotPassword = noop, + clickCreateAccount = noop, + clickPasswordlessLogin = noop, + form +}) => { const idps = getConfig().app?.login?.idps return ( @@ -91,6 +97,9 @@ const LoginForm = ({submitForm, clickForgotPassword = noop, clickCreateAccount = /> + @@ -102,6 +111,7 @@ LoginForm.propTypes = { submitForm: PropTypes.func, clickForgotPassword: PropTypes.func, clickCreateAccount: PropTypes.func, + clickPasswordlessLogin: PropTypes.func, form: PropTypes.object } 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 1b1c335832..0f0b2e5cc8 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 @@ -35,6 +35,7 @@ import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' import LoginForm from '@salesforce/retail-react-app/app/components/login' import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset-password' import RegisterForm from '@salesforce/retail-react-app/app/components/register' +import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' import {noop} from '@salesforce/retail-react-app/app/utils/utils' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' @@ -43,6 +44,7 @@ import {isServer} from '@salesforce/retail-react-app/app/utils/utils' const LOGIN_VIEW = 'login' const REGISTER_VIEW = 'register' const PASSWORD_VIEW = 'password' +const EMAIL_VIEW = 'email' const LOGIN_ERROR = defineMessage({ defaultMessage: "Something's not right with your email or password. Try again.", @@ -291,6 +293,7 @@ export const AuthModal = ({ submitForm={submitForm} clickCreateAccount={() => setCurrentView(REGISTER_VIEW)} clickForgotPassword={() => setCurrentView(PASSWORD_VIEW)} + clickPasswordlessLogin={() => setCurrentView(EMAIL_VIEW)} /> )} {!form.formState.isSubmitSuccessful && currentView === REGISTER_VIEW && ( @@ -310,6 +313,9 @@ export const AuthModal = ({ {form.formState.isSubmitSuccessful && currentView === PASSWORD_VIEW && ( )} + {!form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( + + )} diff --git a/packages/template-retail-react-app/app/pages/check-email/index.jsx b/packages/template-retail-react-app/app/pages/check-email/index.jsx new file mode 100644 index 0000000000..45d3499123 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/check-email/index.jsx @@ -0,0 +1,36 @@ +/* + * 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 {Box, Container} from '@salesforce/retail-react-app/app/components/shared/ui' +import Seo from '@salesforce/retail-react-app/app/components/seo' +import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' + +const CheckEmailPage = () => { + return ( + + + + + + + ) +} + +CheckEmailPage.getTemplateName = () => 'check-email' + +CheckEmailPage.propTypes = {} + +export default CheckEmailPage 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 f7bad16872..2be087f2c8 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -108,6 +108,7 @@ const Login = () => { submitForm={submitForm} clickCreateAccount={() => navigate('/registration')} clickForgotPassword={() => navigate('/reset-password')} + clickPasswordlessLogin={() => navigate('/check-email')} /> diff --git a/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx b/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx index 8329b42357..5877460301 100644 --- a/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx +++ b/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx @@ -5,9 +5,15 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useEffect, useState} from 'react' +import React, {useEffect} from 'react' import {FormattedMessage} from 'react-intl' -import {Box, Container, Stack, Text, Spinner} from '@salesforce/retail-react-app/app/components/shared/ui' +import { + Box, + Container, + Stack, + Text, + Spinner +} from '@salesforce/retail-react-app/app/components/shared/ui' import {useCustomerType} from '@salesforce/commerce-sdk-react' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' @@ -34,12 +40,7 @@ const SocialLoginRedirect = () => { borderRadius="base" > - + import('./pages/registration'), { fallback }) const ResetPassword = loadable(() => import('./pages/reset-password'), {fallback}) +const CheckEmailPage = loadable(() => import('./pages/check-email'), {fallback}) const Account = loadable(() => import('./pages/account'), {fallback}) const Cart = loadable(() => import('./pages/cart'), {fallback}) const Checkout = loadable(() => import('./pages/checkout'), { @@ -70,6 +71,11 @@ export const routes = [ component: ResetPassword, exact: true }, + { + path: '/check-email', + component: CheckEmailPage, + exact: true + }, { path: '/account', component: Account From 4b5a766f2132d3a1e69e4e99bf360ac13a8d505d Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 4 Nov 2024 10:32:42 -0500 Subject: [PATCH 002/100] lint --- .../app/components/check-email/index.jsx | 5 ++--- .../template-retail-react-app/app/components/login/index.jsx | 3 ++- .../app/components/passwordless-login/index.jsx | 2 +- .../app/components/standard-login/index.jsx | 2 +- .../template-retail-react-app/app/hooks/use-auth-modal.js | 1 - packages/template-retail-react-app/app/pages/login/index.jsx | 1 - 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/template-retail-react-app/app/components/check-email/index.jsx b/packages/template-retail-react-app/app/components/check-email/index.jsx index 49df01bb1f..c036e29556 100644 --- a/packages/template-retail-react-app/app/components/check-email/index.jsx +++ b/packages/template-retail-react-app/app/components/check-email/index.jsx @@ -7,7 +7,7 @@ import React from 'react' import PropTypes from 'prop-types' -import {FormattedMessage, useIntl} from 'react-intl' +import {FormattedMessage} from 'react-intl' import {Button, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' @@ -25,9 +25,8 @@ const CheckEmail = ({email}) => { {chunks} }} diff --git a/packages/template-retail-react-app/app/components/login/index.jsx b/packages/template-retail-react-app/app/components/login/index.jsx index c195f3b23f..2fe0fb4a8b 100644 --- a/packages/template-retail-react-app/app/components/login/index.jsx +++ b/packages/template-retail-react-app/app/components/login/index.jsx @@ -12,8 +12,8 @@ import {Alert, Button, Stack, Text} from '@salesforce/retail-react-app/app/compo import {AlertIcon, BrandLogo} from '@salesforce/retail-react-app/app/components/icons' import StandardLogin from '@salesforce/retail-react-app/app/components/standard-login' import PasswordlessLogin from '@salesforce/retail-react-app/app/components/passwordless-login' +import SocialLogin from '@salesforce/retail-react-app/app/components/social-login' import {noop} from '@salesforce/retail-react-app/app/utils/utils' -import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' const LoginForm = ({ submitForm, @@ -110,6 +110,7 @@ LoginForm.propTypes = { handleForgotPasswordClick: PropTypes.func, clickCreateAccount: PropTypes.func, clickPasswordlessLogin: PropTypes.func, + form: PropTypes.object, isPasswordlessEnabled: PropTypes.bool, isSocialEnabled: PropTypes.bool, idps: PropTypes.array[PropTypes.string] diff --git a/packages/template-retail-react-app/app/components/passwordless-login/index.jsx b/packages/template-retail-react-app/app/components/passwordless-login/index.jsx index 0bfa1552e9..8880d03faa 100644 --- a/packages/template-retail-react-app/app/components/passwordless-login/index.jsx +++ b/packages/template-retail-react-app/app/components/passwordless-login/index.jsx @@ -10,7 +10,7 @@ import PropTypes from 'prop-types' import {FormattedMessage} from 'react-intl' import {Button, Divider, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' import LoginFields from '@salesforce/retail-react-app/app/components/forms/login-fields' -import StandardLogin from '../standard-login/index' +import StandardLogin from '@salesforce/retail-react-app/app/components/standard-login' import SocialLogin from '@salesforce/retail-react-app/app/components/social-login' const PasswordlessLogin = ({ diff --git a/packages/template-retail-react-app/app/components/standard-login/index.jsx b/packages/template-retail-react-app/app/components/standard-login/index.jsx index f25f7a3b01..237f41dd42 100644 --- a/packages/template-retail-react-app/app/components/standard-login/index.jsx +++ b/packages/template-retail-react-app/app/components/standard-login/index.jsx @@ -10,7 +10,7 @@ import PropTypes from 'prop-types' import {FormattedMessage} from 'react-intl' import {Button, Divider, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' import LoginFields from '@salesforce/retail-react-app/app/components/forms/login-fields' -import SocialLogin from '../social-login/index' +import SocialLogin from '@salesforce/retail-react-app/app/components/social-login' const StandardLogin = ({ form, 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 fce27f761f..11d2bfb13c 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 @@ -302,7 +302,6 @@ export const AuthModal = ({ isPasswordlessEnabled={isPasswordlessEnabled} isSocialEnabled={isSocialEnabled} idps={idps} - /> )} {!form.formState.isSubmitSuccessful && currentView === REGISTER_VIEW && ( 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 79b29acc09..a3fe9f5dbd 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -109,7 +109,6 @@ const Login = () => { form={form} submitForm={submitForm} clickCreateAccount={() => navigate('/registration')} - clickForgotPassword={() => navigate('/reset-password')} clickPasswordlessLogin={() => navigate('/check-email')} handleForgotPasswordClick={() => navigate('/reset-password')} isPasswordlessEnabled={passwordless?.enabled} From e4523d32f76e019217c73b14258f6080d2c878cc Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 4 Nov 2024 12:00:14 -0500 Subject: [PATCH 003/100] Add test file --- .../app/pages/check-email/index.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/template-retail-react-app/app/pages/check-email/index.test.js diff --git a/packages/template-retail-react-app/app/pages/check-email/index.test.js b/packages/template-retail-react-app/app/pages/check-email/index.test.js new file mode 100644 index 0000000000..6f3007862f --- /dev/null +++ b/packages/template-retail-react-app/app/pages/check-email/index.test.js @@ -0,0 +1,16 @@ +/* + * 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 {screen} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import CheckEmailPage from '@salesforce/retail-react-app/app/pages/check-email' + +test('Check Email Page renders without errors', () => { + renderWithProviders() + expect(screen.getByText('Check Your Email')).toBeInTheDocument() + expect(typeof CheckEmailPage.getTemplateName()).toBe('string') +}) From b8383f96968a150a36a1a8e4fd4ee065bc440556 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 4 Nov 2024 16:21:49 -0500 Subject: [PATCH 004/100] fix email prop --- .../app/components/check-email/index.jsx | 5 +- .../static/translations/compiled/en-GB.json | 40 ++++++++ .../static/translations/compiled/en-US.json | 46 +++++++-- .../static/translations/compiled/en-XA.json | 94 ++++++++++++++++--- .../translations/en-GB.json | 15 +++ .../translations/en-US.json | 18 +++- 6 files changed, 192 insertions(+), 26 deletions(-) diff --git a/packages/template-retail-react-app/app/components/check-email/index.jsx b/packages/template-retail-react-app/app/components/check-email/index.jsx index c036e29556..485863c53f 100644 --- a/packages/template-retail-react-app/app/components/check-email/index.jsx +++ b/packages/template-retail-react-app/app/components/check-email/index.jsx @@ -11,7 +11,7 @@ import {FormattedMessage} from 'react-intl' import {Button, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' -const CheckEmail = ({email}) => { +const CheckEmail = ({email = ''}) => { return ( @@ -26,8 +26,7 @@ const CheckEmail = ({email}) => { defaultMessage="We just sent a login link to {email}" id="auth_modal.check_email.description.just_sent" values={{ - email: {email}, - + email: email, b: (chunks) => {chunks} }} /> 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 472c6e7252..c09648b208 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 @@ -1,4 +1,10 @@ { + "E4HPGd": [ + { + "type": 0, + "value": "Passwordless Login" + } + ], "account.accordion.button.my_account": [ { "type": 0, @@ -361,6 +367,40 @@ "value": "Close login form" } ], + "auth_modal.check_email.button.resend_link": [ + { + "type": 0, + "value": "Resend Link" + } + ], + "auth_modal.check_email.description.check_spam_folder": [ + { + "type": 0, + "value": "The link may take a few minutes to arrive, check your spam folder if you're having trouble finding it" + } + ], + "auth_modal.check_email.description.just_sent": [ + { + "type": 0, + "value": "We just sent a login link to " + }, + { + "children": [ + { + "type": 1, + "value": "email" + } + ], + "type": 8, + "value": "b" + } + ], + "auth_modal.check_email.title.check_your_email": [ + { + "type": 0, + "value": "Check Your Email" + } + ], "auth_modal.description.now_signed_in": [ { "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 26fccfefe2..c09648b208 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 @@ -1,4 +1,10 @@ { + "E4HPGd": [ + { + "type": 0, + "value": "Passwordless Login" + } + ], "account.accordion.button.my_account": [ { "type": 0, @@ -361,6 +367,40 @@ "value": "Close login form" } ], + "auth_modal.check_email.button.resend_link": [ + { + "type": 0, + "value": "Resend Link" + } + ], + "auth_modal.check_email.description.check_spam_folder": [ + { + "type": 0, + "value": "The link may take a few minutes to arrive, check your spam folder if you're having trouble finding it" + } + ], + "auth_modal.check_email.description.just_sent": [ + { + "type": 0, + "value": "We just sent a login link to " + }, + { + "children": [ + { + "type": 1, + "value": "email" + } + ], + "type": 8, + "value": "b" + } + ], + "auth_modal.check_email.title.check_your_email": [ + { + "type": 0, + "value": "Check Your Email" + } + ], "auth_modal.description.now_signed_in": [ { "type": 0, @@ -2199,12 +2239,6 @@ "value": "Sign In" } ], - "login_form.button.social": [ - { - "type": 1, - "value": "message" - } - ], "login_form.link.forgot_password": [ { "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 c27bfbf312..a8082a3bb0 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 @@ -1,4 +1,18 @@ { + "E4HPGd": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƥȧȧşşẇǿǿřḓŀḗḗşş Ŀǿǿɠīƞ" + }, + { + "type": 0, + "value": "]" + } + ], "account.accordion.button.my_account": [ { "type": 0, @@ -753,6 +767,72 @@ "value": "]" } ], + "auth_modal.check_email.button.resend_link": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Řḗḗşḗḗƞḓ Ŀīƞķ" + }, + { + "type": 0, + "value": "]" + } + ], + "auth_modal.check_email.description.check_spam_folder": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŧħḗḗ ŀīƞķ ḿȧȧẏ ŧȧȧķḗḗ ȧȧ ƒḗḗẇ ḿīƞŭŭŧḗḗş ŧǿǿ ȧȧřřīṽḗḗ, ƈħḗḗƈķ ẏǿǿŭŭř şƥȧȧḿ ƒǿǿŀḓḗḗř īƒ ẏǿǿŭŭ'řḗḗ ħȧȧṽīƞɠ ŧřǿǿŭŭƀŀḗḗ ƒīƞḓīƞɠ īŧ" + }, + { + "type": 0, + "value": "]" + } + ], + "auth_modal.check_email.description.just_sent": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ẇḗḗ ĵŭŭşŧ şḗḗƞŧ ȧȧ ŀǿǿɠīƞ ŀīƞķ ŧǿǿ " + }, + { + "children": [ + { + "type": 1, + "value": "email" + } + ], + "type": 8, + "value": "b" + }, + { + "type": 0, + "value": "]" + } + ], + "auth_modal.check_email.title.check_your_email": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƈħḗḗƈķ Ẏǿǿŭŭř Ḗḿȧȧīŀ" + }, + { + "type": 0, + "value": "]" + } + ], "auth_modal.description.now_signed_in": [ { "type": 0, @@ -4695,20 +4775,6 @@ "value": "]" } ], - "login_form.button.social": [ - { - "type": 0, - "value": "[" - }, - { - "type": 1, - "value": "message" - }, - { - "type": 0, - "value": "]" - } - ], "login_form.link.forgot_password": [ { "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 80082e3794..fb75fb5cc8 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1,4 +1,7 @@ { + "E4HPGd": { + "defaultMessage": "Passwordless Login" + }, "account.accordion.button.my_account": { "defaultMessage": "My Account" }, @@ -147,6 +150,18 @@ "auth_modal.button.close.assistive_msg": { "defaultMessage": "Close login form" }, + "auth_modal.check_email.button.resend_link": { + "defaultMessage": "Resend Link" + }, + "auth_modal.check_email.description.check_spam_folder": { + "defaultMessage": "The link may take a few minutes to arrive, check your spam folder if you're having trouble finding it" + }, + "auth_modal.check_email.description.just_sent": { + "defaultMessage": "We just sent a login link to {email}" + }, + "auth_modal.check_email.title.check_your_email": { + "defaultMessage": "Check Your Email" + }, "auth_modal.description.now_signed_in": { "defaultMessage": "You're now signed in." }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 9e66019a5d..fb75fb5cc8 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1,4 +1,7 @@ { + "E4HPGd": { + "defaultMessage": "Passwordless Login" + }, "account.accordion.button.my_account": { "defaultMessage": "My Account" }, @@ -147,6 +150,18 @@ "auth_modal.button.close.assistive_msg": { "defaultMessage": "Close login form" }, + "auth_modal.check_email.button.resend_link": { + "defaultMessage": "Resend Link" + }, + "auth_modal.check_email.description.check_spam_folder": { + "defaultMessage": "The link may take a few minutes to arrive, check your spam folder if you're having trouble finding it" + }, + "auth_modal.check_email.description.just_sent": { + "defaultMessage": "We just sent a login link to {email}" + }, + "auth_modal.check_email.title.check_your_email": { + "defaultMessage": "Check Your Email" + }, "auth_modal.description.now_signed_in": { "defaultMessage": "You're now signed in." }, @@ -942,9 +957,6 @@ "login_form.button.sign_in": { "defaultMessage": "Sign In" }, - "login_form.button.social": { - "defaultMessage": "{message}" - }, "login_form.link.forgot_password": { "defaultMessage": "Forgot password?" }, From b3b1aef214e76515dbfbc2f7493ef5c4b134f0a9 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 6 Nov 2024 10:47:10 -0500 Subject: [PATCH 005/100] hook up handlepasswordlesslogin to new ui buttons --- .../app/components/login/index.jsx | 25 +++---------------- .../components/passwordless-login/index.jsx | 3 +++ .../app/hooks/use-auth-modal.js | 5 ++-- .../app/pages/login/index.jsx | 2 +- .../static/translations/compiled/en-GB.json | 6 ----- .../static/translations/compiled/en-US.json | 6 ----- .../static/translations/compiled/en-XA.json | 14 ----------- .../config/default.js | 2 +- .../translations/en-GB.json | 3 --- .../translations/en-US.json | 3 --- 10 files changed, 10 insertions(+), 59 deletions(-) diff --git a/packages/template-retail-react-app/app/components/login/index.jsx b/packages/template-retail-react-app/app/components/login/index.jsx index 2fe0fb4a8b..c1ae60d1f1 100644 --- a/packages/template-retail-react-app/app/components/login/index.jsx +++ b/packages/template-retail-react-app/app/components/login/index.jsx @@ -12,14 +12,13 @@ import {Alert, Button, Stack, Text} from '@salesforce/retail-react-app/app/compo import {AlertIcon, BrandLogo} from '@salesforce/retail-react-app/app/components/icons' import StandardLogin from '@salesforce/retail-react-app/app/components/standard-login' import PasswordlessLogin from '@salesforce/retail-react-app/app/components/passwordless-login' -import SocialLogin from '@salesforce/retail-react-app/app/components/social-login' import {noop} from '@salesforce/retail-react-app/app/utils/utils' const LoginForm = ({ submitForm, handleForgotPasswordClick, + handlePasswordlessLoginClick, clickCreateAccount = noop, - clickPasswordlessLogin = noop, form, isPasswordlessEnabled = false, isSocialEnabled = false, @@ -54,6 +53,7 @@ const LoginForm = ({ @@ -79,25 +79,6 @@ const LoginForm = ({ id="login_form.action.create_account" /> - - - - - - - - - @@ -109,7 +90,7 @@ LoginForm.propTypes = { submitForm: PropTypes.func, handleForgotPasswordClick: PropTypes.func, clickCreateAccount: PropTypes.func, - clickPasswordlessLogin: PropTypes.func, + handlePasswordlessLoginClick: PropTypes.func, form: PropTypes.object, isPasswordlessEnabled: PropTypes.bool, isSocialEnabled: PropTypes.bool, diff --git a/packages/template-retail-react-app/app/components/passwordless-login/index.jsx b/packages/template-retail-react-app/app/components/passwordless-login/index.jsx index 8880d03faa..9147bd6ad3 100644 --- a/packages/template-retail-react-app/app/components/passwordless-login/index.jsx +++ b/packages/template-retail-react-app/app/components/passwordless-login/index.jsx @@ -16,6 +16,7 @@ import SocialLogin from '@salesforce/retail-react-app/app/components/social-logi const PasswordlessLogin = ({ form, handleForgotPasswordClick, + handlePasswordlessLoginClick, isSocialEnabled = false, idps = [] }) => { @@ -46,6 +47,7 @@ const PasswordlessLogin = ({ type="submit" onClick={() => { form.clearErrors('global') + handlePasswordlessLoginClick() }} isLoading={form.formState.isSubmitting} > @@ -93,6 +95,7 @@ const PasswordlessLogin = ({ PasswordlessLogin.propTypes = { form: PropTypes.object, handleForgotPasswordClick: PropTypes.func, + handlePasswordlessLoginClick: PropTypes.func, isSocialEnabled: PropTypes.bool, idps: PropTypes.arrayOf[PropTypes.string] } 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 11d2bfb13c..27fd52db3e 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 @@ -296,8 +296,7 @@ export const AuthModal = ({ form={form} submitForm={submitForm} clickCreateAccount={() => setCurrentView(REGISTER_VIEW)} - clickForgotPassword={() => setCurrentView(PASSWORD_VIEW)} - clickPasswordlessLogin={() => setCurrentView(EMAIL_VIEW)} + handlePasswordlessLoginClick={() => setCurrentView(EMAIL_VIEW)} handleForgotPasswordClick={() => setCurrentView(PASSWORD_VIEW)} isPasswordlessEnabled={isPasswordlessEnabled} isSocialEnabled={isSocialEnabled} @@ -322,7 +321,7 @@ export const AuthModal = ({ )} {!form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( - + )} 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 a3fe9f5dbd..93704f9ec0 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -109,7 +109,7 @@ const Login = () => { form={form} submitForm={submitForm} clickCreateAccount={() => navigate('/registration')} - clickPasswordlessLogin={() => navigate('/check-email')} + handlePasswordlessLoginClick={() => navigate('/check-email')} handleForgotPasswordClick={() => navigate('/reset-password')} isPasswordlessEnabled={passwordless?.enabled} isSocialEnabled={social?.enabled} 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 c09648b208..00faf9561d 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 @@ -1,10 +1,4 @@ { - "E4HPGd": [ - { - "type": 0, - "value": "Passwordless Login" - } - ], "account.accordion.button.my_account": [ { "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 c09648b208..00faf9561d 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 @@ -1,10 +1,4 @@ { - "E4HPGd": [ - { - "type": 0, - "value": "Passwordless Login" - } - ], "account.accordion.button.my_account": [ { "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 a8082a3bb0..9aa3a67cd2 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 @@ -1,18 +1,4 @@ { - "E4HPGd": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƥȧȧşşẇǿǿřḓŀḗḗşş Ŀǿǿɠīƞ" - }, - { - "type": 0, - "value": "]" - } - ], "account.accordion.button.my_account": [ { "type": 0, diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index b1dd42f5d7..5210724300 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -21,7 +21,7 @@ module.exports = { callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c' }, social: { - enabled: false, + enabled: true, idps: ['google', 'apple'] } }, diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index fb75fb5cc8..8089062a65 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1,7 +1,4 @@ { - "E4HPGd": { - "defaultMessage": "Passwordless Login" - }, "account.accordion.button.my_account": { "defaultMessage": "My Account" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index fb75fb5cc8..8089062a65 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1,7 +1,4 @@ { - "E4HPGd": { - "defaultMessage": "Passwordless Login" - }, "account.accordion.button.my_account": { "defaultMessage": "My Account" }, From 59b4eb2d4058c51c92edbda43b94a0755c0a21f8 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 6 Nov 2024 11:45:33 -0500 Subject: [PATCH 006/100] useref for submitted email --- .../app/components/login/index.jsx | 7 +++++-- .../app/components/passwordless-login/index.jsx | 12 ++++++++++-- .../app/hooks/use-auth-modal.js | 3 ++- .../app/pages/check-email/index.jsx | 7 ++++++- .../app/pages/login/index.jsx | 10 ++++++++-- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/template-retail-react-app/app/components/login/index.jsx b/packages/template-retail-react-app/app/components/login/index.jsx index c1ae60d1f1..13125eb6a8 100644 --- a/packages/template-retail-react-app/app/components/login/index.jsx +++ b/packages/template-retail-react-app/app/components/login/index.jsx @@ -22,7 +22,8 @@ const LoginForm = ({ form, isPasswordlessEnabled = false, isSocialEnabled = false, - idps = [] + idps = [], + submittedEmail = '' }) => { return ( @@ -56,6 +57,7 @@ const LoginForm = ({ handlePasswordlessLoginClick={handlePasswordlessLoginClick} isSocialEnabled={isSocialEnabled} idps={idps} + submittedEmail={submittedEmail} /> ) : ( { const [showPasswordView, setShowPasswordView] = useState(false) @@ -33,6 +34,11 @@ const PasswordlessLogin = ({ } } + const updateSubmittedEmailRef = () => { + console.log('hellooooooooo') + submittedEmail.current = document.getElementById('email').value + } + return ( <> {((!form.formState.isSubmitSuccessful && !showPasswordView) || @@ -47,6 +53,7 @@ const PasswordlessLogin = ({ type="submit" onClick={() => { form.clearErrors('global') + updateSubmittedEmailRef() handlePasswordlessLoginClick() }} isLoading={form.formState.isSubmitting} @@ -97,7 +104,8 @@ PasswordlessLogin.propTypes = { handleForgotPasswordClick: PropTypes.func, handlePasswordlessLoginClick: PropTypes.func, isSocialEnabled: PropTypes.bool, - idps: PropTypes.arrayOf[PropTypes.string] + idps: PropTypes.arrayOf[PropTypes.string], + submittedEmail: PropTypes.string } export default PasswordlessLogin 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 27fd52db3e..3579dfd09e 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 @@ -301,6 +301,7 @@ export const AuthModal = ({ isPasswordlessEnabled={isPasswordlessEnabled} isSocialEnabled={isSocialEnabled} idps={idps} + submittedEmail={submittedEmail} /> )} {!form.formState.isSubmitSuccessful && currentView === REGISTER_VIEW && ( @@ -321,7 +322,7 @@ export const AuthModal = ({ )} {!form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( - + )} diff --git a/packages/template-retail-react-app/app/pages/check-email/index.jsx b/packages/template-retail-react-app/app/pages/check-email/index.jsx index 45d3499123..43a36b18b2 100644 --- a/packages/template-retail-react-app/app/pages/check-email/index.jsx +++ b/packages/template-retail-react-app/app/pages/check-email/index.jsx @@ -6,11 +6,16 @@ */ import React from 'react' +import {useLocation} from 'react-router-dom' import {Box, Container} from '@salesforce/retail-react-app/app/components/shared/ui' import Seo from '@salesforce/retail-react-app/app/components/seo' import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' const CheckEmailPage = () => { + const location = useLocation() + console.log('location.state = ' + location.state) + const email = '' + return ( @@ -23,7 +28,7 @@ const CheckEmailPage = () => { marginBottom={8} borderRadius="base" > - + ) 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 93704f9ec0..f0e8cff4f3 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useEffect} from 'react' +import React, {useEffect, useRef} from 'react' import PropTypes from 'prop-types' import {useIntl, defineMessage} from 'react-intl' import {Box, Container} from '@salesforce/retail-react-app/app/components/shared/ui' @@ -35,6 +35,7 @@ const Login = () => { const {formatMessage} = useIntl() const navigate = useNavigation() const form = useForm() + const submittedEmail = useRef() const location = useLocation() const einstein = useEinstein() const {isRegistered, customerType} = useCustomerType() @@ -109,11 +110,16 @@ const Login = () => { form={form} submitForm={submitForm} clickCreateAccount={() => navigate('/registration')} - handlePasswordlessLoginClick={() => navigate('/check-email')} + handlePasswordlessLoginClick={() => { + navigate('/check-email', { + state: {email: submittedEmail.current} + }) + }} handleForgotPasswordClick={() => navigate('/reset-password')} isPasswordlessEnabled={passwordless?.enabled} isSocialEnabled={social?.enabled} idps={social?.idps} + submittedEmail={submittedEmail} /> From 3a2bc7ff75bb8caa6250ab37a0a55b177b34a5be Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 6 Nov 2024 11:50:39 -0500 Subject: [PATCH 007/100] check that the email is passed and rendered --- .../app/components/check-email/index.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/template-retail-react-app/app/components/check-email/index.test.js diff --git a/packages/template-retail-react-app/app/components/check-email/index.test.js b/packages/template-retail-react-app/app/components/check-email/index.test.js new file mode 100644 index 0000000000..3269a5f6a4 --- /dev/null +++ b/packages/template-retail-react-app/app/components/check-email/index.test.js @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024, 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 {screen} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' + +test('renders CheckEmail component with passed email', () => { + const email = 'test@salesforce.com' + renderWithProviders() + expect(screen.getByText(email)).toBeInTheDocument() +}) From 60b22fe796923bc3875a3247a2fed8a185cfd709 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 6 Nov 2024 12:20:44 -0500 Subject: [PATCH 008/100] check that email view renders the email modal --- .../app/hooks/use-auth-modal.js | 4 ++-- .../app/hooks/use-auth-modal.test.js | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) 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 3579dfd09e..b01c68197e 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 @@ -331,7 +331,7 @@ export const AuthModal = ({ } AuthModal.propTypes = { - initialView: PropTypes.oneOf([LOGIN_VIEW, REGISTER_VIEW, PASSWORD_VIEW]), + initialView: PropTypes.oneOf([LOGIN_VIEW, REGISTER_VIEW, PASSWORD_VIEW, EMAIL_VIEW]), isOpen: PropTypes.bool.isRequired, onOpen: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, @@ -344,7 +344,7 @@ AuthModal.propTypes = { /** * - * @param {('register'|'login'|'password')} initialView - the initial view for the modal + * @param {('register'|'login'|'password'|'email')} initialView - the initial view for the modal * @returns {Object} - Object props to be spread on to the AuthModal component */ export const useAuthModal = (initialView = LOGIN_VIEW) => { 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 920d8eb432..67d7389e31 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 @@ -121,6 +121,20 @@ test('Renders login modal by default', async () => { }) }) +test('Renders check email modal on email mode', async () => { + const user = userEvent.setup() + + renderWithProviders() + + // open the modal + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/check your email/i)).toBeInTheDocument() + }) +}) + // TODO: Fix flaky/broken test // eslint-disable-next-line jest/no-disabled-tests test.skip('Renders error when given incorrect log in credentials', async () => { From 2fc643df0f0476450eaf9ce5b44276a9969510c9 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 7 Nov 2024 11:51:53 -0500 Subject: [PATCH 009/100] disable social login by default --- packages/template-retail-react-app/config/default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 5210724300..b1dd42f5d7 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -21,7 +21,7 @@ module.exports = { callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c' }, social: { - enabled: true, + enabled: false, idps: ['google', 'apple'] } }, From e667f247325f1c4436bfd2971e3e4116f91c1e87 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 7 Nov 2024 12:49:16 -0500 Subject: [PATCH 010/100] remove checkemail page --- .../app/components/login/index.jsx | 4 +- .../components/passwordless-login/index.jsx | 5 +- .../app/pages/check-email/index.jsx | 41 --------------- .../app/pages/check-email/index.test.js | 16 ------ .../app/pages/login/index.jsx | 50 ++++++++++++------- .../template-retail-react-app/app/routes.jsx | 6 --- 6 files changed, 37 insertions(+), 85 deletions(-) delete mode 100644 packages/template-retail-react-app/app/pages/check-email/index.jsx delete mode 100644 packages/template-retail-react-app/app/pages/check-email/index.test.js diff --git a/packages/template-retail-react-app/app/components/login/index.jsx b/packages/template-retail-react-app/app/components/login/index.jsx index 13125eb6a8..919222b4c7 100644 --- a/packages/template-retail-react-app/app/components/login/index.jsx +++ b/packages/template-retail-react-app/app/components/login/index.jsx @@ -23,7 +23,7 @@ const LoginForm = ({ isPasswordlessEnabled = false, isSocialEnabled = false, idps = [], - submittedEmail = '' + submittedEmail }) => { return ( @@ -97,7 +97,7 @@ LoginForm.propTypes = { isPasswordlessEnabled: PropTypes.bool, isSocialEnabled: PropTypes.bool, idps: PropTypes.array[PropTypes.string], - submittedEmail: PropTypes.string + submittedEmail: PropTypes.object } export default LoginForm diff --git a/packages/template-retail-react-app/app/components/passwordless-login/index.jsx b/packages/template-retail-react-app/app/components/passwordless-login/index.jsx index ddc7f59774..15cb295014 100644 --- a/packages/template-retail-react-app/app/components/passwordless-login/index.jsx +++ b/packages/template-retail-react-app/app/components/passwordless-login/index.jsx @@ -19,7 +19,7 @@ const PasswordlessLogin = ({ handlePasswordlessLoginClick, isSocialEnabled = false, idps = [], - submittedEmail = '' + submittedEmail }) => { const [showPasswordView, setShowPasswordView] = useState(false) @@ -35,7 +35,6 @@ const PasswordlessLogin = ({ } const updateSubmittedEmailRef = () => { - console.log('hellooooooooo') submittedEmail.current = document.getElementById('email').value } @@ -105,7 +104,7 @@ PasswordlessLogin.propTypes = { handlePasswordlessLoginClick: PropTypes.func, isSocialEnabled: PropTypes.bool, idps: PropTypes.arrayOf[PropTypes.string], - submittedEmail: PropTypes.string + submittedEmail: PropTypes.object } export default PasswordlessLogin diff --git a/packages/template-retail-react-app/app/pages/check-email/index.jsx b/packages/template-retail-react-app/app/pages/check-email/index.jsx deleted file mode 100644 index 43a36b18b2..0000000000 --- a/packages/template-retail-react-app/app/pages/check-email/index.jsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 {useLocation} from 'react-router-dom' -import {Box, Container} from '@salesforce/retail-react-app/app/components/shared/ui' -import Seo from '@salesforce/retail-react-app/app/components/seo' -import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' - -const CheckEmailPage = () => { - const location = useLocation() - console.log('location.state = ' + location.state) - const email = '' - - return ( - - - - - - - ) -} - -CheckEmailPage.getTemplateName = () => 'check-email' - -CheckEmailPage.propTypes = {} - -export default CheckEmailPage diff --git a/packages/template-retail-react-app/app/pages/check-email/index.test.js b/packages/template-retail-react-app/app/pages/check-email/index.test.js deleted file mode 100644 index 6f3007862f..0000000000 --- a/packages/template-retail-react-app/app/pages/check-email/index.test.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 {screen} from '@testing-library/react' -import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import CheckEmailPage from '@salesforce/retail-react-app/app/pages/check-email' - -test('Check Email Page renders without errors', () => { - renderWithProviders() - expect(screen.getByText('Check Your Email')).toBeInTheDocument() - expect(typeof CheckEmailPage.getTemplateName()).toBe('string') -}) 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 f0e8cff4f3..f03429690f 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useEffect, useRef} from 'react' +import React, {useEffect, useRef, useState} from 'react' import PropTypes from 'prop-types' import {useIntl, defineMessage} from 'react-intl' import {Box, Container} from '@salesforce/retail-react-app/app/components/shared/ui' @@ -23,6 +23,7 @@ import {useForm} from 'react-hook-form' import {useLocation} from 'react-router-dom' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import LoginForm from '@salesforce/retail-react-app/app/components/login' +import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' @@ -31,7 +32,11 @@ const LOGIN_ERROR_MESSAGE = defineMessage({ defaultMessage: 'Incorrect username or password, please try again.', id: 'login_page.error.incorrect_username_or_password' }) -const Login = () => { + +const LOGIN_VIEW = 'login' +const EMAIL_VIEW = 'email' + +const Login = ({initialView = LOGIN_VIEW}) => { const {formatMessage} = useIntl() const navigate = useNavigation() const form = useForm() @@ -41,6 +46,9 @@ const Login = () => { const {isRegistered, customerType} = useCustomerType() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const {passwordless, social} = getConfig().app.login + const isPasswordlessEnabled = !!passwordless?.enabled + const isSocialEnabled = !!social?.enabled + const idps = social?.idps const customerId = useCustomerId() const prevAuthType = usePrevious(customerType) @@ -49,6 +57,11 @@ const Login = () => { {enabled: !!customerId && !isServer, keepPreviousData: true} ) const mergeBasket = useShopperBasketsMutation('mergeBasket') + const [currentView, setCurrentView] = useState(initialView) + + const handlePasswordLoginClick = () => { + setCurrentView(EMAIL_VIEW) + } const submitForm = async (data) => { try { @@ -94,6 +107,7 @@ const Login = () => { useEffect(() => { einstein.sendViewPage(location.pathname) }, []) + return ( @@ -106,21 +120,22 @@ const Login = () => { marginBottom={8} borderRadius="base" > - navigate('/registration')} - handlePasswordlessLoginClick={() => { - navigate('/check-email', { - state: {email: submittedEmail.current} - }) - }} - handleForgotPasswordClick={() => navigate('/reset-password')} - isPasswordlessEnabled={passwordless?.enabled} - isSocialEnabled={social?.enabled} - idps={social?.idps} - submittedEmail={submittedEmail} - /> + {!form.formState.isSubmitSuccessful && currentView === LOGIN_VIEW && ( + navigate('/registration')} + handlePasswordlessLoginClick={handlePasswordLoginClick} + handleForgotPasswordClick={() => navigate('/reset-password')} + isPasswordlessEnabled={isPasswordlessEnabled} + isSocialEnabled={isSocialEnabled} + idps={idps} + submittedEmail={submittedEmail} + /> + )} + {!form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( + + )} ) @@ -129,6 +144,7 @@ const Login = () => { Login.getTemplateName = () => 'login' Login.propTypes = { + initialView: PropTypes.oneOf([LOGIN_VIEW, EMAIL_VIEW]), match: PropTypes.object } diff --git a/packages/template-retail-react-app/app/routes.jsx b/packages/template-retail-react-app/app/routes.jsx index b223ea0cbf..b2b1be2480 100644 --- a/packages/template-retail-react-app/app/routes.jsx +++ b/packages/template-retail-react-app/app/routes.jsx @@ -29,7 +29,6 @@ const Registration = loadable(() => import('./pages/registration'), { fallback }) const ResetPassword = loadable(() => import('./pages/reset-password'), {fallback}) -const CheckEmailPage = loadable(() => import('./pages/check-email'), {fallback}) const Account = loadable(() => import('./pages/account'), {fallback}) const Cart = loadable(() => import('./pages/cart'), {fallback}) const Checkout = loadable(() => import('./pages/checkout'), { @@ -71,11 +70,6 @@ export const routes = [ component: ResetPassword, exact: true }, - { - path: '/check-email', - component: CheckEmailPage, - exact: true - }, { path: '/account', component: Account From 5bfa864791c699bd2e3087e5b862089ad5b4d78a Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 7 Nov 2024 12:51:17 -0500 Subject: [PATCH 011/100] move setcurrentview --- .../template-retail-react-app/app/pages/login/index.jsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 f03429690f..b54f2ea90d 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -59,10 +59,6 @@ const Login = ({initialView = LOGIN_VIEW}) => { const mergeBasket = useShopperBasketsMutation('mergeBasket') const [currentView, setCurrentView] = useState(initialView) - const handlePasswordLoginClick = () => { - setCurrentView(EMAIL_VIEW) - } - const submitForm = async (data) => { try { await login.mutateAsync({username: data.email, password: data.password}) @@ -125,7 +121,9 @@ const Login = ({initialView = LOGIN_VIEW}) => { form={form} submitForm={submitForm} clickCreateAccount={() => navigate('/registration')} - handlePasswordlessLoginClick={handlePasswordLoginClick} + handlePasswordlessLoginClick={() => { + setCurrentView(EMAIL_VIEW) + }} handleForgotPasswordClick={() => navigate('/reset-password')} isPasswordlessEnabled={isPasswordlessEnabled} isSocialEnabled={isSocialEnabled} From 6da68a3136ae100263ffbe942db179970e2da25e Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 7 Nov 2024 13:40:35 -0500 Subject: [PATCH 012/100] update submitform logic based on login flow --- .../app/components/login/index.jsx | 7 +- .../components/passwordless-login/index.jsx | 16 +--- .../app/hooks/use-auth-modal.js | 69 +++++++++-------- .../app/pages/login/index.jsx | 74 +++++++++++-------- 4 files changed, 89 insertions(+), 77 deletions(-) diff --git a/packages/template-retail-react-app/app/components/login/index.jsx b/packages/template-retail-react-app/app/components/login/index.jsx index 919222b4c7..c1ae60d1f1 100644 --- a/packages/template-retail-react-app/app/components/login/index.jsx +++ b/packages/template-retail-react-app/app/components/login/index.jsx @@ -22,8 +22,7 @@ const LoginForm = ({ form, isPasswordlessEnabled = false, isSocialEnabled = false, - idps = [], - submittedEmail + idps = [] }) => { return ( @@ -57,7 +56,6 @@ const LoginForm = ({ handlePasswordlessLoginClick={handlePasswordlessLoginClick} isSocialEnabled={isSocialEnabled} idps={idps} - submittedEmail={submittedEmail} /> ) : ( { const [showPasswordView, setShowPasswordView] = useState(false) @@ -34,10 +33,6 @@ const PasswordlessLogin = ({ } } - const updateSubmittedEmailRef = () => { - submittedEmail.current = document.getElementById('email').value - } - return ( <> {((!form.formState.isSubmitSuccessful && !showPasswordView) || @@ -50,11 +45,7 @@ const PasswordlessLogin = ({ /> - - - ) -} - -CheckEmail.propTypes = { - email: PropTypes.string -} - -export default CheckEmail diff --git a/packages/template-retail-react-app/app/components/email-confirmation/index.jsx b/packages/template-retail-react-app/app/components/email-confirmation/index.jsx new file mode 100644 index 0000000000..a755cdff14 --- /dev/null +++ b/packages/template-retail-react-app/app/components/email-confirmation/index.jsx @@ -0,0 +1,64 @@ +/* + * 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 PropTypes from 'prop-types' +import {FormattedMessage} from 'react-intl' +import {Button, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' +import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' + +const PasswordlessEmailConfirmation = ({form, submitForm, email = ''}) => { + return ( +
+ + + + + + + {chunks} + }} + /> + + + + + + + + + +
+ ) +} + +PasswordlessEmailConfirmation.propTypes = { + form: PropTypes.object, + submitForm: PropTypes.func, + email: PropTypes.string +} + +export default PasswordlessEmailConfirmation diff --git a/packages/template-retail-react-app/app/components/check-email/index.test.js b/packages/template-retail-react-app/app/components/email-confirmation/index.test.js similarity index 52% rename from packages/template-retail-react-app/app/components/check-email/index.test.js rename to packages/template-retail-react-app/app/components/email-confirmation/index.test.js index 3269a5f6a4..e186681b8c 100644 --- a/packages/template-retail-react-app/app/components/check-email/index.test.js +++ b/packages/template-retail-react-app/app/components/email-confirmation/index.test.js @@ -7,10 +7,16 @@ import React from 'react' import {screen} from '@testing-library/react' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' +import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' +import {useForm} from 'react-hook-form' -test('renders CheckEmail component with passed email', () => { +const WrapperComponent = ({...props}) => { + const form = useForm() + return +} + +test('renders PasswordlessEmailConfirmation component with passed email', () => { const email = 'test@salesforce.com' - renderWithProviders() + renderWithProviders() expect(screen.getByText(email)).toBeInTheDocument() }) 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 fee3515f34..5da2e164d2 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 @@ -35,7 +35,7 @@ import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' import LoginForm from '@salesforce/retail-react-app/app/components/login' import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset-password' import RegisterForm from '@salesforce/retail-react-app/app/components/register' -import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' +import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' import {noop} from '@salesforce/retail-react-app/app/utils/utils' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' @@ -174,6 +174,9 @@ export const AuthModal = ({ message: formatMessage(API_ERROR_MESSAGE) }) } + }, + email: async (data) => { + // Handle resend passwordless email logic here } }[currentView](data) } @@ -332,8 +335,12 @@ export const AuthModal = ({ {form.formState.isSubmitSuccessful && currentView === PASSWORD_VIEW && ( )} - {!form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( - + {form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( + )} 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 62cf9b697c..544b7e18d9 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -23,7 +23,7 @@ import {useForm} from 'react-hook-form' import {useLocation} from 'react-router-dom' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import LoginForm from '@salesforce/retail-react-app/app/components/login' -import CheckEmail from '@salesforce/retail-react-app/app/components/check-email' +import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' @@ -99,6 +99,9 @@ const Login = ({initialView = LOGIN_VIEW}) => { } else if (loginType === 'social') { // Handle social login logic here } + }, + email: async (data) => { + // Handle resend passwordless email logic here } }[currentView](data) } @@ -146,7 +149,11 @@ const Login = ({initialView = LOGIN_VIEW}) => { /> )} {form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( - + )} From 7d77678c7dfdfabf47ddddca6f04e0637e83fa76 Mon Sep 17 00:00:00 2001 From: Brian Redmond Date: Mon, 11 Nov 2024 17:00:18 -0500 Subject: [PATCH 014/100] First pass --- packages/template-retail-react-app/app/ssr.js | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 566af3e496..82acd2231a 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -22,6 +22,85 @@ import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/mi import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import helmet from 'helmet' +import crypto from 'crypto' +import express from 'express' +import https from 'https' + +/* + Make a POST request using native https module, wrapped in a Promise with JSON + encode and decode +*/ +function asyncJsonHttpsPost(options, postObject) { + return new Promise((resolve, reject) => { + const req = https.request(options, (response) => { + let data = ''; + + response.on('data', (chunk) => { + data += chunk; + }); + + response.on('end', () => { + resolve(JSON.parse(data)); + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.write(JSON.stringify(postObject)); + + req.end(); + }); +} + +async function emailLink(emailId, templateId, magicLink) { + + function generateUniqueId() { + return crypto.randomBytes(16).toString('hex'); + } + + const tokenOptions = { + method: 'POST', + host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com`, + path: '/v2/token', + headers: { + 'Content-Type': 'application/json', + }, + }; + + const tokenBody = { + grant_type: 'client_credentials', + client_id: process.env.MARKETING_CLOUD_CLIENT_ID, + client_secret: process.env.MARKETING_CLOUD_CLIENT_SECRET + } + + const token = (await asyncJsonHttpsPost(tokenOptions, tokenBody)).access_token + + const emailOptions = { + method: 'POST', + host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.rest.marketingcloudapis.com`, + path: `/messaging/v1/email/messages/${generateUniqueId()}`, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }; + + const emailBody = { + definitionKey: templateId, + recipient: { + //contactKey: 'Jinsu Ha', + to: emailId, + attributes: { 'magic-link': magicLink }, + }, + } + + const emailResponse = await asyncJsonHttpsPost(emailOptions, emailBody) + + return emailResponse +} + const options = { // The build directory (an absolute path) buildDir: path.resolve(process.cwd(), 'build'), @@ -83,6 +162,7 @@ const {handler} = runtime.createHandler(options, (app) => { } }) ) + app.use(express.json()) // Handle the redirect from SLAS as to avoid error app.get('/callback?*', (req, res) => { @@ -92,6 +172,30 @@ const {handler} = runtime.createHandler(options, (app) => { res.send() }) + app.post('/passwordless-login-callback', async (req, res) => { + const base = req.protocol + '://' + req.get('host') + const { + email_id, + token + } = req.body + const magicLink = `${base}/passwordless-login?email=${email_id}&token=${token}` + res.send(magicLink) + //const emailLinkResponse = await emailLink(email_id, process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, magicLink) + //res.send(emailLinkResponse) + }) + + app.post('/reset-password-callback', async (req, res) => { + const base = req.protocol + '://' + req.get('host') + const { + email_id, + token + } = req.body + const magicLink = `${base}/reset-password?email=${email_id}&token=${token}` + res.send(magicLink) + //const emailLinkResponse = await emailLink(email_id, process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, magicLink) + //res.send(emailLinkResponse) + }) + app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico')) From c4f76a278745e4e7a6e1fa7f4b460714b04b917b Mon Sep 17 00:00:00 2001 From: Brian Redmond Date: Tue, 12 Nov 2024 09:05:52 -0500 Subject: [PATCH 015/100] Fix contact key and send email --- packages/template-retail-react-app/app/ssr.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 82acd2231a..54427d5606 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -90,7 +90,7 @@ async function emailLink(emailId, templateId, magicLink) { const emailBody = { definitionKey: templateId, recipient: { - //contactKey: 'Jinsu Ha', + contactKey: emailId, to: emailId, attributes: { 'magic-link': magicLink }, }, @@ -179,9 +179,8 @@ const {handler} = runtime.createHandler(options, (app) => { token } = req.body const magicLink = `${base}/passwordless-login?email=${email_id}&token=${token}` - res.send(magicLink) - //const emailLinkResponse = await emailLink(email_id, process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, magicLink) - //res.send(emailLinkResponse) + const emailLinkResponse = await emailLink(email_id, process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, magicLink) + res.send(emailLinkResponse) }) app.post('/reset-password-callback', async (req, res) => { From f069a4d9f33b2cde2f59218b5eb9b67df33588b1 Mon Sep 17 00:00:00 2001 From: Brian Redmond Date: Tue, 12 Nov 2024 10:05:12 -0500 Subject: [PATCH 016/100] Move email link to external file --- .../app/marketing-cloud-email-link.js | 81 ++++++++++++++++++ packages/template-retail-react-app/app/ssr.js | 82 +------------------ 2 files changed, 84 insertions(+), 79 deletions(-) create mode 100644 packages/template-retail-react-app/app/marketing-cloud-email-link.js diff --git a/packages/template-retail-react-app/app/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/marketing-cloud-email-link.js new file mode 100644 index 0000000000..397a348868 --- /dev/null +++ b/packages/template-retail-react-app/app/marketing-cloud-email-link.js @@ -0,0 +1,81 @@ +import crypto from 'crypto' +import https from 'https' + +/* + Make a POST request using native https module, wrapped in a Promise with JSON + encode and decode +*/ +function asyncJsonHttpsPost(options, postObject) { + return new Promise((resolve, reject) => { + const req = https.request(options, (response) => { + let data = ''; + + response.on('data', (chunk) => { + data += chunk; + }); + + response.on('end', () => { + resolve(JSON.parse(data)); + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.write(JSON.stringify(postObject)); + + req.end(); + }); +} + +async function emailLink(emailId, templateId, magicLink) { + + function generateUniqueId() { + return crypto.randomBytes(16).toString('hex'); + } + + const tokenOptions = { + method: 'POST', + host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com`, + path: '/v2/token', + headers: { + 'Content-Type': 'application/json', + }, + }; + + const tokenBody = { + grant_type: 'client_credentials', + client_id: process.env.MARKETING_CLOUD_CLIENT_ID, + client_secret: process.env.MARKETING_CLOUD_CLIENT_SECRET + } + + const token = (await asyncJsonHttpsPost(tokenOptions, tokenBody)).access_token + + const emailOptions = { + method: 'POST', + host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.rest.marketingcloudapis.com`, + path: `/messaging/v1/email/messages/${generateUniqueId()}`, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }; + + const emailBody = { + definitionKey: templateId, + recipient: { + contactKey: emailId, + to: emailId, + attributes: { 'magic-link': magicLink }, + }, + } + + const emailResponse = await asyncJsonHttpsPost(emailOptions, emailBody) + + return emailResponse +} + +module.exports = { + emailLink +}; \ No newline at end of file diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 54427d5606..786633a957 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -22,84 +22,9 @@ import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/mi import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import helmet from 'helmet' -import crypto from 'crypto' import express from 'express' -import https from 'https' -/* - Make a POST request using native https module, wrapped in a Promise with JSON - encode and decode -*/ -function asyncJsonHttpsPost(options, postObject) { - return new Promise((resolve, reject) => { - const req = https.request(options, (response) => { - let data = ''; - - response.on('data', (chunk) => { - data += chunk; - }); - - response.on('end', () => { - resolve(JSON.parse(data)); - }); - }); - - req.on('error', (error) => { - reject(error); - }); - - req.write(JSON.stringify(postObject)); - - req.end(); - }); -} - -async function emailLink(emailId, templateId, magicLink) { - - function generateUniqueId() { - return crypto.randomBytes(16).toString('hex'); - } - - const tokenOptions = { - method: 'POST', - host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com`, - path: '/v2/token', - headers: { - 'Content-Type': 'application/json', - }, - }; - - const tokenBody = { - grant_type: 'client_credentials', - client_id: process.env.MARKETING_CLOUD_CLIENT_ID, - client_secret: process.env.MARKETING_CLOUD_CLIENT_SECRET - } - - const token = (await asyncJsonHttpsPost(tokenOptions, tokenBody)).access_token - - const emailOptions = { - method: 'POST', - host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.rest.marketingcloudapis.com`, - path: `/messaging/v1/email/messages/${generateUniqueId()}`, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }; - - const emailBody = { - definitionKey: templateId, - recipient: { - contactKey: emailId, - to: emailId, - attributes: { 'magic-link': magicLink }, - }, - } - - const emailResponse = await asyncJsonHttpsPost(emailOptions, emailBody) - - return emailResponse -} +import { emailLink } from './marketing-cloud-email-link' const options = { // The build directory (an absolute path) @@ -190,9 +115,8 @@ const {handler} = runtime.createHandler(options, (app) => { token } = req.body const magicLink = `${base}/reset-password?email=${email_id}&token=${token}` - res.send(magicLink) - //const emailLinkResponse = await emailLink(email_id, process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, magicLink) - //res.send(emailLinkResponse) + const emailLinkResponse = await emailLink(email_id, process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, magicLink) + res.send(emailLinkResponse) }) app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) From 6d66a183cc95b09146ad2ca938f57291b49f94c8 Mon Sep 17 00:00:00 2001 From: Brian Redmond Date: Tue, 12 Nov 2024 10:19:17 -0500 Subject: [PATCH 017/100] Cache token --- .../app/marketing-cloud-email-link.js | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/template-retail-react-app/app/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/marketing-cloud-email-link.js index 397a348868..9e838ee668 100644 --- a/packages/template-retail-react-app/app/marketing-cloud-email-link.js +++ b/packages/template-retail-react-app/app/marketing-cloud-email-link.js @@ -1,6 +1,9 @@ import crypto from 'crypto' import https from 'https' +let marketingCloudToken = "" +let marketingCloudTokenExpiration = new Date() + /* Make a POST request using native https module, wrapped in a Promise with JSON encode and decode @@ -34,23 +37,29 @@ async function emailLink(emailId, templateId, magicLink) { function generateUniqueId() { return crypto.randomBytes(16).toString('hex'); } + console.log(marketingCloudToken) - const tokenOptions = { - method: 'POST', - host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com`, - path: '/v2/token', - headers: { - 'Content-Type': 'application/json', - }, - }; + if (new Date() > marketingCloudTokenExpiration) { + const tokenOptions = { + method: 'POST', + host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com`, + path: '/v2/token', + headers: { + 'Content-Type': 'application/json', + }, + }; - const tokenBody = { - grant_type: 'client_credentials', - client_id: process.env.MARKETING_CLOUD_CLIENT_ID, - client_secret: process.env.MARKETING_CLOUD_CLIENT_SECRET - } + const tokenBody = { + grant_type: 'client_credentials', + client_id: process.env.MARKETING_CLOUD_CLIENT_ID, + client_secret: process.env.MARKETING_CLOUD_CLIENT_SECRET + } - const token = (await asyncJsonHttpsPost(tokenOptions, tokenBody)).access_token + marketingCloudToken = (await asyncJsonHttpsPost(tokenOptions, tokenBody)).access_token + + marketingCloudTokenExpiration = new Date(); + marketingCloudTokenExpiration.setTime(marketingCloudTokenExpiration.getTime() + 15 * 60 * 1000); + } const emailOptions = { method: 'POST', @@ -58,7 +67,7 @@ async function emailLink(emailId, templateId, magicLink) { path: `/messaging/v1/email/messages/${generateUniqueId()}`, headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, + 'Authorization': `Bearer ${marketingCloudToken}`, }, }; From 3457660aa6f21aa8d7894911efffe061a15d9237 Mon Sep 17 00:00:00 2001 From: Brian Redmond Date: Tue, 12 Nov 2024 10:30:21 -0500 Subject: [PATCH 018/100] Fix format --- .../app/marketing-cloud-email-link.js | 80 +++++++++---------- packages/template-retail-react-app/app/ssr.js | 24 +++--- 2 files changed, 53 insertions(+), 51 deletions(-) diff --git a/packages/template-retail-react-app/app/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/marketing-cloud-email-link.js index 9e838ee668..a8b6bd9b80 100644 --- a/packages/template-retail-react-app/app/marketing-cloud-email-link.js +++ b/packages/template-retail-react-app/app/marketing-cloud-email-link.js @@ -1,7 +1,7 @@ import crypto from 'crypto' import https from 'https' -let marketingCloudToken = "" +let marketingCloudToken = '' let marketingCloudTokenExpiration = new Date() /* @@ -11,43 +11,41 @@ let marketingCloudTokenExpiration = new Date() function asyncJsonHttpsPost(options, postObject) { return new Promise((resolve, reject) => { const req = https.request(options, (response) => { - let data = ''; + let data = '' response.on('data', (chunk) => { - data += chunk; - }); + data += chunk + }) response.on('end', () => { - resolve(JSON.parse(data)); - }); - }); + resolve(JSON.parse(data)) + }) + }) req.on('error', (error) => { - reject(error); - }); + reject(error) + }) - req.write(JSON.stringify(postObject)); + req.write(JSON.stringify(postObject)) - req.end(); - }); + req.end() + }) } async function emailLink(emailId, templateId, magicLink) { + function generateUniqueId() { + return crypto.randomBytes(16).toString('hex') + } - function generateUniqueId() { - return crypto.randomBytes(16).toString('hex'); - } - console.log(marketingCloudToken) - - if (new Date() > marketingCloudTokenExpiration) { + if (new Date() > marketingCloudTokenExpiration) { const tokenOptions = { method: 'POST', host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com`, path: '/v2/token', headers: { - 'Content-Type': 'application/json', - }, - }; + 'Content-Type': 'application/json' + } + } const tokenBody = { grant_type: 'client_credentials', @@ -57,34 +55,36 @@ async function emailLink(emailId, templateId, magicLink) { marketingCloudToken = (await asyncJsonHttpsPost(tokenOptions, tokenBody)).access_token - marketingCloudTokenExpiration = new Date(); - marketingCloudTokenExpiration.setTime(marketingCloudTokenExpiration.getTime() + 15 * 60 * 1000); - } + marketingCloudTokenExpiration = new Date() + marketingCloudTokenExpiration.setTime( + marketingCloudTokenExpiration.getTime() + 15 * 60 * 1000 + ) + } - const emailOptions = { + const emailOptions = { method: 'POST', host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.rest.marketingcloudapis.com`, path: `/messaging/v1/email/messages/${generateUniqueId()}`, headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${marketingCloudToken}`, - }, - }; + 'Content-Type': 'application/json', + Authorization: `Bearer ${marketingCloudToken}` + } + } - const emailBody = { + const emailBody = { definitionKey: templateId, recipient: { - contactKey: emailId, - to: emailId, - attributes: { 'magic-link': magicLink }, - }, - } - - const emailResponse = await asyncJsonHttpsPost(emailOptions, emailBody) - - return emailResponse + contactKey: emailId, + to: emailId, + attributes: {'magic-link': magicLink} + } + } + + const emailResponse = await asyncJsonHttpsPost(emailOptions, emailBody) + + return emailResponse } module.exports = { emailLink -}; \ No newline at end of file +} diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 786633a957..0108d202a0 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -24,7 +24,7 @@ import helmet from 'helmet' import express from 'express' -import { emailLink } from './marketing-cloud-email-link' +import {emailLink} from './marketing-cloud-email-link' const options = { // The build directory (an absolute path) @@ -99,23 +99,25 @@ const {handler} = runtime.createHandler(options, (app) => { app.post('/passwordless-login-callback', async (req, res) => { const base = req.protocol + '://' + req.get('host') - const { - email_id, - token - } = req.body + const {email_id, token} = req.body const magicLink = `${base}/passwordless-login?email=${email_id}&token=${token}` - const emailLinkResponse = await emailLink(email_id, process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, magicLink) + const emailLinkResponse = await emailLink( + email_id, + process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, + magicLink + ) res.send(emailLinkResponse) }) app.post('/reset-password-callback', async (req, res) => { const base = req.protocol + '://' + req.get('host') - const { - email_id, - token - } = req.body + const {email_id, token} = req.body const magicLink = `${base}/reset-password?email=${email_id}&token=${token}` - const emailLinkResponse = await emailLink(email_id, process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, magicLink) + const emailLinkResponse = await emailLink( + email_id, + process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, + magicLink + ) res.send(emailLinkResponse) }) From c9cb595342ad6fe9227e6ebfe7314448a65e4028 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 12 Nov 2024 13:54:30 -0500 Subject: [PATCH 019/100] make passwordless login work in auth-modal --- .../app/components/_app-config/index.jsx | 2 +- .../app/hooks/use-auth-modal.js | 34 ++++++++++++++++--- packages/template-retail-react-app/app/ssr.js | 3 +- 3 files changed, 33 insertions(+), 6 deletions(-) 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 679e66e96a..9526983957 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 @@ -68,7 +68,7 @@ const AppConfig = ({children, locals = {}}) => { headers={headers} // Uncomment 'enablePWAKitPrivateClient' to use SLAS private client login flows. // Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting. - // enablePWAKitPrivateClient={true} + enablePWAKitPrivateClient={true} logger={createLogger({packageName: 'commerce-sdk-react'})} > 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 5da2e164d2..4e20f01b94 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 @@ -29,7 +29,9 @@ import { useCustomerBaskets, useShopperCustomersMutation, useShopperBasketsMutation, - ShopperCustomersMutations + ShopperCustomersMutations, + useShopperLoginMutation, + ShopperLoginMutations } from '@salesforce/commerce-sdk-react' import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' import LoginForm from '@salesforce/retail-react-app/app/components/login' @@ -41,7 +43,9 @@ import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' +import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' const LOGIN_VIEW = 'login' const REGISTER_VIEW = 'register' const PASSWORD_VIEW = 'password' @@ -83,6 +87,7 @@ export const AuthModal = ({ const register = useAuthHelper(AuthHelpers.Register) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const [loginType, setLoginType] = useState('password') + const {site} = useMultiSite() const getResetPasswordToken = useShopperCustomersMutation( ShopperCustomersMutations.GetResetPasswordToken @@ -94,6 +99,27 @@ export const AuthModal = ({ ) const mergeBasket = useShopperBasketsMutation('mergeBasket') + const authorizePasswordlessCustomer = useShopperLoginMutation( + ShopperLoginMutations.AuthorizePasswordlessCustomer + ) + + const postAuthorizePasswordlessCustomer = async (email) => { + try { + const body = { + user_id: email, + mode: 'callback', + channel_id: site.id, + callback_uri: absoluteUrl('/passwordless-login-callback') + } + await authorizePasswordlessCustomer.mutateAsync({body}) + } catch (e) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE) + }) + } + } + const submitForm = async (data) => { form.clearErrors() @@ -136,7 +162,7 @@ export const AuthModal = ({ } else if (loginType === 'passwordless') { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) - // Handle passwordless login logic here + postAuthorizePasswordlessCustomer(data.email) } else if (loginType === 'social') { // Handle social login logic here } @@ -175,8 +201,8 @@ export const AuthModal = ({ }) } }, - email: async (data) => { - // Handle resend passwordless email logic here + email: async () => { + postAuthorizePasswordlessCustomer(passwordlessLoginEmail) } }[currentView](data) } diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 0108d202a0..fdccb7d1c8 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -49,7 +49,8 @@ const options = { // Set this to false if using a SLAS public client // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - useSLASPrivateClient: false, + useSLASPrivateClient: true, + applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless\/(login|token))/, // If this is enabled, any HTTP header that has a non ASCII value will be URI encoded // If there any HTTP headers that have been encoded, an additional header will be From fe5aecb6458056da1add5c2b20ad3cb187f169d6 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 12 Nov 2024 14:34:15 -0500 Subject: [PATCH 020/100] fix check email modal showing for password login after user has logged in with paswordless once --- .../app/hooks/use-auth-modal.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 4e20f01b94..7ef57a7c8c 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 @@ -46,11 +46,18 @@ import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' + const LOGIN_VIEW = 'login' const REGISTER_VIEW = 'register' const PASSWORD_VIEW = 'password' const EMAIL_VIEW = 'email' +const LOGIN_TYPES = { + PASSWORD: 'password', + PASSWORDLESS: 'passwordless', + SOCIAL:'social' +} + const LOGIN_ERROR = defineMessage({ defaultMessage: "Something's not right with your email or password. Try again.", id: 'auth_modal.error.incorrect_email_or_password' @@ -86,7 +93,7 @@ export const AuthModal = ({ const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') - const [loginType, setLoginType] = useState('password') + const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) const {site} = useMultiSite() const getResetPasswordToken = useShopperCustomersMutation( @@ -129,7 +136,7 @@ export const AuthModal = ({ return { login: async (data) => { - if (loginType === 'password') { + if (loginType === LOGIN_TYPES.PASSWORD) { try { await login.mutateAsync({ username: data.email, @@ -159,11 +166,11 @@ export const AuthModal = ({ : formatMessage(API_ERROR_MESSAGE) form.setError('global', {type: 'manual', message}) } - } else if (loginType === 'passwordless') { + } else if (loginType === LOGIN_TYPES.PASSWORDLESS) { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) postAuthorizePasswordlessCustomer(data.email) - } else if (loginType === 'social') { + } else if (loginType === LOGIN_TYPES.SOCIAL) { // Handle social login logic here } }, @@ -210,6 +217,7 @@ export const AuthModal = ({ // Reset form and local state when opening the modal useEffect(() => { if (isOpen) { + setLoginType(LOGIN_TYPES.PASSWORD) setCurrentView(initialView) submittedEmail.current = undefined form.reset() @@ -337,7 +345,7 @@ export const AuthModal = ({ form={form} submitForm={submitForm} clickCreateAccount={() => setCurrentView(REGISTER_VIEW)} - handlePasswordlessLoginClick={() => setLoginType('passwordless')} + handlePasswordlessLoginClick={() => setLoginType(LOGIN_TYPES.PASSWORDLESS)} handleForgotPasswordClick={() => setCurrentView(PASSWORD_VIEW)} isPasswordlessEnabled={isPasswordlessEnabled} isSocialEnabled={isSocialEnabled} From a18210da10a20d1306227a91eb4b678a9025bb83 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 13 Nov 2024 10:31:59 -0500 Subject: [PATCH 021/100] create use-passwordless-login hook and support passwordless in /login page --- .../app/constants.js | 7 +++ .../app/hooks/use-auth-modal.js | 60 +++++++------------ .../app/hooks/use-passwordless-login.js | 36 +++++++++++ .../app/marketing-cloud-email-link.js | 12 ++-- .../app/pages/login/index.jsx | 35 ++++++++--- 5 files changed, 99 insertions(+), 51 deletions(-) create mode 100644 packages/template-retail-react-app/app/hooks/use-passwordless-login.js diff --git a/packages/template-retail-react-app/app/constants.js b/packages/template-retail-react-app/app/constants.js index d8f96ab0ec..7ded2c3da8 100644 --- a/packages/template-retail-react-app/app/constants.js +++ b/packages/template-retail-react-app/app/constants.js @@ -231,3 +231,10 @@ export const SHOPPER_CONTEXT_SEARCH_PARAMS = { // Add assignment qualifiers here } } + +// Constants for Login +export const LOGIN_TYPES = { + PASSWORD: 'password', + PASSWORDLESS: 'passwordless', + SOCIAL: 'social' +} 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 7ef57a7c8c..125c26820a 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 @@ -29,9 +29,7 @@ import { useCustomerBaskets, useShopperCustomersMutation, useShopperBasketsMutation, - ShopperCustomersMutations, - useShopperLoginMutation, - ShopperLoginMutations + ShopperCustomersMutations } from '@salesforce/commerce-sdk-react' import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' import LoginForm from '@salesforce/retail-react-app/app/components/login' @@ -39,25 +37,18 @@ import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset import RegisterForm from '@salesforce/retail-react-app/app/components/register' import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' import {noop} from '@salesforce/retail-react-app/app/utils/utils' -import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' +import {API_ERROR_MESSAGE, LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' +import {usePasswordlessLogin} from '@salesforce/retail-react-app/app/hooks/use-passwordless-login' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' -import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' -import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' const LOGIN_VIEW = 'login' const REGISTER_VIEW = 'register' const PASSWORD_VIEW = 'password' const EMAIL_VIEW = 'email' -const LOGIN_TYPES = { - PASSWORD: 'password', - PASSWORDLESS: 'passwordless', - SOCIAL:'social' -} - const LOGIN_ERROR = defineMessage({ defaultMessage: "Something's not right with your email or password. Try again.", id: 'auth_modal.error.incorrect_email_or_password' @@ -94,7 +85,7 @@ export const AuthModal = ({ const register = useAuthHelper(AuthHelpers.Register) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) - const {site} = useMultiSite() + const {postAuthorizePasswordlessCustomer} = usePasswordlessLogin() const getResetPasswordToken = useShopperCustomersMutation( ShopperCustomersMutations.GetResetPasswordToken @@ -106,27 +97,6 @@ export const AuthModal = ({ ) const mergeBasket = useShopperBasketsMutation('mergeBasket') - const authorizePasswordlessCustomer = useShopperLoginMutation( - ShopperLoginMutations.AuthorizePasswordlessCustomer - ) - - const postAuthorizePasswordlessCustomer = async (email) => { - try { - const body = { - user_id: email, - mode: 'callback', - channel_id: site.id, - callback_uri: absoluteUrl('/passwordless-login-callback') - } - await authorizePasswordlessCustomer.mutateAsync({body}) - } catch (e) { - form.setError('global', { - type: 'manual', - message: formatMessage(API_ERROR_MESSAGE) - }) - } - } - const submitForm = async (data) => { form.clearErrors() @@ -169,7 +139,14 @@ export const AuthModal = ({ } else if (loginType === LOGIN_TYPES.PASSWORDLESS) { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) - postAuthorizePasswordlessCustomer(data.email) + try { + postAuthorizePasswordlessCustomer(data.email) + } catch (e) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE) + }) + } } else if (loginType === LOGIN_TYPES.SOCIAL) { // Handle social login logic here } @@ -209,7 +186,14 @@ export const AuthModal = ({ } }, email: async () => { - postAuthorizePasswordlessCustomer(passwordlessLoginEmail) + try { + postAuthorizePasswordlessCustomer(passwordlessLoginEmail) + } catch (e) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE) + }) + } } }[currentView](data) } @@ -345,7 +329,9 @@ export const AuthModal = ({ form={form} submitForm={submitForm} clickCreateAccount={() => setCurrentView(REGISTER_VIEW)} - handlePasswordlessLoginClick={() => setLoginType(LOGIN_TYPES.PASSWORDLESS)} + handlePasswordlessLoginClick={() => + setLoginType(LOGIN_TYPES.PASSWORDLESS) + } handleForgotPasswordClick={() => setCurrentView(PASSWORD_VIEW)} isPasswordlessEnabled={isPasswordlessEnabled} isSocialEnabled={isSocialEnabled} diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js new file mode 100644 index 0000000000..9b5c760be8 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024, 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 {useShopperLoginMutation, ShopperLoginMutations} from '@salesforce/commerce-sdk-react' +import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url' +import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' + +/** + * This hook provides commerce-react-sdk hooks to simplify the passwordless login flow. + */ +export const usePasswordlessLogin = () => { + const {site} = useMultiSite() + + const authorizePasswordlessCustomer = useShopperLoginMutation( + ShopperLoginMutations.AuthorizePasswordlessCustomer + ) + + const postAuthorizePasswordlessCustomer = async (email) => { + const body = { + user_id: email, + mode: 'callback', + channel_id: site.id, + callback_uri: absoluteUrl('/passwordless-login-callback') + } + await authorizePasswordlessCustomer.mutateAsync({body}) + } + + return { + postAuthorizePasswordlessCustomer + } +} + +export default usePasswordlessLogin diff --git a/packages/template-retail-react-app/app/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/marketing-cloud-email-link.js index a8b6bd9b80..13e16f9c8e 100644 --- a/packages/template-retail-react-app/app/marketing-cloud-email-link.js +++ b/packages/template-retail-react-app/app/marketing-cloud-email-link.js @@ -1,3 +1,9 @@ +/* + * Copyright (c) 2024, 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 crypto from 'crypto' import https from 'https' @@ -32,7 +38,7 @@ function asyncJsonHttpsPost(options, postObject) { }) } -async function emailLink(emailId, templateId, magicLink) { +export async function emailLink(emailId, templateId, magicLink) { function generateUniqueId() { return crypto.randomBytes(16).toString('hex') } @@ -84,7 +90,3 @@ async function emailLink(emailId, templateId, magicLink) { return emailResponse } - -module.exports = { - emailLink -} 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 544b7e18d9..eedfad2f12 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -24,10 +24,12 @@ import {useLocation} from 'react-router-dom' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import LoginForm from '@salesforce/retail-react-app/app/components/login' import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' -import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' +import {API_ERROR_MESSAGE, LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' +import {usePasswordlessLogin} from '@salesforce/retail-react-app/app/hooks/use-passwordless-login' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' + const LOGIN_ERROR_MESSAGE = defineMessage({ defaultMessage: 'Incorrect username or password, please try again.', id: 'login_page.error.incorrect_username_or_password' @@ -58,14 +60,15 @@ const Login = ({initialView = LOGIN_VIEW}) => { const mergeBasket = useShopperBasketsMutation('mergeBasket') const [currentView, setCurrentView] = useState(initialView) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') - const [loginType, setLoginType] = useState('password') + const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) + const {postAuthorizePasswordlessCustomer} = usePasswordlessLogin() const submitForm = async (data) => { form.clearErrors() return { login: async (data) => { - if (loginType === 'password') { + if (loginType === LOGIN_TYPES.PASSWORD) { try { await login.mutateAsync({username: data.email, password: data.password}) const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0 @@ -92,16 +95,30 @@ const Login = ({initialView = LOGIN_VIEW}) => { : formatMessage(API_ERROR_MESSAGE) form.setError('global', {type: 'manual', message}) } - } else if (loginType === 'passwordless') { + } else if (loginType === LOGIN_TYPES.PASSWORDLESS) { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) - // Handle passwordless login logic here - } else if (loginType === 'social') { + try { + postAuthorizePasswordlessCustomer(data.email) + } catch (e) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE) + }) + } + } else if (loginType === LOGIN_TYPES.SOCIAL) { // Handle social login logic here } }, - email: async (data) => { - // Handle resend passwordless email logic here + email: async () => { + try { + postAuthorizePasswordlessCustomer(passwordlessLoginEmail) + } catch (e) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE) + }) + } } }[currentView](data) } @@ -140,7 +157,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { submitForm={submitForm} clickCreateAccount={() => navigate('/registration')} handlePasswordlessLoginClick={() => { - setLoginType('passwordless') + setLoginType(LOGIN_TYPES.PASSWORDLESS) }} handleForgotPasswordClick={() => navigate('/reset-password')} isPasswordlessEnabled={isPasswordlessEnabled} From 2d1df559bb6f098bd3cad3f52723c8ae32978f9d Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 13 Nov 2024 13:09:43 -0500 Subject: [PATCH 022/100] add working passwordless login landing page! --- .../app/hooks/use-auth-modal.js | 6 ++--- .../app/hooks/use-passwordless-login.js | 18 ++++++++++---- .../app/pages/login/index.jsx | 24 ++++++++++++++++--- .../template-retail-react-app/app/routes.jsx | 10 ++++++++ packages/template-retail-react-app/app/ssr.js | 4 ++-- 5 files changed, 50 insertions(+), 12 deletions(-) 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 125c26820a..d787fd641d 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 @@ -85,7 +85,7 @@ export const AuthModal = ({ const register = useAuthHelper(AuthHelpers.Register) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) - const {postAuthorizePasswordlessCustomer} = usePasswordlessLogin() + const {authorizePasswordlessLogin} = usePasswordlessLogin() const getResetPasswordToken = useShopperCustomersMutation( ShopperCustomersMutations.GetResetPasswordToken @@ -140,7 +140,7 @@ export const AuthModal = ({ setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) try { - postAuthorizePasswordlessCustomer(data.email) + authorizePasswordlessLogin(data.email) } catch (e) { form.setError('global', { type: 'manual', @@ -187,7 +187,7 @@ export const AuthModal = ({ }, email: async () => { try { - postAuthorizePasswordlessCustomer(passwordlessLoginEmail) + authorizePasswordlessLogin(passwordlessLoginEmail) } catch (e) { form.setError('global', { type: 'manual', diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js index 9b5c760be8..2533210269 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -4,7 +4,12 @@ * 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 {useShopperLoginMutation, ShopperLoginMutations} from '@salesforce/commerce-sdk-react' +import { + AuthHelpers, + useAuthHelper, + useShopperLoginMutation, + ShopperLoginMutations +} from '@salesforce/commerce-sdk-react' import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' @@ -18,7 +23,7 @@ export const usePasswordlessLogin = () => { ShopperLoginMutations.AuthorizePasswordlessCustomer ) - const postAuthorizePasswordlessCustomer = async (email) => { + const authorizePasswordlessLogin = async (email) => { const body = { user_id: email, mode: 'callback', @@ -28,9 +33,14 @@ export const usePasswordlessLogin = () => { await authorizePasswordlessCustomer.mutateAsync({body}) } - return { - postAuthorizePasswordlessCustomer + + const login = useAuthHelper(AuthHelpers.LoginPasswordlessUser) + + const fetchPasswordlessAccessToken = async (token) => { + await login.mutateAsync({pwdlessLoginToken: token}) } + + return {authorizePasswordlessLogin, fetchPasswordlessAccessToken} } export default usePasswordlessLogin 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 eedfad2f12..6a46ba7b21 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -20,6 +20,7 @@ import { import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import Seo from '@salesforce/retail-react-app/app/components/seo' import {useForm} from 'react-hook-form' +import {useRouteMatch} from 'react-router' import {useLocation} from 'react-router-dom' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import LoginForm from '@salesforce/retail-react-app/app/components/login' @@ -43,6 +44,8 @@ const Login = ({initialView = LOGIN_VIEW}) => { const navigate = useNavigation() const form = useForm() const location = useLocation() + const queryParams = new URLSearchParams(location.search) + const {path} = useRouteMatch() const einstein = useEinstein() const {isRegistered, customerType} = useCustomerType() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) @@ -61,7 +64,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const [currentView, setCurrentView] = useState(initialView) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) - const {postAuthorizePasswordlessCustomer} = usePasswordlessLogin() + const {authorizePasswordlessLogin, fetchPasswordlessAccessToken} = usePasswordlessLogin() const submitForm = async (data) => { form.clearErrors() @@ -99,7 +102,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) try { - postAuthorizePasswordlessCustomer(data.email) + authorizePasswordlessLogin(data.email) } catch (e) { form.setError('global', { type: 'manual', @@ -112,7 +115,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { }, email: async () => { try { - postAuthorizePasswordlessCustomer(passwordlessLoginEmail) + authorizePasswordlessLogin(passwordlessLoginEmail) } catch (e) { form.setError('global', { type: 'manual', @@ -123,6 +126,21 @@ const Login = ({initialView = LOGIN_VIEW}) => { }[currentView](data) } + useEffect(() => { + if (path === '/passwordless-login-landing') { + const token = queryParams.get('token') + try { + fetchPasswordlessAccessToken(token) + // TODO Error handling (below catch is not working) + } catch (e) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE) + }) + } + } + }, [path, location]) + // If customer is registered push to account page useEffect(() => { if (isRegistered) { diff --git a/packages/template-retail-react-app/app/routes.jsx b/packages/template-retail-react-app/app/routes.jsx index b2b1be2480..6725477846 100644 --- a/packages/template-retail-react-app/app/routes.jsx +++ b/packages/template-retail-react-app/app/routes.jsx @@ -70,6 +70,16 @@ export const routes = [ component: ResetPassword, exact: true }, + { + path: '/reset-password-landing', + component: ResetPassword, + exact: true + }, + { + path: '/passwordless-login-landing', + component: Login, + exact: true + }, { path: '/account', component: Account diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index fdccb7d1c8..2f1e94d931 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -101,7 +101,7 @@ const {handler} = runtime.createHandler(options, (app) => { app.post('/passwordless-login-callback', async (req, res) => { const base = req.protocol + '://' + req.get('host') const {email_id, token} = req.body - const magicLink = `${base}/passwordless-login?email=${email_id}&token=${token}` + const magicLink = `${base}/passwordless-login-landing?token=${token}` const emailLinkResponse = await emailLink( email_id, process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, @@ -113,7 +113,7 @@ const {handler} = runtime.createHandler(options, (app) => { app.post('/reset-password-callback', async (req, res) => { const base = req.protocol + '://' + req.get('host') const {email_id, token} = req.body - const magicLink = `${base}/reset-password?email=${email_id}&token=${token}` + const magicLink = `${base}/reset-password-landing?token=${token}` const emailLinkResponse = await emailLink( email_id, process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, From acf5410e38edd4e764d34348a88bdf133b8f3275 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 13 Nov 2024 16:45:56 -0500 Subject: [PATCH 023/100] add reset-password-landing page --- .../app/hooks/use-passwordless-login.js | 2 +- .../app/pages/reset-password/index.jsx | 36 +++--- .../reset-password/reset-password-landing.jsx | 116 ++++++++++++++++++ packages/template-retail-react-app/app/ssr.js | 2 +- 4 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js index 2533210269..11fade24e8 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -28,12 +28,12 @@ export const usePasswordlessLogin = () => { user_id: email, mode: 'callback', channel_id: site.id, + // TODO: Should this be set in default.js or constant? callback_uri: absoluteUrl('/passwordless-login-callback') } await authorizePasswordlessCustomer.mutateAsync({body}) } - const login = useAuthHelper(AuthHelpers.LoginPasswordlessUser) const fetchPasswordlessAccessToken = async (token) => { diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.jsx index 10c953162f..ac85626afb 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.jsx @@ -17,15 +17,18 @@ import { } from '@salesforce/retail-react-app/app/components/shared/ui' import {useForm} from 'react-hook-form' import { - useShopperCustomersMutation, - ShopperCustomersMutations + useShopperLoginMutation, + ShopperLoginMutations } from '@salesforce/commerce-sdk-react' import Seo from '@salesforce/retail-react-app/app/components/seo' import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset-password' -import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' +import ResetPasswordLanding from '@salesforce/retail-react-app/app/pages/reset-password/reset-password-landing' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' import {useLocation} from 'react-router-dom' +import {useRouteMatch} from 'react-router' +import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url' const ResetPassword = () => { const form = useForm() @@ -34,16 +37,24 @@ const ResetPassword = () => { const [showSubmittedSuccess, setShowSubmittedSuccess] = useState(false) const einstein = useEinstein() const {pathname} = useLocation() - const getResetPasswordToken = useShopperCustomersMutation( - ShopperCustomersMutations.GetResetPasswordToken + const {path} = useRouteMatch() + const {site} = useMultiSite() + + const getPasswordResetToken = useShopperLoginMutation( + ShopperLoginMutations.GetPasswordResetToken ) const submitForm = async ({email}) => { const body = { - login: email + user_id: email, + mode: 'callback', + channel_id: site.id, + // TODO: Should this be set in default.js or constant? + callback_uri: 'https://webhook.site/a134b707-0514-4655-a293-9cd92073bf12' + // callback_uri: absoluteUrl('/reset-password-landing') } try { - await getResetPasswordToken.mutateAsync({body}) + await getPasswordResetToken.mutateAsync({body}) setSubmittedEmail(email) setShowSubmittedSuccess(!showSubmittedSuccess) } catch (error) { @@ -68,7 +79,9 @@ const ResetPassword = () => { marginBottom={8} borderRadius="base" > - {!showSubmittedSuccess ? ( + {path === '/reset-password-landing' ? ( + + ) : !showSubmittedSuccess ? ( { /> ) : ( - - - - { + const form = useForm() + const {search} = useLocation() + const queryParams = new URLSearchParams(search) + const email = queryParams.get('email') + const token = queryParams.get('token') + const fields = useUpdatePasswordFields({form}) + const password = form.watch('password') + + const submit = async (values) => { + try { + form.clearErrors() + updateCustomerMutation.mutate( + { + parameters: {customerId}, + body: { + firstName: values.firstName, + lastName: values.lastName, + phoneHome: values.phone, + // NOTE/ISSUE + // The sdk is allowing you to change your email to an already-existing email. + // I would expect an error. We also want to keep the email and login the same + // for the customer, but the sdk isn't changing the login when we submit an + // updated email. This will lead to issues where you change your email but end + // up not being able to login since 'login' will no longer match the email. + email: values.email, + login: values.email + } + }, + { + onSuccess: () => { + setIsEditing(false) + toast({ + title: formatMessage({ + defaultMessage: 'Profile updated', + id: 'profile_card.info.profile_updated' + }), + status: 'success', + isClosable: true + }) + headingRef?.current?.focus() + } + } + ) + } catch (error) { + form.setError('global', {type: 'manual', message: error.message}) + } + } + + return ( + + + + + + + + +
+ + {form.formState.errors?.root?.global && ( + + + + {form.formState.errors.root.global.message} + + + )} + + + + + + +
+
+
+ ) +} + + +ResetPasswordLanding.getTemplateName = () => 'reset-password-landing' + +ResetPasswordLanding.propTypes = { + token: PropTypes.string +} + +export default ResetPasswordLanding diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 2f1e94d931..dd3040c0f9 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -50,7 +50,7 @@ const options = { // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set useSLASPrivateClient: true, - applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless\/(login|token))/, + applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless|password\/(login|token|reset))/, // If this is enabled, any HTTP header that has a non ASCII value will be URI encoded // If there any HTTP headers that have been encoded, an additional header will be From f5f781633b0c68408405aa2c4946f5e4b186f2f3 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 13 Nov 2024 16:46:35 -0500 Subject: [PATCH 024/100] Revert "add reset-password-landing page" This reverts commit acf5410e38edd4e764d34348a88bdf133b8f3275. --- .../app/hooks/use-passwordless-login.js | 2 +- .../app/pages/reset-password/index.jsx | 36 +++--- .../reset-password/reset-password-landing.jsx | 116 ------------------ packages/template-retail-react-app/app/ssr.js | 2 +- 4 files changed, 17 insertions(+), 139 deletions(-) delete mode 100644 packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js index 11fade24e8..2533210269 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -28,12 +28,12 @@ export const usePasswordlessLogin = () => { user_id: email, mode: 'callback', channel_id: site.id, - // TODO: Should this be set in default.js or constant? callback_uri: absoluteUrl('/passwordless-login-callback') } await authorizePasswordlessCustomer.mutateAsync({body}) } + const login = useAuthHelper(AuthHelpers.LoginPasswordlessUser) const fetchPasswordlessAccessToken = async (token) => { diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.jsx index ac85626afb..10c953162f 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.jsx @@ -17,18 +17,15 @@ import { } from '@salesforce/retail-react-app/app/components/shared/ui' import {useForm} from 'react-hook-form' import { - useShopperLoginMutation, - ShopperLoginMutations + useShopperCustomersMutation, + ShopperCustomersMutations } from '@salesforce/commerce-sdk-react' import Seo from '@salesforce/retail-react-app/app/components/seo' import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset-password' -import ResetPasswordLanding from '@salesforce/retail-react-app/app/pages/reset-password/reset-password-landing' +import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' -import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' import {useLocation} from 'react-router-dom' -import {useRouteMatch} from 'react-router' -import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url' const ResetPassword = () => { const form = useForm() @@ -37,24 +34,16 @@ const ResetPassword = () => { const [showSubmittedSuccess, setShowSubmittedSuccess] = useState(false) const einstein = useEinstein() const {pathname} = useLocation() - const {path} = useRouteMatch() - const {site} = useMultiSite() - - const getPasswordResetToken = useShopperLoginMutation( - ShopperLoginMutations.GetPasswordResetToken + const getResetPasswordToken = useShopperCustomersMutation( + ShopperCustomersMutations.GetResetPasswordToken ) const submitForm = async ({email}) => { const body = { - user_id: email, - mode: 'callback', - channel_id: site.id, - // TODO: Should this be set in default.js or constant? - callback_uri: 'https://webhook.site/a134b707-0514-4655-a293-9cd92073bf12' - // callback_uri: absoluteUrl('/reset-password-landing') + login: email } try { - await getPasswordResetToken.mutateAsync({body}) + await getResetPasswordToken.mutateAsync({body}) setSubmittedEmail(email) setShowSubmittedSuccess(!showSubmittedSuccess) } catch (error) { @@ -79,9 +68,7 @@ const ResetPassword = () => { marginBottom={8} borderRadius="base" > - {path === '/reset-password-landing' ? ( - - ) : !showSubmittedSuccess ? ( + {!showSubmittedSuccess ? ( { /> ) : ( + + + + { - const form = useForm() - const {search} = useLocation() - const queryParams = new URLSearchParams(search) - const email = queryParams.get('email') - const token = queryParams.get('token') - const fields = useUpdatePasswordFields({form}) - const password = form.watch('password') - - const submit = async (values) => { - try { - form.clearErrors() - updateCustomerMutation.mutate( - { - parameters: {customerId}, - body: { - firstName: values.firstName, - lastName: values.lastName, - phoneHome: values.phone, - // NOTE/ISSUE - // The sdk is allowing you to change your email to an already-existing email. - // I would expect an error. We also want to keep the email and login the same - // for the customer, but the sdk isn't changing the login when we submit an - // updated email. This will lead to issues where you change your email but end - // up not being able to login since 'login' will no longer match the email. - email: values.email, - login: values.email - } - }, - { - onSuccess: () => { - setIsEditing(false) - toast({ - title: formatMessage({ - defaultMessage: 'Profile updated', - id: 'profile_card.info.profile_updated' - }), - status: 'success', - isClosable: true - }) - headingRef?.current?.focus() - } - } - ) - } catch (error) { - form.setError('global', {type: 'manual', message: error.message}) - } - } - - return ( - - - - - - - - -
- - {form.formState.errors?.root?.global && ( - - - - {form.formState.errors.root.global.message} - - - )} - - - - - - -
-
-
- ) -} - - -ResetPasswordLanding.getTemplateName = () => 'reset-password-landing' - -ResetPasswordLanding.propTypes = { - token: PropTypes.string -} - -export default ResetPasswordLanding diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index dd3040c0f9..2f1e94d931 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -50,7 +50,7 @@ const options = { // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set useSLASPrivateClient: true, - applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless|password\/(login|token|reset))/, + applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless\/(login|token))/, // If this is enabled, any HTTP header that has a non ASCII value will be URI encoded // If there any HTTP headers that have been encoded, an additional header will be From c459a5cb5573817bd2d78c5b86e2665bd8f2a4e9 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 15 Nov 2024 11:24:37 -0500 Subject: [PATCH 025/100] get callback_uri from default.js --- .../app/hooks/use-passwordless-login.js | 5 +++-- packages/template-retail-react-app/config/default.js | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js index 2533210269..7b172765f1 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -10,14 +10,15 @@ import { useShopperLoginMutation, ShopperLoginMutations } from '@salesforce/commerce-sdk-react' -import {absoluteUrl} from '@salesforce/retail-react-app/app/utils/url' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' /** * This hook provides commerce-react-sdk hooks to simplify the passwordless login flow. */ export const usePasswordlessLogin = () => { const {site} = useMultiSite() + const {passwordless} = getConfig().app.login const authorizePasswordlessCustomer = useShopperLoginMutation( ShopperLoginMutations.AuthorizePasswordlessCustomer @@ -28,7 +29,7 @@ export const usePasswordlessLogin = () => { user_id: email, mode: 'callback', channel_id: site.id, - callback_uri: absoluteUrl('/passwordless-login-callback') + callback_uri: passwordless.callbackURI } await authorizePasswordlessCustomer.mutateAsync({body}) } diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index b1dd42f5d7..620db72e8c 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -18,7 +18,7 @@ module.exports = { login: { passwordless: { enabled: true, - callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c' + callbackURI: 'https://wasatch-mrt-passwordless-poc.mrt-storefront-staging.com/passwordless-login-callback' }, social: { enabled: false, @@ -34,7 +34,7 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', + clientId: '80255e22-8504-45e3-b1a3-749fd4475bb7', organizationId: 'f_ecom_zzrf_001', shortCode: '8o7m175y', siteId: 'RefArchGlobal' From 9e021213b5b5a36e08778e9bca292a3b46780320 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 15 Nov 2024 12:05:11 -0500 Subject: [PATCH 026/100] revert default.js to use client c9c45bfd --- packages/template-retail-react-app/config/default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 620db72e8c..404f951c08 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -34,7 +34,7 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: '80255e22-8504-45e3-b1a3-749fd4475bb7', + clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', organizationId: 'f_ecom_zzrf_001', shortCode: '8o7m175y', siteId: 'RefArchGlobal' From e30c98522e0f9907a275f1c36815ffb985091091 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 19 Nov 2024 13:45:51 -0500 Subject: [PATCH 027/100] add unit test for use-passwordless-login.js --- .../app/hooks/use-passwordless-login.js | 4 +- .../app/hooks/use-passwordless-login.test.js | 85 +++++++++++++++++++ .../app/pages/login/index.jsx | 4 +- 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js index 7b172765f1..afe9b17c8a 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -37,11 +37,11 @@ export const usePasswordlessLogin = () => { const login = useAuthHelper(AuthHelpers.LoginPasswordlessUser) - const fetchPasswordlessAccessToken = async (token) => { + const loginWithPasswordlessAccessToken = async (token) => { await login.mutateAsync({pwdlessLoginToken: token}) } - return {authorizePasswordlessLogin, fetchPasswordlessAccessToken} + return {authorizePasswordlessLogin, loginWithPasswordlessAccessToken} } export default usePasswordlessLogin diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js new file mode 100644 index 0000000000..41ad48eed2 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js @@ -0,0 +1,85 @@ +/* + * 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 {fireEvent, screen, waitFor} from '@testing-library/react' +import { + useAuthHelper, + useShopperLoginMutation, +} from '@salesforce/commerce-sdk-react' +import mockConfig from '@salesforce/retail-react-app/config/mocks/default' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import usePasswordlessLogin from '@salesforce/retail-react-app/app/hooks/use-passwordless-login' + +const mockEmail = 'test@email.com' +const mockToken = '123456' +const mockSiteId = mockConfig.app.defaultSite +const mockCallbackUri = mockConfig.app.login.passwordless.callbackURI + +const MockComponent = () => { + const {authorizePasswordlessLogin, loginWithPasswordlessAccessToken} = usePasswordlessLogin() + + return ( +
+
+ ) +} + +jest.mock('@salesforce/commerce-sdk-react', () => { + const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...originalModule, + useAuthHelper: jest.fn(), + useShopperLoginMutation: jest.fn(), + } +}) + +const authorizePasswordlessCustomer = {mutateAsync: jest.fn()} +useShopperLoginMutation.mockImplementation(() => {return authorizePasswordlessCustomer}) + +const login = {mutateAsync: jest.fn()} +useAuthHelper.mockImplementation(() => {return login}) + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('The usePasswordlessLogin', () => { + test('authorizePasswordlessLogin sends expected api request', async () => { + renderWithProviders() + + const trigger = screen.getByTestId('authorize-passwordless-login') + await fireEvent.click(trigger) + await waitFor(() => { + expect(authorizePasswordlessCustomer.mutateAsync).toHaveBeenCalled() + expect(authorizePasswordlessCustomer.mutateAsync).toHaveBeenCalledWith( + { + body: { + user_id: mockEmail, + mode: 'callback', + channel_id: mockSiteId, + callback_uri: mockCallbackUri + } + } + ) + }) + }) + + test('loginWithPasswordlessAccessToken sends expected api request', async () => { + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passwordless-access-token') + await fireEvent.click(trigger) + await waitFor(() => { + expect(login.mutateAsync).toHaveBeenCalled() + expect(login.mutateAsync).toHaveBeenCalledWith( + {pwdlessLoginToken: mockToken} + ) + }) + }) +}) 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 e112529d19..1995608480 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -64,7 +64,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const [currentView, setCurrentView] = useState(initialView) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) - const {authorizePasswordlessLogin, fetchPasswordlessAccessToken} = usePasswordlessLogin() + const {authorizePasswordlessLogin, loginWithPasswordlessAccessToken} = usePasswordlessLogin() const submitForm = async (data) => { form.clearErrors() @@ -130,7 +130,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { if (path === '/passwordless-login-landing') { const token = queryParams.get('token') try { - fetchPasswordlessAccessToken(token) + loginWithPasswordlessAccessToken(token) // TODO Error handling (below catch is not working) } catch (e) { form.setError('global', { From bfc543bbcf708a32f63c09381385c5cd2ff74c90 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 19 Nov 2024 14:40:49 -0500 Subject: [PATCH 028/100] display better error message for 401 from /oauth2/passwordless/token API --- .../app/hooks/use-passwordless-login.js | 1 - .../app/hooks/use-passwordless-login.test.js | 45 ++++++++++--------- .../app/pages/login/index.jsx | 20 +++++---- .../static/translations/compiled/en-GB.json | 6 +++ .../static/translations/compiled/en-US.json | 6 +++ .../static/translations/compiled/en-XA.json | 14 ++++++ .../translations/en-GB.json | 3 ++ .../translations/en-US.json | 3 ++ 8 files changed, 68 insertions(+), 30 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js index afe9b17c8a..087dabe4e0 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -34,7 +34,6 @@ export const usePasswordlessLogin = () => { await authorizePasswordlessCustomer.mutateAsync({body}) } - const login = useAuthHelper(AuthHelpers.LoginPasswordlessUser) const loginWithPasswordlessAccessToken = async (token) => { diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js index 41ad48eed2..b6eea6f02b 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js @@ -6,10 +6,7 @@ */ import React from 'react' import {fireEvent, screen, waitFor} from '@testing-library/react' -import { - useAuthHelper, - useShopperLoginMutation, -} from '@salesforce/commerce-sdk-react' +import {useAuthHelper, useShopperLoginMutation} from '@salesforce/commerce-sdk-react' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import usePasswordlessLogin from '@salesforce/retail-react-app/app/hooks/use-passwordless-login' @@ -24,8 +21,14 @@ const MockComponent = () => { return (
-
) } @@ -35,15 +38,19 @@ jest.mock('@salesforce/commerce-sdk-react', () => { return { ...originalModule, useAuthHelper: jest.fn(), - useShopperLoginMutation: jest.fn(), + useShopperLoginMutation: jest.fn() } }) const authorizePasswordlessCustomer = {mutateAsync: jest.fn()} -useShopperLoginMutation.mockImplementation(() => {return authorizePasswordlessCustomer}) +useShopperLoginMutation.mockImplementation(() => { + return authorizePasswordlessCustomer +}) const login = {mutateAsync: jest.fn()} -useAuthHelper.mockImplementation(() => {return login}) +useAuthHelper.mockImplementation(() => { + return login +}) afterEach(() => { jest.clearAllMocks() @@ -57,16 +64,14 @@ describe('The usePasswordlessLogin', () => { await fireEvent.click(trigger) await waitFor(() => { expect(authorizePasswordlessCustomer.mutateAsync).toHaveBeenCalled() - expect(authorizePasswordlessCustomer.mutateAsync).toHaveBeenCalledWith( - { - body: { - user_id: mockEmail, - mode: 'callback', - channel_id: mockSiteId, - callback_uri: mockCallbackUri - } + expect(authorizePasswordlessCustomer.mutateAsync).toHaveBeenCalledWith({ + body: { + user_id: mockEmail, + mode: 'callback', + channel_id: mockSiteId, + callback_uri: mockCallbackUri } - ) + }) }) }) @@ -77,9 +82,7 @@ describe('The usePasswordlessLogin', () => { await fireEvent.click(trigger) await waitFor(() => { expect(login.mutateAsync).toHaveBeenCalled() - expect(login.mutateAsync).toHaveBeenCalledWith( - {pwdlessLoginToken: mockToken} - ) + expect(login.mutateAsync).toHaveBeenCalledWith({pwdlessLoginToken: mockToken}) }) }) }) 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 1995608480..7579f54e77 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -35,6 +35,10 @@ const LOGIN_ERROR_MESSAGE = defineMessage({ defaultMessage: 'Incorrect username or password, please try again.', id: 'login_page.error.incorrect_username_or_password' }) +const INVALID_TOKEN_ERROR_MESSAGE = defineMessage({ + defaultMessage: 'Invalid token, please try again.', + id: 'login_page.error.invalid_token' +}) const LOGIN_VIEW = 'login' const EMAIL_VIEW = 'email' @@ -113,7 +117,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { // Handle social login logic here } }, - email: async () => { + email: () => { try { authorizePasswordlessLogin(passwordlessLoginEmail) } catch (e) { @@ -126,17 +130,17 @@ const Login = ({initialView = LOGIN_VIEW}) => { }[currentView](data) } - useEffect(() => { + useEffect(async () => { if (path === '/passwordless-login-landing') { const token = queryParams.get('token') try { - loginWithPasswordlessAccessToken(token) - // TODO Error handling (below catch is not working) + await loginWithPasswordlessAccessToken(token) } catch (e) { - form.setError('global', { - type: 'manual', - message: formatMessage(API_ERROR_MESSAGE) - }) + console.warn(`JINSU WARN ${e.message}`) + const message = /Unauthorized/i.test(e.message) + ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) + : formatMessage(API_ERROR_MESSAGE) + form.setError('global', {type: 'manual', message}) } } }, [path, location]) 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 9109cad507..4eed1ffaff 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 @@ -2281,6 +2281,12 @@ "value": "Incorrect username or password, please try again." } ], + "login_page.error.invalid_token": [ + { + "type": 0, + "value": "Invalid token, please try again." + } + ], "offline_banner.description.browsing_offline_mode": [ { "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 9109cad507..4eed1ffaff 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 @@ -2281,6 +2281,12 @@ "value": "Incorrect username or password, please try again." } ], + "login_page.error.invalid_token": [ + { + "type": 0, + "value": "Invalid token, please try again." + } + ], "offline_banner.description.browsing_offline_mode": [ { "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 171278008a..a5223b9775 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 @@ -4873,6 +4873,20 @@ "value": "]" } ], + "login_page.error.invalid_token": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Īƞṽȧȧŀīḓ ŧǿǿķḗḗƞ, ƥŀḗḗȧȧşḗḗ ŧřẏ ȧȧɠȧȧīƞ." + }, + { + "type": 0, + "value": "]" + } + ], "offline_banner.description.browsing_offline_mode": [ { "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 90b68543df..67ae5360f8 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -978,6 +978,9 @@ "login_page.error.incorrect_username_or_password": { "defaultMessage": "Incorrect username or password, please try again." }, + "login_page.error.invalid_token": { + "defaultMessage": "Invalid token, please try again." + }, "offline_banner.description.browsing_offline_mode": { "defaultMessage": "You're currently browsing in offline mode" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 90b68543df..67ae5360f8 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -978,6 +978,9 @@ "login_page.error.incorrect_username_or_password": { "defaultMessage": "Incorrect username or password, please try again." }, + "login_page.error.invalid_token": { + "defaultMessage": "Invalid token, please try again." + }, "offline_banner.description.browsing_offline_mode": { "defaultMessage": "You're currently browsing in offline mode" }, From 06377fdcda4e01500cd21dc25db7858d1a41a357 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 19 Nov 2024 16:40:54 -0500 Subject: [PATCH 029/100] call mergeBasket during passwordless login --- .../app/pages/login/index.jsx | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) 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 7579f54e77..e9d2c8d021 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -70,6 +70,34 @@ const Login = ({initialView = LOGIN_VIEW}) => { const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) const {authorizePasswordlessLogin, loginWithPasswordlessAccessToken} = usePasswordlessLogin() + const handleMergeBasket = () => { + const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0 + // we only want to merge basket when the user is logged in as a recurring user + // only recurring users trigger the login mutation, new user triggers register mutation + // this logic needs to stay in this block because this is the only place that tells if a user is a recurring user + // if you change logic here, also change it in login page + const shouldMergeBasket = hasBasketItem && prevAuthType === 'guest' + if (shouldMergeBasket) { + try { + mergeBasket.mutate({ + headers: { + // This is not required since the request has no body + // but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed. + 'Content-Type': 'application/json' + }, + parameters: { + createDestinationBasket: true + } + }) + } catch (e) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE) + }) + } + } + } + const submitForm = async (data) => { form.clearErrors() @@ -78,30 +106,13 @@ const Login = ({initialView = LOGIN_VIEW}) => { if (loginType === LOGIN_TYPES.PASSWORD) { try { await login.mutateAsync({username: data.email, password: data.password}) - const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0 - // we only want to merge basket when the user is logged in as a recurring user - // only recurring users trigger the login mutation, new user triggers register mutation - // this logic needs to stay in this block because this is the only place that tells if a user is a recurring user - // if you change logic here, also change it in login page - const shouldMergeBasket = hasBasketItem && prevAuthType === 'guest' - if (shouldMergeBasket) { - mergeBasket.mutate({ - headers: { - // This is not required since the request has no body - // but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed. - 'Content-Type': 'application/json' - }, - parameters: { - createDestinationBasket: true - } - }) - } } catch (error) { const message = /Unauthorized/i.test(error.message) ? formatMessage(LOGIN_ERROR_MESSAGE) : formatMessage(API_ERROR_MESSAGE) form.setError('global', {type: 'manual', message}) } + handleMergeBasket() } else if (loginType === LOGIN_TYPES.PASSWORDLESS) { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) @@ -136,12 +147,12 @@ const Login = ({initialView = LOGIN_VIEW}) => { try { await loginWithPasswordlessAccessToken(token) } catch (e) { - console.warn(`JINSU WARN ${e.message}`) const message = /Unauthorized/i.test(e.message) ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) : formatMessage(API_ERROR_MESSAGE) form.setError('global', {type: 'manual', message}) } + handleMergeBasket() } }, [path, location]) From 65932e201596c8c3e8ef453d678bf585db469b99 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 20 Nov 2024 16:03:05 -0500 Subject: [PATCH 030/100] use private client in default.js --- packages/template-retail-react-app/config/default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 404f951c08..f16767433f 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -34,7 +34,7 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', + clientId: '80255e22-8504-45e3-b1a3-749fd4475bb7', // TODO: SLAS Private Client Revert before merging organizationId: 'f_ecom_zzrf_001', shortCode: '8o7m175y', siteId: 'RefArchGlobal' From 6d578f0249fccf1c9e55d5c5745c68b76da2ec49 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 21 Nov 2024 13:34:57 -0500 Subject: [PATCH 031/100] fix error after logging in + merge basket impl WIPd --- .../app/pages/login/index.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 e9d2c8d021..ccc33da7d0 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -60,7 +60,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const customerId = useCustomerId() const prevAuthType = usePrevious(customerType) - const {data: baskets} = useCustomerBaskets( + const {data: baskets, isSuccess: isSuccessCustomerBaskets} = useCustomerBaskets( {parameters: {customerId}}, {enabled: !!customerId && !isServer, keepPreviousData: true} ) @@ -141,11 +141,11 @@ const Login = ({initialView = LOGIN_VIEW}) => { }[currentView](data) } - useEffect(async () => { - if (path === '/passwordless-login-landing') { + useEffect(() => { + if (path === '/passwordless-login-landing' && isSuccessCustomerBaskets && prevAuthType) { const token = queryParams.get('token') try { - await loginWithPasswordlessAccessToken(token) + loginWithPasswordlessAccessToken(token) } catch (e) { const message = /Unauthorized/i.test(e.message) ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) @@ -154,7 +154,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { } handleMergeBasket() } - }, [path, location]) + }, [path, isSuccessCustomerBaskets, prevAuthType]) // If customer is registered push to account page useEffect(() => { From 03e0b0878d606ce03a625cbcb4aa04a88b445694 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 22 Nov 2024 09:53:26 -0500 Subject: [PATCH 032/100] use authotizePasswordless from AuthHelpers --- packages/commerce-sdk-react/src/auth/index.ts | 4 +++- packages/commerce-sdk-react/src/provider.tsx | 3 +++ .../app/hooks/use-passwordless-login.js | 21 ++++--------------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index 626135b645..33a57f69fa 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1087,6 +1087,7 @@ class Auth { async authorizePasswordless(parameters: AuthorizePasswordlessParams) { const userid = parameters.userid const callbackURI = this.callbackURI + const usid = this.get('usid') const mode = callbackURI ? 'callback' : 'sms' await helpers.authorizePasswordless( @@ -1096,8 +1097,9 @@ class Auth { }, { ...(callbackURI && {callbackURI: callbackURI}), + ...(usid && {usid}), userid, - mode: mode + mode, } ) } diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index a4e13fbbad..16aad2188f 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -147,6 +147,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { silenceWarnings, logger: configLogger, defaultDnt, + callbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL }) @@ -163,6 +164,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { clientSecret, silenceWarnings, configLogger, + defaultDnt, + callbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL ]) diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js index 087dabe4e0..9ff65c8950 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -6,32 +6,19 @@ */ import { AuthHelpers, - useAuthHelper, - useShopperLoginMutation, - ShopperLoginMutations + useAuthHelper } from '@salesforce/commerce-sdk-react' -import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' -import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' /** * This hook provides commerce-react-sdk hooks to simplify the passwordless login flow. */ export const usePasswordlessLogin = () => { - const {site} = useMultiSite() - const {passwordless} = getConfig().app.login - - const authorizePasswordlessCustomer = useShopperLoginMutation( - ShopperLoginMutations.AuthorizePasswordlessCustomer + const authorizePasswordless = useAuthHelper( + AuthHelpers.AuthorizePasswordless ) const authorizePasswordlessLogin = async (email) => { - const body = { - user_id: email, - mode: 'callback', - channel_id: site.id, - callback_uri: passwordless.callbackURI - } - await authorizePasswordlessCustomer.mutateAsync({body}) + await authorizePasswordless.mutateAsync({userid: email}) } const login = useAuthHelper(AuthHelpers.LoginPasswordlessUser) From b9408ed1e1f85f0998a8bf8865f5412bb712134d Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 22 Nov 2024 10:34:07 -0500 Subject: [PATCH 033/100] pass a path for callbackURI --- packages/template-retail-react-app/config/default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index f16767433f..0bbefadb40 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -18,7 +18,7 @@ module.exports = { login: { passwordless: { enabled: true, - callbackURI: 'https://wasatch-mrt-passwordless-poc.mrt-storefront-staging.com/passwordless-login-callback' + callbackURI: '/passwordless-login-callback' }, social: { enabled: false, From 5dd32185caf26b44dd92e58c1be5b7d84de864a5 Mon Sep 17 00:00:00 2001 From: Brian Redmond Date: Fri, 22 Nov 2024 13:03:36 -0500 Subject: [PATCH 034/100] Add test for post endpoints --- .../app/marketing-cloud-email-link.js | 73 +++++++++++-------- .../app/marketing-cloud-email-link.test.js | 30 ++++++++ packages/template-retail-react-app/app/ssr.js | 56 ++++++++------ 3 files changed, 105 insertions(+), 54 deletions(-) create mode 100644 packages/template-retail-react-app/app/marketing-cloud-email-link.test.js diff --git a/packages/template-retail-react-app/app/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/marketing-cloud-email-link.js index 13e16f9c8e..ffca5797b8 100644 --- a/packages/template-retail-react-app/app/marketing-cloud-email-link.js +++ b/packages/template-retail-react-app/app/marketing-cloud-email-link.js @@ -7,6 +7,10 @@ import crypto from 'crypto' import https from 'https' +/** + * Tokens are valid for 20 minutes. We store it at the top level scope to reuse + * it during the lambda invocation. We'll refresh it after 15 minutes. + */ let marketingCloudToken = '' let marketingCloudTokenExpiration = new Date() @@ -14,9 +18,16 @@ let marketingCloudTokenExpiration = new Date() Make a POST request using native https module, wrapped in a Promise with JSON encode and decode */ -function asyncJsonHttpsPost(options, postObject) { +export function asyncJsonHttpsPost(url, postObject, headers = {}) { return new Promise((resolve, reject) => { - const req = https.request(options, (response) => { + const options = { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json' + } + } + const req = https.request(url, options, (response) => { let data = '' response.on('data', (chunk) => { @@ -24,7 +35,11 @@ function asyncJsonHttpsPost(options, postObject) { }) response.on('end', () => { - resolve(JSON.parse(data)) + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(JSON.parse(data)); + } else { + reject(new Error(`Request failed with status code ${response.statusCode}: ${data}`)); + } }) }) @@ -38,28 +53,21 @@ function asyncJsonHttpsPost(options, postObject) { }) } -export async function emailLink(emailId, templateId, magicLink) { - function generateUniqueId() { - return crypto.randomBytes(16).toString('hex') - } +function generateUniqueId() { + return crypto.randomBytes(16).toString('hex') +} +async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { if (new Date() > marketingCloudTokenExpiration) { - const tokenOptions = { - method: 'POST', - host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com`, - path: '/v2/token', - headers: { - 'Content-Type': 'application/json' - } - } + const tokenUrl = `https://${marketingCloudConfig.subdomain}.auth.marketingcloudapis.com/v2/token` const tokenBody = { grant_type: 'client_credentials', - client_id: process.env.MARKETING_CLOUD_CLIENT_ID, - client_secret: process.env.MARKETING_CLOUD_CLIENT_SECRET + client_id: marketingCloudConfig.clientId, + client_secret: marketingCloudConfig.clientSecret } - marketingCloudToken = (await asyncJsonHttpsPost(tokenOptions, tokenBody)).access_token + marketingCloudToken = (await asyncJsonHttpsPost(tokenUrl, tokenBody)).access_token marketingCloudTokenExpiration = new Date() marketingCloudTokenExpiration.setTime( @@ -67,26 +75,31 @@ export async function emailLink(emailId, templateId, magicLink) { ) } - const emailOptions = { - method: 'POST', - host: `${process.env.MARKETING_CLOUD_SUBDOMAIN}.rest.marketingcloudapis.com`, - path: `/messaging/v1/email/messages/${generateUniqueId()}`, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${marketingCloudToken}` - } - } + const emailUrl = `https://${marketingCloudConfig.subdomain}.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}` + + const emailHeaders = { Authorization: `Bearer ${marketingCloudToken}` } const emailBody = { - definitionKey: templateId, + definitionKey: marketingCloudConfig.templateId, recipient: { contactKey: emailId, to: emailId, - attributes: {'magic-link': magicLink} + attributes: {'magic-link': marketingCloudConfig.magicLink} } } - const emailResponse = await asyncJsonHttpsPost(emailOptions, emailBody) + const emailResponse = await asyncJsonHttpsPost(emailUrl, emailBody, emailHeaders) return emailResponse } + +export async function emailLink(emailId, templateId, magicLink) { + const marketingCloudConfig = { + clientId: process.env.MARKETING_CLOUD_CLIENT_ID, + clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET, + magicLink: magicLink, + subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN, + templateId: templateId + } + return await sendMarketingCloudEmail(emailId, marketingCloudConfig) +} diff --git a/packages/template-retail-react-app/app/marketing-cloud-email-link.test.js b/packages/template-retail-react-app/app/marketing-cloud-email-link.test.js new file mode 100644 index 0000000000..0d1b32df3c --- /dev/null +++ b/packages/template-retail-react-app/app/marketing-cloud-email-link.test.js @@ -0,0 +1,30 @@ +jest.mock('https', () => { + return { + request: jest.fn((url, options, callback) => { + const response = { + on: jest.fn((event, callback) => { + if (event === 'data') { + callback(response.data); + } else if (event === 'end') { + callback(); + } else if (event === 'on') { + callback(); + }}), + statusCode: 200, + headers: {}, + data: '{ "data": "test" }', + }; + + callback(response); + }), + }; + }); +import { emailLink } from './marketing-cloud-email-link' + +describe('emailLink()', () => { + it('should send an email with a magic link', async () => { + const result = await emailLink('test@example.com', '123', 'https://magic-link.example.com'); + + expect(result).toEqual({ data: "test" }); + }); +}); diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 2f1e94d931..ca0a280143 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -24,7 +24,11 @@ import helmet from 'helmet' import express from 'express' -import {emailLink} from './marketing-cloud-email-link' +const ENABLE_SSR_POST = (process.env.ENABLE_SSR_POST || "").toLowerCase() === "true" + +if (ENABLE_SSR_POST) { + import {emailLink} from './marketing-cloud-email-link' +} const options = { // The build directory (an absolute path) @@ -88,7 +92,9 @@ const {handler} = runtime.createHandler(options, (app) => { } }) ) - app.use(express.json()) + if (ENABLE_SSR_POST) { + app.use(express.json()) + } // Handle the redirect from SLAS as to avoid error app.get('/callback?*', (req, res) => { @@ -98,29 +104,31 @@ const {handler} = runtime.createHandler(options, (app) => { res.send() }) - app.post('/passwordless-login-callback', async (req, res) => { - const base = req.protocol + '://' + req.get('host') - const {email_id, token} = req.body - const magicLink = `${base}/passwordless-login-landing?token=${token}` - const emailLinkResponse = await emailLink( - email_id, - process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, - magicLink - ) - res.send(emailLinkResponse) - }) + . if (ENABLE_SSR_POST) { + app.post('/passwordless-login-callback', async (req, res) => { + const base = req.protocol + '://' + req.get('host') + const {email_id, token} = req.body + const magicLink = `${base}/passwordless-login-landing?token=${token}` + const emailLinkResponse = await emailLink( + email_id, + process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, + magicLink + ) + res.send(emailLinkResponse) + }) - app.post('/reset-password-callback', async (req, res) => { - const base = req.protocol + '://' + req.get('host') - const {email_id, token} = req.body - const magicLink = `${base}/reset-password-landing?token=${token}` - const emailLinkResponse = await emailLink( - email_id, - process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, - magicLink - ) - res.send(emailLinkResponse) - }) + app.post('/reset-password-callback', async (req, res) => { + const base = req.protocol + '://' + req.get('host') + const {email_id, token} = req.body + const magicLink = `${base}/reset-password-landing?token=${token}` + const emailLinkResponse = await emailLink( + email_id, + process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, + magicLink + ) + res.send(emailLinkResponse) + }) + } app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico')) From 9cba398ed30ada9c076cce396d4cc31bb53892df Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 22 Nov 2024 14:11:22 -0500 Subject: [PATCH 035/100] make merge basket work for passwordless login --- .../template-retail-react-app/app/pages/login/index.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 ccc33da7d0..3f70127305 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -142,7 +142,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { } useEffect(() => { - if (path === '/passwordless-login-landing' && isSuccessCustomerBaskets && prevAuthType) { + if (path === '/passwordless-login-landing' && isSuccessCustomerBaskets) { const token = queryParams.get('token') try { loginWithPasswordlessAccessToken(token) @@ -152,13 +152,13 @@ const Login = ({initialView = LOGIN_VIEW}) => { : formatMessage(API_ERROR_MESSAGE) form.setError('global', {type: 'manual', message}) } - handleMergeBasket() } - }, [path, isSuccessCustomerBaskets, prevAuthType]) + }, [path, isSuccessCustomerBaskets]) - // If customer is registered push to account page + // If customer is registered push to account page and merge the basket useEffect(() => { if (isRegistered) { + handleMergeBasket() if (location?.state?.directedFrom) { navigate(location.state.directedFrom) } else { From 8a6a779f06fb7583b6d25d09dcf478e24a3899f0 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 22 Nov 2024 14:43:26 -0500 Subject: [PATCH 036/100] fix errors in ssr.js --- packages/template-retail-react-app/app/ssr.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index ca0a280143..bf522b30f9 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -26,10 +26,16 @@ import express from 'express' const ENABLE_SSR_POST = (process.env.ENABLE_SSR_POST || "").toLowerCase() === "true" -if (ENABLE_SSR_POST) { - import {emailLink} from './marketing-cloud-email-link' +let emailLink +async function loadMarketingCloudEmailLink() { + if (ENABLE_SSR_POST) { + const {emailLink} = await import('./marketing-cloud-email-link') + emailLink = emailLink + } } +loadMarketingCloudEmailLink() + const options = { // The build directory (an absolute path) buildDir: path.resolve(process.cwd(), 'build'), @@ -104,7 +110,7 @@ const {handler} = runtime.createHandler(options, (app) => { res.send() }) - . if (ENABLE_SSR_POST) { + if (ENABLE_SSR_POST) { app.post('/passwordless-login-callback', async (req, res) => { const base = req.protocol + '://' + req.get('host') const {email_id, token} = req.body From 6c5beb2b91a90c35325a1d112b33a875c2adfe14 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 22 Nov 2024 15:54:05 -0500 Subject: [PATCH 037/100] fix ssr.js by always importing marketing-cloud-email-link --- packages/template-retail-react-app/app/ssr.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index bf522b30f9..c071bb3801 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -23,19 +23,10 @@ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import helmet from 'helmet' import express from 'express' +import {emailLink} from './marketing-cloud-email-link' const ENABLE_SSR_POST = (process.env.ENABLE_SSR_POST || "").toLowerCase() === "true" -let emailLink -async function loadMarketingCloudEmailLink() { - if (ENABLE_SSR_POST) { - const {emailLink} = await import('./marketing-cloud-email-link') - emailLink = emailLink - } -} - -loadMarketingCloudEmailLink() - const options = { // The build directory (an absolute path) buildDir: path.resolve(process.cwd(), 'build'), From 03047f822192a3874a30bc290c98b089a5ab2688 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 22 Nov 2024 16:25:21 -0500 Subject: [PATCH 038/100] update use-passwordless-login test --- .../app/hooks/use-passwordless-login.test.js | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js index b6eea6f02b..463f8ac46a 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js @@ -6,7 +6,7 @@ */ import React from 'react' import {fireEvent, screen, waitFor} from '@testing-library/react' -import {useAuthHelper, useShopperLoginMutation} from '@salesforce/commerce-sdk-react' +import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import usePasswordlessLogin from '@salesforce/retail-react-app/app/hooks/use-passwordless-login' @@ -38,18 +38,17 @@ jest.mock('@salesforce/commerce-sdk-react', () => { return { ...originalModule, useAuthHelper: jest.fn(), - useShopperLoginMutation: jest.fn() } }) -const authorizePasswordlessCustomer = {mutateAsync: jest.fn()} -useShopperLoginMutation.mockImplementation(() => { - return authorizePasswordlessCustomer -}) - -const login = {mutateAsync: jest.fn()} -useAuthHelper.mockImplementation(() => { - return login +const authorizePasswordless = {mutateAsync: jest.fn()} +const loginPasswordlessUser = {mutateAsync: jest.fn()} +useAuthHelper.mockImplementation((param) => { + if (param === AuthHelpers.LoginPasswordlessUser) { + return loginPasswordlessUser + } else if (param === AuthHelpers.AuthorizePasswordless) { + return authorizePasswordless + } }) afterEach(() => { @@ -63,15 +62,8 @@ describe('The usePasswordlessLogin', () => { const trigger = screen.getByTestId('authorize-passwordless-login') await fireEvent.click(trigger) await waitFor(() => { - expect(authorizePasswordlessCustomer.mutateAsync).toHaveBeenCalled() - expect(authorizePasswordlessCustomer.mutateAsync).toHaveBeenCalledWith({ - body: { - user_id: mockEmail, - mode: 'callback', - channel_id: mockSiteId, - callback_uri: mockCallbackUri - } - }) + expect(authorizePasswordless.mutateAsync).toHaveBeenCalled() + expect(authorizePasswordless.mutateAsync).toHaveBeenCalledWith({userid: mockEmail}) }) }) @@ -81,8 +73,8 @@ describe('The usePasswordlessLogin', () => { const trigger = screen.getByTestId('login-with-passwordless-access-token') await fireEvent.click(trigger) await waitFor(() => { - expect(login.mutateAsync).toHaveBeenCalled() - expect(login.mutateAsync).toHaveBeenCalledWith({pwdlessLoginToken: mockToken}) + expect(loginPasswordlessUser.mutateAsync).toHaveBeenCalled() + expect(loginPasswordlessUser.mutateAsync).toHaveBeenCalledWith({pwdlessLoginToken: mockToken}) }) }) }) From d90eaa4355fb0161b4f1a8e1650091d224895817 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 3 Dec 2024 15:39:33 -0500 Subject: [PATCH 039/100] lint and create marketing-cloud folder --- .../app/hooks/use-passwordless-login.js | 9 +---- .../app/hooks/use-passwordless-login.test.js | 6 ++- .../app/marketing-cloud-email-link.test.js | 30 --------------- packages/template-retail-react-app/app/ssr.js | 4 +- .../marketing-cloud-email-link.js | 16 +++++--- .../marketing-cloud-email-link.test.js | 37 +++++++++++++++++++ 6 files changed, 55 insertions(+), 47 deletions(-) delete mode 100644 packages/template-retail-react-app/app/marketing-cloud-email-link.test.js rename packages/template-retail-react-app/app/{ => utils/marketing-cloud}/marketing-cloud-email-link.js (86%) create mode 100644 packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.test.js diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js index 9ff65c8950..28ce921e9e 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js @@ -4,18 +4,13 @@ * 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 { - AuthHelpers, - useAuthHelper -} from '@salesforce/commerce-sdk-react' +import {AuthHelpers, useAuthHelper} from '@salesforce/commerce-sdk-react' /** * This hook provides commerce-react-sdk hooks to simplify the passwordless login flow. */ export const usePasswordlessLogin = () => { - const authorizePasswordless = useAuthHelper( - AuthHelpers.AuthorizePasswordless - ) + const authorizePasswordless = useAuthHelper(AuthHelpers.AuthorizePasswordless) const authorizePasswordlessLogin = async (email) => { await authorizePasswordless.mutateAsync({userid: email}) diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js index 463f8ac46a..4861f02b7d 100644 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js +++ b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js @@ -37,7 +37,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') return { ...originalModule, - useAuthHelper: jest.fn(), + useAuthHelper: jest.fn() } }) @@ -74,7 +74,9 @@ describe('The usePasswordlessLogin', () => { await fireEvent.click(trigger) await waitFor(() => { expect(loginPasswordlessUser.mutateAsync).toHaveBeenCalled() - expect(loginPasswordlessUser.mutateAsync).toHaveBeenCalledWith({pwdlessLoginToken: mockToken}) + expect(loginPasswordlessUser.mutateAsync).toHaveBeenCalledWith({ + pwdlessLoginToken: mockToken + }) }) }) }) diff --git a/packages/template-retail-react-app/app/marketing-cloud-email-link.test.js b/packages/template-retail-react-app/app/marketing-cloud-email-link.test.js deleted file mode 100644 index 0d1b32df3c..0000000000 --- a/packages/template-retail-react-app/app/marketing-cloud-email-link.test.js +++ /dev/null @@ -1,30 +0,0 @@ -jest.mock('https', () => { - return { - request: jest.fn((url, options, callback) => { - const response = { - on: jest.fn((event, callback) => { - if (event === 'data') { - callback(response.data); - } else if (event === 'end') { - callback(); - } else if (event === 'on') { - callback(); - }}), - statusCode: 200, - headers: {}, - data: '{ "data": "test" }', - }; - - callback(response); - }), - }; - }); -import { emailLink } from './marketing-cloud-email-link' - -describe('emailLink()', () => { - it('should send an email with a magic link', async () => { - const result = await emailLink('test@example.com', '123', 'https://magic-link.example.com'); - - expect(result).toEqual({ data: "test" }); - }); -}); diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index c071bb3801..a2bb9403a4 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -23,9 +23,9 @@ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import helmet from 'helmet' import express from 'express' -import {emailLink} from './marketing-cloud-email-link' +import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link' -const ENABLE_SSR_POST = (process.env.ENABLE_SSR_POST || "").toLowerCase() === "true" +const ENABLE_SSR_POST = (process.env.ENABLE_SSR_POST || '').toLowerCase() === 'true' const options = { // The build directory (an absolute path) diff --git a/packages/template-retail-react-app/app/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js similarity index 86% rename from packages/template-retail-react-app/app/marketing-cloud-email-link.js rename to packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js index ffca5797b8..2585100bc4 100644 --- a/packages/template-retail-react-app/app/marketing-cloud-email-link.js +++ b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js @@ -36,10 +36,12 @@ export function asyncJsonHttpsPost(url, postObject, headers = {}) { response.on('end', () => { if (response.statusCode >= 200 && response.statusCode < 300) { - resolve(JSON.parse(data)); - } else { - reject(new Error(`Request failed with status code ${response.statusCode}: ${data}`)); - } + resolve(JSON.parse(data)) + } else { + reject( + new Error(`Request failed with status code ${response.statusCode}: ${data}`) + ) + } }) }) @@ -75,9 +77,11 @@ async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { ) } - const emailUrl = `https://${marketingCloudConfig.subdomain}.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}` + const emailUrl = `https://${ + marketingCloudConfig.subdomain + }.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}` - const emailHeaders = { Authorization: `Bearer ${marketingCloudToken}` } + const emailHeaders = {Authorization: `Bearer ${marketingCloudToken}`} const emailBody = { definitionKey: marketingCloudConfig.templateId, diff --git a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.test.js b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.test.js new file mode 100644 index 0000000000..f7457d8bec --- /dev/null +++ b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.test.js @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024, 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 + */ +jest.mock('https', () => { + return { + request: jest.fn((url, options, callback) => { + const response = { + on: jest.fn((event, callback) => { + if (event === 'data') { + callback(response.data) + } else if (event === 'end') { + callback() + } else if (event === 'on') { + callback() + } + }), + statusCode: 200, + headers: {}, + data: '{ "data": "test" }' + } + + callback(response) + }) + } +}) +import {emailLink} from '@salesforce/retail-react-app/../../app/utils/marketing-cloud/marketing-cloud-email-link' + +describe('emailLink()', () => { + it('should send an email with a magic link', async () => { + const result = await emailLink('test@example.com', '123', 'https://magic-link.example.com') + + expect(result).toEqual({data: 'test'}) + }) +}) From 2c9565e12f0b045b0c1c7f13619cb1fd8edd2201 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 5 Dec 2024 14:05:45 -0500 Subject: [PATCH 040/100] remove enable_ssr_post --- packages/template-retail-react-app/app/ssr.js | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index a2bb9403a4..645a5c012b 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -89,9 +89,6 @@ const {handler} = runtime.createHandler(options, (app) => { } }) ) - if (ENABLE_SSR_POST) { - app.use(express.json()) - } // Handle the redirect from SLAS as to avoid error app.get('/callback?*', (req, res) => { @@ -101,31 +98,29 @@ const {handler} = runtime.createHandler(options, (app) => { res.send() }) - if (ENABLE_SSR_POST) { - app.post('/passwordless-login-callback', async (req, res) => { - const base = req.protocol + '://' + req.get('host') - const {email_id, token} = req.body - const magicLink = `${base}/passwordless-login-landing?token=${token}` - const emailLinkResponse = await emailLink( - email_id, - process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, - magicLink - ) - res.send(emailLinkResponse) - }) + app.post('/passwordless-login-callback', express.json(), async (req, res) => { + const base = req.protocol + '://' + req.get('host') + const {email_id, token} = req.body + const magicLink = `${base}/passwordless-login-landing?token=${token}` + const emailLinkResponse = await emailLink( + email_id, + process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, + magicLink + ) + res.send(emailLinkResponse) + }) - app.post('/reset-password-callback', async (req, res) => { - const base = req.protocol + '://' + req.get('host') - const {email_id, token} = req.body - const magicLink = `${base}/reset-password-landing?token=${token}` - const emailLinkResponse = await emailLink( - email_id, - process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, - magicLink - ) - res.send(emailLinkResponse) - }) - } + app.post('/reset-password-callback', express.json(), async (req, res) => { + const base = req.protocol + '://' + req.get('host') + const {email_id, token} = req.body + const magicLink = `${base}/reset-password-landing?token=${token}` + const emailLinkResponse = await emailLink( + email_id, + process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, + magicLink + ) + res.send(emailLinkResponse) + }) app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico')) From 1b90e449804ed35a9e59f6315c8a13f312186a8f Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 5 Dec 2024 15:16:36 -0500 Subject: [PATCH 041/100] cleanup --- packages/template-retail-react-app/app/ssr.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 645a5c012b..c769554d51 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -25,8 +25,6 @@ import helmet from 'helmet' import express from 'express' import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link' -const ENABLE_SSR_POST = (process.env.ENABLE_SSR_POST || '').toLowerCase() === 'true' - const options = { // The build directory (an absolute path) buildDir: path.resolve(process.cwd(), 'build'), From 9fa1fbdd76e7fc1a59669ff4cc94cdfab4901068 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 16 Dec 2024 15:47:31 -0500 Subject: [PATCH 042/100] add comments for marketing-cloud-email-link.js --- .../marketing-cloud-email-link.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js index 2585100bc4..1d231f2fb6 100644 --- a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js +++ b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js @@ -4,6 +4,24 @@ * 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 */ + +/** + * This file is responsible for integrating with the Marketing Cloud APIs to + * send emails with a magic link to a specified contact using the Marketing Cloud API. + * For this integration to work, a template email with a `%%magic-link%%` personalization string inserted + * must exist in your Marketing Cloud org. + * + * More details here: https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/transactional-messaging-get-started.html + * + * High Level Flow: + * 1. It retrieves an access token from the Marketing Cloud API using the + * provided client ID and client secret. + * 2. It constructs the email message URL using the generated unique ID and the + * provided template ID. + * 3. It sends the email message containing the magic link to the specified contact + * using the Marketing Cloud API. + */ + import crypto from 'crypto' import https from 'https' @@ -55,10 +73,26 @@ export function asyncJsonHttpsPost(url, postObject, headers = {}) { }) } +/** + * Generates a unique ID for the email message. + * + * @return {string} A unique ID for the email message. + */ function generateUniqueId() { return crypto.randomBytes(16).toString('hex') } +/** + * Sends an email to a specified contact using the Marketing Cloud API. The template email must have a + * `%%magic-link%%` personalization string inserted. + * https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5 + * + * @param {string} email - The email address of the contact to whom the email will be sent. + * @param {string} templateId - The ID of the email template to be used for the email. + * @param {string} magicLink - The magic link to be included in the email. + * + * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. + */ async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { if (new Date() > marketingCloudTokenExpiration) { const tokenUrl = `https://${marketingCloudConfig.subdomain}.auth.marketingcloudapis.com/v2/token` @@ -97,6 +131,16 @@ async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { return emailResponse } +/** + * Generates a unique ID, constructs an email message URL, and sends the email to the specified contact + * using the Marketing Cloud API. + * + * @param {string} email - The email address of the contact to whom the email will be sent. + * @param {string} templateId - The ID of the email template to be used for the email. + * @param {string} magicLink - The magic link to be included in the email. + * + * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. + */ export async function emailLink(emailId, templateId, magicLink) { const marketingCloudConfig = { clientId: process.env.MARKETING_CLOUD_CLIENT_ID, From 8042db80937b3e42ff79ee7a13dce3c9e3405a15 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Dec 2024 14:51:26 -0500 Subject: [PATCH 043/100] renmae callbackURI to passwordlessLoginCallbackURI --- .../commerce-sdk-react/src/auth/index.test.ts | 2 +- packages/commerce-sdk-react/src/auth/index.ts | 16 ++++++++-------- packages/commerce-sdk-react/src/provider.tsx | 10 +++++----- .../app/components/_app-config/index.jsx | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts index 1d870f6407..cb819dd90a 100644 --- a/packages/commerce-sdk-react/src/auth/index.test.ts +++ b/packages/commerce-sdk-react/src/auth/index.test.ts @@ -78,7 +78,7 @@ const config = { proxy: 'proxy', redirectURI: 'redirectURI', logger: console, - callbackURI: 'callbackURI' + passwordlessLoginCallbackURI: 'passwordlessLoginCallbackURI' } const configSLASPrivate = { diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index 90656369f5..6d3d4d0489 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -48,7 +48,7 @@ interface AuthConfig extends ApiClientConfigParams { silenceWarnings?: boolean logger: Logger defaultDnt?: boolean - callbackURI?: string + passwordlessLoginCallbackURI?: string refreshTokenRegisteredCookieTTL?: number refreshTokenGuestCookieTTL?: number } @@ -222,7 +222,7 @@ class Auth { private logger: Logger private defaultDnt: boolean | undefined private isPrivate: boolean - private callbackURI: string + private passwordlessLoginCallbackURI: string private refreshTokenRegisteredCookieTTL: number | undefined private refreshTokenGuestCookieTTL: number | undefined private refreshTrustedAgentHandler: @@ -233,7 +233,6 @@ class Auth { // Special endpoint for injecting SLAS private client secret. const baseUrl = config.proxy.split(MOBIFY_PATH)[0] const privateClientEndpoint = `${baseUrl}${SLAS_PRIVATE_PROXY_PATH}` - const callbackURI = config.callbackURI this.client = new ShopperLogin({ proxy: config.enablePWAKitPrivateClient ? privateClientEndpoint : config.proxy, @@ -314,10 +313,11 @@ class Auth { this.isPrivate = !!this.clientSecret - this.callbackURI = callbackURI - ? isAbsoluteUrl(callbackURI) - ? callbackURI - : `${baseUrl}${callbackURI}` + const passwordlessLoginCallbackURI = config.passwordlessLoginCallbackURI + this.passwordlessLoginCallbackURI = passwordlessLoginCallbackURI + ? isAbsoluteUrl(passwordlessLoginCallbackURI) + ? passwordlessLoginCallbackURI + : `${baseUrl}${passwordlessLoginCallbackURI}` : '' } @@ -1093,7 +1093,7 @@ class Auth { */ async authorizePasswordless(parameters: AuthorizePasswordlessParams) { const userid = parameters.userid - const callbackURI = this.callbackURI + const callbackURI = this.passwordlessLoginCallbackURI const usid = this.get('usid') const mode = callbackURI ? 'callback' : 'sms' diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index 16aad2188f..db578ddfc0 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -43,7 +43,7 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams { silenceWarnings?: boolean logger?: Logger defaultDnt?: boolean - callbackURI?: string + passwordlessLoginCallbackURI?: string refreshTokenRegisteredCookieTTL?: number refreshTokenGuestCookieTTL?: number } @@ -124,7 +124,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { silenceWarnings, logger, defaultDnt, - callbackURI, + passwordlessLoginCallbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL } = props @@ -147,7 +147,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { silenceWarnings, logger: configLogger, defaultDnt, - callbackURI, + passwordlessLoginCallbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL }) @@ -165,7 +165,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { silenceWarnings, configLogger, defaultDnt, - callbackURI, + passwordlessLoginCallbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL ]) @@ -246,7 +246,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { silenceWarnings, logger: configLogger, defaultDnt, - callbackURI, + passwordlessLoginCallbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL }} 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 9526983957..8303a2fa8e 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 @@ -63,7 +63,7 @@ const AppConfig = ({children, locals = {}}) => { locale={locals.locale?.id} currency={locals.locale?.preferredCurrency} redirectURI={`${appOrigin}/callback`} - callbackURI={locals.appConfig.login?.passwordless?.callbackURI} + passwordlessLoginCallbackURI={locals.appConfig.login?.passwordless?.callbackURI} proxy={`${appOrigin}${commerceApiConfig.proxyPath}`} headers={headers} // Uncomment 'enablePWAKitPrivateClient' to use SLAS private client login flows. From 12feb9ef0eca6f5e0cb4c391d8157a79d2be3730 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Dec 2024 15:18:08 -0500 Subject: [PATCH 044/100] create a function for handlePasswordlessLogin --- .../app/hooks/use-auth-modal.js | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) 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 b419190e96..ba083d3a3d 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 @@ -103,6 +103,17 @@ export const AuthModal = ({ const onLoginSuccess = () => { navigate('/account') } + + const handlePasswordlessLogin = async (email) => { + try { + await authorizePasswordlessLogin(email); + } catch (error) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE), + }) + } + } return { login: async (data) => { @@ -139,14 +150,7 @@ export const AuthModal = ({ } else if (loginType === LOGIN_TYPES.PASSWORDLESS) { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) - try { - authorizePasswordlessLogin(data.email) - } catch (e) { - form.setError('global', { - type: 'manual', - message: formatMessage(API_ERROR_MESSAGE) - }) - } + handlePasswordlessLogin(passwordlessLoginEmail) } }, register: async (data) => { @@ -184,14 +188,7 @@ export const AuthModal = ({ } }, email: async () => { - try { - authorizePasswordlessLogin(passwordlessLoginEmail) - } catch (e) { - form.setError('global', { - type: 'manual', - message: formatMessage(API_ERROR_MESSAGE) - }) - } + handlePasswordlessLogin(passwordlessLoginEmail) } }[currentView](data) } From 83656b9fae956134158fe68c90ea6e17ba2334d6 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 18 Dec 2024 15:48:27 -0500 Subject: [PATCH 045/100] remove use-passwordless-login hooks --- .../app/hooks/use-auth-modal.js | 9 +- .../app/hooks/use-passwordless-login.js | 28 ------- .../app/hooks/use-passwordless-login.test.js | 82 ------------------- .../app/pages/login/index.jsx | 37 ++++----- 4 files changed, 21 insertions(+), 135 deletions(-) delete mode 100644 packages/template-retail-react-app/app/hooks/use-passwordless-login.js delete mode 100644 packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js 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 ba083d3a3d..4f4b4a099a 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 @@ -40,7 +40,6 @@ import {noop} from '@salesforce/retail-react-app/app/utils/utils' import {API_ERROR_MESSAGE, LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' -import {usePasswordlessLogin} from '@salesforce/retail-react-app/app/hooks/use-passwordless-login' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' @@ -83,9 +82,9 @@ export const AuthModal = ({ const toast = useToast() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) + const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) - const {authorizePasswordlessLogin} = usePasswordlessLogin() const getResetPasswordToken = useShopperCustomersMutation( ShopperCustomersMutations.GetResetPasswordToken @@ -106,7 +105,7 @@ export const AuthModal = ({ const handlePasswordlessLogin = async (email) => { try { - await authorizePasswordlessLogin(email); + await authorizePasswordlessLogin.mutateAsync({userid: email}) } catch (error) { form.setError('global', { type: 'manual', @@ -150,7 +149,7 @@ export const AuthModal = ({ } else if (loginType === LOGIN_TYPES.PASSWORDLESS) { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) - handlePasswordlessLogin(passwordlessLoginEmail) + await handlePasswordlessLogin(data.email) } }, register: async (data) => { @@ -188,7 +187,7 @@ export const AuthModal = ({ } }, email: async () => { - handlePasswordlessLogin(passwordlessLoginEmail) + await handlePasswordlessLogin(passwordlessLoginEmail) } }[currentView](data) } diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.js deleted file mode 100644 index 28ce921e9e..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2024, 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 {AuthHelpers, useAuthHelper} from '@salesforce/commerce-sdk-react' - -/** - * This hook provides commerce-react-sdk hooks to simplify the passwordless login flow. - */ -export const usePasswordlessLogin = () => { - const authorizePasswordless = useAuthHelper(AuthHelpers.AuthorizePasswordless) - - const authorizePasswordlessLogin = async (email) => { - await authorizePasswordless.mutateAsync({userid: email}) - } - - const login = useAuthHelper(AuthHelpers.LoginPasswordlessUser) - - const loginWithPasswordlessAccessToken = async (token) => { - await login.mutateAsync({pwdlessLoginToken: token}) - } - - return {authorizePasswordlessLogin, loginWithPasswordlessAccessToken} -} - -export default usePasswordlessLogin diff --git a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js b/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js deleted file mode 100644 index 4861f02b7d..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-passwordless-login.test.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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 {fireEvent, screen, waitFor} from '@testing-library/react' -import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' -import mockConfig from '@salesforce/retail-react-app/config/mocks/default' -import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import usePasswordlessLogin from '@salesforce/retail-react-app/app/hooks/use-passwordless-login' - -const mockEmail = 'test@email.com' -const mockToken = '123456' -const mockSiteId = mockConfig.app.defaultSite -const mockCallbackUri = mockConfig.app.login.passwordless.callbackURI - -const MockComponent = () => { - const {authorizePasswordlessLogin, loginWithPasswordlessAccessToken} = usePasswordlessLogin() - - return ( -
-
- ) -} - -jest.mock('@salesforce/commerce-sdk-react', () => { - const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') - return { - ...originalModule, - useAuthHelper: jest.fn() - } -}) - -const authorizePasswordless = {mutateAsync: jest.fn()} -const loginPasswordlessUser = {mutateAsync: jest.fn()} -useAuthHelper.mockImplementation((param) => { - if (param === AuthHelpers.LoginPasswordlessUser) { - return loginPasswordlessUser - } else if (param === AuthHelpers.AuthorizePasswordless) { - return authorizePasswordless - } -}) - -afterEach(() => { - jest.clearAllMocks() -}) - -describe('The usePasswordlessLogin', () => { - test('authorizePasswordlessLogin sends expected api request', async () => { - renderWithProviders() - - const trigger = screen.getByTestId('authorize-passwordless-login') - await fireEvent.click(trigger) - await waitFor(() => { - expect(authorizePasswordless.mutateAsync).toHaveBeenCalled() - expect(authorizePasswordless.mutateAsync).toHaveBeenCalledWith({userid: mockEmail}) - }) - }) - - test('loginWithPasswordlessAccessToken sends expected api request', async () => { - renderWithProviders() - - const trigger = screen.getByTestId('login-with-passwordless-access-token') - await fireEvent.click(trigger) - await waitFor(() => { - expect(loginPasswordlessUser.mutateAsync).toHaveBeenCalled() - expect(loginPasswordlessUser.mutateAsync).toHaveBeenCalledWith({ - pwdlessLoginToken: mockToken - }) - }) - }) -}) 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 8d3a68b49c..4b7ed2a22c 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -27,7 +27,6 @@ import LoginForm from '@salesforce/retail-react-app/app/components/login' import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' import {API_ERROR_MESSAGE, LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' -import {usePasswordlessLogin} from '@salesforce/retail-react-app/app/hooks/use-passwordless-login' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' @@ -53,6 +52,8 @@ const Login = ({initialView = LOGIN_VIEW}) => { const einstein = useEinstein() const {isRegistered, customerType} = useCustomerType() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) + const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser) + const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const {passwordless = {}, social = {}} = getConfig().app.login || {} const isPasswordlessEnabled = !!passwordless?.enabled const isSocialEnabled = !!social?.enabled @@ -68,7 +69,6 @@ const Login = ({initialView = LOGIN_VIEW}) => { const [currentView, setCurrentView] = useState(initialView) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) - const {authorizePasswordlessLogin, loginWithPasswordlessAccessToken} = usePasswordlessLogin() const handleMergeBasket = () => { const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0 @@ -101,6 +101,17 @@ const Login = ({initialView = LOGIN_VIEW}) => { const submitForm = async (data) => { form.clearErrors() + const handlePasswordlessLogin = async (email) => { + try { + await authorizePasswordlessLogin.mutateAsync({userid: email}) + } catch (error) { + form.setError('global', { + type: 'manual', + message: formatMessage(API_ERROR_MESSAGE), + }) + } + } + return { login: async (data) => { if (loginType === LOGIN_TYPES.PASSWORD) { @@ -116,25 +127,11 @@ const Login = ({initialView = LOGIN_VIEW}) => { } else if (loginType === LOGIN_TYPES.PASSWORDLESS) { setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) - try { - authorizePasswordlessLogin(data.email) - } catch (e) { - form.setError('global', { - type: 'manual', - message: formatMessage(API_ERROR_MESSAGE) - }) - } + await handlePasswordlessLogin(data.email) } }, - email: () => { - try { - authorizePasswordlessLogin(passwordlessLoginEmail) - } catch (e) { - form.setError('global', { - type: 'manual', - message: formatMessage(API_ERROR_MESSAGE) - }) - } + email: async () => { + await handlePasswordlessLogin(passwordlessLoginEmail) } }[currentView](data) } @@ -143,7 +140,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { if (path === '/passwordless-login-landing' && isSuccessCustomerBaskets) { const token = queryParams.get('token') try { - loginWithPasswordlessAccessToken(token) + loginPasswordless.mutate({pwdlessLoginToken: token}) } catch (e) { const message = /Unauthorized/i.test(e.message) ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) From 6c330009020a81d824e06b5f2b9b9eb077697d45 Mon Sep 17 00:00:00 2001 From: Jinsu Ha <91205717+hajinsuha1@users.noreply.github.com> Date: Wed, 18 Dec 2024 17:06:48 -0500 Subject: [PATCH 046/100] @W-15953350 Reset Password (#2132) Revamps the Reset Password flow in the PWA Kit to leverage the SLAS password reset process and Marketing Cloud integration to send the reset password magic link to the shopper. --------- Signed-off-by: Yuna Kim <84923642+yunakim714@users.noreply.github.com> Co-authored-by: bredmond-sf <53481005+bredmond-sf@users.noreply.github.com> Co-authored-by: yunakim714 Co-authored-by: Yuna Kim <84923642+yunakim714@users.noreply.github.com> --- packages/commerce-sdk-react/src/auth/index.ts | 69 ++++++++- .../src/hooks/useAuthHelper.ts | 2 + packages/commerce-sdk-react/src/utils.ts | 13 ++ .../app/components/reset-password/index.jsx | 132 +++++++++++------- .../components/reset-password/index.test.js | 95 +++++++++++++ .../app/constants.js | 7 + .../app/hooks/use-auth-modal.js | 68 ++------- .../app/hooks/use-password-reset.js | 53 +++++++ .../app/hooks/use-password-reset.test.js | 94 +++++++++++++ .../app/pages/login/index.jsx | 10 +- .../app/pages/reset-password/index.jsx | 67 ++------- .../app/pages/reset-password/index.test.jsx | 72 +--------- .../reset-password/reset-password-landing.jsx | 101 ++++++++++++++ .../template-retail-react-app/app/routes.jsx | 5 +- packages/template-retail-react-app/app/ssr.js | 15 +- .../static/translations/compiled/en-GB.json | 50 ++----- .../static/translations/compiled/en-US.json | 50 ++----- .../static/translations/compiled/en-XA.json | 98 ++++--------- .../config/default.js | 7 +- .../translations/en-GB.json | 18 +-- .../translations/en-US.json | 18 +-- 21 files changed, 630 insertions(+), 414 deletions(-) create mode 100644 packages/template-retail-react-app/app/components/reset-password/index.test.js create mode 100644 packages/template-retail-react-app/app/hooks/use-password-reset.js create mode 100644 packages/template-retail-react-app/app/hooks/use-password-reset.test.js create mode 100644 packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index 6d3d4d0489..bde38de139 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -20,7 +20,8 @@ import { isOriginTrusted, onClient, getDefaultCookieAttributes, - isAbsoluteUrl + isAbsoluteUrl, + stringToBase64 } from '../utils' import { MOBIFY_PATH, @@ -1106,7 +1107,7 @@ class Auth { ...(callbackURI && {callbackURI: callbackURI}), ...(usid && {usid}), userid, - mode, + mode } ) } @@ -1133,6 +1134,70 @@ class Auth { return token } + /** + * A wrapper method for the SLAS endpoint: getPasswordResetToken. + * + */ + async getPasswordResetToken(parameters: ShopperLoginTypes.PasswordActionRequest) { + const slasClient = this.client + const callbackURI = parameters.callback_uri || this.callbackURI + + const options = { + headers: { + Authorization: '' + }, + body: { + user_id: parameters.user_id, + mode: 'callback', + channel_id: slasClient.clientConfig.parameters.siteId, + client_id: slasClient.clientConfig.parameters.clientId, + callback_uri: callbackURI, + hint: 'cross_device' + } + } + + // Only set authorization header if using private client + if (this.clientSecret) { + options.headers.Authorization = `Basic ${stringToBase64( + `${slasClient.clientConfig.parameters.clientId}:${this.clientSecret}` + )}` + } + + const res = await slasClient.getPasswordResetToken(options) + return res + } + + /** + * A wrapper method for the SLAS endpoint: resetPassword. + * + */ + async resetPassword(parameters: ShopperLoginTypes.PasswordActionVerifyRequest) { + const slasClient = this.client + const options = { + headers: { + Authorization: '' + }, + body: { + pwd_action_token: parameters.pwd_action_token, + channel_id: slasClient.clientConfig.parameters.siteId, + client_id: slasClient.clientConfig.parameters.clientId, + new_password: parameters.new_password, + user_id: parameters.user_id + } + } + + // Only set authorization header if using private client + if (this.clientSecret) { + options.headers.Authorization = `Basic ${stringToBase64( + `${slasClient.clientConfig.parameters.clientId}:${this.clientSecret}` + )}` + } + // TODO: no code verifier needed with the fix blair has made, delete this when the fix has been merged to production + // @ts-ignore + const res = await this.client.resetPassword(options) + return res + } + /** * Decode SLAS JWT and extract information such as customer id, usid, etc. * diff --git a/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts b/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts index fc3c42e49f..12085ef28c 100644 --- a/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts +++ b/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts @@ -24,11 +24,13 @@ export const AuthHelpers = { AuthorizePasswordless: 'authorizePasswordless', LoginPasswordlessUser: 'getPasswordLessAccessToken', AuthorizeIDP: 'authorizeIDP', + GetPasswordResetToken: 'getPasswordResetToken', LoginIDPUser: 'loginIDPUser', LoginGuestUser: 'loginGuestUser', LoginRegisteredUserB2C: 'loginRegisteredUserB2C', Logout: 'logout', Register: 'register', + ResetPassword: 'resetPassword', UpdateCustomerPassword: 'updateCustomerPassword' } as const /** diff --git a/packages/commerce-sdk-react/src/utils.ts b/packages/commerce-sdk-react/src/utils.ts index e826f462d6..cbc5c99495 100644 --- a/packages/commerce-sdk-react/src/utils.ts +++ b/packages/commerce-sdk-react/src/utils.ts @@ -130,3 +130,16 @@ export function detectCookiesAvailable(options?: CookieAttributes) { export function isAbsoluteUrl(url: string): boolean { return /^(https?:\/\/)/i.test(url) } + +/** + * Provides a platform-specific method for Base64 encoding. + * + * - In a browser environment (where `window` and `document` are defined), + * the native `btoa` function is used. + * - In a non-browser environment (like Node.js), a fallback is provided + * that uses `Buffer` to perform the Base64 encoding. + */ +export const stringToBase64 = + typeof window === 'object' && typeof window.document === 'object' + ? btoa + : (unencoded: string): string => Buffer.from(unencoded).toString('base64') diff --git a/packages/template-retail-react-app/app/components/reset-password/index.jsx b/packages/template-retail-react-app/app/components/reset-password/index.jsx index 69a3384ebc..755f57862a 100644 --- a/packages/template-retail-react-app/app/components/reset-password/index.jsx +++ b/packages/template-retail-react-app/app/components/reset-password/index.jsx @@ -16,64 +16,98 @@ import ResetPasswordFields from '@salesforce/retail-react-app/app/components/for const ResetPasswordForm = ({submitForm, clickSignIn = noop, form}) => { return ( - - - + {!form.formState.isSubmitSuccessful ? ( + <> + + + + + + + + + + + +
+ + {form.formState.errors?.global && ( + + + + {form.formState.errors.global.message} + + + )} + + + + + + + + + + + + +
+ + ) : ( + + - - - - -
-
- - {form.formState.errors?.global && ( - - - - {form.formState.errors.global.message} - - - )} - - - + - - - - - - + -
+ )}
) } diff --git a/packages/template-retail-react-app/app/components/reset-password/index.test.js b/packages/template-retail-react-app/app/components/reset-password/index.test.js new file mode 100644 index 0000000000..c578173cce --- /dev/null +++ b/packages/template-retail-react-app/app/components/reset-password/index.test.js @@ -0,0 +1,95 @@ +/* + * 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 PropTypes from 'prop-types' +import {screen, waitFor, within} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import ResetPasswordForm from '.' +import mockConfig from '@salesforce/retail-react-app/config/mocks/default' +import {useForm} from 'react-hook-form' + +const MockedComponent = ({mockSubmitForm, mockClickSignIn}) => { + const form = useForm() + return ( +
+ +
+ ) +} + +MockedComponent.propTypes = { + mockSubmitForm: PropTypes.func, + mockClickSignIn: PropTypes.func +} + +const MockedErrorComponent = () => { + const form = useForm() + const mockForm = { + ...form, + formState: { + ...form.formState, + errors: { + global: {message: 'Something went wrong'} + } + } + } + return ( +
+ +
+ ) +} + +test('Allows customer to generate password token and see success message', async () => { + const mockSubmitForm = jest.fn(async (data) => ({ + password: jest.fn(async (passwordData) => { + // Mock behavior inside the password function + console.log('Password function called with:', passwordData) + }) + })) + const mockClickSignIn = jest.fn() + // render our test component + const {user} = renderWithProviders( + , + { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + } + ) + + // enter credentials and submit + await user.type(await screen.findByLabelText('Email'), 'foo@test.com') + await user.click( + within(await screen.findByTestId('sf-auth-modal-form')).getByText(/reset password/i) + ) + await waitFor(() => { + expect(mockSubmitForm).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByText(/you will receive an email/i)).toBeInTheDocument() + expect(screen.getByText(/foo@test.com/i)).toBeInTheDocument() + }) + + await user.click(screen.getByText('Back to Sign In')) + + expect(mockClickSignIn).toHaveBeenCalledTimes(1) +}) + +test('Renders error message with form error state', async () => { + // Render our test component + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) + + await waitFor(() => { + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument() + }) +}) diff --git a/packages/template-retail-react-app/app/constants.js b/packages/template-retail-react-app/app/constants.js index 7ded2c3da8..979ba1bfd8 100644 --- a/packages/template-retail-react-app/app/constants.js +++ b/packages/template-retail-react-app/app/constants.js @@ -91,6 +91,10 @@ export const API_ERROR_MESSAGE = defineMessage({ id: 'global.error.something_went_wrong', defaultMessage: 'Something went wrong. Try again!' }) +export const INVALID_TOKEN_ERROR_MESSAGE = defineMessage({ + defaultMessage: 'Invalid token, please try again.', + id: 'global.error.invalid_token' +}) export const HOME_HREF = '/' @@ -238,3 +242,6 @@ export const LOGIN_TYPES = { PASSWORDLESS: 'passwordless', SOCIAL: 'social' } + +// Constants for Password Reset +export const RESET_PASSWORD_LANDING_PATH = '/reset-password-landing' 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 4f4b4a099a..311a439482 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 @@ -4,19 +4,16 @@ * 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, {useEffect, useRef, useState} from 'react' +import React, {useEffect, useState} from 'react' import PropTypes from 'prop-types' -import {defineMessage, FormattedMessage, useIntl} from 'react-intl' +import {defineMessage, useIntl} from 'react-intl' import {useForm} from 'react-hook-form' import { - Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay, - Stack, - Text, useDisclosure, useToast } from '@salesforce/retail-react-app/app/components/shared/ui' @@ -27,11 +24,8 @@ import { useCustomerId, useCustomerType, useCustomerBaskets, - useShopperCustomersMutation, - useShopperBasketsMutation, - ShopperCustomersMutations + useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' -import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' import LoginForm from '@salesforce/retail-react-app/app/components/login' import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset-password' import RegisterForm from '@salesforce/retail-react-app/app/components/register' @@ -40,6 +34,7 @@ import {noop} from '@salesforce/retail-react-app/app/utils/utils' import {API_ERROR_MESSAGE, LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' 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' @@ -78,17 +73,14 @@ export const AuthModal = ({ const navigate = useNavigation() const [currentView, setCurrentView] = useState(initialView) const form = useForm() - const submittedEmail = useRef() const toast = useToast() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) - const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) - const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') + const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) - - const getResetPasswordToken = useShopperCustomersMutation( - ShopperCustomersMutations.GetResetPasswordToken - ) + const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') + const {getPasswordResetToken} = usePasswordReset() + const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const {data: baskets} = useCustomerBaskets( {parameters: {customerId}}, @@ -175,10 +167,7 @@ export const AuthModal = ({ }, password: async (data) => { try { - const body = { - login: data.email - } - await getResetPasswordToken.mutateAsync({body}) + await getPasswordResetToken(data.email) } catch (e) { form.setError('global', { type: 'manual', @@ -197,7 +186,6 @@ export const AuthModal = ({ if (isOpen) { setLoginType(LOGIN_TYPES.PASSWORD) setCurrentView(initialView) - submittedEmail.current = undefined form.reset() } }, [isOpen]) @@ -266,39 +254,6 @@ export const AuthModal = ({ const onBackToSignInClick = () => initialView === PASSWORD_VIEW ? onClose() : setCurrentView(LOGIN_VIEW) - // TODO: Remove this to a separate component when fixing password reset flow - const PasswordResetSuccess = () => ( - - - - - - - - {chunks} - }} - /> - - - - - - ) - return ( )} - {!form.formState.isSubmitSuccessful && currentView === PASSWORD_VIEW && ( + {currentView === PASSWORD_VIEW && ( )} - {form.formState.isSubmitSuccessful && currentView === PASSWORD_VIEW && ( - - )} {form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( { + const showToast = useToast() + const {formatMessage} = useIntl() + const appOrigin = useAppOrigin() + const config = getConfig() + const resetPasswordCallback = + config.app.login?.resetPassword?.callbackURI || '/reset-password-callback' + + const getPasswordResetTokenMutation = useAuthHelper(AuthHelpers.GetPasswordResetToken) + const resetPasswordMutation = useAuthHelper(AuthHelpers.ResetPassword) + + const getPasswordResetToken = async (email) => { + await getPasswordResetTokenMutation.mutateAsync({ + user_id: email, + callback_uri: `${appOrigin}${resetPasswordCallback}` + }) + } + + const resetPassword = async ({email, token, newPassword}) => { + await resetPasswordMutation.mutateAsync( + {user_id: email, pwd_action_token: token, new_password: newPassword}, + { + onSuccess: () => { + showToast({ + title: formatMessage({ + defaultMessage: 'Password Reset Success', + id: 'password_reset_success.toast' + }), + status: 'success', + position: 'bottom-right' + }) + } + } + ) + } + + return {getPasswordResetToken, resetPassword} +} diff --git a/packages/template-retail-react-app/app/hooks/use-password-reset.test.js b/packages/template-retail-react-app/app/hooks/use-password-reset.test.js new file mode 100644 index 0000000000..016f99292b --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-password-reset.test.js @@ -0,0 +1,94 @@ +/* + * 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 {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' + +const mockEmail = 'test@email.com' +const mockToken = '123456' +const mockNewPassword = 'new-password' + +const MockComponent = () => { + const {getPasswordResetToken, resetPassword} = usePasswordReset() + + return ( +
+
+ ) +} + +jest.mock('@salesforce/commerce-sdk-react', () => { + const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...originalModule, + useAuthHelper: jest.fn() + } +}) + +const getPasswordResetToken = {mutateAsync: jest.fn()} +const resetPassword = {mutateAsync: jest.fn()} +useAuthHelper.mockImplementation((param) => { + if (param === AuthHelpers.ResetPassword) { + return resetPassword + } else if (param === AuthHelpers.GetPasswordResetToken) { + return getPasswordResetToken + } +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('usePasswordReset', () => { + test('getPasswordResetToken sends expected api request', async () => { + renderWithProviders() + + const trigger = screen.getByTestId('get-password-reset-token') + await fireEvent.click(trigger) + await waitFor(() => { + expect(getPasswordResetToken.mutateAsync).toHaveBeenCalled() + expect(getPasswordResetToken.mutateAsync).toHaveBeenCalledWith({ + user_id: mockEmail, + callback_uri: 'https://www.domain.com/reset-password-callback' + }) + }) + }) + + test('resetPassword sends expected api request', async () => { + renderWithProviders() + + const trigger = screen.getByTestId('reset-password') + await fireEvent.click(trigger) + await waitFor(() => { + expect(resetPassword.mutateAsync).toHaveBeenCalled() + expect(resetPassword.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + pwd_action_token: mockToken, + new_password: mockNewPassword, + user_id: mockEmail + }), + expect.anything() + ) + }) + }) +}) 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 4b7ed2a22c..a25b1f497a 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -25,7 +25,11 @@ import {useLocation} from 'react-router-dom' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import LoginForm from '@salesforce/retail-react-app/app/components/login' import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' -import {API_ERROR_MESSAGE, LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants' +import { + API_ERROR_MESSAGE, + INVALID_TOKEN_ERROR_MESSAGE, + LOGIN_TYPES +} from '@salesforce/retail-react-app/app/constants' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' @@ -34,10 +38,6 @@ const LOGIN_ERROR_MESSAGE = defineMessage({ defaultMessage: 'Incorrect username or password, please try again.', id: 'login_page.error.incorrect_username_or_password' }) -const INVALID_TOKEN_ERROR_MESSAGE = defineMessage({ - defaultMessage: 'Invalid token, please try again.', - id: 'login_page.error.invalid_token' -}) const LOGIN_VIEW = 'login' const EMAIL_VIEW = 'email' diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.jsx index 10c953162f..0609313201 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.jsx @@ -5,47 +5,31 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useState, useEffect} from 'react' +import React, {useEffect} from 'react' import PropTypes from 'prop-types' -import {FormattedMessage} from 'react-intl' -import { - Box, - Button, - Container, - Stack, - Text -} from '@salesforce/retail-react-app/app/components/shared/ui' +import {Box, Container} from '@salesforce/retail-react-app/app/components/shared/ui' import {useForm} from 'react-hook-form' -import { - useShopperCustomersMutation, - ShopperCustomersMutations -} from '@salesforce/commerce-sdk-react' import Seo from '@salesforce/retail-react-app/app/components/seo' import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset-password' -import {BrandLogo} from '@salesforce/retail-react-app/app/components/icons' +import ResetPasswordLanding from '@salesforce/retail-react-app/app/pages/reset-password/reset-password-landing' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' 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} from '@salesforce/retail-react-app/app/constants' const ResetPassword = () => { const form = useForm() const navigate = useNavigation() - const [submittedEmail, setSubmittedEmail] = useState('') - const [showSubmittedSuccess, setShowSubmittedSuccess] = useState(false) const einstein = useEinstein() const {pathname} = useLocation() - const getResetPasswordToken = useShopperCustomersMutation( - ShopperCustomersMutations.GetResetPasswordToken - ) + const {path} = useRouteMatch() + const {getPasswordResetToken} = usePasswordReset() const submitForm = async ({email}) => { - const body = { - login: email - } try { - await getResetPasswordToken.mutateAsync({body}) - setSubmittedEmail(email) - setShowSubmittedSuccess(!showSubmittedSuccess) + await getPasswordResetToken(email) } catch (error) { form.setError('global', {type: 'manual', message: error.message}) } @@ -68,41 +52,14 @@ const ResetPassword = () => { marginBottom={8} borderRadius="base" > - {!showSubmittedSuccess ? ( + {path === RESET_PASSWORD_LANDING_PATH ? ( + + ) : ( navigate('/login')} /> - ) : ( - - - - - - - - {chunks} - }} - /> - - - - )} diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx index b33daf52ac..02a8c18562 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx @@ -14,16 +14,6 @@ import { import ResetPassword from '.' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' -const mockRegisteredCustomer = { - authType: 'registered', - customerId: 'registeredCustomerId', - customerNo: 'testno', - email: 'darek@test.com', - firstName: 'Tester', - lastName: 'Testing', - login: 'darek@test.com' -} - const MockedComponent = () => { return (
@@ -36,25 +26,6 @@ const MockedComponent = () => { beforeEach(() => { jest.resetModules() window.history.pushState({}, 'Reset Password', createPathWithDefaults('/reset-password')) - global.server.use( - rest.post('*/customers', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(200), ctx.json(mockRegisteredCustomer)) - }), - 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(mockRegisteredCustomer)) - }) - ) }) afterEach(() => { jest.resetModules() @@ -63,6 +34,8 @@ afterEach(() => { window.history.pushState({}, 'Reset Password', createPathWithDefaults('/reset-password')) }) +jest.setTimeout(20000) + test('Allows customer to go to sign in page', async () => { // render our test component const {user} = renderWithProviders(, { @@ -78,17 +51,7 @@ test('Allows customer to go to sign in page', async () => { test('Allows customer to generate password token', async () => { global.server.use( - rest.post('*/create-reset-token', (req, res, ctx) => - res( - ctx.delay(0), - ctx.json({ - email: 'foo@test.com', - expiresInMinutes: 10, - login: 'foo@test.com', - resetToken: 'testresettoken' - }) - ) - ) + rest.post('*/password/reset', (req, res, ctx) => res(ctx.delay(0), ctx.status(200))) ) // render our test component const {user} = renderWithProviders(, { @@ -101,9 +64,8 @@ test('Allows customer to generate password token', async () => { within(await screen.findByTestId('sf-auth-modal-form')).getByText(/reset password/i) ) - expect(await screen.findByText(/password reset/i, {}, {timeout: 12000})).toBeInTheDocument() - await waitFor(() => { + expect(screen.getByText(/you will receive an email/i)).toBeInTheDocument() expect(screen.getByText(/foo@test.com/i)).toBeInTheDocument() }) @@ -113,29 +75,3 @@ test('Allows customer to generate password token', async () => { expect(window.location.pathname).toBe('/uk/en-GB/login') }) }) - -test('Renders error message from server', async () => { - global.server.use( - rest.post('*/create-reset-token', (req, res, ctx) => - res( - ctx.delay(0), - ctx.status(500), - ctx.json({ - detail: 'Something went wrong', - title: 'Error', - type: '/error' - }) - ) - ) - ) - const {user} = renderWithProviders() - - await user.type(await screen.findByLabelText('Email'), 'foo@test.com') - await user.click( - within(await screen.findByTestId('sf-auth-modal-form')).getByText(/reset password/i) - ) - - await waitFor(() => { - expect(screen.getByText('500 Internal Server Error')).toBeInTheDocument() - }) -}) 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 new file mode 100644 index 0000000000..ccee30f243 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx @@ -0,0 +1,101 @@ +/* + * 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 PropTypes from 'prop-types' +import {useForm} from 'react-hook-form' +import {useLocation} from 'react-router-dom' +import {useIntl, FormattedMessage} from 'react-intl' +import { + Alert, + Button, + Container, + Stack, + Text +} from '@salesforce/retail-react-app/app/components/shared/ui' +import {AlertIcon, BrandLogo} from '@salesforce/retail-react-app/app/components/icons' +import Field from '@salesforce/retail-react-app/app/components/field' +import PasswordRequirements from '@salesforce/retail-react-app/app/components/forms/password-requirements' +import useUpdatePasswordFields from '@salesforce/retail-react-app/app/components/forms/useUpdatePasswordFields' +import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset' +import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' +import { + API_ERROR_MESSAGE, + INVALID_TOKEN_ERROR_MESSAGE +} from '@salesforce/retail-react-app/app/constants' + +const ResetPasswordLanding = () => { + const form = useForm() + const {formatMessage} = useIntl() + const {search} = useLocation() + const navigate = useNavigation() + const queryParams = new URLSearchParams(search) + const email = queryParams.get('email') + const token = queryParams.get('token') + const fields = useUpdatePasswordFields({form}) + const password = form.watch('password') + const {resetPassword} = usePasswordReset() + + const submit = async (values) => { + form.clearErrors() + try { + await resetPassword({email, token, newPassword: values.password}) + navigate('/login') + } catch (error) { + const message = /Unauthorized/i.test(error.message) + ? formatMessage(INVALID_TOKEN_ERROR_MESSAGE) + : formatMessage(API_ERROR_MESSAGE) + form.setError('global', {type: 'manual', message}) + } + } + + return ( + + + + + + + + +
+ + {form.formState.errors?.global && ( + + + + {form.formState.errors.global.message} + + + )} + + + + + + +
+
+
+ ) +} + +ResetPasswordLanding.getTemplateName = () => 'reset-password-landing' + +ResetPasswordLanding.propTypes = { + token: PropTypes.string +} + +export default ResetPasswordLanding diff --git a/packages/template-retail-react-app/app/routes.jsx b/packages/template-retail-react-app/app/routes.jsx index 936282194c..4699414505 100644 --- a/packages/template-retail-react-app/app/routes.jsx +++ b/packages/template-retail-react-app/app/routes.jsx @@ -20,6 +20,9 @@ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {Skeleton} from '@salesforce/retail-react-app/app/components/shared/ui' import {configureRoutes} from '@salesforce/retail-react-app/app/utils/routes-utils' +// Constants +import {RESET_PASSWORD_LANDING_PATH} from '@salesforce/retail-react-app/app/constants' + const fallback = const socialRedirectURI = getConfig()?.app?.login?.social?.redirectURI @@ -72,7 +75,7 @@ export const routes = [ exact: true }, { - path: '/reset-password-landing', + path: RESET_PASSWORD_LANDING_PATH, component: ResetPassword, exact: true }, diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index c769554d51..f9a063f376 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -24,6 +24,9 @@ import helmet from 'helmet' import express from 'express' import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link' +import {RESET_PASSWORD_LANDING_PATH} from '@salesforce/retail-react-app/app/constants' + +const config = getConfig() const options = { // The build directory (an absolute path) @@ -33,7 +36,7 @@ const options = { defaultCacheTimeSeconds: 600, // The contents of the config file for the current environment - mobify: getConfig(), + mobify: config, // The port that the local dev server listens on port: 3000, @@ -49,7 +52,8 @@ const options = { // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set useSLASPrivateClient: true, - applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless\/(login|token))/, + applySLASPrivateClientToEndpoints: + /oauth2\/(token|passwordless|password\/(login|token|reset|action))/, // If this is enabled, any HTTP header that has a non ASCII value will be URI encoded // If there any HTTP headers that have been encoded, an additional header will be @@ -62,6 +66,9 @@ const options = { const runtime = getRuntime() +const resetPasswordCallback = + config.app.login?.resetPassword?.callbackURI || '/reset-password-callback' + const {handler} = runtime.createHandler(options, (app) => { // Set default HTTP security headers required by PWA Kit app.use(defaultPwaKitSecurityHeaders) @@ -108,10 +115,10 @@ const {handler} = runtime.createHandler(options, (app) => { res.send(emailLinkResponse) }) - app.post('/reset-password-callback', express.json(), async (req, res) => { + app.post(resetPasswordCallback, express.json(), async (req, res) => { const base = req.protocol + '://' + req.get('host') const {email_id, token} = req.body - const magicLink = `${base}/reset-password-landing?token=${token}` + const magicLink = `${base}${RESET_PASSWORD_LANDING_PATH}?token=${token}&email=${email_id}` const emailLinkResponse = await emailLink( email_id, process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, 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 5b40e53db3..5aedd06f49 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 @@ -1521,6 +1521,12 @@ "value": "Wishlist" } ], + "global.error.invalid_token": [ + { + "type": 0, + "value": "Invalid token, please try again." + } + ], "global.error.something_went_wrong": [ { "type": 0, @@ -2281,12 +2287,6 @@ "value": "Incorrect username or password, please try again." } ], - "login_page.error.invalid_token": [ - { - "type": 0, - "value": "Invalid token, please try again." - } - ], "offline_banner.description.browsing_offline_mode": [ { "type": 0, @@ -2509,6 +2509,12 @@ "value": "1 uppercase letter" } ], + "password_reset_success.toast": [ + { + "type": 0, + "value": "Password Reset Success" + } + ], "payment_selection.heading.credit_card": [ { "type": 0, @@ -2985,38 +2991,6 @@ "value": "Create an account and get first access to the very best products, inspiration and community." } ], - "reset_password.button.back_to_sign_in": [ - { - "type": 0, - "value": "Back to Sign In" - } - ], - "reset_password.info.receive_email_shortly": [ - { - "type": 0, - "value": "You will receive an email at " - }, - { - "children": [ - { - "type": 1, - "value": "email" - } - ], - "type": 8, - "value": "b" - }, - { - "type": 0, - "value": " with a link to reset your password shortly." - } - ], - "reset_password.title.password_reset": [ - { - "type": 0, - "value": "Password Reset" - } - ], "reset_password_form.action.sign_in": [ { "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 5b40e53db3..5aedd06f49 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 @@ -1521,6 +1521,12 @@ "value": "Wishlist" } ], + "global.error.invalid_token": [ + { + "type": 0, + "value": "Invalid token, please try again." + } + ], "global.error.something_went_wrong": [ { "type": 0, @@ -2281,12 +2287,6 @@ "value": "Incorrect username or password, please try again." } ], - "login_page.error.invalid_token": [ - { - "type": 0, - "value": "Invalid token, please try again." - } - ], "offline_banner.description.browsing_offline_mode": [ { "type": 0, @@ -2509,6 +2509,12 @@ "value": "1 uppercase letter" } ], + "password_reset_success.toast": [ + { + "type": 0, + "value": "Password Reset Success" + } + ], "payment_selection.heading.credit_card": [ { "type": 0, @@ -2985,38 +2991,6 @@ "value": "Create an account and get first access to the very best products, inspiration and community." } ], - "reset_password.button.back_to_sign_in": [ - { - "type": 0, - "value": "Back to Sign In" - } - ], - "reset_password.info.receive_email_shortly": [ - { - "type": 0, - "value": "You will receive an email at " - }, - { - "children": [ - { - "type": 1, - "value": "email" - } - ], - "type": 8, - "value": "b" - }, - { - "type": 0, - "value": " with a link to reset your password shortly." - } - ], - "reset_password.title.password_reset": [ - { - "type": 0, - "value": "Password Reset" - } - ], "reset_password_form.action.sign_in": [ { "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 20eaaedc58..d89e33bb53 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 @@ -3185,6 +3185,20 @@ "value": "]" } ], + "global.error.invalid_token": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Īƞṽȧȧŀīḓ ŧǿǿķḗḗƞ, ƥŀḗḗȧȧşḗḗ ŧřẏ ȧȧɠȧȧīƞ." + }, + { + "type": 0, + "value": "]" + } + ], "global.error.something_went_wrong": [ { "type": 0, @@ -4873,20 +4887,6 @@ "value": "]" } ], - "login_page.error.invalid_token": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Īƞṽȧȧŀīḓ ŧǿǿķḗḗƞ, ƥŀḗḗȧȧşḗḗ ŧřẏ ȧȧɠȧȧīƞ." - }, - { - "type": 0, - "value": "]" - } - ], "offline_banner.description.browsing_offline_mode": [ { "type": 0, @@ -5349,6 +5349,20 @@ "value": "]" } ], + "password_reset_success.toast": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƥȧȧşşẇǿǿřḓ Řḗḗşḗḗŧ Şŭŭƈƈḗḗşş" + }, + { + "type": 0, + "value": "]" + } + ], "payment_selection.heading.credit_card": [ { "type": 0, @@ -6321,62 +6335,6 @@ "value": "]" } ], - "reset_password.button.back_to_sign_in": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ɓȧȧƈķ ŧǿǿ Şīɠƞ Īƞ" - }, - { - "type": 0, - "value": "]" - } - ], - "reset_password.info.receive_email_shortly": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ẏǿǿŭŭ ẇīŀŀ řḗḗƈḗḗīṽḗḗ ȧȧƞ ḗḗḿȧȧīŀ ȧȧŧ " - }, - { - "children": [ - { - "type": 1, - "value": "email" - } - ], - "type": 8, - "value": "b" - }, - { - "type": 0, - "value": " ẇīŧħ ȧȧ ŀīƞķ ŧǿǿ řḗḗşḗḗŧ ẏǿǿŭŭř ƥȧȧşşẇǿǿřḓ şħǿǿřŧŀẏ." - }, - { - "type": 0, - "value": "]" - } - ], - "reset_password.title.password_reset": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƥȧȧşşẇǿǿřḓ Řḗḗşḗḗŧ" - }, - { - "type": 0, - "value": "]" - } - ], "reset_password_form.action.sign_in": [ { "type": 0, diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 8e0b4731e2..98533c4c2d 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -21,9 +21,12 @@ module.exports = { callbackURI: '/passwordless-login-callback' }, social: { - enabled: true, + enabled: false, idps: ['google', 'apple'], redirectURI: '/social-callback' + }, + resetPassword: { + callbackURI: '/reset-password-callback' } }, defaultSite: 'RefArchGlobal', @@ -35,7 +38,7 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: '80255e22-8504-45e3-b1a3-749fd4475bb7', // TODO: SLAS Private Client Revert before merging + clientId: '3a15f34e-fecd-4fcc-8235-86b70978e629', organizationId: 'f_ecom_zzrf_001', shortCode: '8o7m175y', siteId: 'RefArchGlobal' diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index da914bf15b..b1424c800d 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -627,6 +627,9 @@ "global.account.link.wishlist": { "defaultMessage": "Wishlist" }, + "global.error.invalid_token": { + "defaultMessage": "Invalid token, please try again." + }, "global.error.something_went_wrong": { "defaultMessage": "Something went wrong. Try again!" }, @@ -978,9 +981,6 @@ "login_page.error.incorrect_username_or_password": { "defaultMessage": "Incorrect username or password, please try again." }, - "login_page.error.invalid_token": { - "defaultMessage": "Invalid token, please try again." - }, "offline_banner.description.browsing_offline_mode": { "defaultMessage": "You're currently browsing in offline mode" }, @@ -1077,6 +1077,9 @@ "defaultMessage": "1 uppercase letter", "description": "Password requirement" }, + "password_reset_success.toast": { + "defaultMessage": "Password Reset Success" + }, "payment_selection.heading.credit_card": { "defaultMessage": "Credit Card" }, @@ -1264,15 +1267,6 @@ "register_form.message.create_an_account": { "defaultMessage": "Create an account and get first access to the very best products, inspiration and community." }, - "reset_password.button.back_to_sign_in": { - "defaultMessage": "Back to Sign In" - }, - "reset_password.info.receive_email_shortly": { - "defaultMessage": "You will receive an email at {email} with a link to reset your password shortly." - }, - "reset_password.title.password_reset": { - "defaultMessage": "Password Reset" - }, "reset_password_form.action.sign_in": { "defaultMessage": "Sign in" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index da914bf15b..b1424c800d 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -627,6 +627,9 @@ "global.account.link.wishlist": { "defaultMessage": "Wishlist" }, + "global.error.invalid_token": { + "defaultMessage": "Invalid token, please try again." + }, "global.error.something_went_wrong": { "defaultMessage": "Something went wrong. Try again!" }, @@ -978,9 +981,6 @@ "login_page.error.incorrect_username_or_password": { "defaultMessage": "Incorrect username or password, please try again." }, - "login_page.error.invalid_token": { - "defaultMessage": "Invalid token, please try again." - }, "offline_banner.description.browsing_offline_mode": { "defaultMessage": "You're currently browsing in offline mode" }, @@ -1077,6 +1077,9 @@ "defaultMessage": "1 uppercase letter", "description": "Password requirement" }, + "password_reset_success.toast": { + "defaultMessage": "Password Reset Success" + }, "payment_selection.heading.credit_card": { "defaultMessage": "Credit Card" }, @@ -1264,15 +1267,6 @@ "register_form.message.create_an_account": { "defaultMessage": "Create an account and get first access to the very best products, inspiration and community." }, - "reset_password.button.back_to_sign_in": { - "defaultMessage": "Back to Sign In" - }, - "reset_password.info.receive_email_shortly": { - "defaultMessage": "You will receive an email at {email} with a link to reset your password shortly." - }, - "reset_password.title.password_reset": { - "defaultMessage": "Password Reset" - }, "reset_password_form.action.sign_in": { "defaultMessage": "Sign in" }, From 073154ecf1ce549ccac2ff8eec70a4d7372adc11 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 19 Dec 2024 10:21:48 -0500 Subject: [PATCH 047/100] create PASSWORDLESS_LOGIN_LANDING_PATH in constants --- packages/template-retail-react-app/app/constants.js | 3 +++ .../template-retail-react-app/app/pages/login/index.jsx | 8 ++++++-- packages/template-retail-react-app/app/routes.jsx | 4 ++-- packages/template-retail-react-app/app/ssr.js | 8 +++++--- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/template-retail-react-app/app/constants.js b/packages/template-retail-react-app/app/constants.js index 979ba1bfd8..851fc5ee57 100644 --- a/packages/template-retail-react-app/app/constants.js +++ b/packages/template-retail-react-app/app/constants.js @@ -245,3 +245,6 @@ export const LOGIN_TYPES = { // 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' 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 a25b1f497a..2c36c92cca 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -28,7 +28,8 @@ import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/comp import { API_ERROR_MESSAGE, INVALID_TOKEN_ERROR_MESSAGE, - LOGIN_TYPES + LOGIN_TYPES, + PASSWORDLESS_LOGIN_LANDING_PATH } from '@salesforce/retail-react-app/app/constants' import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' @@ -136,8 +137,11 @@ const Login = ({initialView = LOGIN_VIEW}) => { }[currentView](data) } + // Handles passwordless login by retrieving the 'token' from the query parameters and + // 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' && isSuccessCustomerBaskets) { + if (path === PASSWORDLESS_LOGIN_LANDING_PATH && isSuccessCustomerBaskets) { const token = queryParams.get('token') try { loginPasswordless.mutate({pwdlessLoginToken: token}) diff --git a/packages/template-retail-react-app/app/routes.jsx b/packages/template-retail-react-app/app/routes.jsx index 4699414505..8437903686 100644 --- a/packages/template-retail-react-app/app/routes.jsx +++ b/packages/template-retail-react-app/app/routes.jsx @@ -21,7 +21,7 @@ import {Skeleton} from '@salesforce/retail-react-app/app/components/shared/ui' import {configureRoutes} from '@salesforce/retail-react-app/app/utils/routes-utils' // Constants -import {RESET_PASSWORD_LANDING_PATH} from '@salesforce/retail-react-app/app/constants' +import {PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH} from '@salesforce/retail-react-app/app/constants' const fallback = const socialRedirectURI = getConfig()?.app?.login?.social?.redirectURI @@ -80,7 +80,7 @@ export const routes = [ exact: true }, { - path: '/passwordless-login-landing', + path: PASSWORDLESS_LOGIN_LANDING_PATH, component: Login, exact: true }, diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index f9a063f376..60f2904082 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -24,7 +24,7 @@ import helmet from 'helmet' import express from 'express' import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link' -import {RESET_PASSWORD_LANDING_PATH} from '@salesforce/retail-react-app/app/constants' +import {PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH} from '@salesforce/retail-react-app/app/constants' const config = getConfig() @@ -68,6 +68,8 @@ const runtime = getRuntime() const resetPasswordCallback = config.app.login?.resetPassword?.callbackURI || '/reset-password-callback' +const passwordlessLoginCallback = + config.app.login?.passwordless?.callbackURI || '/passwordless-login-callback' const {handler} = runtime.createHandler(options, (app) => { // Set default HTTP security headers required by PWA Kit @@ -103,10 +105,10 @@ const {handler} = runtime.createHandler(options, (app) => { res.send() }) - app.post('/passwordless-login-callback', express.json(), async (req, res) => { + app.post(passwordlessLoginCallback, express.json(), async (req, res) => { const base = req.protocol + '://' + req.get('host') const {email_id, token} = req.body - const magicLink = `${base}/passwordless-login-landing?token=${token}` + const magicLink = `${base}${PASSWORDLESS_LOGIN_LANDING_PATH}?token=${token}` const emailLinkResponse = await emailLink( email_id, process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, From 0fbb136fea5ffe098832600b2fced527a790244c Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 19 Dec 2024 12:39:59 -0500 Subject: [PATCH 048/100] remove getting callbackURI from config in commerce-sdk-react/src/auth --- packages/commerce-sdk-react/src/auth/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index bde38de139..d4a2988289 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1140,7 +1140,7 @@ class Auth { */ async getPasswordResetToken(parameters: ShopperLoginTypes.PasswordActionRequest) { const slasClient = this.client - const callbackURI = parameters.callback_uri || this.callbackURI + const callbackURI = parameters.callback_uri const options = { headers: { From e4413ca9bec67f62d85af82d00a1233c33380664 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 19 Dec 2024 12:52:32 -0500 Subject: [PATCH 049/100] update default.js to use working SLAS private client --- packages/template-retail-react-app/config/default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 98533c4c2d..c61872d66c 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -38,7 +38,7 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: '3a15f34e-fecd-4fcc-8235-86b70978e629', + clientId: '80255e22-8504-45e3-b1a3-749fd4475bb7', // TODO: SLAS Private Client Revert before merging organizationId: 'f_ecom_zzrf_001', shortCode: '8o7m175y', siteId: 'RefArchGlobal' From 00db78f02773aa7513ee81b8c8aba3a1aa9fc692 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Dec 2024 10:15:48 -0500 Subject: [PATCH 050/100] add sendMagicLinkEmail function in ssr.js --- packages/template-retail-react-app/app/ssr.js | 61 +++++++++++++------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 60f2904082..79685c347e 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -71,6 +71,29 @@ const resetPasswordCallback = 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. +async function sendMagicLinkEmail(req, res, landingPath, emailTemplate) { + // Extract the base URL from the request + const base = req.protocol + '://' + req.get('host') + + // Extract the email_id and token from the request body + const {email_id, token} = req.body + + // Construct the magic link URL + let magicLink = `${base}${landingPath}?token=${token}` + if (landingPath === RESET_PASSWORD_LANDING_PATH) { + // Add email query parameter for reset password flow + magicLink += `&email=${email_id}` + } + + // Call the emailLink function to send an email with the magic link using Marketing Cloud + const emailLinkResponse = await emailLink(email_id, emailTemplate, magicLink) + + // Send the response + res.send(emailLinkResponse) +} + const {handler} = runtime.createHandler(options, (app) => { // Set default HTTP security headers required by PWA Kit app.use(defaultPwaKitSecurityHeaders) @@ -105,28 +128,30 @@ const {handler} = runtime.createHandler(options, (app) => { res.send() }) - app.post(passwordlessLoginCallback, express.json(), async (req, res) => { - const base = req.protocol + '://' + req.get('host') - const {email_id, token} = req.body - const magicLink = `${base}${PASSWORDLESS_LOGIN_LANDING_PATH}?token=${token}` - const emailLinkResponse = await emailLink( - email_id, - process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE, - magicLink + // Handles the passwordless login callback route. SLAS makes a POST request to this + // endpoint sending the email address and passwordless token. Then this endpoint calls + // the sendMagicLinkEmail function to send an email with the passwordless login magic link. + // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-passwordless-login.html#receive-the-callback + app.post(passwordlessLoginCallback, express.json(), (req, res) => { + sendMagicLinkEmail( + req, + res, + PASSWORDLESS_LOGIN_LANDING_PATH, + process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE ) - res.send(emailLinkResponse) }) - app.post(resetPasswordCallback, express.json(), async (req, res) => { - const base = req.protocol + '://' + req.get('host') - const {email_id, token} = req.body - const magicLink = `${base}${RESET_PASSWORD_LANDING_PATH}?token=${token}&email=${email_id}` - const emailLinkResponse = await emailLink( - email_id, - process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE, - magicLink + // Handles the reset password callback route. SLAS makes a POST request to this + // endpoint sending the email address and reset password token. Then this endpoint calls + // the sendMagicLinkEmail function to send an email with the reset password magic link. + // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-password-reset.html#slas-password-reset-flow + app.post(resetPasswordCallback, express.json(), (req, res) => { + sendMagicLinkEmail( + req, + res, + RESET_PASSWORD_LANDING_PATH, + process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE ) - res.send(emailLinkResponse) }) app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt')) From 2a56b201b9e3c0131c83ba2fd352c6683440ffa8 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Dec 2024 11:41:48 -0500 Subject: [PATCH 051/100] update marketing-cloud-email-link.js to use built-in fetch --- .../marketing-cloud-email-link.js | 111 +++++++----------- 1 file changed, 41 insertions(+), 70 deletions(-) diff --git a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js index 1d231f2fb6..f271b21588 100644 --- a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js +++ b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js @@ -10,7 +10,7 @@ * send emails with a magic link to a specified contact using the Marketing Cloud API. * For this integration to work, a template email with a `%%magic-link%%` personalization string inserted * must exist in your Marketing Cloud org. - * + * * More details here: https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/transactional-messaging-get-started.html * * High Level Flow: @@ -18,12 +18,11 @@ * provided client ID and client secret. * 2. It constructs the email message URL using the generated unique ID and the * provided template ID. - * 3. It sends the email message containing the magic link to the specified contact + * 3. It sends the email message containing the magic link to the specified contact * using the Marketing Cloud API. */ import crypto from 'crypto' -import https from 'https' /** * Tokens are valid for 20 minutes. We store it at the top level scope to reuse @@ -32,47 +31,6 @@ import https from 'https' let marketingCloudToken = '' let marketingCloudTokenExpiration = new Date() -/* - Make a POST request using native https module, wrapped in a Promise with JSON - encode and decode -*/ -export function asyncJsonHttpsPost(url, postObject, headers = {}) { - return new Promise((resolve, reject) => { - const options = { - method: 'POST', - headers: { - ...headers, - 'Content-Type': 'application/json' - } - } - const req = https.request(url, options, (response) => { - let data = '' - - response.on('data', (chunk) => { - data += chunk - }) - - response.on('end', () => { - if (response.statusCode >= 200 && response.statusCode < 300) { - resolve(JSON.parse(data)) - } else { - reject( - new Error(`Request failed with status code ${response.statusCode}: ${data}`) - ) - } - }) - }) - - req.on('error', (error) => { - reject(error) - }) - - req.write(JSON.stringify(postObject)) - - req.end() - }) -} - /** * Generates a unique ID for the email message. * @@ -84,7 +42,7 @@ function generateUniqueId() { /** * Sends an email to a specified contact using the Marketing Cloud API. The template email must have a - * `%%magic-link%%` personalization string inserted. + * `%%magic-link%%` personalization string inserted. * https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5 * * @param {string} email - The email address of the contact to whom the email will be sent. @@ -94,41 +52,54 @@ function generateUniqueId() { * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. */ async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { + // Refresh token if expired if (new Date() > marketingCloudTokenExpiration) { - const tokenUrl = `https://${marketingCloudConfig.subdomain}.auth.marketingcloudapis.com/v2/token` - - const tokenBody = { - grant_type: 'client_credentials', - client_id: marketingCloudConfig.clientId, - client_secret: marketingCloudConfig.clientSecret - } + const {clientId, clientSecret, subdomain} = marketingCloudConfig + const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token` + const tokenResponse = await fetch(tokenUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret + }) + }) - marketingCloudToken = (await asyncJsonHttpsPost(tokenUrl, tokenBody)).access_token + if (!tokenResponse.ok) + throw new Error( + 'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.' + ) - marketingCloudTokenExpiration = new Date() - marketingCloudTokenExpiration.setTime( - marketingCloudTokenExpiration.getTime() + 15 * 60 * 1000 - ) + const {access_token} = await tokenResponse.json() + marketingCloudToken = access_token + // Set expiration to 15 mins + marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000) } + // Send the email const emailUrl = `https://${ marketingCloudConfig.subdomain }.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}` + const emailResponse = await fetch(emailUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${marketingCloudToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + definitionKey: marketingCloudConfig.templateId, + recipient: { + contactKey: emailId, + to: emailId, + attributes: {'magic-link': marketingCloudConfig.magicLink} + } + }) + }) - const emailHeaders = {Authorization: `Bearer ${marketingCloudToken}`} - - const emailBody = { - definitionKey: marketingCloudConfig.templateId, - recipient: { - contactKey: emailId, - to: emailId, - attributes: {'magic-link': marketingCloudConfig.magicLink} - } - } - - const emailResponse = await asyncJsonHttpsPost(emailUrl, emailBody, emailHeaders) + if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud') - return emailResponse + return await emailResponse.json() } /** From c10a9fa0547ae8f64e3c2e0358a8716e1227e74d Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Dec 2024 12:34:08 -0500 Subject: [PATCH 052/100] add warning if marketing cloud env vars are not set --- .../marketing-cloud/marketing-cloud-email-link.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js index f271b21588..29d8f08cbe 100644 --- a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js +++ b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.js @@ -113,6 +113,18 @@ async function sendMarketingCloudEmail(emailId, marketingCloudConfig) { * @return {Promise} A promise that resolves to the response object received from the Marketing Cloud API. */ export async function emailLink(emailId, templateId, magicLink) { + if (!process.env.MARKETING_CLOUD_CLIENT_ID) { + console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.') + } + + if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) { + console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.') + } + + if (!process.env.MARKETING_CLOUD_SUBDOMAIN) { + console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.') + } + const marketingCloudConfig = { clientId: process.env.MARKETING_CLOUD_CLIENT_ID, clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET, From 7b46eb845dd63905056c2e589bdb3b408a05c65b Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Dec 2024 12:47:38 -0500 Subject: [PATCH 053/100] lint --- .../app/components/forms/profile-fields.jsx | 2 +- .../template-retail-react-app/app/hooks/use-auth-modal.js | 8 ++++---- .../template-retail-react-app/app/pages/login/index.jsx | 4 ++-- packages/template-retail-react-app/app/routes.jsx | 5 ++++- packages/template-retail-react-app/app/ssr.js | 5 ++++- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/template-retail-react-app/app/components/forms/profile-fields.jsx b/packages/template-retail-react-app/app/components/forms/profile-fields.jsx index 857303a4bf..f71f478b5d 100644 --- a/packages/template-retail-react-app/app/components/forms/profile-fields.jsx +++ b/packages/template-retail-react-app/app/components/forms/profile-fields.jsx @@ -6,7 +6,7 @@ */ import React from 'react' import PropTypes from 'prop-types' -import {FormattedMessage, defineMessage, useIntl} from 'react-intl' +import {defineMessage, useIntl} from 'react-intl' import {SimpleGrid, Stack} from '@salesforce/retail-react-app/app/components/shared/ui' import useProfileFields from '@salesforce/retail-react-app/app/components/forms/useProfileFields' import Field from '@salesforce/retail-react-app/app/components/field' 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 311a439482..050458da43 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 @@ -76,7 +76,7 @@ export const AuthModal = ({ const toast = useToast() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) - + const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD) const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('') const {getPasswordResetToken} = usePasswordReset() @@ -94,17 +94,17 @@ export const AuthModal = ({ const onLoginSuccess = () => { navigate('/account') } - + const handlePasswordlessLogin = async (email) => { try { await authorizePasswordlessLogin.mutateAsync({userid: email}) } catch (error) { form.setError('global', { type: 'manual', - message: formatMessage(API_ERROR_MESSAGE), + message: formatMessage(API_ERROR_MESSAGE) }) } - } + } return { login: async (data) => { 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 2c36c92cca..9c442a1395 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -108,10 +108,10 @@ const Login = ({initialView = LOGIN_VIEW}) => { } catch (error) { form.setError('global', { type: 'manual', - message: formatMessage(API_ERROR_MESSAGE), + message: formatMessage(API_ERROR_MESSAGE) }) } - } + } return { login: async (data) => { diff --git a/packages/template-retail-react-app/app/routes.jsx b/packages/template-retail-react-app/app/routes.jsx index 8437903686..5927bfc542 100644 --- a/packages/template-retail-react-app/app/routes.jsx +++ b/packages/template-retail-react-app/app/routes.jsx @@ -21,7 +21,10 @@ import {Skeleton} from '@salesforce/retail-react-app/app/components/shared/ui' import {configureRoutes} from '@salesforce/retail-react-app/app/utils/routes-utils' // Constants -import {PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH} from '@salesforce/retail-react-app/app/constants' +import { + PASSWORDLESS_LOGIN_LANDING_PATH, + RESET_PASSWORD_LANDING_PATH +} from '@salesforce/retail-react-app/app/constants' const fallback = const socialRedirectURI = getConfig()?.app?.login?.social?.redirectURI diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 79685c347e..af27810b86 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -24,7 +24,10 @@ import helmet from 'helmet' import express from 'express' import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link' -import {PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH} from '@salesforce/retail-react-app/app/constants' +import { + PASSWORDLESS_LOGIN_LANDING_PATH, + RESET_PASSWORD_LANDING_PATH +} from '@salesforce/retail-react-app/app/constants' const config = getConfig() From 8f78ea85367f0ed0a406d04d8b6c65158551879e Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Dec 2024 13:26:44 -0500 Subject: [PATCH 054/100] update marketing-cloud-email-link unit test --- .../marketing-cloud-email-link.test.js | 72 +++++++++++++------ 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.test.js b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.test.js index f7457d8bec..1d2c8b3ac4 100644 --- a/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.test.js +++ b/packages/template-retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link.test.js @@ -4,34 +4,62 @@ * 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 */ -jest.mock('https', () => { - return { - request: jest.fn((url, options, callback) => { - const response = { - on: jest.fn((event, callback) => { - if (event === 'data') { - callback(response.data) - } else if (event === 'end') { - callback() - } else if (event === 'on') { - callback() - } - }), - statusCode: 200, - headers: {}, - data: '{ "data": "test" }' - } +import fetchMock from 'jest-fetch-mock' +import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link' + +const fetchOriginal = global.fetch +const originalEnv = process.env - callback(response) - }) +beforeAll(() => { + global.fetch = fetchMock + global.fetch.mockResponse(JSON.stringify({})) + process.env = { + ...originalEnv, + MARKETING_CLOUD_CLIENT_ID: 'mc_client_id', + MARKETING_CLOUD_CLIENT_SECRET: 'mc_client_secret', + MARKETING_CLOUD_SUBDOMAIN: 'mc_subdomain.com' } }) -import {emailLink} from '@salesforce/retail-react-app/../../app/utils/marketing-cloud/marketing-cloud-email-link' + +afterAll(() => { + global.fetch = fetchOriginal + process.env = originalEnv +}) describe('emailLink()', () => { it('should send an email with a magic link', async () => { - const result = await emailLink('test@example.com', '123', 'https://magic-link.example.com') + const email = 'test@example.com' + const templateId = '123' + const magicLink = 'https://magic-link.example.com' + await emailLink(email, templateId, magicLink) - expect(result).toEqual({data: 'test'}) + expect(fetch).toHaveBeenCalledTimes(2) + expect(fetch).toHaveBeenNthCalledWith( + 1, + `https://${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com/v2/token`, + { + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: process.env.MARKETING_CLOUD_CLIENT_ID, + client_secret: process.env.MARKETING_CLOUD_CLIENT_SECRET + }), + headers: {'Content-Type': 'application/json'}, + method: 'POST' + } + ) + expect(fetch).toHaveBeenNthCalledWith( + 2, + expect.stringContaining( + `https://${process.env.MARKETING_CLOUD_SUBDOMAIN}.rest.marketingcloudapis.com/messaging/v1/email/messages/` + ), + { + body: JSON.stringify({ + definitionKey: templateId, + recipient: {contactKey: email, to: email, attributes: {'magic-link': magicLink}} + }), + headers: expect.objectContaining({'Content-Type': 'application/json'}), + method: 'POST' + } + ) }) }) From b1f717a2a0384278189d13f7e6aca65e6b0c6616 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 23 Dec 2024 15:14:20 -0500 Subject: [PATCH 055/100] fix white screen when submitting passwordless email --- .../app/components/passwordless-login/index.jsx | 3 ++- .../template-retail-react-app/app/hooks/use-auth-modal.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/template-retail-react-app/app/components/passwordless-login/index.jsx b/packages/template-retail-react-app/app/components/passwordless-login/index.jsx index 2382869fe6..503974752f 100644 --- a/packages/template-retail-react-app/app/components/passwordless-login/index.jsx +++ b/packages/template-retail-react-app/app/components/passwordless-login/index.jsx @@ -12,6 +12,7 @@ import {Button, Divider, Stack, Text} from '@salesforce/retail-react-app/app/com import LoginFields from '@salesforce/retail-react-app/app/components/forms/login-fields' import StandardLogin from '@salesforce/retail-react-app/app/components/standard-login' import SocialLogin from '@salesforce/retail-react-app/app/components/social-login' +import {LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants' const PasswordlessLogin = ({ form, @@ -24,7 +25,7 @@ const PasswordlessLogin = ({ const [showPasswordView, setShowPasswordView] = useState(false) const handlePasswordButton = async (e) => { - setLoginType('password') + setLoginType(LOGIN_TYPES.PASSWORD) const isValid = await form.trigger() // Manually trigger the browser native form validations const domForm = e.target.closest('form') 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 050458da43..6d95a5f524 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 @@ -98,6 +98,7 @@ export const AuthModal = ({ const handlePasswordlessLogin = async (email) => { try { await authorizePasswordlessLogin.mutateAsync({userid: email}) + setCurrentView(EMAIL_VIEW) } catch (error) { form.setError('global', { type: 'manual', @@ -139,7 +140,6 @@ export const AuthModal = ({ form.setError('global', {type: 'manual', message}) } } else if (loginType === LOGIN_TYPES.PASSWORDLESS) { - setCurrentView(EMAIL_VIEW) setPasswordlessLoginEmail(data.email) await handlePasswordlessLogin(data.email) } @@ -302,7 +302,7 @@ export const AuthModal = ({ clickSignIn={onBackToSignInClick} /> )} - {form.formState.isSubmitSuccessful && currentView === EMAIL_VIEW && ( + {currentView === EMAIL_VIEW && ( Date: Thu, 26 Dec 2024 16:46:49 -0500 Subject: [PATCH 056/100] announce content in email confirmation dialog --- .../components/email-confirmation/index.jsx | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/packages/template-retail-react-app/app/components/email-confirmation/index.jsx b/packages/template-retail-react-app/app/components/email-confirmation/index.jsx index a755cdff14..84c44e97e1 100644 --- a/packages/template-retail-react-app/app/components/email-confirmation/index.jsx +++ b/packages/template-retail-react-app/app/components/email-confirmation/index.jsx @@ -17,39 +17,40 @@ const PasswordlessEmailConfirmation = ({form, submitForm, email = ''}) => { onSubmit={form.handleSubmit(submitForm)} data-testid="sf-form-resend-passwordless-email" > - - - - - - - {chunks} - }} - /> - - - + + + + - - + + + {chunks} + }} + /> + + + + + + ) From 322dea2cbc0d0ce18cf91a9231614f6e13df949b Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 26 Dec 2024 17:00:36 -0500 Subject: [PATCH 057/100] revert default.js changes --- packages/template-retail-react-app/config/default.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index c61872d66c..42aa6ae179 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -17,7 +17,7 @@ module.exports = { }, login: { passwordless: { - enabled: true, + enabled: false, callbackURI: '/passwordless-login-callback' }, social: { @@ -38,7 +38,7 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: '80255e22-8504-45e3-b1a3-749fd4475bb7', // TODO: SLAS Private Client Revert before merging + clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', organizationId: 'f_ecom_zzrf_001', shortCode: '8o7m175y', siteId: 'RefArchGlobal' From efe0d1b56e3bc9324b3a570d8e014b0be10cde0e Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 27 Dec 2024 09:49:28 -0500 Subject: [PATCH 058/100] remove enabling private client from template retail react app _app_config --- .../app/components/_app-config/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8303a2fa8e..dd12c53838 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 @@ -68,7 +68,7 @@ const AppConfig = ({children, locals = {}}) => { headers={headers} // Uncomment 'enablePWAKitPrivateClient' to use SLAS private client login flows. // Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting. - enablePWAKitPrivateClient={true} + // enablePWAKitPrivateClient={true} logger={createLogger({packageName: 'commerce-sdk-react'})} > From a3d7117c0336d8fa8051e3a66eab3a24977914e5 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 30 Dec 2024 12:31:42 -0500 Subject: [PATCH 059/100] initial implementation of checkout passwordless login --- .../app/hooks/use-auth-modal.js | 4 ++- .../pages/checkout/partials/contact-info.jsx | 29 +++++++++++++++++-- .../pages/checkout/partials/login-state.jsx | 5 +++- 3 files changed, 33 insertions(+), 5 deletions(-) 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 6d95a5f524..48846f487a 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 @@ -50,6 +50,7 @@ const LOGIN_ERROR = defineMessage({ export const AuthModal = ({ initialView = LOGIN_VIEW, + initialEmail = '', onLoginSuccess = noop, onRegistrationSuccess = noop, isOpen, @@ -78,7 +79,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) @@ -317,6 +318,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..d7467e8253 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 @@ -40,12 +40,12 @@ import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce 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,6 +62,11 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const [showPasswordField, setShowPasswordField] = useState(false) const [signOutConfirmDialogIsOpen, setSignOutConfirmDialogIsOpen] = useState(false) + // TODO use constant + const [authModalView, setAuthModalView] = useState('') + const authModal = useAuthModal(authModalView) + const [isPasswordlessLoginClicked, setIsPasswordlessLoginClicked] = useState(false) + const submitForm = async (data) => { setError(null) try { @@ -82,7 +87,15 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id }) } } - goToNextStep() + if (isPasswordlessLoginClicked) { + // TODO is current error handling sufficient + await authorizePasswordlessLogin.mutateAsync({userid: data.email}) + // TODO use constant + setAuthModalView('email') + authModal.onOpen() + } else { + goToNextStep() + } } catch (error) { if (/Unauthorized/i.test(error.message)) { setError( @@ -108,6 +121,8 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } const onForgotPasswordClick = () => { + // TODO make this a constant + setAuthModalView('password') authModal.onOpen() } @@ -117,6 +132,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/login-state.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/login-state.jsx index e5fe705b56..ade93ad42b 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} > @@ -77,6 +78,7 @@ const LoginState = ({ setShowLoginButtons(!showLoginButtons) }} > + {/* TODO: Possibly change to GO Back to Login */} Date: Mon, 30 Dec 2024 13:49:35 -0500 Subject: [PATCH 060/100] reset isPasswordlessLoginClicked after authorizePasswordlessLogin API call is made --- .../app/pages/checkout/partials/contact-info.jsx | 1 + 1 file changed, 1 insertion(+) 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 d7467e8253..c5837420c1 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 @@ -93,6 +93,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id // TODO use constant setAuthModalView('email') authModal.onOpen() + setIsPasswordlessLoginClicked(false) } else { goToNextStep() } From 2e2994d5e98b066b9040a6da0cf621f0bce1827c Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 30 Dec 2024 14:08:39 -0500 Subject: [PATCH 061/100] use constant for auth modal view values --- .../app/hooks/use-auth-modal.js | 8 ++++---- .../app/pages/checkout/partials/contact-info.jsx | 11 ++++------- 2 files changed, 8 insertions(+), 11 deletions(-) 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 48846f487a..bbaf6ac99f 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 @@ -38,10 +38,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.", 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 c5837420c1..53ab7cbdc9 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,7 +32,7 @@ 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' @@ -62,8 +62,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const [showPasswordField, setShowPasswordField] = useState(false) const [signOutConfirmDialogIsOpen, setSignOutConfirmDialogIsOpen] = useState(false) - // TODO use constant - const [authModalView, setAuthModalView] = useState('') + const [authModalView, setAuthModalView] = useState(PASSWORD_VIEW) const authModal = useAuthModal(authModalView) const [isPasswordlessLoginClicked, setIsPasswordlessLoginClicked] = useState(false) @@ -90,8 +89,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id if (isPasswordlessLoginClicked) { // TODO is current error handling sufficient await authorizePasswordlessLogin.mutateAsync({userid: data.email}) - // TODO use constant - setAuthModalView('email') + setAuthModalView(EMAIL_VIEW) authModal.onOpen() setIsPasswordlessLoginClicked(false) } else { @@ -122,8 +120,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } const onForgotPasswordClick = () => { - // TODO make this a constant - setAuthModalView('password') + setAuthModalView(PASSWORD_VIEW) authModal.onOpen() } From a9ac1ca02c39bb065b845af97040289b7f855273 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 30 Dec 2024 15:15:51 -0500 Subject: [PATCH 062/100] add unit tests for LoginState --- .../checkout/partials/login-state.test.js | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 packages/template-retail-react-app/app/pages/checkout/partials/login-state.test.js 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..95a744ce7b --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/partials/login-state.test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import LoginState from './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', () => { + const { getByRole, getByText } = renderWithProviders() + expect(getByText('Or Login With')).toBeInTheDocument(); + expect(getByRole("button", {name: 'Secure Link'})).toBeInTheDocument(); + expect(getByRole("button", {name: 'Password'})).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', () => { + const { getByRole, getByText } = renderWithProviders() + expect(getByText('Or Login With')).toBeInTheDocument(); + expect(getByRole("button", {name: /Google/i})).toBeInTheDocument(); + expect(getByRole("button", {name: /Apple/i})).toBeInTheDocument(); + expect(getByRole("button", {name: 'Password'})).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(); + }); +}); \ No newline at end of file From 51196148c53c362b0e1618f99c1eb622da5fc5e0 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 31 Dec 2024 09:49:25 -0500 Subject: [PATCH 063/100] reword "Checkout as Guest" to "Back to Sign In Options" --- .../pages/checkout/partials/login-state.jsx | 5 ++--- .../checkout/partials/login-state.test.js | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) 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 ade93ad42b..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 @@ -78,10 +78,9 @@ const LoginState = ({ setShowLoginButtons(!showLoginButtons) }} > - {/* TODO: Possibly change to GO Back to Login */} ) 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 index 95a744ce7b..2b2df2ca11 100644 --- 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 @@ -26,11 +26,14 @@ describe('LoginState', () => { expect(mockTogglePasswordField).toHaveBeenCalled() }) - test('shows passwordless login button if enabled', () => { - const { getByRole, getByText } = renderWithProviders() + 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(); - expect(getByRole("button", {name: 'Password'})).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', () => { @@ -39,12 +42,15 @@ describe('LoginState', () => { expect(queryByRole("button", {name: 'Secure Link'})).not.toBeInTheDocument(); }); - test('shows social login buttons if enabled', () => { - const { getByRole, getByText } = renderWithProviders() + 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(); - expect(getByRole("button", {name: 'Password'})).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', () => { From 669568ca35f1b89678691b0d0ce2fc1563abfe50 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 2 Jan 2025 09:57:11 -0500 Subject: [PATCH 064/100] allow lower case for idp names --- .../app/components/social-login/index.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/components/social-login/index.jsx b/packages/template-retail-react-app/app/components/social-login/index.jsx index 13e17e3ee2..fc76034a1b 100644 --- a/packages/template-retail-react-app/app/components/social-login/index.jsx +++ b/packages/template-retail-react-app/app/components/social-login/index.jsx @@ -52,7 +52,8 @@ const SocialLogin = ({form, idps}) => { const redirectURI = buildRedirectURI(appOrigin, redirectPath) const isIdpValid = (name) => { - return name in IDP_CONFIG && IDP_CONFIG[name.toLowerCase()] + const formattedName = name.toLowerCase() + return formattedName in IDP_CONFIG && IDP_CONFIG[formattedName] } useEffect(() => { From 7b5e45054489d05a0bf6c26115a898f594944925 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 2 Jan 2025 10:18:34 -0500 Subject: [PATCH 065/100] don't save guest when passwordless login is clicked --- .../pages/checkout/partials/contact-info.jsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 53ab7cbdc9..26646e8b51 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 @@ -69,6 +69,14 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const submitForm = async (data) => { setError(null) try { + if (isPasswordlessLoginClicked) { + // TODO is current error handling sufficient + await authorizePasswordlessLogin.mutateAsync({userid: data.email}) + setAuthModalView(EMAIL_VIEW) + authModal.onOpen() + setIsPasswordlessLoginClicked(false) + return + } if (!data.password) { await updateCustomerForBasket.mutateAsync({ parameters: {basketId: basket.basketId}, @@ -86,15 +94,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id }) } } - if (isPasswordlessLoginClicked) { - // TODO is current error handling sufficient - await authorizePasswordlessLogin.mutateAsync({userid: data.email}) - setAuthModalView(EMAIL_VIEW) - authModal.onOpen() - setIsPasswordlessLoginClicked(false) - } else { - goToNextStep() - } + goToNextStep() } catch (error) { if (/Unauthorized/i.test(error.message)) { setError( From b9b51d8a5d9a31ff39527aa81c3843ee0f063900 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 2 Jan 2025 14:00:41 -0500 Subject: [PATCH 066/100] add unit tests for ContactInfo --- .../checkout/partials/contact-info.test.js | 186 +++++++++++++++--- 1 file changed, 164 insertions(+), 22 deletions(-) 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..c0d21d479b 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,29 @@ * 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 +40,158 @@ 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) + + // 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() + }) + + 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() + }) - // switch to login - const trigger = screen.getByText(/Already have an account\? Log in/i) - await user.click(trigger) + test('allows login', async () => { + const {user} = renderWithProviders() - // 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) + // 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) - // check that forgot password modal is open - const withinForm = within(screen.getByTestId('sf-auth-modal-form')) - expect(withinForm.getByText(/Reset Password/i)).toBeInTheDocument() + 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() + }) + + // 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('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() + }) }) From af4ceb2a7229d64b38a2d1dbe97035e4ae729fad Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 2 Jan 2025 14:03:35 -0500 Subject: [PATCH 067/100] lint --- .../pages/checkout/partials/contact-info.jsx | 12 +- .../checkout/partials/contact-info.test.js | 60 +++++---- .../checkout/partials/login-state.test.js | 114 ++++++++++-------- 3 files changed, 106 insertions(+), 80 deletions(-) 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 26646e8b51..1b4bb7f3cd 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,7 +32,12 @@ 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, EMAIL_VIEW, PASSWORD_VIEW, 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' @@ -221,10 +226,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id - + {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 c0d21d479b..6c4aa0261d 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 @@ -9,23 +9,24 @@ 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 {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() }, + [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]) + useAuthHelper: jest + .fn() + .mockImplementation((helperType) => mockAuthHelperFunctions[helperType]) } }) @@ -49,7 +50,7 @@ afterEach(() => { jest.resetModules() }) -describe ('passwordless and social disabled', () => { +describe('passwordless and social disabled', () => { test('renders component', async () => { const {user} = renderWithProviders( @@ -70,7 +71,7 @@ describe ('passwordless and social disabled', () => { }) test('does not allow login if email or password is missing', async () => { - const {user} = renderWithProviders() + const {user} = renderWithProviders() // switch to login const trigger = screen.getByText(/Already have an account\? Log in/i) @@ -84,31 +85,32 @@ describe ('passwordless and social disabled', () => { }) test('allows login', async () => { - const {user} = renderWithProviders() + 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}) + expect( + mockAuthHelperFunctions[AuthHelpers.LoginRegisteredUserB2C].mutateAsync + ).toHaveBeenCalledWith({username: validEmail, password: password}) }) }) -describe ('passwordless enabled', () => { +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)) - }), - + }) ) }) @@ -135,7 +137,7 @@ describe ('passwordless enabled', () => { 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) @@ -146,7 +148,7 @@ describe ('passwordless enabled', () => { test('allows passwordless login', async () => { const {user} = renderWithProviders() - + // enter a valid email address await user.type(screen.getByLabelText('Email'), validEmail) @@ -155,21 +157,25 @@ describe ('passwordless enabled', () => { // 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}) - + 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() }) - - // resend the email + + // resend the email user.click(screen.getByText(/Resend Link/i)) - expect(mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync).toHaveBeenCalledWith({userid: validEmail}) + expect( + mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync + ).toHaveBeenCalledWith({userid: validEmail}) }) test('allows login using password', async () => { - const {user} = renderWithProviders() + const {user} = renderWithProviders() // enter a valid email address await user.type(screen.getByLabelText('Email'), validEmail) @@ -177,19 +183,23 @@ describe ('passwordless enabled', () => { // 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}) + expect( + mockAuthHelperFunctions[AuthHelpers.LoginRegisteredUserB2C].mutateAsync + ).toHaveBeenCalledWith({username: validEmail, password: password}) }) }) -describe ('social login enabled', () => { +describe('social login enabled', () => { test('renders component', async () => { - const {getByRole} = renderWithProviders() + 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.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/login-state.test.js index 2b2df2ca11..266908bbd7 100644 --- 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 @@ -1,5 +1,11 @@ -import React from 'react'; -import LoginState from './login-state'; +/* + * 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' @@ -12,51 +18,59 @@ const WrapperComponent = ({...props}) => { } 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(); - }); -}); \ No newline at end of file + 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() + }) +}) From 8dfbd13f62035512e55a9a0f703df8d1914ee083 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 2 Jan 2025 14:15:52 -0500 Subject: [PATCH 068/100] translations --- .../app/static/translations/compiled/en-GB.json | 6 ++++++ .../app/static/translations/compiled/en-US.json | 6 ++++++ .../app/static/translations/compiled/en-XA.json | 14 ++++++++++++++ .../translations/en-GB.json | 3 +++ .../translations/en-US.json | 3 +++ 5 files changed, 32 insertions(+) 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 5aedd06f49..5550b88423 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 5aedd06f49..5550b88423 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 d89e33bb53..b512455b2b 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 b1424c800d..5ffa47af78 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 b1424c800d..5ffa47af78 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" }, From 5e6141dafed51744d74102c95b88b8faf571413d Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 2 Jan 2025 15:11:47 -0500 Subject: [PATCH 069/100] remove todo --- .../app/pages/checkout/partials/contact-info.jsx | 1 - 1 file changed, 1 deletion(-) 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 1b4bb7f3cd..8997772a31 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 @@ -75,7 +75,6 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id setError(null) try { if (isPasswordlessLoginClicked) { - // TODO is current error handling sufficient await authorizePasswordlessLogin.mutateAsync({userid: data.email}) setAuthModalView(EMAIL_VIEW) authModal.onOpen() From 692f0e93d642dcad645183b0d69fcbe2728a8f8f Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 3 Jan 2025 10:02:53 -0500 Subject: [PATCH 070/100] fix initialEmail not getting set in auth modal --- .../template-retail-react-app/app/hooks/use-auth-modal.js | 4 ++++ .../app/pages/checkout/partials/contact-info.test.js | 1 + 2 files changed, 5 insertions(+) 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 bbaf6ac99f..a7d635f3c7 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 @@ -207,6 +207,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 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 6c4aa0261d..82fd6a5e16 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 @@ -165,6 +165,7 @@ describe('passwordless enabled', () => { 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 From c536635d9ecf98d58dbbeaea5689130604d00496 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 6 Jan 2025 09:45:47 -0500 Subject: [PATCH 071/100] fix typo in comments --- packages/template-retail-react-app/app/ssr.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index af27810b86..fcba69868a 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') From b7cfad0559eab53b0241c7e3ba1f7bec9e308c3c Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 6 Jan 2025 12:42:42 -0500 Subject: [PATCH 072/100] handle error states for reset password and social login --- .../app/components/social-login/index.jsx | 6 ++++-- .../template-retail-react-app/app/constants.js | 6 +++++- .../app/hooks/use-auth-modal.js | 10 +++++----- .../app/pages/social-login-redirect/index.jsx | 4 ++-- .../app/static/translations/compiled/en-GB.json | 6 ++++++ .../app/static/translations/compiled/en-US.json | 6 ++++++ .../app/static/translations/compiled/en-XA.json | 14 ++++++++++++++ .../translations/en-GB.json | 3 +++ .../translations/en-US.json | 3 +++ 9 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/template-retail-react-app/app/components/social-login/index.jsx b/packages/template-retail-react-app/app/components/social-login/index.jsx index 13e17e3ee2..efa3559f7e 100644 --- a/packages/template-retail-react-app/app/components/social-login/index.jsx +++ b/packages/template-retail-react-app/app/components/social-login/index.jsx @@ -67,7 +67,7 @@ const SocialLogin = ({form, idps}) => { }) }, [idps]) - const onSocialLoginClick = async () => { + const onSocialLoginClick = async (name) => { try { // Save the path where the user logged in setSessionJSONItem('returnToPage', window.location.pathname) @@ -93,7 +93,9 @@ const SocialLogin = ({form, idps}) => { return ( config && (