Skip to content

Commit 80dacdc

Browse files
Fix potential race condition bug in pwless login (#2758)
* fix pwless login race conditions
1 parent 832c9c4 commit 80dacdc

File tree

12 files changed

+306
-154
lines changed

12 files changed

+306
-154
lines changed

packages/template-retail-react-app/CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- Provide support for partial hydration [#2696](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2696)
1515
- Show Automatic Bonus Products on Cart Page [#2704](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2704)
1616
- Support Standard Products [2697](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2697)
17-
17+
- Fix passwordless race conditions in form submission [#2758](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2758)
1818

1919
## v6.1.0 (May 22, 2025)
2020

@@ -452,4 +452,4 @@ The versions published below were not published on npm, and the versioning match
452452

453453
### v1.0.0 (Sep 08, 2021)
454454

455-
- PWA Kit General Availability and open source. 🎉
455+
- PWA Kit General Availability and open source. 🎉

packages/template-retail-react-app/app/components/passwordless-login/index.jsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,19 @@ import {Button, Divider, Stack, Text} from '@salesforce/retail-react-app/app/com
1212
import LoginFields from '@salesforce/retail-react-app/app/components/forms/login-fields'
1313
import StandardLogin from '@salesforce/retail-react-app/app/components/standard-login'
1414
import SocialLogin from '@salesforce/retail-react-app/app/components/social-login'
15-
import {LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants'
1615

16+
const noop = () => {}
1717
const PasswordlessLogin = ({
1818
form,
1919
handleForgotPasswordClick,
2020
handlePasswordlessLoginClick,
2121
isSocialEnabled = false,
2222
idps = [],
23-
setLoginType
23+
setLoginType = noop
2424
}) => {
2525
const [showPasswordView, setShowPasswordView] = useState(false)
2626

2727
const handlePasswordButton = async (e) => {
28-
setLoginType(LOGIN_TYPES.PASSWORD)
2928
const isValid = await form.trigger()
3029
// Manually trigger the browser native form validations
3130
const domForm = e.target.closest('form')
@@ -48,8 +47,8 @@ const PasswordlessLogin = ({
4847
/>
4948
<Button
5049
type="submit"
51-
onClick={() => {
52-
handlePasswordlessLoginClick()
50+
onClick={(e) => {
51+
handlePasswordlessLoginClick(e)
5352
form.clearErrors('global')
5453
}}
5554
isLoading={form.formState.isSubmitting}

packages/template-retail-react-app/app/components/passwordless-login/index.test.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ describe('PasswordlessLogin component', () => {
3131
})
3232

3333
test('renders password input after "Password" button is clicked', async () => {
34-
const mockSetLoginType = jest.fn()
35-
const {user} = renderWithProviders(<WrapperComponent setLoginType={mockSetLoginType} />)
34+
const {user} = renderWithProviders(<WrapperComponent />)
3635

3736
await user.type(screen.getByLabelText('Email'), 'myemail@test.com')
3837
await user.click(screen.getByRole('button', {name: 'Password'}))
@@ -42,8 +41,7 @@ describe('PasswordlessLogin component', () => {
4241
})
4342

4443
test('stays on page when email field has form validation errors after the "Password" button is clicked', async () => {
45-
const mockSetLoginType = jest.fn()
46-
const {user} = renderWithProviders(<WrapperComponent setLoginType={mockSetLoginType} />)
44+
const {user} = renderWithProviders(<WrapperComponent />)
4745

4846
await user.type(screen.getByLabelText('Email'), 'badEmail')
4947
await user.click(screen.getByRole('button', {name: 'Password'}))

packages/template-retail-react-app/app/components/social-login/index.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ const SocialLogin = ({form, idps = []}) => {
9999
return (
100100
config && (
101101
<Button
102+
key={name}
102103
onClick={() => {
103104
onSocialLoginClick(name)
104105
}}

packages/template-retail-react-app/app/components/standard-login/index.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ const StandardLogin = ({
5555
)}
5656
{hideEmail && (
5757
<Button
58-
onClick={() => setShowPasswordView(false)}
58+
onClick={() => {
59+
form.resetField('password')
60+
setShowPasswordView(false)
61+
}}
5962
borderColor="gray.500"
6063
color="blue.600"
6164
variant="outline"

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

Lines changed: 68 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ import {
3535
API_ERROR_MESSAGE,
3636
CREATE_ACCOUNT_FIRST_ERROR_MESSAGE,
3737
FEATURE_UNAVAILABLE_ERROR_MESSAGE,
38-
LOGIN_TYPES,
3938
PASSWORDLESS_ERROR_MESSAGES,
4039
USER_NOT_FOUND_ERROR
4140
} from '@salesforce/retail-react-app/app/constants'
@@ -88,8 +87,6 @@ export const AuthModal = ({
8887
const register = useAuthHelper(AuthHelpers.Register)
8988
const appOrigin = useAppOrigin()
9089

91-
const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD)
92-
const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState(initialEmail)
9390
const {getPasswordResetToken} = usePasswordReset()
9491
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
9592
const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI
@@ -103,66 +100,67 @@ export const AuthModal = ({
103100
)
104101
const mergeBasket = useShopperBasketsMutation('mergeBasket')
105102

106-
const submitForm = async (data) => {
103+
const handlePasswordlessLogin = async (email) => {
104+
try {
105+
const redirectPath = window.location.pathname + (window.location.search || '')
106+
await authorizePasswordlessLogin.mutateAsync({
107+
userid: email,
108+
callbackURI: `${callbackURL}?redirectUrl=${redirectPath}`
109+
})
110+
setCurrentView(EMAIL_VIEW)
111+
} catch (error) {
112+
const message = USER_NOT_FOUND_ERROR.test(error.message)
113+
? formatMessage(CREATE_ACCOUNT_FIRST_ERROR_MESSAGE)
114+
: PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message))
115+
? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE)
116+
: formatMessage(API_ERROR_MESSAGE)
117+
form.setError('global', {type: 'manual', message})
118+
}
119+
}
120+
121+
const submitForm = async (data, isPasswordless = false) => {
107122
form.clearErrors()
108123

109124
const onLoginSuccess = () => {
110125
navigate('/account')
111126
}
112127

113-
const handlePasswordlessLogin = async (email) => {
114-
try {
115-
const redirectPath = window.location.pathname + window.location.search
116-
await authorizePasswordlessLogin.mutateAsync({
117-
userid: email,
118-
callbackURI: `${callbackURL}?redirectUrl=${redirectPath}`
119-
})
120-
setCurrentView(EMAIL_VIEW)
121-
} catch (error) {
122-
const message = USER_NOT_FOUND_ERROR.test(error.message)
123-
? formatMessage(CREATE_ACCOUNT_FIRST_ERROR_MESSAGE)
124-
: PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message))
125-
? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE)
126-
: formatMessage(API_ERROR_MESSAGE)
127-
form.setError('global', {type: 'manual', message})
128-
}
129-
}
130-
131128
return {
132129
login: async (data) => {
133-
if (loginType === LOGIN_TYPES.PASSWORD) {
134-
try {
135-
await login.mutateAsync({
136-
username: data.email,
137-
password: data.password
130+
if (isPasswordless) {
131+
const email = data.email
132+
await handlePasswordlessLogin(email)
133+
return
134+
}
135+
136+
try {
137+
await login.mutateAsync({
138+
username: data.email,
139+
password: data.password
140+
})
141+
const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0
142+
// we only want to merge basket when the user is logged in as a recurring user
143+
// only recurring users trigger the login mutation, new user triggers register mutation
144+
// this logic needs to stay in this block because this is the only place that tells if a user is a recurring user
145+
// if you change logic here, also change it in login page
146+
const shouldMergeBasket = hasBasketItem && prevAuthType === 'guest'
147+
if (shouldMergeBasket) {
148+
mergeBasket.mutate({
149+
headers: {
150+
// This is not required since the request has no body
151+
// but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
152+
'Content-Type': 'application/json'
153+
},
154+
parameters: {
155+
createDestinationBasket: true
156+
}
138157
})
139-
const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0
140-
// we only want to merge basket when the user is logged in as a recurring user
141-
// only recurring users trigger the login mutation, new user triggers register mutation
142-
// this logic needs to stay in this block because this is the only place that tells if a user is a recurring user
143-
// if you change logic here, also change it in login page
144-
const shouldMergeBasket = hasBasketItem && prevAuthType === 'guest'
145-
if (shouldMergeBasket) {
146-
mergeBasket.mutate({
147-
headers: {
148-
// This is not required since the request has no body
149-
// but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
150-
'Content-Type': 'application/json'
151-
},
152-
parameters: {
153-
createDestinationBasket: true
154-
}
155-
})
156-
}
157-
} catch (error) {
158-
const message = /Unauthorized/i.test(error.message)
159-
? formatMessage(LOGIN_ERROR)
160-
: formatMessage(API_ERROR_MESSAGE)
161-
form.setError('global', {type: 'manual', message})
162158
}
163-
} else if (loginType === LOGIN_TYPES.PASSWORDLESS) {
164-
setPasswordlessLoginEmail(data.email)
165-
await handlePasswordlessLogin(data.email)
159+
} catch (error) {
160+
const message = /Unauthorized/i.test(error.message)
161+
? formatMessage(LOGIN_ERROR)
162+
: formatMessage(API_ERROR_MESSAGE)
163+
form.setError('global', {type: 'manual', message})
166164
}
167165
},
168166
register: async (data) => {
@@ -198,15 +196,15 @@ export const AuthModal = ({
198196
}
199197
},
200198
email: async () => {
201-
await handlePasswordlessLogin(passwordlessLoginEmail)
199+
const email = form.getValues().email || initialEmail
200+
await handlePasswordlessLogin(email)
202201
}
203202
}[currentView](data)
204203
}
205204

206205
// Reset form and local state when opening the modal
207206
useEffect(() => {
208207
if (isOpen) {
209-
setLoginType(LOGIN_TYPES.PASSWORD)
210208
setCurrentView(initialView)
211209
form.reset()
212210
}
@@ -223,15 +221,14 @@ export const AuthModal = ({
223221
fieldsRef?.[initialField]?.ref.focus()
224222
}, [form.control?.fieldsRef?.current])
225223

226-
// Clear form state when changing views
227224
useEffect(() => {
228-
form.reset()
225+
// we don't want to reset the form on email view
226+
// because we want to pass the email to PasswordlessEmailConfirmation
227+
if (currentView !== EMAIL_VIEW) {
228+
form.reset()
229+
}
229230
}, [currentView])
230231

231-
useEffect(() => {
232-
setPasswordlessLoginEmail(initialEmail)
233-
}, [initialEmail])
234-
235232
useEffect(() => {
236233
// Lets determine if the user has either logged in, or registed.
237234
const loggingIn = currentView === LOGIN_VIEW
@@ -302,16 +299,20 @@ export const AuthModal = ({
302299
{!form.formState.isSubmitSuccessful && currentView === LOGIN_VIEW && (
303300
<LoginForm
304301
form={form}
305-
submitForm={submitForm}
302+
submitForm={(data) => {
303+
const shouldUsePasswordless =
304+
isPasswordlessEnabled && !data.password
305+
return submitForm(data, shouldUsePasswordless)
306+
}}
306307
clickCreateAccount={() => setCurrentView(REGISTER_VIEW)}
307-
handlePasswordlessLoginClick={() =>
308-
setLoginType(LOGIN_TYPES.PASSWORDLESS)
309-
}
308+
//TODO: potentially remove this prop in the next major release since
309+
// we don't need to use this props anymore
310+
handlePasswordlessLoginClick={noop}
310311
handleForgotPasswordClick={() => setCurrentView(PASSWORD_VIEW)}
311312
isPasswordlessEnabled={isPasswordlessEnabled}
312313
isSocialEnabled={isSocialEnabled}
313314
idps={idps}
314-
setLoginType={setLoginType}
315+
setLoginType={noop}
315316
/>
316317
)}
317318
{!form.formState.isSubmitSuccessful && currentView === REGISTER_VIEW && (
@@ -332,7 +333,7 @@ export const AuthModal = ({
332333
<PasswordlessEmailConfirmation
333334
form={form}
334335
submitForm={submitForm}
335-
email={passwordlessLoginEmail}
336+
email={form.getValues().email || initialEmail}
336337
/>
337338
)}
338339
</ModalBody>

0 commit comments

Comments
 (0)