Skip to content

Commit 65dfe27

Browse files
committed
integrate OTPAuth modal in login flows
1 parent f005c92 commit 65dfe27

File tree

2 files changed

+191
-170
lines changed

2 files changed

+191
-170
lines changed

packages/template-retail-react-app/app/hooks/use-auth-modal.js

Lines changed: 132 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,22 @@ import {
2929
import LoginForm from '@salesforce/retail-react-app/app/components/login'
3030
import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset-password'
3131
import RegisterForm from '@salesforce/retail-react-app/app/components/register'
32-
import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index'
32+
import OtpAuth from '@salesforce/retail-react-app/app/components/otp-auth'
3333
import {noop} from '@salesforce/retail-react-app/app/utils/utils'
3434
import {
3535
API_ERROR_MESSAGE,
3636
FEATURE_UNAVAILABLE_ERROR_MESSAGE,
37-
PASSWORDLESS_ERROR_MESSAGES
37+
PASSWORDLESS_ERROR_MESSAGES,
38+
INVALID_TOKEN_ERROR,
39+
INVALID_TOKEN_ERROR_MESSAGE
3840
} from '@salesforce/retail-react-app/app/constants'
3941
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
4042
import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous'
4143
import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset'
4244
import {isServer} from '@salesforce/retail-react-app/app/utils/utils'
4345
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
44-
import {buildAbsoluteUrl} from '@salesforce/retail-react-app/app/utils/url'
46+
import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths'
47+
import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'
4548
import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
4649

4750
export const LOGIN_VIEW = 'login'
@@ -79,6 +82,7 @@ export const AuthModal = ({
7982

8083
const navigate = useNavigation()
8184
const [currentView, setCurrentView] = useState(initialView)
85+
const [isOtpAuthOpen, setIsOtpAuthOpen] = useState(false)
8286
const form = useForm()
8387
const toast = useToast()
8488
const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C)
@@ -87,10 +91,11 @@ export const AuthModal = ({
8791

8892
const {getPasswordResetToken} = usePasswordReset()
8993
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
90-
const passwordlessConfig = getConfig().app.login?.passwordless
91-
const passwordlessConfigCallback = passwordlessConfig?.callbackURI
92-
const passwordlessMode = passwordlessConfig?.mode
93-
const callbackURL = buildAbsoluteUrl(appOrigin, passwordlessConfigCallback)
94+
const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser)
95+
const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI
96+
const callbackURL = isAbsoluteURL(passwordlessConfigCallback)
97+
? passwordlessConfigCallback
98+
: `${appOrigin}${getEnvBasePath()}${passwordlessConfigCallback}`
9499

95100
const {data: baskets} = useCustomerBaskets(
96101
{parameters: {customerId}},
@@ -100,13 +105,19 @@ export const AuthModal = ({
100105

101106
const handlePasswordlessLogin = async (email) => {
102107
try {
108+
// TODO: use proper parameters from the config
103109
const redirectPath = window.location.pathname + (window.location.search || '')
104110
await authorizePasswordlessLogin.mutateAsync({
105111
userid: email,
106-
mode: passwordlessMode,
107-
...(callbackURL && {callbackURI: `${callbackURL}?redirectUrl=${redirectPath}`})
112+
mode: 'email',
113+
locale: 'en-GB',
114+
callbackURI: `${callbackURL}?redirectUrl=${redirectPath}`
108115
})
109-
setCurrentView(EMAIL_VIEW)
116+
// Close AuthModal first, then open OtpAuth modal after a brief delay
117+
onClose()
118+
setTimeout(() => {
119+
setIsOtpAuthOpen(true)
120+
}, 150) // Small delay to allow AuthModal to close first
110121
} catch (error) {
111122
const message = PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message))
112123
? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE)
@@ -115,6 +126,34 @@ export const AuthModal = ({
115126
}
116127
}
117128

129+
const handleMergeBasket = () => {
130+
const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0
131+
// we only want to merge basket when the user is logged in as a recurring user
132+
// only recurring users trigger the login mutation, new user triggers register mutation
133+
// this logic needs to stay in this block because this is the only place that tells if a user is a recurring user
134+
// if you change logic here, also change it in login page
135+
const shouldMergeBasket = hasBasketItem && prevAuthType === 'guest'
136+
if (shouldMergeBasket) {
137+
try {
138+
mergeBasket.mutate({
139+
headers: {
140+
// This is not required since the request has no body
141+
// but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
142+
'Content-Type': 'application/json'
143+
},
144+
parameters: {
145+
createDestinationBasket: true
146+
}
147+
})
148+
} catch (error) {
149+
form.setError('global', {
150+
type: 'manual',
151+
message: formatMessage(API_ERROR_MESSAGE)
152+
})
153+
}
154+
}
155+
}
156+
118157
const submitForm = async (data, isPasswordless = false) => {
119158
form.clearErrors()
120159

@@ -135,30 +174,13 @@ export const AuthModal = ({
135174
username: data.email,
136175
password: data.password
137176
})
138-
const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0
139-
// we only want to merge basket when the user is logged in as a recurring user
140-
// only recurring users trigger the login mutation, new user triggers register mutation
141-
// this logic needs to stay in this block because this is the only place that tells if a user is a recurring user
142-
// if you change logic here, also change it in login page
143-
const shouldMergeBasket = hasBasketItem && prevAuthType === 'guest'
144-
if (shouldMergeBasket) {
145-
mergeBasket.mutate({
146-
headers: {
147-
// This is not required since the request has no body
148-
// but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
149-
'Content-Type': 'application/json'
150-
},
151-
parameters: {
152-
createDestinationBasket: true
153-
}
154-
})
155-
}
156177
} catch (error) {
157178
const message = /Unauthorized/i.test(error.message)
158179
? formatMessage(LOGIN_ERROR)
159180
: formatMessage(API_ERROR_MESSAGE)
160181
form.setError('global', {type: 'manual', message})
161182
}
183+
handleMergeBasket()
162184
},
163185
register: async (data) => {
164186
try {
@@ -191,14 +213,23 @@ export const AuthModal = ({
191213
: formatMessage(API_ERROR_MESSAGE)
192214
form.setError('global', {type: 'manual', message})
193215
}
194-
},
195-
email: async () => {
196-
const email = form.getValues().email || initialEmail
197-
await handlePasswordlessLogin(email)
198216
}
199217
}[currentView](data)
200218
}
201219

220+
const handleOtpVerification = async (pwdlessLoginToken) => {
221+
try {
222+
await loginPasswordless.mutateAsync({pwdlessLoginToken})
223+
} catch (e) {
224+
const errorData = await e.response?.json()
225+
const message = INVALID_TOKEN_ERROR.test(errorData.message)
226+
? formatMessage(INVALID_TOKEN_ERROR_MESSAGE)
227+
: formatMessage(API_ERROR_MESSAGE)
228+
form.setError('global', {type: 'manual', message})
229+
}
230+
handleMergeBasket()
231+
}
232+
202233
// Reset form and local state when opening the modal
203234
useEffect(() => {
204235
if (isOpen) {
@@ -219,25 +250,23 @@ export const AuthModal = ({
219250
}, [form.control?.fieldsRef?.current])
220251

221252
useEffect(() => {
222-
// we don't want to reset the form on email view
223-
// because we want to pass the email to PasswordlessEmailConfirmation
224-
if (currentView !== EMAIL_VIEW) {
225-
form.reset()
226-
}
253+
form.reset()
227254
}, [currentView])
228255

229256
useEffect(() => {
230257
// Lets determine if the user has either logged in, or registed.
231258
const loggingIn = currentView === LOGIN_VIEW
232259
const registering = currentView === REGISTER_VIEW
233-
const isNowRegistered = isOpen && isRegistered && (loggingIn || registering)
260+
const isNowRegistered =
261+
(isOpen || isOtpAuthOpen) && isRegistered && (loggingIn || registering)
234262
// If the customer changed, but it's not because they logged in or registered. Do nothing.
235263
if (!isNowRegistered) {
236264
return
237265
}
238266

239-
// We are done with the modal.
267+
// We are done with the modal. Close any modals that are open.
240268
onClose()
269+
setIsOtpAuthOpen(false)
241270

242271
// Show a toast only for those registed users returning to the site.
243272
if (loggingIn) {
@@ -275,67 +304,70 @@ export const AuthModal = ({
275304
initialView === PASSWORD_VIEW ? onClose() : setCurrentView(LOGIN_VIEW)
276305

277306
return (
278-
<Modal
279-
size="sm"
280-
closeOnOverlayClick={false}
281-
data-testid="sf-auth-modal"
282-
isOpen={isOpen}
283-
onOpen={onOpen}
284-
onClose={onClose}
285-
{...props}
286-
>
287-
<ModalOverlay />
288-
<ModalContent>
289-
<ModalCloseButton
290-
aria-label={formatMessage({
291-
id: 'auth_modal.button.close.assistive_msg',
292-
defaultMessage: 'Close login form'
293-
})}
294-
/>
295-
<ModalBody pb={8} bg="white" paddingBottom={14} marginTop={14}>
296-
{!form.formState.isSubmitSuccessful && currentView === LOGIN_VIEW && (
297-
<LoginForm
298-
form={form}
299-
submitForm={(data) => {
300-
const shouldUsePasswordless =
301-
isPasswordlessEnabled && !data.password
302-
return submitForm(data, shouldUsePasswordless)
303-
}}
304-
clickCreateAccount={() => setCurrentView(REGISTER_VIEW)}
305-
//TODO: potentially remove this prop in the next major release since
306-
// we don't need to use this props anymore
307-
handlePasswordlessLoginClick={noop}
308-
handleForgotPasswordClick={() => setCurrentView(PASSWORD_VIEW)}
309-
isPasswordlessEnabled={isPasswordlessEnabled}
310-
isSocialEnabled={isSocialEnabled}
311-
idps={idps}
312-
setLoginType={noop}
313-
/>
314-
)}
315-
{!form.formState.isSubmitSuccessful && currentView === REGISTER_VIEW && (
316-
<RegisterForm
317-
form={form}
318-
submitForm={submitForm}
319-
clickSignIn={onBackToSignInClick}
320-
/>
321-
)}
322-
{currentView === PASSWORD_VIEW && (
323-
<ResetPasswordForm
324-
form={form}
325-
submitForm={submitForm}
326-
clickSignIn={onBackToSignInClick}
327-
/>
328-
)}
329-
{currentView === EMAIL_VIEW && (
330-
<PasswordlessEmailConfirmation
331-
form={form}
332-
submitForm={submitForm}
333-
email={form.getValues().email || initialEmail}
334-
/>
335-
)}
336-
</ModalBody>
337-
</ModalContent>
338-
</Modal>
307+
<>
308+
<Modal
309+
size="sm"
310+
closeOnOverlayClick={false}
311+
data-testid="sf-auth-modal"
312+
isOpen={isOpen}
313+
onOpen={onOpen}
314+
onClose={onClose}
315+
{...props}
316+
>
317+
<ModalOverlay />
318+
<ModalContent>
319+
<ModalCloseButton
320+
aria-label={formatMessage({
321+
id: 'auth_modal.button.close.assistive_msg',
322+
defaultMessage: 'Close login form'
323+
})}
324+
/>
325+
<ModalBody pb={8} bg="white" paddingBottom={14} marginTop={14}>
326+
{!form.formState.isSubmitSuccessful && currentView === LOGIN_VIEW && (
327+
<LoginForm
328+
form={form}
329+
submitForm={(data) => {
330+
const shouldUsePasswordless =
331+
isPasswordlessEnabled && !data.password
332+
return submitForm(data, shouldUsePasswordless)
333+
}}
334+
clickCreateAccount={() => setCurrentView(REGISTER_VIEW)}
335+
//TODO: potentially remove this prop in the next major release since
336+
// we don't need to use this props anymore
337+
handlePasswordlessLoginClick={noop}
338+
handleForgotPasswordClick={() => setCurrentView(PASSWORD_VIEW)}
339+
isPasswordlessEnabled={isPasswordlessEnabled}
340+
isSocialEnabled={isSocialEnabled}
341+
idps={idps}
342+
setLoginType={noop}
343+
/>
344+
)}
345+
{!form.formState.isSubmitSuccessful && currentView === REGISTER_VIEW && (
346+
<RegisterForm
347+
form={form}
348+
submitForm={submitForm}
349+
clickSignIn={onBackToSignInClick}
350+
/>
351+
)}
352+
{currentView === PASSWORD_VIEW && (
353+
<ResetPasswordForm
354+
form={form}
355+
submitForm={submitForm}
356+
clickSignIn={onBackToSignInClick}
357+
/>
358+
)}
359+
</ModalBody>
360+
</ModalContent>
361+
</Modal>
362+
<OtpAuth
363+
isOpen={isOtpAuthOpen}
364+
onClose={() => setIsOtpAuthOpen(false)}
365+
form={form}
366+
handleSendEmailOtp={handlePasswordlessLogin}
367+
handleOtpVerification={handleOtpVerification}
368+
hideCheckoutAsGuestButton={true}
369+
/>
370+
</>
339371
)
340372
}
341373

0 commit comments

Comments
 (0)