Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
33732bd
Add code stubs from SPIKE
yunakim714 Jan 9, 2026
49d41c6
Call out to authorizeWebauthnRegistration auth helper
yunakim714 Jan 12, 2026
016ac69
Merge branch 'feature/webauthn-login' into W-20474693-passkey-creation
yunakim714 Jan 12, 2026
ba2e2e8
Show toast on account page if customer is registered
yunakim714 Jan 12, 2026
2d8e140
pull in OTP Auth modal changes from 1 click checkout feature branch
hajinsuha1 Dec 31, 2025
d626a93
Add OTP auth modal
yunakim714 Jan 13, 2026
7a4b57c
Add test files
yunakim714 Jan 13, 2026
028e560
Only show Create Passkey toast if webauthn is browser compatible
yunakim714 Jan 13, 2026
9cab5e2
Only show toast on successful login or account creation
yunakim714 Jan 14, 2026
319d109
Show passkey toast on checkout page
yunakim714 Jan 14, 2026
240a7b8
Add close button to toast
yunakim714 Jan 14, 2026
13704ab
Create passkey context so modal opens from anywhere within the storef…
yunakim714 Jan 15, 2026
6c8f205
Add test file for provider:
yunakim714 Jan 15, 2026
152e35d
Localize text
yunakim714 Jan 15, 2026
dd47c53
Localize text
yunakim714 Jan 15, 2026
f80a2bd
Remove passkey creation from checkout
yunakim714 Jan 16, 2026
a180a8b
Lint
yunakim714 Jan 16, 2026
674b624
Cleanup
yunakim714 Jan 16, 2026
608910a
Fix bug
yunakim714 Jan 16, 2026
405ee0f
Update to preview version of commerce-sdk-react for now - REVERT when…
yunakim714 Jan 16, 2026
f4e8321
Lint
yunakim714 Jan 16, 2026
83de486
Remove eslint comments
yunakim714 Jan 20, 2026
1a5cea4
Resolve CI error
yunakim714 Jan 20, 2026
a0e6bc3
Lint
yunakim714 Jan 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
356 changes: 356 additions & 0 deletions packages/template-retail-react-app/app/components/otp-auth/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
/*
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otp-auth files have been pulled from the One click checkout feature branch. This PR reuses the OtpAuth modal created by the 1CC team.

* Copyright (c) 2024, 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, {useState, useEffect} from 'react'
import PropTypes from 'prop-types'
import {FormattedMessage} from 'react-intl'
import {
Button,
Input,
SimpleGrid,
Stack,
Text,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay
} from '@salesforce/retail-react-app/app/components/shared/ui'
import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein'
import {useUsid, useCustomerType, useDNT} from '@salesforce/commerce-sdk-react'
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
import {useOtpInputs} from '@salesforce/retail-react-app/app/hooks/use-otp-inputs'
import {useCountdown} from '@salesforce/retail-react-app/app/hooks/use-countdown'

const OtpAuth = ({
isOpen,
onClose,
form,
handleSendEmailOtp,
handleOtpVerification,
onCheckoutAsGuest,
isGuestRegistration = false,
isPasskeyRegistration = false,
hideCheckoutAsGuestButton = false
}) => {
const OTP_LENGTH = 8
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState('')
const [resendTimer, setResendTimer] = useCountdown(0)

// Privacy-aware user identification hooks
const {getUsidWhenReady} = useUsid()
const {isRegistered} = useCustomerType()
const {data: customer} = useCurrentCustomer()
const {effectiveDnt} = useDNT()

// Einstein tracking
const {sendViewPage} = useEinstein()

// Get privacy-compliant user identifier
const getUserIdentifier = async () => {
// Respect Do Not Track
if (effectiveDnt) {
return '__DNT__'
}
// Use customer ID for registered users
if (isRegistered && customer?.customerId) {
return customer.customerId
}
// Use USID for guest users
const usid = await getUsidWhenReady()
return usid
}

const track = async (path, payload = {}) => {
const userId = await getUserIdentifier()
sendViewPage(path, {
userId,
userType: isRegistered ? 'registered' : 'guest',
dntCompliant: effectiveDnt,
...payload
})
}

const otpInputs = useOtpInputs(OTP_LENGTH, (code) => {
if (code.length === OTP_LENGTH) {
handleVerify(code)
}
})

useEffect(() => {
if (isOpen) {
otpInputs.clear()
setError('')
form.setValue('otp', '')

// Track OTP modal view activity
track('/otp-authentication', {
activity: 'otp_modal_viewed',
context: 'authentication'
})

setTimeout(() => otpInputs.inputRefs.current[0]?.focus(), 100)
}
}, [isOpen])

const handleVerify = async (code = otpInputs.values.join('')) => {
if (code.length !== OTP_LENGTH) return

setIsVerifying(true)
setError('')

// Track OTP verification attempt
track('/otp-verification', {
activity: 'otp_verification_attempted',
context: 'authentication',
otpLength: code.length
})

try {
const result = await handleOtpVerification(code)
if (result && !result.success) {
setError(result.error)
otpInputs.clear()

// Track failed OTP verification
track('/otp-verification-failed', {
activity: 'otp_verification_failed',
context: 'authentication',
error: result.error
})
}
} finally {
setIsVerifying(false)
// Track successful OTP verification
track('/otp-verification-success', {
activity: 'otp_verification_successful',
context: 'authentication'
})
}
}

const handleResend = async () => {
setResendTimer(5)
try {
await track('/otp-resend', {
activity: 'otp_code_resent',
context: 'authentication',
resendAttempt: true
})
await handleSendEmailOtp(form.getValues('email'))
} catch (error) {
setResendTimer(0)
await track('/otp-resend-failed', {
activity: 'otp_resend_failed',
context: 'authentication',
error: error.message
})
console.error('Error resending code:', error)
}
}

const handleCheckoutAsGuest = async () => {
// Track checkout as guest selection
await track('/checkout-as-guest', {
activity: 'checkout_as_guest_selected',
context: 'otp_authentication',
userChoice: 'guest_checkout'
})

if (onCheckoutAsGuest) {
onCheckoutAsGuest()
}
onClose()
}

const handleInputChange = (index, value) => {
const code = otpInputs.setValue(index, value)
setError('') // Clear error on user input
if (typeof code === 'string') {
form.setValue('otp', code)
if (code.length === OTP_LENGTH) {
handleVerify(code)
}
}
}

const isResendDisabled = resendTimer > 0 || isVerifying

return (
<Modal isOpen={isOpen} onClose={onClose} isCentered size="lg" closeOnOverlayClick={false}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{isGuestRegistration ? (
<FormattedMessage
defaultMessage="Create an account"
id="otp.title.create_account"
/>
) : (
<FormattedMessage
defaultMessage="Confirm it's you"
id="otp.title.confirm_its_you"
/>
)}
</ModalHeader>
<ModalCloseButton disabled={isVerifying} />
<ModalBody pb={6}>
<Stack spacing={12} paddingLeft={4} paddingRight={4} alignItems="center">
<Text fontSize="md" maxWidth="300px" textAlign="center">
{isGuestRegistration ? (
<FormattedMessage
defaultMessage="We sent a one-time password (OTP) to your email. To create your account and proceed to checkout, enter the {otpLength}-digit code below."
id="otp.message.enter_code_for_account_guest"
values={{otpLength: OTP_LENGTH}}
/>
) : isPasskeyRegistration ? (
<FormattedMessage
defaultMessage="We sent a one-time password (OTP) to your email to confirm your identity. Enter the {otpLength}-digit code below to continue."
id="otp.message.enter_code_for_passkey_registration"
values={{otpLength: OTP_LENGTH}}
/>
) : (
<FormattedMessage
defaultMessage="To log in to your account, enter the code sent to your email."
id="otp.message.enter_code_for_account_returning"
/>
)}
</Text>

{/* OTP Input */}
<SimpleGrid columns={OTP_LENGTH} spacing={3}>
{Array.from({length: OTP_LENGTH}).map((_, index) => (
<Input
key={index}
ref={(el) => (otpInputs.inputRefs.current[index] = el)}
value={otpInputs.values[index]}
onChange={(e) => handleInputChange(index, e.target.value)}
onKeyDown={(e) => otpInputs.handleKeyDown(index, e)}
onPaste={otpInputs.handlePaste}
type="text"
inputMode="numeric"
maxLength={1}
textAlign="center"
fontSize="lg"
fontWeight="bold"
size="lg"
width="48px"
height="56px"
borderRadius="md"
borderColor="gray.300"
borderWidth="2px"
disabled={isVerifying}
_focus={{
borderColor: 'blue.500',
boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)'
}}
_hover={{
borderColor: 'gray.400'
}}
/>
))}
</SimpleGrid>

