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
0 commit comments