Skip to content

Commit 33732bd

Browse files
committed
Add code stubs from SPIKE
1 parent ee8bc68 commit 33732bd

File tree

3 files changed

+266
-2
lines changed

3 files changed

+266
-2
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* Copyright (c) 2026, salesforce.com, inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import React, {useState, useEffect} from 'react'
9+
import PropTypes from 'prop-types'
10+
import {useIntl} from 'react-intl'
11+
import {decode as base64Decode, encode as base64Encode} from 'base64-arraybuffer'
12+
import {
13+
Button,
14+
FormControl,
15+
FormLabel,
16+
Input,
17+
Modal,
18+
ModalBody,
19+
ModalCloseButton,
20+
ModalContent,
21+
ModalHeader,
22+
ModalOverlay,
23+
Alert,
24+
AlertIcon,
25+
Text
26+
} from '@salesforce/retail-react-app/app/components/shared/ui'
27+
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
28+
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
29+
30+
/**
31+
* Convert base64url string to Uint8Array
32+
* WebAuthn requires binary data, but API returns base64url strings
33+
*/
34+
const base64urlToUint8Array = (base64url) => {
35+
// Add padding and convert base64url to base64
36+
const padding = '===='.substring(0, (4 - (base64url.length % 4)) % 4)
37+
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/')
38+
return new Uint8Array(base64Decode(base64))
39+
}
40+
41+
/**
42+
* Convert Uint8Array to base64url string
43+
* Server expects base64url strings, not binary data
44+
*/
45+
const uint8arrayToBase64url = (bytes) => {
46+
const uint8array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
47+
return base64Encode(uint8array.buffer).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
48+
}
49+
50+
/**
51+
* Modal for registering a new passkey with a nickname
52+
*/
53+
const PasskeyRegistrationModal = ({isOpen, onClose}) => {
54+
const {formatMessage} = useIntl()
55+
const [passkeyNickname, setPasskeyNickname] = useState('')
56+
const [isLoading, setIsLoading] = useState(false)
57+
const [error, setError] = useState(null)
58+
const {data: customer} = useCurrentCustomer()
59+
60+
const config = getConfig()
61+
const commerceAPI = config.app.commerceAPI
62+
const webauthnConfig = config.app.login.webauthn
63+
64+
// Hardcoded values for local SLAS development
65+
// TODO: Move these to config or let them be pulled in automatically by Commerce SDK
66+
const CLIENT_ID = 'd6ae9df8-e13f-48f4-a413-b9820d9a39bc'
67+
const CLIENT_SECRET = '9MBWoGTfPmUsm9ityrAN'
68+
const TENANT_ID = 'bldm_stg'
69+
const CALLBACK_URI = 'http://localhost:9010/callback'
70+
const SITE_ID = 'SiteGenesis'
71+
72+
const handleResendCode = async () => {
73+
await handleRegisterPasskey()
74+
}
75+
76+
const handleRegisterPasskey = async () => {
77+
setIsLoading(true)
78+
setError(null)
79+
80+
// THE API CALL TO /oauth2/webauthn/register/authorize SHOULD BE REPLACED BY A COMMERCE SDK CALL
81+
try {
82+
const params = new URLSearchParams({
83+
channel_id: SITE_ID,
84+
user_id: customer.email,
85+
mode: 'callback',
86+
client_id: CLIENT_ID,
87+
callback_uri: CALLBACK_URI
88+
})
89+
90+
console.log('webauthn/register/authorize params:', params.toString())
91+
92+
await fetch(
93+
`http://localhost:9020/api/v1/organizations/${TENANT_ID}/oauth2/webauthn/register/authorize`,
94+
{
95+
method: 'POST',
96+
headers: {
97+
'Content-Type': 'application/x-www-form-urlencoded',
98+
'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`
99+
},
100+
body: params.toString()
101+
}
102+
)
103+
104+
console.log('Passkey registration initiated. Check SLAS for OTP')
105+
console.log('Passkey nickname:', passkeyNickname)
106+
107+
// Move to verification step
108+
setStep('verification')
109+
} catch (err) {
110+
console.error('Error authorizing passkey registration:', err)
111+
setError(err.message || 'Failed to authorize passkey registration')
112+
} finally {
113+
setIsLoading(false)
114+
}
115+
}
116+
117+
const resetState = () => {
118+
setStep('register')
119+
setPasskeyNickname('')
120+
setVerificationCode('')
121+
setError(null)
122+
}
123+
124+
const handleClose = () => {
125+
resetState()
126+
onClose()
127+
}
128+
129+
// Reset state when modal opens
130+
useEffect(() => {
131+
if (isOpen) {
132+
resetState()
133+
}
134+
}, [isOpen])
135+
136+
return (
137+
<Modal isOpen={isOpen} onClose={handleClose} size="md">
138+
<ModalOverlay />
139+
<ModalContent>
140+
<ModalHeader>
141+
{formatMessage({
142+
defaultMessage: 'Create Passkey',
143+
id: 'auth_modal.passkey.title'
144+
})}
145+
</ModalHeader>
146+
<ModalCloseButton
147+
aria-label={formatMessage({
148+
id: 'auth_modal.passkey.button.close.assistive_msg',
149+
defaultMessage: 'Close passkey form'
150+
})}
151+
/>
152+
<ModalBody pb={6}>
153+
{error && (
154+
<Alert status="error" mb={4}>
155+
<AlertIcon />
156+
{error}
157+
</Alert>
158+
)}
159+
160+
<FormControl>
161+
<FormLabel>
162+
{formatMessage({
163+
defaultMessage: 'Passkey Nickname',
164+
id: 'auth_modal.passkey.label.nickname'
165+
})}
166+
</FormLabel>
167+
<Input
168+
placeholder="e.g., 'iPhone', 'Personal Laptop'"
169+
value={passkeyNickname}
170+
onChange={(e) => setPasskeyNickname(e.target.value)}
171+
mb={4}
172+
isDisabled={isLoading}
173+
/>
174+
<Button
175+
width="full"
176+
colorScheme="blue"
177+
onClick={handleRegisterPasskey}
178+
isLoading={isLoading}
179+
loadingText="Registering..."
180+
>
181+
{formatMessage({
182+
defaultMessage: 'Register Passkey',
183+
id: 'auth_modal.passkey.button.register'
184+
})}
185+
</Button>
186+
</FormControl>
187+
188+
</ModalBody>
189+
</ModalContent>
190+
</Modal>
191+
)
192+
}
193+
194+
PasskeyRegistrationModal.propTypes = {
195+
isOpen: PropTypes.bool.isRequired,
196+
onClose: PropTypes.func.isRequired
197+
}
198+
199+
export default PasskeyRegistrationModal

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ export const AuthModal = ({
240240
onClose()
241241

242242
// Show a toast only for those registed users returning to the site.
243-
if (loggingIn) {
243+
// Only show toast when customer data is available (user is logged in and data is loaded)
244+
if (loggingIn && customer.data) {
244245
toast({
245246
variant: 'subtle',
246247
title: `${formatMessage(
@@ -269,7 +270,7 @@ export const AuthModal = ({
269270
// Execute action to be performed on successful registration
270271
onRegistrationSuccess()
271272
}
272-
}, [isRegistered])
273+
}, [isRegistered, customer.data])
273274

274275
const onBackToSignInClick = () =>
275276
initialView === PASSWORD_VIEW ? onClose() : setCurrentView(LOGIN_VIEW)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright (c) 2026, salesforce.com, inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import React from 'react'
9+
import {
10+
Box,
11+
Button,
12+
useDisclosure,
13+
useToast
14+
} from '@salesforce/retail-react-app/app/components/shared/ui'
15+
16+
/**
17+
* Custom hook to manage passkey registration prompt (toast and modal)
18+
* @returns {Object} Object containing showToast function and passkey modal state
19+
*/
20+
export const usePasskeyRegistration = () => {
21+
const toast = useToast()
22+
const passkeyModal = useDisclosure()
23+
24+
const showToast = () => {
25+
toast({
26+
position: 'top-right',
27+
duration: 9000,
28+
isClosable: true,
29+
render: () => (
30+
<Box
31+
color="white"
32+
p={4}
33+
bg="green.500"
34+
borderRadius="md"
35+
boxShadow="lg"
36+
maxWidth="400px"
37+
>
38+
<Box mb={3} fontWeight="medium">
39+
Account successfully created! Create a passkey for more secure and easier
40+
login next time
41+
</Box>
42+
<Button
43+
size="sm"
44+
colorScheme="whiteAlpha"
45+
onClick={() => {
46+
toast.closeAll()
47+
passkeyModal.onOpen()
48+
}}
49+
>
50+
Create Passkey
51+
</Button>
52+
</Box>
53+
)
54+
})
55+
}
56+
57+
return {
58+
showToast,
59+
passkeyModal: {
60+
isOpen: passkeyModal.isOpen,
61+
onClose: passkeyModal.onClose
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)