{/* Loading indicator during verification */}
{isVerifying && (
<Text fontSize="sm" color="blue.500">
<FormattedMessage
defaultMessage="Verifying code..."
id="otp.message.verifying"
/>
</Text>
)}

{/* Error message */}
{error && (
<Text fontSize="sm" color="red.500" textAlign="center">
{error}
</Text>
)}

{/* Buttons */}
<HStack spacing={4} width="100%" justifyContent="center">
{!hideCheckoutAsGuestButton && (
<Button
onClick={handleCheckoutAsGuest}
variant="solid"
size="lg"
minWidth="160px"
isDisabled={isVerifying}
bg="gray.50"
color="gray.800"
fontWeight="bold"
border="none"
_hover={{
bg: 'gray.100'
}}
_active={{
bg: 'gray.200'
}}
>
{isGuestRegistration ? (
<FormattedMessage
defaultMessage="Cancel"
id="otp.button.cancel_guest_registration"
/>
) : (
<FormattedMessage
defaultMessage="Checkout as a Guest"
id="otp.button.checkout_as_guest"
/>
)}
</Button>
)}

<Button
onClick={handleResend}
variant="solid"
size="lg"
colorScheme={isResendDisabled ? 'gray' : 'blue'}
bg={isResendDisabled ? 'gray.300' : 'blue.500'}
minWidth="160px"
isDisabled={isResendDisabled}
_hover={isResendDisabled ? {} : {bg: 'blue.600'}}
_disabled={{bg: 'gray.300', color: 'gray.600'}}
>
{resendTimer > 0 ? (
<FormattedMessage
defaultMessage="Resend code in {timer} seconds..."
id="otp.button.resend_timer"
values={{timer: resendTimer}}
/>
) : (
<FormattedMessage
defaultMessage="Resend Code"
id="otp.button.resend_code"
/>
)}
</Button>
</HStack>
</Stack>
</ModalBody>
</ModalContent>
</Modal>
)
}

OtpAuth.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
form: PropTypes.object.isRequired,
handleSendEmailOtp: PropTypes.func.isRequired,
handleOtpVerification: PropTypes.func.isRequired,
onCheckoutAsGuest: PropTypes.func,
isGuestRegistration: PropTypes.bool,
isPasskeyRegistration: PropTypes.bool,
hideCheckoutAsGuestButton: PropTypes.bool
}

export default OtpAuth
Loading
Loading