-
Notifications
You must be signed in to change notification settings - Fork 52
feat(pay): add WebView for hosted collect data form #367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| import { useRef, useState, useCallback, useEffect } from 'react'; | ||
| import { View, StyleSheet, ActivityIndicator } from 'react-native'; | ||
| import { WebView, WebViewNavigation } from 'react-native-webview'; | ||
|
|
||
| import { useTheme } from '@/hooks/useTheme'; | ||
| import { Text } from '@/components/Text'; | ||
| import { Spacing } from '@/utils/ThemeUtil'; | ||
| import LogStore from '@/store/LogStore'; | ||
|
|
||
| interface CollectDataWebViewProps { | ||
| url: string; | ||
| onComplete: () => void; | ||
| onError: (error: string) => void; | ||
| } | ||
|
|
||
| export function CollectDataWebView({ | ||
| url, | ||
| onComplete, | ||
| onError, | ||
| }: CollectDataWebViewProps) { | ||
| const Theme = useTheme(); | ||
| const webViewRef = useRef<WebView>(null); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const isMountedRef = useRef(true); | ||
|
|
||
| useEffect(() => { | ||
| isMountedRef.current = true; | ||
| return () => { | ||
| isMountedRef.current = false; | ||
| LogStore.log( | ||
| 'WebView unmounted', | ||
| 'CollectDataWebView', | ||
| 'cleanup', | ||
| ); | ||
| }; | ||
| }, []); | ||
|
|
||
| const handleNavigationStateChange = useCallback( | ||
| (navState: WebViewNavigation) => { | ||
| if (!isMountedRef.current) return; | ||
|
|
||
| LogStore.log( | ||
| 'WebView navigation', | ||
| 'CollectDataWebView', | ||
| 'handleNavigationStateChange', | ||
| { url: navState.url }, | ||
| ); | ||
|
|
||
| if ( | ||
| navState.url.includes('/success') || | ||
| navState.url.includes('/complete') | ||
| ) { | ||
| LogStore.log( | ||
| 'Data collection completed', | ||
| 'CollectDataWebView', | ||
| 'handleNavigationStateChange', | ||
| ); | ||
| onComplete(); | ||
| } | ||
| }, | ||
| [onComplete], | ||
| ); | ||
|
|
||
| const handleError = useCallback( | ||
| (syntheticEvent: { nativeEvent: { description: string } }) => { | ||
| if (!isMountedRef.current) return; | ||
|
|
||
| const { description } = syntheticEvent.nativeEvent; | ||
| LogStore.error('WebView error', 'CollectDataWebView', 'handleError', { | ||
| error: description, | ||
| }); | ||
| onError(description || 'Failed to load the form'); | ||
| }, | ||
| [onError], | ||
| ); | ||
|
|
||
| const handleMessage = useCallback( | ||
| (event: { nativeEvent: { data: string } }) => { | ||
| if (!isMountedRef.current) return; | ||
|
|
||
| try { | ||
| const message = JSON.parse(event.nativeEvent.data) as { | ||
| type: 'IC_COMPLETE' | 'IC_ERROR'; | ||
| success: boolean; | ||
| error?: string; | ||
| }; | ||
| LogStore.log( | ||
| 'WebView message received', | ||
| 'CollectDataWebView', | ||
| 'handleMessage', | ||
| { message }, | ||
| ); | ||
|
|
||
| if (message.type === 'IC_COMPLETE' && message.success) { | ||
| onComplete(); | ||
| } else if (message.type === 'IC_ERROR' || !message.success) { | ||
| onError(message.error || 'Form submission failed'); | ||
| } | ||
| } catch { | ||
| LogStore.log( | ||
| 'WebView message (non-JSON)', | ||
| 'CollectDataWebView', | ||
| 'handleMessage', | ||
| { data: event.nativeEvent.data }, | ||
| ); | ||
| } | ||
| }, | ||
| [onComplete, onError], | ||
| ); | ||
|
|
||
| return ( | ||
| <View style={styles.container}> | ||
| {isLoading && ( | ||
| <View | ||
| style={[ | ||
| styles.loadingOverlay, | ||
| { backgroundColor: Theme['bg-primary'] }, | ||
| ]} | ||
| > | ||
| <ActivityIndicator size="large" color={Theme['bg-accent-primary']} /> | ||
| <Text variant="md-400" color="text-secondary" style={styles.loadingText}> | ||
| Loading form... | ||
| </Text> | ||
| </View> | ||
| )} | ||
| <WebView | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Auto Review Issue: WebView security - missing origin whitelist Severity: HIGH Context:
Recommendation: Add origin whitelist to restrict allowed domains: <WebView
ref={webViewRef}
source={{ uri: url }}
originWhitelist={['https://pay.walletconnect.com', 'https://pay.reown.com']}
// ... rest of props
/> |
||
| ref={webViewRef} | ||
| source={{ uri: url }} | ||
| style={[styles.webView, { backgroundColor: Theme['bg-primary'] }]} | ||
| onLoadStart={() => setIsLoading(true)} | ||
| onLoadEnd={() => setIsLoading(false)} | ||
| onNavigationStateChange={handleNavigationStateChange} | ||
| onError={handleError} | ||
| onMessage={handleMessage} | ||
| javaScriptEnabled | ||
| domStorageEnabled | ||
| startInLoadingState | ||
| scalesPageToFit | ||
| showsVerticalScrollIndicator={false} | ||
| /> | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| const styles = StyleSheet.create({ | ||
| container: { | ||
| flex: 1, | ||
| }, | ||
| webView: { | ||
| flex: 1, | ||
| }, | ||
| loadingOverlay: { | ||
| ...StyleSheet.absoluteFillObject, | ||
| justifyContent: 'center', | ||
| alignItems: 'center', | ||
| zIndex: 1, | ||
| }, | ||
| loadingText: { | ||
| marginTop: Spacing[4], | ||
| }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ import type { | |
| import { LoadingView } from './LoadingView'; | ||
| import { IntroView } from './IntroView'; | ||
| import { CollectDataView } from './CollectDataView'; | ||
| import { CollectDataWebView } from './CollectDataWebView'; | ||
| import { ConfirmPaymentView } from './ConfirmPaymentView'; | ||
| import { ResultView } from './ResultView'; | ||
| import { ViewWrapper } from './ViewWrapper'; | ||
|
|
@@ -40,6 +41,10 @@ export default function PaymentOptionsModal() { | |
| // Derived values | ||
| const hasCollectData = | ||
| paymentData?.collectData && paymentData.collectData.fields.length > 0; | ||
| // The `url` property is a new addition to the API - cast to access it | ||
| const collectDataUrl = ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Auto Review Issue: Type safety - unsafe type casting Severity: LOW Context:
Recommendation: Extend type properly or add runtime check: // Option 1: Type extension (preferred)
interface CollectDataWithUrl extends CollectData {
url?: string;
}
const collectDataUrl = (paymentData?.collectData as CollectDataWithUrl | undefined)?.url;
// Option 2: Add comment explaining temporary nature
const collectDataUrl = (
// TEMP: url not yet in @walletconnect/pay types v1.x
paymentData?.collectData as { url?: string } | undefined
)?.url;SummaryFunctional implementation with good error handling and UX flow. Critical security concern: WebView lacks origin restrictions allowing arbitrary URLs. Add |
||
| paymentData?.collectData as { url?: string } | undefined | ||
| )?.url; | ||
|
|
||
| // Transition from loading to intro when data is available | ||
| useEffect(() => { | ||
|
|
@@ -96,18 +101,50 @@ export default function PaymentOptionsModal() { | |
| case 'collectData': | ||
| dispatch({ type: 'SET_STEP', payload: 'intro' }); | ||
| break; | ||
| case 'collectDataWebView': | ||
| // Closing WebView means form was completed, move to confirm step | ||
| dispatch({ type: 'SET_STEP', payload: 'confirm' }); | ||
| break; | ||
| case 'confirm': | ||
| dispatch({ type: 'CLEAR_SELECTED_OPTION' }); | ||
| dispatch({ | ||
| type: 'SET_STEP', | ||
| payload: hasCollectData ? 'collectData' : 'intro', | ||
| }); | ||
| if (hasCollectData) { | ||
| // When going back from confirm, go to intro (not back to WebView) | ||
| dispatch({ type: 'SET_STEP', payload: 'intro' }); | ||
| } else { | ||
| dispatch({ type: 'SET_STEP', payload: 'intro' }); | ||
| } | ||
| break; | ||
| default: | ||
| onClose(); | ||
| } | ||
| }, [state.step, hasCollectData, onClose]); | ||
|
|
||
| const handleWebViewComplete = useCallback(() => { | ||
| LogStore.log( | ||
| 'WebView data collection completed', | ||
| 'PaymentOptionsModal', | ||
| 'handleWebViewComplete', | ||
| ); | ||
| dispatch({ type: 'SET_STEP', payload: 'confirm' }); | ||
| }, []); | ||
|
|
||
| const handleWebViewError = useCallback((error: string) => { | ||
| LogStore.error( | ||
| 'WebView data collection error', | ||
| 'PaymentOptionsModal', | ||
| 'handleWebViewError', | ||
| { error }, | ||
| ); | ||
| dispatch({ | ||
| type: 'SET_RESULT', | ||
| payload: { | ||
| status: 'error', | ||
| message: error || 'Failed to complete data collection', | ||
| }, | ||
| }); | ||
| dispatch({ type: 'SET_STEP', payload: 'result' }); | ||
| }, []); | ||
|
|
||
| const updateCollectedField = useCallback( | ||
| (fieldId: string, value: string, fieldType?: string) => { | ||
| const formattedValue = | ||
|
|
@@ -206,11 +243,16 @@ export default function PaymentOptionsModal() { | |
| return; | ||
| } | ||
|
|
||
| dispatch({ | ||
| type: 'SET_STEP', | ||
| payload: hasCollectData ? 'collectData' : 'confirm', | ||
| }); | ||
| }, [hasCollectData, paymentData?.options]); | ||
| if (hasCollectData) { | ||
| // Use WebView if URL is available, otherwise use native form | ||
| dispatch({ | ||
| type: 'SET_STEP', | ||
| payload: collectDataUrl ? 'collectDataWebView' : 'collectData', | ||
| }); | ||
| } else { | ||
| dispatch({ type: 'SET_STEP', payload: 'confirm' }); | ||
| } | ||
| }, [hasCollectData, collectDataUrl, paymentData?.options]); | ||
|
|
||
| const handleCollectDataNext = useCallback(() => { | ||
| if (!paymentData?.collectData) { | ||
|
|
@@ -522,6 +564,15 @@ export default function PaymentOptionsModal() { | |
| /> | ||
| ); | ||
|
|
||
| case 'collectDataWebView': | ||
| return ( | ||
| <CollectDataWebView | ||
| url={collectDataUrl!} | ||
| onComplete={handleWebViewComplete} | ||
| onError={handleWebViewError} | ||
| /> | ||
| ); | ||
|
|
||
| case 'confirm': | ||
| return ( | ||
| <ConfirmPaymentView | ||
|
|
@@ -567,6 +618,9 @@ export default function PaymentOptionsModal() { | |
| handleIntroNext, | ||
| updateCollectedField, | ||
| handleCollectDataNext, | ||
| collectDataUrl, | ||
| handleWebViewComplete, | ||
| handleWebViewError, | ||
| onSelectOption, | ||
| onApprovePayment, | ||
| onClose, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 Auto Review Issue: postMessage validation insufficient
Severity: MEDIUM
Category: security
Tool: Claude Auto Review
Context:
handleMessageonly validates message structure, not originRecommendation: Validate message origin before processing: