From ef2c8774f72b452c0dd0ca42412e01284b0c142d Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 5 Nov 2025 14:43:41 -0500 Subject: [PATCH 01/65] feat: add account created toast and register passkey modal - Created CreatePasskeyModal component for registering new passkeys with custom nicknames - Added useAccountCreatedToast hook to show success toast with passkey promotion after account creation - Integrated passkey registration flow into auth modal, checkout confirmation, and registration pages - Implemented initial WebAuthn registration API call to /oauth2/webauthn/register/authorize - Added UI elements for passkey nickname input and registration button - Update --- .../components/create-passkey-modal/index.jsx | 139 ++++++++++++++++++ .../app/hooks/use-account-created-toast.js | 66 +++++++++ .../app/hooks/use-auth-modal.js | 27 ++-- .../app/pages/checkout/confirmation.jsx | 6 + .../app/pages/registration/index.jsx | 4 + 5 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 packages/template-retail-react-app/app/components/create-passkey-modal/index.jsx create mode 100644 packages/template-retail-react-app/app/hooks/use-account-created-toast.js diff --git a/packages/template-retail-react-app/app/components/create-passkey-modal/index.jsx b/packages/template-retail-react-app/app/components/create-passkey-modal/index.jsx new file mode 100644 index 0000000000..d521a82fe3 --- /dev/null +++ b/packages/template-retail-react-app/app/components/create-passkey-modal/index.jsx @@ -0,0 +1,139 @@ +/* + * 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, {useState} from 'react' +import PropTypes from 'prop-types' +import {useIntl} from 'react-intl' +import { + Button, + FormControl, + FormLabel, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Alert, + AlertIcon +} from '@salesforce/retail-react-app/app/components/shared/ui' +import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' + +/** + * Modal for creating a new passkey with a nickname + */ +const CreatePasskeyModal = ({isOpen, onClose}) => { + const {formatMessage} = useIntl() + const [passkeyNickname, setPasskeyNickname] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const {data: customer} = useCurrentCustomer() + + const config = getConfig() + const commerceAPI = config.app.commerceAPI + const webauthnConfig = config.app.login.webauthn + + const handleRegisterPasskey = async () => { + setIsLoading(true) + setError(null) + + // THE API CALL TO /oauth2/webauthn/register/authorize SHOULD BE REPLACED BY A COMMERCE SDK CALL + // Makes a request to the SLAS API on a local server + try { + const params = new URLSearchParams() + params.append('channel_id', commerceAPI.parameters.siteId) + params.append('user_id', customer.email) + params.append('mode', 'callback') + params.append('client_id', commerceAPI.parameters.clientId) + params.append('callback_uri', webauthnConfig.callbackURI) + + console.log('Body', params.toString()) + + const response = await fetch( + `http://localhost:9020/api/v1/organizations/${commerceAPI.parameters.organizationId}/oauth2/webauthn/register/authorize`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${btoa(`${commerceAPI.parameters.clientId}:${process.env.CLIENT_SECRET || '9MBWoGTfPmUsm9ityrAN'}`)}` + }, + body: params.toString() + } + ) + + const data = await response.json() + console.log('Passkey registration initiated:', data) + console.log('Passkey nickname:', passkeyNickname) + + onClose() + setPasskeyNickname('') + } catch (err) { + console.error('Error registering passkey:', err) + setError(err.message || 'Failed to register passkey') + } finally { + setIsLoading(false) + } + } + + return ( + + + + + {formatMessage({ + defaultMessage: 'Create Passkey', + id: 'auth_modal.passkey.title' + })} + + + + + + {formatMessage({ + defaultMessage: 'Passkey Nickname', + id: 'auth_modal.passkey.label.nickname' + })} + + setPasskeyNickname(e.target.value)} + mb={4} + isDisabled={isLoading} + /> + + + + + + ) +} + +CreatePasskeyModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired +} + +export default CreatePasskeyModal diff --git a/packages/template-retail-react-app/app/hooks/use-account-created-toast.js b/packages/template-retail-react-app/app/hooks/use-account-created-toast.js new file mode 100644 index 0000000000..965fc98ae5 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-account-created-toast.js @@ -0,0 +1,66 @@ +/* + * 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, + Button, + useDisclosure, + useToast +} from '@salesforce/retail-react-app/app/components/shared/ui' +import CreatePasskeyModal from '@salesforce/retail-react-app/app/components/create-passkey-modal' + +/** + * Custom hook to show account creation success toast with passkey promotion + * @returns {Object} Object containing showToast function and PasskeyModal component + */ +export const useAccountCreatedToast = () => { + const toast = useToast() + const passkeyModal = useDisclosure() + + const showToast = () => { + toast({ + position: 'top-right', + duration: 9000, + isClosable: true, + render: () => ( + + + Account successfully created! Create a passkey for more secure and easier + login next time + + + + ) + }) + } + + const PasskeyModal = () => ( + + ) + + return { + showToast, + PasskeyModal + } +} 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 3a537402b1..12ad654010 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 @@ -39,6 +39,7 @@ import { 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 {useAccountCreatedToast} from '@salesforce/retail-react-app/app/hooks/use-account-created-toast' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' @@ -82,6 +83,7 @@ export const AuthModal = ({ const [currentView, setCurrentView] = useState(initialView) const form = useForm() const toast = useToast() + const {showToast, PasskeyModal} = useAccountCreatedToast() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) const appOrigin = useAppOrigin() @@ -266,6 +268,9 @@ export const AuthModal = ({ } if (registering) { + // Show toast notification for successful registration + showToast() + // Execute action to be performed on successful registration onRegistrationSuccess() } @@ -275,15 +280,16 @@ export const AuthModal = ({ initialView === PASSWORD_VIEW ? onClose() : setCurrentView(LOGIN_VIEW) return ( - + <> + + + + ) } diff --git a/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx b/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx index ff4b2c35d0..333ed62951 100644 --- a/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx @@ -52,6 +52,7 @@ import ShipmentDetails from '@salesforce/retail-react-app/app/pages/checkout/par import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrency} from '@salesforce/retail-react-app/app/hooks' +import {useAccountCreatedToast} from '@salesforce/retail-react-app/app/hooks/use-account-created-toast' // Constants import { @@ -83,6 +84,7 @@ const CheckoutConfirmation = () => { {} ) const form = useForm() + const {showToast, PasskeyModal} = useAccountCreatedToast() const hasMultipleShipments = order?.shipments && order.shipments.length > 1 @@ -159,6 +161,9 @@ const CheckoutConfirmation = () => { // Save the shipping address from this order, should not block account creation await saveShippingAddress(registerData.customerId) + // Show account created toast + showToast() + navigate(`/account`) } catch (error) { if (!error.response) { @@ -582,6 +587,7 @@ const CheckoutConfirmation = () => { + ) } diff --git a/packages/template-retail-react-app/app/pages/registration/index.jsx b/packages/template-retail-react-app/app/pages/registration/index.jsx index fc824574d4..a53f868c1a 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.jsx @@ -17,6 +17,7 @@ import RegisterForm from '@salesforce/retail-react-app/app/components/register' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' +import {useAccountCreatedToast} from '@salesforce/retail-react-app/app/hooks/use-account-created-toast' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' const Registration = () => { @@ -28,6 +29,7 @@ const Registration = () => { const dataCloud = useDataCloud() const {pathname} = useLocation() const register = useAuthHelper(AuthHelpers.Register) + const {showToast, PasskeyModal} = useAccountCreatedToast() const submitForm = async (data) => { const body = { @@ -49,6 +51,7 @@ const Registration = () => { useEffect(() => { if (isRegistered) { + showToast() navigate('/account') } }, [isRegistered]) @@ -83,6 +86,7 @@ const Registration = () => { clickSignIn={() => navigate('/login')} /> + ) } From 38a634a5d11f0c865f80a27a314bae188063db50 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Wed, 5 Nov 2025 15:42:16 -0500 Subject: [PATCH 02/65] feat: rename and refactor passkey registration components - Renamed CreatePasskeyModal component to PasskeyRegistrationModal for clearer naming - Renamed useAccountCreatedToast hook to usePasskeyRegistration to better reflect its purpose - Refactored modal state management to pass props directly instead of wrapping in component - Updated imports and references across auth, registration and checkout confirmation pages - Simplified modal state object returned from usePasskeyRegistration hook - Updated component --- .../index.jsx | 8 ++--- .../app/hooks/use-auth-modal.js | 30 ++++--------------- ...t.js => use-passkey-registration-modal.js} | 16 +++++----- .../app/pages/checkout/confirmation.jsx | 7 +++-- .../app/pages/registration/index.jsx | 7 +++-- 5 files changed, 24 insertions(+), 44 deletions(-) rename packages/template-retail-react-app/app/components/{create-passkey-modal => passkey-registration-modal}/index.jsx (96%) rename packages/template-retail-react-app/app/hooks/{use-account-created-toast.js => use-passkey-registration-modal.js} (77%) diff --git a/packages/template-retail-react-app/app/components/create-passkey-modal/index.jsx b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx similarity index 96% rename from packages/template-retail-react-app/app/components/create-passkey-modal/index.jsx rename to packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx index d521a82fe3..5b94e67ef7 100644 --- a/packages/template-retail-react-app/app/components/create-passkey-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx @@ -26,9 +26,9 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' /** - * Modal for creating a new passkey with a nickname + * Modal for registering a new passkey with a nickname */ -const CreatePasskeyModal = ({isOpen, onClose}) => { +const PasskeyRegistrationModal = ({isOpen, onClose}) => { const {formatMessage} = useIntl() const [passkeyNickname, setPasskeyNickname] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -131,9 +131,9 @@ const CreatePasskeyModal = ({isOpen, onClose}) => { ) } -CreatePasskeyModal.propTypes = { +PasskeyRegistrationModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired } -export default CreatePasskeyModal +export default PasskeyRegistrationModal 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 12ad654010..e6e334e490 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 @@ -39,7 +39,8 @@ import { 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 {useAccountCreatedToast} from '@salesforce/retail-react-app/app/hooks/use-account-created-toast' +import {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration-modal' +import PasskeyRegistrationModal from '@salesforce/retail-react-app/app/components/passkey-registration-modal' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' @@ -83,7 +84,7 @@ export const AuthModal = ({ const [currentView, setCurrentView] = useState(initialView) const form = useForm() const toast = useToast() - const {showToast, PasskeyModal} = useAccountCreatedToast() + const {showToast, passkeyModal} = usePasskeyRegistration() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) const appOrigin = useAppOrigin() @@ -243,34 +244,13 @@ export const AuthModal = ({ // Show a toast only for those registed users returning to the site. if (loggingIn) { - toast({ - variant: 'subtle', - title: `${formatMessage( - { - defaultMessage: 'Welcome {name},', - id: 'auth_modal.info.welcome_user' - }, - { - name: customer.data?.firstName || '' - } - )}`, - description: `${formatMessage({ - defaultMessage: "You're now signed in.", - id: 'auth_modal.description.now_signed_in' - })}`, - status: 'success', - position: 'top-right', - isClosable: true - }) - // Execute action to be performed on successful login onLoginSuccess() } if (registering) { - // Show toast notification for successful registration + // Show account created toast with create passkey button showToast() - // Execute action to be performed on successful registration onRegistrationSuccess() } @@ -343,7 +323,7 @@ export const AuthModal = ({ - + ) } diff --git a/packages/template-retail-react-app/app/hooks/use-account-created-toast.js b/packages/template-retail-react-app/app/hooks/use-passkey-registration-modal.js similarity index 77% rename from packages/template-retail-react-app/app/hooks/use-account-created-toast.js rename to packages/template-retail-react-app/app/hooks/use-passkey-registration-modal.js index 965fc98ae5..e841ca9396 100644 --- a/packages/template-retail-react-app/app/hooks/use-account-created-toast.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-registration-modal.js @@ -12,13 +12,12 @@ import { useDisclosure, useToast } from '@salesforce/retail-react-app/app/components/shared/ui' -import CreatePasskeyModal from '@salesforce/retail-react-app/app/components/create-passkey-modal' /** - * Custom hook to show account creation success toast with passkey promotion - * @returns {Object} Object containing showToast function and PasskeyModal component + * Custom hook to manage passkey registration prompt (toast and modal) + * @returns {Object} Object containing showToast function and passkey modal state */ -export const useAccountCreatedToast = () => { +export const usePasskeyRegistration = () => { const toast = useToast() const passkeyModal = useDisclosure() @@ -55,12 +54,11 @@ export const useAccountCreatedToast = () => { }) } - const PasskeyModal = () => ( - - ) - return { showToast, - PasskeyModal + passkeyModal: { + isOpen: passkeyModal.isOpen, + onClose: passkeyModal.onClose + } } } diff --git a/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx b/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx index 333ed62951..8d0867ec92 100644 --- a/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx @@ -52,7 +52,8 @@ import ShipmentDetails from '@salesforce/retail-react-app/app/pages/checkout/par import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrency} from '@salesforce/retail-react-app/app/hooks' -import {useAccountCreatedToast} from '@salesforce/retail-react-app/app/hooks/use-account-created-toast' +import {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration-modal' +import PasskeyRegistrationModal from '@salesforce/retail-react-app/app/components/passkey-registration-modal' // Constants import { @@ -84,7 +85,7 @@ const CheckoutConfirmation = () => { {} ) const form = useForm() - const {showToast, PasskeyModal} = useAccountCreatedToast() + const {showToast, passkeyModal} = usePasskeyRegistration() const hasMultipleShipments = order?.shipments && order.shipments.length > 1 @@ -587,7 +588,7 @@ const CheckoutConfirmation = () => { - + ) } diff --git a/packages/template-retail-react-app/app/pages/registration/index.jsx b/packages/template-retail-react-app/app/pages/registration/index.jsx index a53f868c1a..3373455b2c 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.jsx @@ -17,7 +17,8 @@ import RegisterForm from '@salesforce/retail-react-app/app/components/register' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' -import {useAccountCreatedToast} from '@salesforce/retail-react-app/app/hooks/use-account-created-toast' +import {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration-modal' +import PasskeyRegistrationModal from '@salesforce/retail-react-app/app/components/passkey-registration-modal' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' const Registration = () => { @@ -29,7 +30,7 @@ const Registration = () => { const dataCloud = useDataCloud() const {pathname} = useLocation() const register = useAuthHelper(AuthHelpers.Register) - const {showToast, PasskeyModal} = useAccountCreatedToast() + const {showToast, passkeyModal} = usePasskeyRegistration() const submitForm = async (data) => { const body = { @@ -86,7 +87,7 @@ const Registration = () => { clickSignIn={() => navigate('/login')} /> - + ) } From 82aad0142aede3e145e0725b2068e588ab93201f Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Thu, 6 Nov 2025 09:31:25 -0500 Subject: [PATCH 03/65] feat: add hardcoded config for local passkey registration testing --- .../passkey-registration-modal/index.jsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx index 5b94e67ef7..4d063ed49a 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx @@ -44,24 +44,28 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { setError(null) // THE API CALL TO /oauth2/webauthn/register/authorize SHOULD BE REPLACED BY A COMMERCE SDK CALL + // For now these are hardcoded values to make passkey registration work with a locally running SLAS // Makes a request to the SLAS API on a local server try { + const clientId = 'd6ae9df8-e13f-48f4-a413-b9820d9a39bc' + const clientIdSecret = '9MBWoGTfPmUsm9ityrAN' + const tenant_id = 'bldm_stg' + const callbackUri = 'http://localhost:9010/callback' const params = new URLSearchParams() params.append('channel_id', commerceAPI.parameters.siteId) params.append('user_id', customer.email) params.append('mode', 'callback') - params.append('client_id', commerceAPI.parameters.clientId) - params.append('callback_uri', webauthnConfig.callbackURI) - - console.log('Body', params.toString()) + params.append('client_id', clientId) + params.append('callback_uri', callbackUri) + console.log('Body', params) const response = await fetch( - `http://localhost:9020/api/v1/organizations/${commerceAPI.parameters.organizationId}/oauth2/webauthn/register/authorize`, + `http://localhost:9020/api/v1/organizations/${tenant_id}/oauth2/webauthn/register/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': `Basic ${btoa(`${commerceAPI.parameters.clientId}:${process.env.CLIENT_SECRET || '9MBWoGTfPmUsm9ityrAN'}`)}` + 'Authorization': `Basic ${btoa(`${clientId}:${clientIdSecret}`)}` }, body: params.toString() } From 318856f2f134fb2a576e3d58a48f4955d626f4bd Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 7 Nov 2025 09:30:33 -0500 Subject: [PATCH 04/65] feat: implement passkey registration with email verification - Added two-step passkey registration flow with email verification code - Implemented WebAuthn credential creation using browser's native API - Added base64url encoding/decoding utilities for WebAuthn binary data handling - Created verification code input with auto-submit on 8 digits - Added resend code functionality for verification step - Added state management to handle registration steps and form data - Updated modal UI to show different --- .../passkey-registration-modal/index.jsx | 332 +++++++++++++++--- 1 file changed, 276 insertions(+), 56 deletions(-) diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx index 4d063ed49a..fb9fc2586c 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx @@ -5,9 +5,10 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useState} from 'react' +import React, {useState, useEffect} from 'react' import PropTypes from 'prop-types' import {useIntl} from 'react-intl' +import {decode as base64Decode, encode as base64Encode} from 'base64-arraybuffer' import { Button, FormControl, @@ -20,11 +21,32 @@ import { ModalHeader, ModalOverlay, Alert, - AlertIcon + AlertIcon, + Text } from '@salesforce/retail-react-app/app/components/shared/ui' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +/** + * Convert base64url string to Uint8Array + * WebAuthn requires binary data, but API returns base64url strings + */ +const base64urlToUint8Array = (base64url) => { + // Add padding and convert base64url to base64 + const padding = '===='.substring(0, (4 - (base64url.length % 4)) % 4) + const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/') + return new Uint8Array(base64Decode(base64)) +} + +/** + * Convert Uint8Array to base64url string + * Server expects base64url strings, not binary data + */ +const uint8arrayToBase64url = (bytes) => { + const uint8array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + return base64Encode(uint8array.buffer).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + /** * Modal for registering a new passkey with a nickname */ @@ -33,67 +55,209 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { const [passkeyNickname, setPasskeyNickname] = useState('') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const [step, setStep] = useState('register') // 'register' or 'verification' + const [verificationCode, setVerificationCode] = useState('') const {data: customer} = useCurrentCustomer() const config = getConfig() const commerceAPI = config.app.commerceAPI const webauthnConfig = config.app.login.webauthn + // Hardcoded values for local SLAS development + // TODO: Move these to config or let them be pulled in automatically by Commerce SDK + const CLIENT_ID = 'd6ae9df8-e13f-48f4-a413-b9820d9a39bc' + const CLIENT_SECRET = '9MBWoGTfPmUsm9ityrAN' + const TENANT_ID = 'bldm_stg' + const CALLBACK_URI = 'http://localhost:9010/callback' + const SITE_ID = 'SiteGenesis' + + const handleResendCode = async () => { + await handleRegisterPasskey() + } + const handleRegisterPasskey = async () => { setIsLoading(true) setError(null) // THE API CALL TO /oauth2/webauthn/register/authorize SHOULD BE REPLACED BY A COMMERCE SDK CALL - // For now these are hardcoded values to make passkey registration work with a locally running SLAS - // Makes a request to the SLAS API on a local server try { - const clientId = 'd6ae9df8-e13f-48f4-a413-b9820d9a39bc' - const clientIdSecret = '9MBWoGTfPmUsm9ityrAN' - const tenant_id = 'bldm_stg' - const callbackUri = 'http://localhost:9010/callback' - const params = new URLSearchParams() - params.append('channel_id', commerceAPI.parameters.siteId) - params.append('user_id', customer.email) - params.append('mode', 'callback') - params.append('client_id', clientId) - params.append('callback_uri', callbackUri) - console.log('Body', params) + const params = new URLSearchParams({ + channel_id: SITE_ID, + user_id: customer.email, + mode: 'callback', + client_id: CLIENT_ID, + callback_uri: CALLBACK_URI + }) - const response = await fetch( - `http://localhost:9020/api/v1/organizations/${tenant_id}/oauth2/webauthn/register/authorize`, + console.log('webauthn/register/authorize params:', params.toString()) + + await fetch( + `http://localhost:9020/api/v1/organizations/${TENANT_ID}/oauth2/webauthn/register/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': `Basic ${btoa(`${clientId}:${clientIdSecret}`)}` + 'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}` }, body: params.toString() } ) - const data = await response.json() - console.log('Passkey registration initiated:', data) + console.log('Passkey registration initiated. Check SLAS for OTP') console.log('Passkey nickname:', passkeyNickname) - onClose() - setPasskeyNickname('') + // Move to verification step + setStep('verification') + } catch (err) { + console.error('Error authorizing passkey registration:', err) + setError(err.message || 'Failed to authorize passkey registration') + } finally { + setIsLoading(false) + } + } + + const handleVerificationCodeChange = (value) => { + setVerificationCode(value) + + // Auto-submit when 8 digits are entered + if (value.length === 8) { + handleVerifyCode(value) + } + } + + const handleVerifyCode = async (code) => { + setIsLoading(true) + setError(null) + + try { + const params = new URLSearchParams({ + channel_id: SITE_ID, + client_id: CLIENT_ID, + user_id: customer.email, + pwd_action_token: code, + display_name: customer?.email, + nick_name: passkeyNickname + }) + console.log('webauthn/register/start params:', params.toString()) + + const response = await fetch( + `http://localhost:9020/api/v1/organizations/${TENANT_ID}/oauth2/webauthn/register/start`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}` + }, + body: params.toString() + } + ) + const data = await response.json() + + console.log('Registration started response:', data) + + // Transform base64url strings to Uint8Array for WebAuthn API + // The response is already the publicKey object, so we wrap it + const credentialCreateOptions = { + publicKey: { + ...data, + challenge: base64urlToUint8Array(data.challenge), + user: { + ...data.user, + id: base64urlToUint8Array(data.user.id) + }, + excludeCredentials: data.excludeCredentials?.map(credential => ({ + ...credential, + id: base64urlToUint8Array(credential.id) + })) || [] + } + } + + console.log('Transformed credential options:', credentialCreateOptions) + + // Create the passkey credential using WebAuthn API + console.log('Calling navigator.credentials.create()...') + const credential = await navigator.credentials.create(credentialCreateOptions) + console.log('Credential created:', credential) + + // Transform credential - encode ArrayBuffers to base64url for JSON serialization + const encodedCredential = { + type: credential.type, + id: credential.id, + response: { + attestationObject: uint8arrayToBase64url(new Uint8Array(credential.response.attestationObject)), + clientDataJSON: uint8arrayToBase64url(new Uint8Array(credential.response.clientDataJSON)), + transports: credential.response.getTransports ? credential.response.getTransports() : [] + }, + clientExtensionResults: credential.getClientExtensionResults() + } + + console.log('Encoded credential:', encodedCredential) + + // Call /finish endpoint to complete registration + const finishRequest = { + client_id: CLIENT_ID, + username: customer.email, + channel_id: SITE_ID, + pwd_action_token: code, + credentialNickName: passkeyNickname, + credential: encodedCredential + } + + console.log('Calling /finish endpoint...') + const finishResponse = await fetch( + `http://localhost:9020/api/v1/organizations/${TENANT_ID}/oauth2/webauthn/register/finish`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(finishRequest) + } + ) + + const finishData = await finishResponse.json() + console.log('Finish response:', finishData) + + handleClose() } catch (err) { - console.error('Error registering passkey:', err) - setError(err.message || 'Failed to register passkey') + console.error('Error starting passkey registration:', err) + setError(err.message || 'Failed to start passkey registration') } finally { setIsLoading(false) } } + const resetState = () => { + setStep('register') + setPasskeyNickname('') + setVerificationCode('') + setError(null) + } + + const handleClose = () => { + resetState() + onClose() + } + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + resetState() + } + }, [isOpen]) + return ( - + - {formatMessage({ - defaultMessage: 'Create Passkey', - id: 'auth_modal.passkey.title' - })} + {step === 'register' + ? formatMessage({ + defaultMessage: 'Create Passkey', + id: 'auth_modal.passkey.title' + }) + : formatMessage({ + defaultMessage: 'Verify Your Email', + id: 'auth_modal.passkey.verification.title' + })} { })} /> - - - {formatMessage({ - defaultMessage: 'Passkey Nickname', - id: 'auth_modal.passkey.label.nickname' - })} - - setPasskeyNickname(e.target.value)} - mb={4} - isDisabled={isLoading} - /> - - + {error && ( + + + {error} + + )} + + {step === 'register' ? ( + + + {formatMessage({ + defaultMessage: 'Passkey Nickname', + id: 'auth_modal.passkey.label.nickname' + })} + + setPasskeyNickname(e.target.value)} + mb={4} + isDisabled={isLoading} + /> + + + ) : ( + + + {formatMessage( + { + defaultMessage: + 'We sent an 8-digit code to your email: {email}. Enter the code to confirm your identity and your account.', + id: 'auth_modal.passkey.verification.message' + }, + {email: customer?.email} + )} + + + {formatMessage({ + defaultMessage: 'Verification Code', + id: 'auth_modal.passkey.verification.label' + })} + + { + const value = e.target.value.replace(/\D/g, '').slice(0, 8) + handleVerificationCodeChange(value) + }} + placeholder="Enter 8-digit code" + maxLength={8} + type="text" + inputMode="numeric" + pattern="[0-9]*" + textAlign="center" + fontSize="2xl" + letterSpacing="widest" + isDisabled={isLoading} + mb={4} + /> + + + )} From 39cb5c8befd35ddd13fc66385ef82c47e3fc9173 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 7 Nov 2025 10:17:10 -0500 Subject: [PATCH 05/65] feat: centralize passkey registration prompt to account page - Moved PasskeyRegistrationModal from auth/registration/checkout pages to account page for consistent user experience - Added session storage flag 'newAccountCreated' to track when to show passkey registration prompt - Simplified registration flow by removing duplicate passkey modal instances - Updated auth flows to defer passkey registration until user reaches account page - Removed unused passkey imports from auth-modal, registration, --- .../app/hooks/use-auth-modal.js | 9 ++------- .../app/pages/account/index.jsx | 15 +++++++++++++++ .../app/pages/checkout/confirmation.jsx | 8 ++------ .../app/pages/registration/index.jsx | 7 ++----- 4 files changed, 21 insertions(+), 18 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 e6e334e490..14994de3ac 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 @@ -39,8 +39,6 @@ import { 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 {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration-modal' -import PasskeyRegistrationModal from '@salesforce/retail-react-app/app/components/passkey-registration-modal' import {isServer} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' @@ -84,7 +82,6 @@ export const AuthModal = ({ const [currentView, setCurrentView] = useState(initialView) const form = useForm() const toast = useToast() - const {showToast, passkeyModal} = usePasskeyRegistration() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const register = useAuthHelper(AuthHelpers.Register) const appOrigin = useAppOrigin() @@ -249,8 +246,8 @@ export const AuthModal = ({ } if (registering) { - // Show account created toast with create passkey button - showToast() + // Set flag for passkey toast on account page + sessionStorage.setItem('newAccountCreated', 'true') // Execute action to be performed on successful registration onRegistrationSuccess() } @@ -322,8 +319,6 @@ export const AuthModal = ({ - - ) } diff --git a/packages/template-retail-react-app/app/pages/account/index.jsx b/packages/template-retail-react-app/app/pages/account/index.jsx index 51a2587aa8..cb849b0b27 100644 --- a/packages/template-retail-react-app/app/pages/account/index.jsx +++ b/packages/template-retail-react-app/app/pages/account/index.jsx @@ -45,6 +45,8 @@ import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {isHydrated} from '@salesforce/retail-react-app/app/utils/utils' +import {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration-modal' +import PasskeyRegistrationModal from '@salesforce/retail-react-app/app/components/passkey-registration-modal' const onClient = typeof window !== 'undefined' const LogoutButton = ({onClick}) => { @@ -96,8 +98,20 @@ const Account = () => { const einstein = useEinstein() const dataCloud = useDataCloud() + + const {showToast, passkeyModal} = usePasskeyRegistration() const {buildUrl} = useMultiSite() + + // Show passkey toast if user just registered (from any source) + useEffect(() => { + if (sessionStorage.getItem('newAccountCreated')) { + showToast() + // Clear flag immediately + sessionStorage.removeItem('newAccountCreated') + } + }, []) + /**************** Einstein ****************/ useEffect(() => { einstein.sendViewPage(location.pathname) @@ -243,6 +257,7 @@ const Account = () => { + ) } diff --git a/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx b/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx index 8d0867ec92..316669bc1e 100644 --- a/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx @@ -52,8 +52,6 @@ import ShipmentDetails from '@salesforce/retail-react-app/app/pages/checkout/par import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrency} from '@salesforce/retail-react-app/app/hooks' -import {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration-modal' -import PasskeyRegistrationModal from '@salesforce/retail-react-app/app/components/passkey-registration-modal' // Constants import { @@ -85,7 +83,6 @@ const CheckoutConfirmation = () => { {} ) const form = useForm() - const {showToast, passkeyModal} = usePasskeyRegistration() const hasMultipleShipments = order?.shipments && order.shipments.length > 1 @@ -162,8 +159,8 @@ const CheckoutConfirmation = () => { // Save the shipping address from this order, should not block account creation await saveShippingAddress(registerData.customerId) - // Show account created toast - showToast() + // Set flag for passkey toast on account page + sessionStorage.setItem('newAccountCreated', 'true') navigate(`/account`) } catch (error) { @@ -588,7 +585,6 @@ const CheckoutConfirmation = () => { - ) } diff --git a/packages/template-retail-react-app/app/pages/registration/index.jsx b/packages/template-retail-react-app/app/pages/registration/index.jsx index 3373455b2c..ad8aa52904 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.jsx @@ -17,8 +17,6 @@ import RegisterForm from '@salesforce/retail-react-app/app/components/register' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' -import {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration-modal' -import PasskeyRegistrationModal from '@salesforce/retail-react-app/app/components/passkey-registration-modal' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' const Registration = () => { @@ -30,7 +28,6 @@ const Registration = () => { const dataCloud = useDataCloud() const {pathname} = useLocation() const register = useAuthHelper(AuthHelpers.Register) - const {showToast, passkeyModal} = usePasskeyRegistration() const submitForm = async (data) => { const body = { @@ -52,7 +49,8 @@ const Registration = () => { useEffect(() => { if (isRegistered) { - showToast() + // Set flag for passkey toast on account page + sessionStorage.setItem('newAccountCreated', 'true') navigate('/account') } }, [isRegistered]) @@ -87,7 +85,6 @@ const Registration = () => { clickSignIn={() => navigate('/login')} /> - ) } From ad550f705765c7b7e07656c82797759d25523133 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 7 Nov 2025 11:27:44 -0500 Subject: [PATCH 06/65] refactor: standardize session storage handling for new accounts - Replaced direct sessionStorage calls with utility functions (setSessionJSONItem, getSessionJSONItem, clearSessionJSONItem) - Consolidated new account flag handling across auth modal, registration, checkout, and account pages - Added temporary login trigger for passkey registration testing - Updated all components to use consistent storage approach for newAccountCreated flag --- .../template-retail-react-app/app/hooks/use-auth-modal.js | 7 +++++-- .../template-retail-react-app/app/pages/account/index.jsx | 6 +++--- .../app/pages/checkout/confirmation.jsx | 3 ++- .../app/pages/registration/index.jsx | 3 ++- 4 files changed, 12 insertions(+), 7 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 14994de3ac..a80921b5db 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 @@ -39,7 +39,7 @@ import { 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 {isServer, setSessionJSONItem} from '@salesforce/retail-react-app/app/utils/utils' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils' @@ -241,13 +241,16 @@ export const AuthModal = ({ // Show a toast only for those registed users returning to the site. if (loggingIn) { + // To simplify testing I trigger the register passkey flow from login + // In reality this should be triggered only upon registration. + setSessionJSONItem('newAccountCreated', true) // Execute action to be performed on successful login onLoginSuccess() } if (registering) { // Set flag for passkey toast on account page - sessionStorage.setItem('newAccountCreated', 'true') + setSessionJSONItem('newAccountCreated', true) // Execute action to be performed on successful registration onRegistrationSuccess() } diff --git a/packages/template-retail-react-app/app/pages/account/index.jsx b/packages/template-retail-react-app/app/pages/account/index.jsx index cb849b0b27..1eff1f8ce9 100644 --- a/packages/template-retail-react-app/app/pages/account/index.jsx +++ b/packages/template-retail-react-app/app/pages/account/index.jsx @@ -44,7 +44,7 @@ import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' -import {isHydrated} from '@salesforce/retail-react-app/app/utils/utils' +import {isHydrated, getSessionJSONItem, clearSessionJSONItem} from '@salesforce/retail-react-app/app/utils/utils' import {usePasskeyRegistration} from '@salesforce/retail-react-app/app/hooks/use-passkey-registration-modal' import PasskeyRegistrationModal from '@salesforce/retail-react-app/app/components/passkey-registration-modal' @@ -105,10 +105,10 @@ const Account = () => { // Show passkey toast if user just registered (from any source) useEffect(() => { - if (sessionStorage.getItem('newAccountCreated')) { + if (getSessionJSONItem('newAccountCreated')) { showToast() // Clear flag immediately - sessionStorage.removeItem('newAccountCreated') + clearSessionJSONItem('newAccountCreated') } }, []) diff --git a/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx b/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx index 316669bc1e..d5741335f0 100644 --- a/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/confirmation.jsx @@ -52,6 +52,7 @@ import ShipmentDetails from '@salesforce/retail-react-app/app/pages/checkout/par import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrency} from '@salesforce/retail-react-app/app/hooks' +import {setSessionJSONItem} from '@salesforce/retail-react-app/app/utils/utils' // Constants import { @@ -160,7 +161,7 @@ const CheckoutConfirmation = () => { await saveShippingAddress(registerData.customerId) // Set flag for passkey toast on account page - sessionStorage.setItem('newAccountCreated', 'true') + setSessionJSONItem('newAccountCreated', true) navigate(`/account`) } catch (error) { diff --git a/packages/template-retail-react-app/app/pages/registration/index.jsx b/packages/template-retail-react-app/app/pages/registration/index.jsx index ad8aa52904..72a2731493 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.jsx @@ -17,6 +17,7 @@ import RegisterForm from '@salesforce/retail-react-app/app/components/register' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' +import {setSessionJSONItem} from '@salesforce/retail-react-app/app/utils/utils' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' const Registration = () => { @@ -50,7 +51,7 @@ const Registration = () => { useEffect(() => { if (isRegistered) { // Set flag for passkey toast on account page - sessionStorage.setItem('newAccountCreated', 'true') + setSessionJSONItem('newAccountCreated', true) navigate('/account') } }, [isRegistered]) From df2cdc002d194fa6bf278d5f80ba1b7c41e073e1 Mon Sep 17 00:00:00 2001 From: Yuna Kim <84923642+yunakim714@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:14:00 -0500 Subject: [PATCH 07/65] @W-20223950 - Add config for passkey login (#3497) --- .../assets/bootstrap/js/config/default.js.hbs | 10 ++++++++++ .../@salesforce/retail-react-app/config/default.js.hbs | 10 ++++++++++ packages/template-retail-react-app/config/default.js | 4 ++++ 3 files changed, 24 insertions(+) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index d963923259..aca25f5768 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -93,6 +93,16 @@ module.exports = { callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', // The landing path for reset password landingPath: '/reset-password-landing' + }, + passkey: { + // Enables or disables passkey login for the site. Defaults to: false + {{#if answers.project.demo.enableDemoSettings}} + enabled: true, + {{else}} + enabled: false, + {{/if}} + // The callback URI must be an absolute URL (i.e. third-party URI) set up by the developer. + callbackURI: process.env.PASSKEY_CALLBACK_URI } }, // The default site for your app. This value will be used when a siteRef could not be determined from the url diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 4033258366..9a07995a09 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -93,6 +93,16 @@ module.exports = { callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', // The landing path for reset password landingPath: '/reset-password-landing' + }, + passkey: { + // Enables or disables passkey login for the site. Defaults to: false + {{#if answers.project.demo.enableDemoSettings}} + enabled: true, + {{else}} + enabled: false, + {{/if}} + // The callback URI must be an absolute URL (i.e. third-party URI) set up by the developer. + callbackURI: process.env.PASSKEY_CALLBACK_URI } }, // The default site for your app. This value will be used when a siteRef could not be determined from the url diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index cf172df1e1..8e6a5e7548 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -44,6 +44,10 @@ module.exports = { resetPassword: { callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback', landingPath: '/reset-password-landing' + }, + passkey: { + enabled: false, + callbackURI: process.env.PASSKEY_CALLBACK_URI } }, defaultSite: 'RefArchGlobal', From 51f587ae90eca53b68379765d74983568dd418c7 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Fri, 2 Jan 2026 14:01:40 -0500 Subject: [PATCH 08/65] display create passkey prompt upon showing account page --- .../template-retail-react-app/app/pages/account/index.jsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/account/index.jsx b/packages/template-retail-react-app/app/pages/account/index.jsx index 1eff1f8ce9..540edb97f2 100644 --- a/packages/template-retail-react-app/app/pages/account/index.jsx +++ b/packages/template-retail-react-app/app/pages/account/index.jsx @@ -103,13 +103,9 @@ const Account = () => { const {buildUrl} = useMultiSite() - // Show passkey toast if user just registered (from any source) + // Show passkey toast when user is on the account page useEffect(() => { - if (getSessionJSONItem('newAccountCreated')) { - showToast() - // Clear flag immediately - clearSessionJSONItem('newAccountCreated') - } + showToast() }, []) /**************** Einstein ****************/ From ce6ea325c7f1c7db0990bf05c90674b2b1f6dc31 Mon Sep 17 00:00:00 2001 From: Yuna Kim <84923642+yunakim714@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:09:29 -0500 Subject: [PATCH 09/65] @W-20328381 - Add Webauthn Register/Authenticate APIs to `commerce-sdk-react` (#3561) --- .../commerce-sdk-react/src/auth/index.test.ts | 137 +++++++++++++- packages/commerce-sdk-react/src/auth/index.ts | 169 +++++++++++++++++- .../src/hooks/ShopperLogin/cache.ts | 8 +- .../src/hooks/ShopperLogin/mutation.test.ts | 30 +++- .../src/hooks/ShopperLogin/mutation.ts | 27 ++- .../src/hooks/useAuthHelper.ts | 7 +- 6 files changed, 364 insertions(+), 14 deletions(-) diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts index cc377c5b1c..95fbba67dc 100644 --- a/packages/commerce-sdk-react/src/auth/index.test.ts +++ b/packages/commerce-sdk-react/src/auth/index.test.ts @@ -70,7 +70,14 @@ jest.mock('commerce-sdk-isomorphic', () => { fetchOptions: { credentials: config?.fetchOptions?.credentials || 'same-origin' } - } + }, + authorizeWebauthnRegistration: jest.fn().mockResolvedValue({}), + startWebauthnUserRegistration: jest.fn().mockResolvedValue({}), + finishWebauthnUserRegistration: jest.fn().mockResolvedValue({}), + startWebauthnAuthentication: jest.fn().mockResolvedValue({}), + finishWebauthnAuthentication: jest + .fn() + .mockResolvedValue({tokenResponse: TOKEN_RESPONSE}) })) } }) @@ -1280,3 +1287,131 @@ describe('hybridAuthEnabled property toggles clearECOMSession', () => { expect(auth.get('dwsid')).toBe('test-dwsid-value') }) }) + +describe('Webauthn', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const PUBLIC_KEY_CREDENTIAL_JSON: ShopperLoginTypes.PublicKeyCredentialJson = { + id: 'credential-id', + rawId: 'raw-credential-id', + type: 'public-key', + response: { + authenticatorData: [], + clientDataJSON: [], + signature: [], + userHandle: null + } as ShopperLoginTypes.AuthenticatorAssertionResponseJson + } + + test('authorizeWebauthnRegistration', async () => { + const auth = new Auth(config) + await auth.authorizeWebauthnRegistration({ + user_id: 'test-user-id', + mode: 'test-mode', + channel_id: 'test-channel-id' + }) + + expect((auth as any).client.authorizeWebauthnRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: { + user_id: 'test-user-id', + mode: 'test-mode', + channel_id: 'test-channel-id' + } + }) + }) + + test('startWebauthnUserRegistration', async () => { + const auth = new Auth(config) + await auth.startWebauthnUserRegistration({ + channel_id: 'test-channel-id', + display_name: 'test-display-name', + nick_name: 'test-nick-name', + client_id: 'test-client-id', + pwd_action_token: 'test-pwd-action-token', + user_id: 'test-user-id' + }) + + expect((auth as any).client.startWebauthnUserRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: { + display_name: 'test-display-name', + nick_name: 'test-nick-name', + client_id: 'test-client-id', + channel_id: 'test-channel-id', + pwd_action_token: 'test-pwd-action-token', + user_id: 'test-user-id' + } + }) + }) + + test('finishWebauthnUserRegistration', async () => { + const auth = new Auth(config) + await auth.finishWebauthnUserRegistration({ + client_id: 'test-client-id', + username: 'test-username', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + channel_id: 'test-channel-id', + pwd_action_token: 'test-pwd-action-token' + }) + + expect((auth as any).client.finishWebauthnUserRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: { + client_id: 'test-client-id', + username: 'test-username', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + channel_id: 'test-channel-id', + pwd_action_token: 'test-pwd-action-token' + } + }) + }) + + test('startWebauthnAuthentication', async () => { + const auth = new Auth(config) + await auth.startWebauthnAuthentication({ + user_id: 'test-user-id', + channel_id: 'test-channel-id', + client_id: 'test-client-id' + }) + + expect((auth as any).client.startWebauthnAuthentication).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: { + user_id: 'test-user-id', + channel_id: 'test-channel-id', + client_id: 'test-client-id' + } + }) + }) + + test('finishWebauthnAuthentication', async () => { + const auth = new Auth(config) + await auth.finishWebauthnAuthentication({ + client_id: 'test-client-id', + channel_id: 'test-channel-id', + credential: PUBLIC_KEY_CREDENTIAL_JSON + }) + + expect((auth as any).client.finishWebauthnAuthentication).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: { + client_id: 'test-client-id', + channel_id: 'test-channel-id', + credential: PUBLIC_KEY_CREDENTIAL_JSON + } + }) + }) +}) diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index 19039e5eb0..0ee699ab93 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1315,6 +1315,19 @@ class Auth { return token } + /** + * Get Basic auth header for private client requests. + * Returns undefined if not using a private client. + */ + private getBasicAuthHeader(client: ShopperLogin): string | undefined { + return ( + this.clientSecret && + `Basic ${stringToBase64( + `${client.clientConfig.parameters.clientId}:${this.clientSecret}` + )}` + ) + } + /** * A wrapper method for the SLAS endpoint: getPasswordResetToken. * @@ -1340,10 +1353,9 @@ class Auth { } // Only set authorization header if using private client - if (this.clientSecret) { - options.headers.Authorization = `Basic ${stringToBase64( - `${slasClient.clientConfig.parameters.clientId}:${this.clientSecret}` - )}` + const authHeader = this.getBasicAuthHeader(slasClient) + if (authHeader) { + options.headers.Authorization = authHeader } const res = await slasClient.getPasswordResetToken(options) @@ -1371,10 +1383,9 @@ class Auth { } // Only set authorization header if using private client - if (this.clientSecret) { - options.headers.Authorization = `Basic ${stringToBase64( - `${slasClient.clientConfig.parameters.clientId}:${this.clientSecret}` - )}` + const authHeader = this.getBasicAuthHeader(slasClient) + if (authHeader) { + options.headers.Authorization = authHeader } // TODO: no code verifier needed with the fix blair has made, delete this when the fix has been merged to production // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -1423,6 +1434,146 @@ class Auth { uido } } -} + /** + * A wrapper method for the SLAS endpoint: authorizeWebauthnRegistration. + */ + async authorizeWebauthnRegistration( + parameters: ShopperLoginTypes.authorizeWebauthnRegistrationBodyType + ) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + user_id: parameters.user_id, + mode: parameters.mode, + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + // Optional params + ...(parameters.locale && {locale: parameters.locale}), + ...(parameters.client_id && {client_id: parameters.client_id}), + ...(parameters.code_challenge && {code_challenge: parameters.code_challenge}), + ...(parameters.callback_uri && {callback_uri: parameters.callback_uri}), + ...(parameters.idp_name && {idp_name: parameters.idp_name}), + ...(parameters.hint && {hint: parameters.hint}) + } + } + + return await slasClient.authorizeWebauthnRegistration(options) + } + + /** + * A wrapper method for the SLAS endpoint: startWebauthnUserRegistration. + */ + async startWebauthnUserRegistration( + parameters: ShopperLoginTypes.startWebauthnUserRegistrationBodyType + ) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + pwd_action_token: parameters.pwd_action_token, + user_id: parameters.user_id, + // Optional params + ...(parameters.display_name && {display_name: parameters.display_name}), + ...(parameters.nick_name && {nick_name: parameters.nick_name}), + ...(parameters.client_id && {client_id: parameters.client_id}) + } + } + + return await slasClient.startWebauthnUserRegistration(options) + } + + /** + * A wrapper method for the SLAS endpoint: finishWebauthnUserRegistration. + */ + async finishWebauthnUserRegistration(parameters: ShopperLoginTypes.RegistrationFinishRequest) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, + username: parameters.username, + credential: parameters.credential, + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + pwd_action_token: parameters.pwd_action_token + } + } + + return await slasClient.finishWebauthnUserRegistration(options) + } + + /** + * A wrapper method for the SLAS endpoint: startWebauthnAuthentication. + */ + async startWebauthnAuthentication( + parameters: ShopperLoginTypes.startWebauthnAuthenticationBodyType + ) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + user_id: parameters.user_id, + // Optional params + ...(parameters.tenant_id && {tenant_id: parameters.tenant_id}) + } + } + + return await slasClient.startWebauthnAuthentication(options) + } + + /** + * A wrapper method for the SLAS endpoint: finishWebauthnAuthentication. + */ + async finishWebauthnAuthentication(parameters: ShopperLoginTypes.AuthenticateFinishRequest) { + const slasClient = this.client + const authHeader = this.getBasicAuthHeader(slasClient) + const options = { + headers: { + Authorization: authHeader ?? '' + }, + body: { + // Required params + client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, + channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, + credential: parameters.credential, + // Optional params + ...(parameters.user_id && {user_id: parameters.user_id}), + ...(parameters.email && {email: parameters.email}), + ...(parameters.tenant_id && {tenant_id: parameters.tenant_id}), + ...(parameters.usid && {usid: parameters.usid}) + } + } + + const res = await slasClient.finishWebauthnAuthentication(options) + + const tokenResponse = res.tokenResponse + if (!tokenResponse) { + throw new Error('finishWebauthnAuthentication did not return a tokenResponse.') + } + + this.handleTokenResponse(tokenResponse, false) + + return tokenResponse + } +} export default Auth diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/cache.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/cache.ts index 23c3d86c5c..e8ca0df4c1 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/cache.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/cache.ts @@ -28,5 +28,11 @@ export const cacheUpdateMatrix: CacheUpdateMatrix = { resetPassword: noop, getPasswordLessAccessToken: noop, revokeToken: noop, - introspectToken: noop + introspectToken: noop, + // WebAuthn methods - these will be available when commerce-sdk-isomorphic is updated + startWebauthnUserRegistration: noop, + finishWebauthnUserRegistration: noop, + authorizeWebauthnRegistration: noop, + startWebauthnAuthentication: noop, + finishWebauthnAuthentication: noop } diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.test.ts index dd14131491..dd00b3e640 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.test.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.test.ts @@ -28,6 +28,19 @@ const CLIENT_KEY = CLIENT_KEYS.SHOPPER_LOGIN type Client = NonNullable const loginEndpoint = '/shopper/auth/' + +const PUBLIC_KEY_CREDENTIAL_JSON: ShopperLoginTypes.PublicKeyCredentialJson = { + id: 'credential-id', + rawId: 'raw-credential-id', + type: 'public-key', + response: { + authenticatorData: [], + clientDataJSON: [], + signature: [], + userHandle: null + } as ShopperLoginTypes.AuthenticatorAssertionResponseJson +} + // Additional properties are ignored, so we can use this mega-options object for all endpoints const OPTIONS = { parameters: { @@ -46,6 +59,7 @@ const OPTIONS = { code: 'code', code_challenge: 'code_challenge', code_verifier: 'code_verifier', + credential: PUBLIC_KEY_CREDENTIAL_JSON, dwsid: 'dwsid', grant_type: 'client_credentials' as const, hint: 'hint', @@ -56,6 +70,7 @@ const OPTIONS = { pwd_action_token: 'pwd_action_token', pwdless_login_token: 'pwdless_login_token', redirect_uri: 'redirect_uri', + username: 'username', token: 'token', user_id: 'user_id' } @@ -73,6 +88,14 @@ const TOKEN_RESPONSE: ShopperLoginTypes.TokenResponse = { refresh_token_expires_in: 0 } +const PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS: ShopperLoginTypes.PublicKeyCredentialRequestOptions = { + challenge: 'challenge', + timeout: 60000, + rpId: 'rp-id', + allowCredentials: [], + userVerification: 'preferred' +} + // --- TEST CASES --- // type Implemented = ShopperLoginMutation // This is an object rather than an array to more easily ensure we cover all mutations @@ -91,7 +114,12 @@ const testMap: TestMap = { introspectToken: [OPTIONS, {token: 'token'}], resetPassword: [OPTIONS, undefined], revokeToken: [OPTIONS, {token: 'token'}], - logoutCustomer: [OPTIONS, TOKEN_RESPONSE] + logoutCustomer: [OPTIONS, TOKEN_RESPONSE], + startWebauthnUserRegistration: [OPTIONS, PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS], + finishWebauthnUserRegistration: [OPTIONS, undefined], + authorizeWebauthnRegistration: [OPTIONS, undefined], + startWebauthnAuthentication: [OPTIONS, PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS], + finishWebauthnAuthentication: [OPTIONS, TOKEN_RESPONSE] } // Type assertion is necessary because `Object.entries` is limited const testCases = Object.entries(testMap) as Array<[Implemented, TestMap[Implemented]]> diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.ts index 07acfc6fad..49d6b1614e 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/mutation.ts @@ -97,7 +97,32 @@ The value of the `_sfdc_client_auth` header must be a Base64-encoded string. The * Returns the token properties. A basic auth header with Base64-encoded `clientId:secret` is required in the Authorization header, as well as an access token or refresh token. Use `token_type_hint` to help identify the token. * @returns A TanStack Query mutation hook for interacting with the Shopper Login `introspectToken` endpoint. */ - IntrospectToken: 'introspectToken' + IntrospectToken: 'introspectToken', + /** + * Start WebAuthn passkey registration. Starts the WebAuthn registration process by generating credential creation options. Returns the challenge and other parameters needed by the authenticator to create a new credential. + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `startWebauthnRegistration` endpoint. + */ + StartWebauthnUserRegistration: 'startWebauthnUserRegistration', + /** + * Finish WebAuthn passkey registration. Completes the WebAuthn registration process by verifying the credential created by the authenticator. Stores the public key and credential information for future authentication. + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `finishWebauthnRegistration` endpoint. + */ + FinishWebauthnUserRegistration: 'finishWebauthnUserRegistration', + /** + * Authorize WebAuthn passkey registration. Authorizes a user to register a WebAuthn credential (passkey). This endpoint validates the user's credentials and creates a password action token that can be used to start the registration process. The token is sent to the user via the specified channel (email or SMS). + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `authorizeWebauthnRegistration` endpoint. + */ + AuthorizeWebauthnRegistration: 'authorizeWebauthnRegistration', + /** + * Start WebAuthn passkey authentication. Starts the WebAuthn authentication process by generating credential request options. Returns the challenge and allowed credentials for the user to authenticate with. + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `startWebauthnAuthentication` endpoint. + */ + StartWebauthnAuthentication: 'startWebauthnAuthentication', + /** + * Finish WebAuthn passkey authentication. Completes the WebAuthn authentication process by verifying the assertion from the authenticator. Returns OAuth tokens upon successful authentication. + * @returns A TanStack Query mutation hook for interacting with the Shopper Login `finishWebauthnAuthentication` endpoint. + */ + FinishWebauthnAuthentication: 'finishWebauthnAuthentication' } as const /** diff --git a/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts b/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts index bd4a33f316..85f9c835f6 100644 --- a/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts +++ b/packages/commerce-sdk-react/src/hooks/useAuthHelper.ts @@ -31,7 +31,12 @@ export const AuthHelpers = { Logout: 'logout', Register: 'register', ResetPassword: 'resetPassword', - UpdateCustomerPassword: 'updateCustomerPassword' + UpdateCustomerPassword: 'updateCustomerPassword', + StartWebauthnUserRegistration: 'startWebauthnUserRegistration', + FinishWebauthnUserRegistration: 'finishWebauthnUserRegistration', + AuthorizeWebauthnRegistration: 'authorizeWebauthnRegistration', + StartWebauthnAuthentication: 'startWebauthnAuthentication', + FinishWebauthnAuthentication: 'finishWebauthnAuthentication' } as const /** * @group Helpers From 678cf40cb46320bdf545f6336bd2842df75ed5cb Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 19 Jan 2026 10:51:14 -0500 Subject: [PATCH 10/65] Implement passkey login hook and call it when the contact-info component renders --- packages/commerce-sdk-react/src/auth/index.ts | 4 +- .../app/hooks/use-passkey-login.js | 49 +++++++++++++++++++ .../pages/checkout/partials/contact-info.jsx | 9 +++- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 packages/template-retail-react-app/app/hooks/use-passkey-login.js diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index 0ee699ab93..250e38315b 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1532,12 +1532,14 @@ class Auth { // Required params client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, - user_id: parameters.user_id, // Optional params + ...(parameters.user_id && {user_id: parameters.user_id}), ...(parameters.tenant_id && {tenant_id: parameters.tenant_id}) } } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TODO: user_id is optional, but commerce-sdk-isomorphic expects it to be required. Remove this comment after commerce-sdk-isomorphic is updated. return await slasClient.startWebauthnAuthentication(options) } diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js new file mode 100644 index 0000000000..31aa3ac6c0 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -0,0 +1,49 @@ +/* + * 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 {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' + +/** + * This hook provides commerce-react-sdk hooks to simplify the passkey login flow. + */ +export const usePasskeyLogin = () => { + const startWebauthnAuthentication = useAuthHelper(AuthHelpers.StartWebauthnAuthentication) + const startPasskeyLogin = async () => { + const config = getConfig() + + // Check if passkey is enabled in config + if (!config?.app?.login?.passkey?.enabled) { + return + } + + // Availability of window.PublicKeyCredential means WebAuthn is usable + if ( + !window.PublicKeyCredential || + !window.PublicKeyCredential.isConditionalMediationAvailable + ) { + return + } + + // Check if conditional mediation is available. Conditional mediation is a feature + // that allows the browser to prompt the user for a password only if the user has + // not already authenticated with the same device in the current session. + const isCMA = await window.PublicKeyCredential.isConditionalMediationAvailable() + if (!isCMA) { + return + } + + try { + const startResponse = await startWebauthnAuthentication.mutateAsync({}) + console.log('startResponse ->', startResponse) + return startResponse + } catch (error) { + console.error('Error starting passkey authentication:', error) + } + } + + return {startPasskeyLogin} +} 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 01472ac64b..51cca2b77e 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 @@ -46,6 +46,7 @@ import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origi import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' +import {usePasskeyLogin} from '@salesforce/retail-react-app/app/hooks/use-passkey-login' import { API_ERROR_MESSAGE, FEATURE_UNAVAILABLE_ERROR_MESSAGE, @@ -63,6 +64,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket') const mergeBasket = useShopperBasketsMutation('mergeBasket') + const {startPasskeyLogin} = usePasskeyLogin() const {step, STEPS, goToStep, goToNextStep} = useCheckout() @@ -79,7 +81,8 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const [authModalView, setAuthModalView] = useState(PASSWORD_VIEW) const authModal = useAuthModal(authModalView) - const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI + const config = getConfig() + const passwordlessConfigCallback = config.app.login?.passwordless?.callbackURI const callbackURL = isAbsoluteURL(passwordlessConfigCallback) ? passwordlessConfigCallback : `${appOrigin}${getEnvBasePath()}${passwordlessConfigCallback}` @@ -157,6 +160,10 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } }, [showPasswordField]) + useEffect(() => { + startPasskeyLogin() + }, []) + const onPasswordlessLoginClick = async (e) => { const isValid = await form.trigger('email') const domForm = e.target.closest('form') From 0043eaf2552a8b0ab723722fb9363c678448b41e Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Mon, 19 Jan 2026 15:52:34 -0500 Subject: [PATCH 11/65] Add finishWebauthnAuthentication to usePasskeyLogin hook and enhance error handling - Integrated finishWebauthnAuthentication to complete the passkey login flow. - Improved error handling and logging for authentication processes. - Added helper functions for base64url encoding/decoding to facilitate credential processing. --- .../app/hooks/use-passkey-login.js | 100 ++++++++++++++++-- 1 file changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js index 31aa3ac6c0..7290c8b896 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -6,12 +6,14 @@ */ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' +import {decode as base64Decode, encode as base64Encode} from 'base64-arraybuffer' /** * This hook provides commerce-react-sdk hooks to simplify the passkey login flow. */ export const usePasskeyLogin = () => { const startWebauthnAuthentication = useAuthHelper(AuthHelpers.StartWebauthnAuthentication) + const finishWebauthnAuthentication = useAuthHelper(AuthHelpers.FinishWebauthnAuthentication) const startPasskeyLogin = async () => { const config = getConfig() @@ -28,21 +30,101 @@ export const usePasskeyLogin = () => { return } - // Check if conditional mediation is available. Conditional mediation is a feature - // that allows the browser to prompt the user for a password only if the user has - // not already authenticated with the same device in the current session. + // Check if conditional mediation is available. Conditional mediation is a feature of the WebAuthn API that allows passkeys to appear in the browser's standard autofill suggestions, alongside saved passwords. This allows users to sign in with a passkey using the standard username input field, rather than clicking a dedicated passkey login button. + // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/isConditionalMediationAvailable const isCMA = await window.PublicKeyCredential.isConditionalMediationAvailable() if (!isCMA) { return } - try { - const startResponse = await startWebauthnAuthentication.mutateAsync({}) - console.log('startResponse ->', startResponse) - return startResponse - } catch (error) { - console.error('Error starting passkey authentication:', error) + const startWebauthnAuthenticationResponse = await startWebauthnAuthentication.mutateAsync({}) + + if (!startWebauthnAuthenticationResponse) { + // TODO: display localized error message to user + console.error('Error starting passkey authentication:', startWebauthnAuthenticationResponse) + return + } + + // const startWebauthnAuthenticationResponseData = { + // publicKey: { + // challenge: 'DZdUeRgEm5m1D8Fqp8pzZZesdHkf1Pqoe-MqCA8gVw8', + // timeout: 60000, + // rpId: 'localhost', + // extensions: {} + // } + // } + + // Transform response for WebAuthn API to send to navigator.credentials.get() + // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get + const options = { + publicKey: { + challenge: base64urlToUint8Array(startWebauthnAuthenticationResponse.publicKey.challenge), + timeout: startWebauthnAuthenticationResponse.publicKey.timeout, + rpId: startWebauthnAuthenticationResponse.publicKey.rpId, + allowCredentials: (startWebauthnAuthenticationResponse.publicKey.allowCredentials || []).map((credential) => ({ + type: credential.type, + id: base64urlToUint8Array(credential.id), + transports: credential.transports + })), + mediation: 'conditional' + } + } + + const credential = await navigator.credentials.get(options) + + if (!credential) { + // TODO: display localized error message to user + console.error('No credential returned') + return + } + + // Encode credential before sending to SLAS + const encodedCredential = { + id: credential.id, + rawId: uint8arrayToBase64url(new Uint8Array(credential.rawId)), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + authenticatorData: uint8arrayToBase64url( + new Uint8Array(credential.response.authenticatorData) + ), + clientDataJSON: uint8arrayToBase64url( + new Uint8Array(credential.response.clientDataJSON) + ), + signature: uint8arrayToBase64url(new Uint8Array(credential.response.signature)), + userHandle: uint8arrayToBase64url(new Uint8Array(credential.response.userHandle)) + } } + + const finishWebauthnAuthenticationResponse = await finishWebauthnAuthentication.mutateAsync( + { + credential: encodedCredential + } + ) + + console.log('finishWebauthnAuthenticationResponse ->', finishWebauthnAuthenticationResponse) + + if (!finishWebauthnAuthenticationResponse) { + // TODO: display localized error message to user + console.error( + 'Error finishing passkey authentication:', + finishWebauthnAuthenticationResponse + ) + return + } + + return + } + + // Helper functions for base64url encoding/decoding + const base64urlToUint8Array = (base64url) => { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + return new Uint8Array(base64Decode(base64)) + } + + const uint8arrayToBase64url = (uint8array) => { + const base64 = base64Encode(uint8array.buffer) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } return {startPasskeyLogin} From b6ea2d4dbc0ae66f84a20ba188347aeb22931909 Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 20 Jan 2026 15:12:19 -0500 Subject: [PATCH 12/65] add unit tests and rename to loginWithPasskey --- .../app/hooks/use-passkey-login.js | 94 +++----- .../app/hooks/use-passkey-login.test.js | 221 ++++++++++++++++++ 2 files changed, 251 insertions(+), 64 deletions(-) create mode 100644 packages/template-retail-react-app/app/hooks/use-passkey-login.test.js diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js index 7290c8b896..dd440891df 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -4,6 +4,7 @@ * 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 */ +/* global PublicKeyCredential */ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' import {decode as base64Decode, encode as base64Encode} from 'base64-arraybuffer' @@ -14,7 +15,15 @@ import {decode as base64Decode, encode as base64Encode} from 'base64-arraybuffer export const usePasskeyLogin = () => { const startWebauthnAuthentication = useAuthHelper(AuthHelpers.StartWebauthnAuthentication) const finishWebauthnAuthentication = useAuthHelper(AuthHelpers.FinishWebauthnAuthentication) - const startPasskeyLogin = async () => { + + const uint8arrayToBase64url = (input) => { + // Accept either ArrayBuffer or Uint8Array + const buffer = new Uint8Array(input) + const base64 = base64Encode(buffer) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + } + + const loginWithPasskey = async () => { const config = getConfig() // Check if passkey is enabled in config @@ -23,76 +32,47 @@ export const usePasskeyLogin = () => { } // Availability of window.PublicKeyCredential means WebAuthn is usable - if ( - !window.PublicKeyCredential || - !window.PublicKeyCredential.isConditionalMediationAvailable - ) { + if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) { return } // Check if conditional mediation is available. Conditional mediation is a feature of the WebAuthn API that allows passkeys to appear in the browser's standard autofill suggestions, alongside saved passwords. This allows users to sign in with a passkey using the standard username input field, rather than clicking a dedicated passkey login button. // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/isConditionalMediationAvailable - const isCMA = await window.PublicKeyCredential.isConditionalMediationAvailable() + const isCMA = await PublicKeyCredential.isConditionalMediationAvailable() if (!isCMA) { return } - const startWebauthnAuthenticationResponse = await startWebauthnAuthentication.mutateAsync({}) - - if (!startWebauthnAuthenticationResponse) { - // TODO: display localized error message to user - console.error('Error starting passkey authentication:', startWebauthnAuthenticationResponse) - return - } - - // const startWebauthnAuthenticationResponseData = { - // publicKey: { - // challenge: 'DZdUeRgEm5m1D8Fqp8pzZZesdHkf1Pqoe-MqCA8gVw8', - // timeout: 60000, - // rpId: 'localhost', - // extensions: {} - // } - // } + const startWebauthnAuthenticationResponse = await startWebauthnAuthentication.mutateAsync( + {} + ) // Transform response for WebAuthn API to send to navigator.credentials.get() // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get - const options = { - publicKey: { - challenge: base64urlToUint8Array(startWebauthnAuthenticationResponse.publicKey.challenge), - timeout: startWebauthnAuthenticationResponse.publicKey.timeout, - rpId: startWebauthnAuthenticationResponse.publicKey.rpId, - allowCredentials: (startWebauthnAuthenticationResponse.publicKey.allowCredentials || []).map((credential) => ({ - type: credential.type, - id: base64urlToUint8Array(credential.id), - transports: credential.transports - })), - mediation: 'conditional' - } - } + const options = PublicKeyCredential.parseRequestOptionsFromJSON( + startWebauthnAuthenticationResponse.publicKey + ) - const credential = await navigator.credentials.get(options) + const credential = await navigator.credentials.get({ + publicKey: options, + mediation: 'conditional' + }) if (!credential) { - // TODO: display localized error message to user - console.error('No credential returned') - return + throw new Error('Error getting passkey credential:', credential) } // Encode credential before sending to SLAS const encodedCredential = { id: credential.id, - rawId: uint8arrayToBase64url(new Uint8Array(credential.rawId)), + rawId: uint8arrayToBase64url(credential.rawId), type: credential.type, clientExtensionResults: credential.getClientExtensionResults(), response: { - authenticatorData: uint8arrayToBase64url( - new Uint8Array(credential.response.authenticatorData) - ), - clientDataJSON: uint8arrayToBase64url( - new Uint8Array(credential.response.clientDataJSON) - ), - signature: uint8arrayToBase64url(new Uint8Array(credential.response.signature)), - userHandle: uint8arrayToBase64url(new Uint8Array(credential.response.userHandle)) + authenticatorData: uint8arrayToBase64url(credential.response.authenticatorData), + clientDataJSON: uint8arrayToBase64url(credential.response.clientDataJSON), + signature: uint8arrayToBase64url(credential.response.signature), + userHandle: uint8arrayToBase64url(credential.response.userHandle) } } @@ -105,27 +85,13 @@ export const usePasskeyLogin = () => { console.log('finishWebauthnAuthenticationResponse ->', finishWebauthnAuthenticationResponse) if (!finishWebauthnAuthenticationResponse) { - // TODO: display localized error message to user - console.error( + throw new Error( 'Error finishing passkey authentication:', finishWebauthnAuthenticationResponse ) - return } - return } - // Helper functions for base64url encoding/decoding - const base64urlToUint8Array = (base64url) => { - const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') - return new Uint8Array(base64Decode(base64)) - } - - const uint8arrayToBase64url = (uint8array) => { - const base64 = base64Encode(uint8array.buffer) - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') - } - - return {startPasskeyLogin} + return {loginWithPasskey} } diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js new file mode 100644 index 0000000000..7b826b81c7 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js @@ -0,0 +1,221 @@ +/* + * 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 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 {usePasskeyLogin} from '@salesforce/retail-react-app/app/hooks/use-passkey-login' +import mockConfig from '@salesforce/retail-react-app/config/mocks/default' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' + +const mockCredential = { + id: 'test-credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + getClientExtensionResults: () => ({}), + toJSON: () => ({ + id: 'test-credential-id', + rawId: 'AAAAAAAAAAA', + type: 'public-key', + clientExtensionResults: {}, + response: { + authenticatorData: 'AAAAAAAAAAA', + clientDataJSON: 'AAAAAAAAAAA', + signature: 'AAAAAAAAAAA', + userHandle: 'AAAAAAAAAAA' + } + }), + response: { + authenticatorData: new ArrayBuffer(8), + clientDataJSON: new ArrayBuffer(8), + signature: new ArrayBuffer(8), + userHandle: new ArrayBuffer(8) + } +} + +const mockStartWebauthnAuthenticationResponse = { + publicKey: { + challenge: 'DZdUeRgEm5m1D8Fqp8pzZZesdHkf1Pqoe-MqCA8gVw8', + timeout: 60000, + rpId: 'localhost', + allowCredentials: [ + { + id: 'test-credential-id', + type: 'public-key', + transports: [] + } + ] + } +} + +const mockFinishWebauthnAuthenticationResponse = { + tokenResponse: { + access_token: 'test-access-token', + customer_id: 'test-customer-id', + refresh_token: 'test-refresh-token', + usid: 'test-usid' + } +} + +// Mock getConfig to enable passkey +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: jest.fn() +})) + +// Mock commerce-sdk-react +jest.mock('@salesforce/commerce-sdk-react', () => { + const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...originalModule, + useAuthHelper: jest.fn() + } +}) + +// Mock WebAuthn APIs +const mockGetCredentials = jest.fn() +// Mock PublicKeyCredential static methods +const mockIsConditionalMediationAvailable = jest.fn() +const mockParseRequestOptionsFromJSON = jest.fn() + +const startWebauthnAuthentication = {mutateAsync: jest.fn()} +const finishWebauthnAuthentication = {mutateAsync: jest.fn()} + +useAuthHelper.mockImplementation((param) => { + if (param === AuthHelpers.StartWebauthnAuthentication) { + return startWebauthnAuthentication + } else if (param === AuthHelpers.FinishWebauthnAuthentication) { + return finishWebauthnAuthentication + } +}) + +const MockComponent = () => { + const {loginWithPasskey} = usePasskeyLogin() + return ( +
+
+ ) +} + +describe('usePasskeyLogin', () => { + beforeEach(() => { + jest.clearAllMocks() + + getConfig.mockReturnValue(mockConfig) + + // Mock PublicKeyCredential with static methods + const mockPublicKeyCredential = { + isConditionalMediationAvailable: mockIsConditionalMediationAvailable, + parseRequestOptionsFromJSON: mockParseRequestOptionsFromJSON + } + global.window.PublicKeyCredential = mockPublicKeyCredential + global.PublicKeyCredential = mockPublicKeyCredential + + // Default mock implementations for PublicKeyCredential static methods + mockIsConditionalMediationAvailable.mockResolvedValue(true) + // parseRequestOptionsFromJSON should return parsed options, not a credential + mockParseRequestOptionsFromJSON.mockReturnValue({ + challenge: mockStartWebauthnAuthenticationResponse.publicKey.challenge, + timeout: mockStartWebauthnAuthenticationResponse.publicKey.timeout, + rpId: mockStartWebauthnAuthenticationResponse.publicKey.rpId + }) + + // Mock navigator.credentials.get + global.navigator.credentials = { + get: mockGetCredentials + } + + // Mock navigator.credentials.get to return a mock credential + mockGetCredentials.mockResolvedValue(mockCredential) + + startWebauthnAuthentication.mutateAsync.mockResolvedValue( + mockStartWebauthnAuthenticationResponse + ) + + finishWebauthnAuthentication.mutateAsync.mockResolvedValue( + mockFinishWebauthnAuthenticationResponse + ) + }) + + test('calls webauthn authenticate start and finish endpoints when all conditions are met', async () => { + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => { + expect(startWebauthnAuthentication.mutateAsync).toHaveBeenCalledWith({}) + expect(mockGetCredentials).toHaveBeenCalled() + expect(finishWebauthnAuthentication.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + credential: expect.objectContaining({ + id: 'test-credential-id', + rawId: expect.any(String), + type: 'public-key', + clientExtensionResults: {}, + response: expect.objectContaining({ + authenticatorData: expect.any(String), + clientDataJSON: expect.any(String), + signature: expect.any(String), + userHandle: expect.any(String) + }) + }) + }) + ) + }) + }) + + test('does not call startWebauthnAuthentication API when passkey is not enabled', async () => { + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: { + enabled: false + } + } + } + }) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + expect(startWebauthnAuthentication.mutateAsync).not.toHaveBeenCalled() + }) + + test('does not start passkey login when PublicKeyCredential is not available', async () => { + delete global.window.PublicKeyCredential + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + expect(startWebauthnAuthentication.mutateAsync).not.toHaveBeenCalled() + expect(mockGetCredentials).not.toHaveBeenCalled() + }) + + test('does not start passkey login when conditional mediation is not available', async () => { + mockIsConditionalMediationAvailable.mockResolvedValue(false) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => { + expect(mockIsConditionalMediationAvailable).toHaveBeenCalled() + }) + + expect(startWebauthnAuthentication.mutateAsync).not.toHaveBeenCalled() + expect(mockGetCredentials).not.toHaveBeenCalled() + }) +}) From dfbd5bbc041f00f2292cafa53a71208861cf922f Mon Sep 17 00:00:00 2001 From: Jinsu Ha Date: Tue, 20 Jan 2026 15:14:27 -0500 Subject: [PATCH 13/65] rename startPasskeyLogin to loginWithPasskey in contact-info --- .../app/pages/checkout/partials/contact-info.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 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 51cca2b77e..c2c80c879c 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 @@ -64,7 +64,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket') const mergeBasket = useShopperBasketsMutation('mergeBasket') - const {startPasskeyLogin} = usePasskeyLogin() + const {loginWithPasskey} = usePasskeyLogin() const {step, STEPS, goToStep, goToNextStep} = useCheckout() @@ -161,7 +161,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id }, [showPasswordField]) useEffect(() => { - startPasskeyLogin() + loginWithPasskey() }, []) const onPasswordlessLoginClick = async (e) => { From 22df1c16a8ba996d7cfe1c56ecec4584b9261fe9 Mon Sep 17 00:00:00 2001 From: Yuna Kim <84923642+yunakim714@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:43:32 -0500 Subject: [PATCH 14/65] @W-20474693 - [WebauthN] Passkey Registration (#3571) Co-authored-by: Jinsu Ha --- packages/commerce-sdk-react/package-lock.json | 51 +- packages/commerce-sdk-react/package.json | 2 +- .../app/components/_app/index.jsx | 266 ++--- .../app/components/otp-auth/index.jsx | 356 +++++++ .../app/components/otp-auth/index.test.js | 957 ++++++++++++++++++ .../passkey-registration-modal/index.jsx | 181 ++++ .../passkey-registration-modal/index.test.js | 203 ++++ .../app/contexts/index.js | 5 + .../passkey-registration-provider.jsx | 46 + .../passkey-registration-provider.test.jsx | 188 ++++ .../app/hooks/use-auth-modal.js | 30 +- .../app/hooks/use-countdown.js | 20 + .../app/hooks/use-otp-inputs.js | 59 ++ .../app/hooks/use-passkey-registration.js | 81 ++ .../hooks/use-passkey-registration.test.js | 216 ++++ .../app/pages/account/index.jsx | 1 + .../app/pages/checkout/index.jsx | 5 +- .../app/pages/login/index.jsx | 38 +- .../app/pages/registration/index.jsx | 26 + .../static/translations/compiled/en-GB.json | 132 +++ .../static/translations/compiled/en-US.json | 132 +++ .../static/translations/compiled/en-XA.json | 276 +++++ .../config/mocks/default.js | 5 + .../translations/en-GB.json | 54 + .../translations/en-US.json | 54 + 25 files changed, 3200 insertions(+), 184 deletions(-) create mode 100644 packages/template-retail-react-app/app/components/otp-auth/index.jsx create mode 100644 packages/template-retail-react-app/app/components/otp-auth/index.test.js create mode 100644 packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx create mode 100644 packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js create mode 100644 packages/template-retail-react-app/app/contexts/passkey-registration-provider.jsx create mode 100644 packages/template-retail-react-app/app/contexts/passkey-registration-provider.test.jsx create mode 100644 packages/template-retail-react-app/app/hooks/use-countdown.js create mode 100644 packages/template-retail-react-app/app/hooks/use-otp-inputs.js create mode 100644 packages/template-retail-react-app/app/hooks/use-passkey-registration.js create mode 100644 packages/template-retail-react-app/app/hooks/use-passkey-registration.test.js diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index f133077044..1d782bc4fe 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -9,7 +9,7 @@ "version": "4.4.0-dev", "license": "See license in LICENSE", "dependencies": { - "commerce-sdk-isomorphic": "4.2.0", + "commerce-sdk-isomorphic": "4.0.0-unstable-20260116080750", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, @@ -920,13 +920,12 @@ "license": "MIT" }, "node_modules/commerce-sdk-isomorphic": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-4.2.0.tgz", - "integrity": "sha512-DB2nP5nuCG3o2hFqUkx4NR/8nLeoTVY/RVYkN8Y8ALv3EnLL5iFUa2cohnxBxMz/+dB52DqS2grgZXWHAinMww==", + "version": "4.0.0-unstable-20260116080750", + "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-4.0.0-unstable-20260116080750.tgz", + "integrity": "sha512-quJaOMQ/TC/Dc+iSYylgJza5V4IeRGqo0DQHyIpS1UyV4boEsk0+rhYKZxSiX0P2TG4rZpYN9sw9AkGSN0VKQw==", "license": "BSD-3-Clause", "dependencies": { "nanoid": "^3.3.8", - "node-fetch": "2.6.13", "seedrandom": "^3.0.5" }, "engines": { @@ -2479,26 +2478,6 @@ "node": ">= 10.13" } }, - "node_modules/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/nodemon": { "version": "2.0.22", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", @@ -3471,12 +3450,6 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/typedoc": { "version": "0.24.8", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.24.8.tgz", @@ -3587,22 +3560,6 @@ "dev": true, "license": "MIT" }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index 024b68e4db..396995cf48 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -40,7 +40,7 @@ "version": "node ./scripts/version.js" }, "dependencies": { - "commerce-sdk-isomorphic": "4.2.0", + "commerce-sdk-isomorphic": "4.0.0-unstable-20260116080750", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx index 34e310bfb0..dc04f6d89c 100644 --- a/packages/template-retail-react-app/app/components/_app/index.jsx +++ b/packages/template-retail-react-app/app/components/_app/index.jsx @@ -33,7 +33,10 @@ import { import {SkipNavLink, SkipNavContent} from '@chakra-ui/skip-nav' // Contexts -import {CurrencyProvider} from '@salesforce/retail-react-app/app/contexts' +import { + CurrencyProvider, + PasskeyRegistrationProvider +} from '@salesforce/retail-react-app/app/contexts' // Local Project Components import Header from '@salesforce/retail-react-app/app/components/header' @@ -325,144 +328,155 @@ const App = (props) => { defaultLocale={DEFAULT_LOCALE} > - - - - - - - {/* Urls for all localized versions of this page (including current page) + + + + + + + + {/* Urls for all localized versions of this page (including current page) For more details on hrefLang, see https://developers.google.com/search/docs/advanced/crawling/localized-versions */} - {site.l10n?.supportedLocales.map((locale) => ( + {site.l10n?.supportedLocales.map((locale) => ( + + ))} + {/* A general locale as fallback. For example: "en" if default locale is "en-GB" */} - ))} - {/* A general locale as fallback. For example: "en" if default locale is "en-GB" */} - - {/* A wider fallback for user locales that the app does not support */} - - - - {commerceAgentConfiguration?.enabled === 'true' && ( - 0} - /> - )} - - - - - Skip to Content - {storeLocatorEnabled && ( - + + + {commerceAgentConfiguration?.enabled === 'true' && ( + 0} /> )} - - - {!isCheckout ? ( - <> - -
- - - - - - - -
- - ) : ( - - )} -
-
- {!isOnline && } - - - - + + + Skip to Content + {storeLocatorEnabled && ( + + )} + + + {!isCheckout ? ( + <> + +
+ + + + + + + +
+ + ) : ( + + )} +
+
+ {!isOnline && } + + + - - {children} - -
-
- - - {!isCheckout ?