From 09d4ba46fa0307e956320407a8be559b0ef40fb6 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 16 Nov 2025 22:18:52 +0100 Subject: [PATCH] Update qr-scanner.tsx UX flow (#1347) --- .../app/(tabs)/settings/_layout.tsx | 14 + .../app/(tabs)/settings/qr-confirm.tsx | 245 ++++++++++++ .../app/(tabs)/settings/qr-result.tsx | 110 ++++++ .../app/(tabs)/settings/qr-scanner.tsx | 372 +++--------------- apps/mobile-app/app/_layout.tsx | 3 +- .../components/LoadingIndicator.tsx | 9 +- 6 files changed, 424 insertions(+), 329 deletions(-) create mode 100644 apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx create mode 100644 apps/mobile-app/app/(tabs)/settings/qr-result.tsx diff --git a/apps/mobile-app/app/(tabs)/settings/_layout.tsx b/apps/mobile-app/app/(tabs)/settings/_layout.tsx index 99dede09f..98150d83e 100644 --- a/apps/mobile-app/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/settings/_layout.tsx @@ -133,6 +133,20 @@ export default function SettingsLayout(): React.ReactNode { ...defaultHeaderOptions, }} /> + + ); } \ No newline at end of file diff --git a/apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx b/apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx new file mode 100644 index 000000000..2c05b2453 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx @@ -0,0 +1,245 @@ +import { router, useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { View, Alert, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useColors } from '@/hooks/useColorScheme'; +import { useTranslation } from '@/hooks/useTranslation'; + +import LoadingIndicator from '@/components/LoadingIndicator'; +import { ThemedButton } from '@/components/themed/ThemedButton'; +import { ThemedContainer } from '@/components/themed/ThemedContainer'; +import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; +import { ThemedText } from '@/components/themed/ThemedText'; +import { useApp } from '@/context/AppContext'; +import { useWebApi } from '@/context/WebApiContext'; +import NativeVaultManager from '@/specs/NativeVaultManager'; + +/** + * QR Code confirmation screen for mobile unlock. + */ +export default function QRConfirmScreen() : React.ReactNode { + const colors = useColors(); + const { t } = useTranslation(); + const { username } = useApp(); + const webApi = useWebApi(); + const insets = useSafeAreaInsets(); + const { requestId } = useLocalSearchParams<{ requestId: string }>(); + + const [isProcessing, setIsProcessing] = useState(false); + + /** + * Handle mobile unlock QR code. + */ + const handleMobileUnlock = async (requestId: string) : Promise => { + try { + // Fetch the public key from server + const response = await webApi.authFetch<{ clientPublicKey: string }>( + `auth/mobile-unlock/request/${requestId}`, + { method: 'GET' } + ); + + const publicKeyJWK = response.clientPublicKey; + + // Encrypt the decryption key using native module + const encryptedKey = await NativeVaultManager.encryptDecryptionKeyForMobileUnlock(publicKeyJWK); + + // Submit the encrypted key to the server + await webApi.authFetch( + 'auth/mobile-unlock/submit', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + requestId, + encryptedDecryptionKey: encryptedKey, + username: username, + }), + } + ); + + // Success! Navigate to result page + router.replace({ + pathname: '/(tabs)/settings/qr-result', + params: { + success: 'true', + message: t('settings.qrScanner.mobileUnlock.successDescription'), + }, + }); + } catch (error) { + console.error('Mobile unlock error:', error); + let errorMsg = t('settings.qrScanner.mobileUnlock.genericError'); + + if (error instanceof Error) { + if (error.message.includes('ENCRYPTION_ERROR')) { + errorMsg = t('settings.qrScanner.mobileUnlock.vaultLocked'); + } else if (error.message.includes('404')) { + errorMsg = t('settings.qrScanner.mobileUnlock.requestExpired'); + } else if (error.message.includes('401') || error.message.includes('403')) { + errorMsg = t('settings.qrScanner.mobileUnlock.unauthorized'); + } + } + + // Error! Navigate to result page + router.replace({ + pathname: '/(tabs)/settings/qr-result', + params: { + success: 'false', + message: errorMsg, + }, + }); + } + }; + + /** + * Handle confirmation - authenticate user first, then process the scanned QR code. + */ + const handleConfirm = async () : Promise => { + if (!requestId) { + return; + } + + setIsProcessing(true); + + try { + let authenticated = false; + + // Check which authentication method is available + const pinEnabled = await NativeVaultManager.isPinEnabled(); + + if (pinEnabled) { + // PIN is enabled, use PIN unlock + try { + await NativeVaultManager.showPinUnlock(); + authenticated = true; + } catch (pinError: any) { + // User cancelled PIN or PIN failed + console.log('PIN unlock cancelled or failed:', pinError); + Alert.alert( + t('common.error'), + t('settings.qrScanner.mobileUnlock.authenticationFailed') + ); + setIsProcessing(false); + return; + } + } else { + // Try biometric authentication + try { + authenticated = await NativeVaultManager.authenticateUser( + t('settings.qrScanner.mobileUnlock.authenticationRequired') + ); + } catch (authError: any) { + console.error('Biometric authentication error:', authError); + Alert.alert( + t('common.error'), + t('settings.qrScanner.mobileUnlock.authenticationFailed') + ); + setIsProcessing(false); + return; + } + } + + if (!authenticated) { + Alert.alert( + t('common.error'), + t('settings.qrScanner.mobileUnlock.authenticationFailed') + ); + setIsProcessing(false); + return; + } + + // Process the mobile unlock + await handleMobileUnlock(requestId); + } catch (error) { + console.error('QR code processing error:', error); + Alert.alert( + t('common.error'), + error instanceof Error ? error.message : t('common.errors.unknownError') + ); + } finally { + setIsProcessing(false); + } + }; + + /** + * Handle dismiss - go back to settings. + */ + const handleDismiss = () : void => { + router.back(); + }; + + const styles = StyleSheet.create({ + confirmationContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: 20, + }, + confirmationTitle: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'center', + }, + confirmationText: { + fontSize: 16, + lineHeight: 24, + marginBottom: 12, + textAlign: 'center', + }, + buttonContainer: { + gap: 12, + marginTop: 20, + paddingBottom: insets.bottom + 80, + paddingHorizontal: 20, + }, + button: { + width: '100%', + }, + cancelButton: { + backgroundColor: colors.secondary, + }, + }); + + // Show loading during processing + if (isProcessing) { + return ( + + + + + + ); + } + + // Show confirmation screen + return ( + + + + + {t('settings.qrScanner.mobileUnlock.confirmTitle')} + + + {t('settings.qrScanner.mobileUnlock.confirmMessage', { username })} + + + + + + + + + + ); +} diff --git a/apps/mobile-app/app/(tabs)/settings/qr-result.tsx b/apps/mobile-app/app/(tabs)/settings/qr-result.tsx new file mode 100644 index 000000000..17c89edec --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/qr-result.tsx @@ -0,0 +1,110 @@ +import { Ionicons } from '@expo/vector-icons'; +import { router, useLocalSearchParams } from 'expo-router'; +import { View, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useColors } from '@/hooks/useColorScheme'; +import { useTranslation } from '@/hooks/useTranslation'; + +import { ThemedButton } from '@/components/themed/ThemedButton'; +import { ThemedContainer } from '@/components/themed/ThemedContainer'; +import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; +import { ThemedText } from '@/components/themed/ThemedText'; + +/** + * QR Code result screen - shows success or error after mobile unlock attempt. + */ +export default function QRResultScreen() : React.ReactNode { + const colors = useColors(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { success, message } = useLocalSearchParams<{ success: string; message?: string }>(); + + const isSuccess = success === 'true'; + + const handleDismiss = () : void => { + /* + * Switch to credentials tab immediately for smooth UX, + * then clean up the settings stack in the background. + */ + router.back(); + }; + + const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: 20, + }, + resultContainer: { + alignItems: 'center', + backgroundColor: (isSuccess ? colors.success : colors.destructive) + '10', + borderColor: isSuccess ? colors.success : colors.destructive, + borderRadius: 12, + borderWidth: 2, + marginBottom: 20, + padding: 20, + width: '100%', + }, + icon: { + marginBottom: 16, + }, + title: { + color: isSuccess ? colors.success : colors.destructive, + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + textAlign: 'center', + }, + message: { + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + }, + buttonContainer: { + marginTop: 20, + paddingBottom: insets.bottom + 80, + paddingHorizontal: 20, + width: '100%', + }, + button: { + width: '100%', + }, + }); + + return ( + + + + + + + {isSuccess + ? t('settings.qrScanner.mobileUnlock.successTitle') + : t('common.error')} + + + {message || (isSuccess + ? t('settings.qrScanner.mobileUnlock.successDescription') + : t('settings.qrScanner.mobileUnlock.genericError'))} + + + + + + + + + + ); +} diff --git a/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx index 37ec16869..75fba65d3 100644 --- a/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx +++ b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx @@ -2,20 +2,16 @@ import { Ionicons } from '@expo/vector-icons'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { router, useLocalSearchParams } from 'expo-router'; import { useState, useEffect } from 'react'; -import { View, TouchableOpacity, Alert, StyleSheet, Platform } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View, TouchableOpacity, Alert, StyleSheet } from 'react-native'; import { useColors } from '@/hooks/useColorScheme'; +import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; import { useTranslation } from '@/hooks/useTranslation'; -import { ThemedButton } from '@/components/themed/ThemedButton'; +import LoadingIndicator from '@/components/LoadingIndicator'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; -import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; import { ThemedText } from '@/components/themed/ThemedText'; -import { ThemedView } from '@/components/themed/ThemedView'; -import { useApp } from '@/context/AppContext'; import { useWebApi } from '@/context/WebApiContext'; -import NativeVaultManager from '@/specs/NativeVaultManager'; // QR Code type prefixes const QR_CODE_PREFIXES = { @@ -57,14 +53,9 @@ function parseQRCode(data: string): ScannedQRCode { export default function QRScannerScreen() : React.ReactNode { const colors = useColors(); const { t } = useTranslation(); - const { username } = useApp(); const webApi = useWebApi(); - const insets = useSafeAreaInsets(); const [permission, requestPermission] = useCameraPermissions(); - const [isProcessing, setIsProcessing] = useState(false); - const [scannedData, setScannedData] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); + const [isLoadingAfterScan, setIsLoadingAfterScan] = useMinDurationLoading(false, 500); const { url } = useLocalSearchParams<{ url?: string }>(); // Request camera permission on mount @@ -98,64 +89,11 @@ export default function QRScannerScreen() : React.ReactNode { }, [url]); /** - * Handle mobile unlock QR code. - */ - const handleMobileUnlock = async (requestId: string) : Promise => { - try { - // Fetch the public key from server - const response = await webApi.authFetch<{ clientPublicKey: string }>( - `auth/mobile-unlock/request/${requestId}`, - { method: 'GET' } - ); - - const publicKeyJWK = response.clientPublicKey; - - // Encrypt the decryption key using native module - // This ensures the decryption key never touches React Native code - const encryptedKey = await NativeVaultManager.encryptDecryptionKeyForMobileUnlock(publicKeyJWK); - - // Submit the encrypted key to the server - await webApi.authFetch( - 'auth/mobile-unlock/submit', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - requestId, - encryptedDecryptionKey: encryptedKey, - username: username, - }), - } - ); - - // Success! - setSuccessMessage(t('settings.qrScanner.mobileUnlock.successDescription')); - } catch (error) { - console.error('Mobile unlock error:', error); - let errorMsg = t('settings.qrScanner.mobileUnlock.genericError'); - - if (error instanceof Error) { - if (error.message.includes('ENCRYPTION_ERROR')) { - errorMsg = t('settings.qrScanner.mobileUnlock.vaultLocked'); - } else if (error.message.includes('404')) { - errorMsg = t('settings.qrScanner.mobileUnlock.requestExpired'); - } else if (error.message.includes('401') || error.message.includes('403')) { - errorMsg = t('settings.qrScanner.mobileUnlock.unauthorized'); - } - } - - setErrorMessage(errorMsg); - } - }; - - /** - * Handle barcode scanned - show confirmation screen. + * Handle barcode scanned - validate request and navigate to confirmation. */ const handleBarcodeScanned = ({ data }: { data: string }) : void => { // Prevent multiple scans - if (scannedData) { + if (isLoadingAfterScan) { return; } @@ -171,109 +109,55 @@ export default function QRScannerScreen() : React.ReactNode { return; } - // Show confirmation screen - setScannedData(parsedData); + // Validate the request and navigate (with min 500ms loading) + validateAndNavigate(parsedData); }; /** - * Handle confirmation - authenticate user first, then process the scanned QR code. + * Validate the QR request with the server before navigating. */ - const handleConfirm = async () : Promise => { - if (!scannedData) { - return; - } - - setIsProcessing(true); + const validateAndNavigate = async (parsedData: ScannedQRCode) : Promise => { + setIsLoadingAfterScan(true); try { - let authenticated = false; - - // Check which authentication method is available - const pinEnabled = await NativeVaultManager.isPinEnabled(); - - if (pinEnabled) { - // PIN is enabled, use PIN unlock - try { - await NativeVaultManager.showPinUnlock(); - authenticated = true; - } catch (pinError: any) { - // User cancelled PIN or PIN failed - console.log('PIN unlock cancelled or failed:', pinError); - Alert.alert( - t('common.error'), - t('settings.qrScanner.mobileUnlock.authenticationFailed') - ); - setIsProcessing(false); - return; - } - } else { - // Try biometric authentication - try { - authenticated = await NativeVaultManager.authenticateUser( - t('settings.qrScanner.mobileUnlock.authenticationRequired') - ); - } catch (authError: any) { - console.error('Biometric authentication error:', authError); - Alert.alert( - t('common.error'), - t('settings.qrScanner.mobileUnlock.authenticationFailed') - ); - setIsProcessing(false); - return; - } - } - - if (!authenticated) { - Alert.alert( - t('common.error'), - t('settings.qrScanner.mobileUnlock.authenticationFailed') + if (parsedData.type === 'MOBILE_UNLOCK') { + // Fetch the public key from server to validate the request exists + await webApi.authFetch<{ clientPublicKey: string }>( + `auth/mobile-unlock/request/${parsedData.payload}`, + { method: 'GET' } ); - setIsProcessing(false); - return; - } - // Route to appropriate handler based on type - switch (scannedData.type) { - case 'MOBILE_UNLOCK': - await handleMobileUnlock(scannedData.payload); - break; - // Future cases: - // case 'PASSKEY': - // await handlePasskey(scannedData.payload); - // break; - default: - Alert.alert( - t('common.error'), - t('settings.qrScanner.unsupportedQrType') - ); + // Request is valid, navigate to confirmation page + // Min duration of 500ms is handled by useMinDurationLoading + setIsLoadingAfterScan(false); + + router.replace({ + pathname: '/(tabs)/settings/qr-confirm', + params: { requestId: parsedData.payload }, + }); } } catch (error) { - console.error('QR code processing error:', error); + setIsLoadingAfterScan(false); + + console.error('QR validation error:', error); + let errorMsg = t('settings.qrScanner.mobileUnlock.genericError'); + + if (error instanceof Error) { + if (error.message.includes('404')) { + errorMsg = t('settings.qrScanner.mobileUnlock.requestExpired'); + } else if (error.message.includes('401') || error.message.includes('403')) { + errorMsg = t('settings.qrScanner.mobileUnlock.unauthorized'); + } + } + Alert.alert( t('common.error'), - error instanceof Error ? error.message : t('common.errors.unknownError') + errorMsg, + [{ text: t('common.ok') }] ); - } finally { - setIsProcessing(false); } }; - /** - * Handle cancel - go back to scanning. - */ - const handleCancel = () : void => { - setScannedData(null); - setSuccessMessage(null); - setErrorMessage(null); - }; - - /** - * Handle dismiss - go back to settings. - */ - const handleDismiss = () : void => { - router.back(); - }; - const styles = StyleSheet.create({ camera: { flex: 1, @@ -305,184 +189,32 @@ export default function QRScannerScreen() : React.ReactNode { top: 16, zIndex: 10, }, - confirmationContainer: { + loadingContainer: { alignItems: 'center', flex: 1, justifyContent: 'center', padding: 20, }, - confirmationTitle: { - fontSize: 24, - fontWeight: 'bold', - marginBottom: 20, - textAlign: 'center', - }, - confirmationText: { - fontSize: 16, - lineHeight: 24, - marginBottom: 12, - textAlign: 'center', - }, - buttonContainer: { - gap: 12, - marginTop: 20, - paddingBottom: insets.bottom + 80, - paddingHorizontal: 20, - }, - button: { - width: '100%', - }, - cancelButton: { - backgroundColor: colors.secondary, - }, - successContainer: { - alignItems: 'center', - backgroundColor: colors.success + '10', - borderColor: colors.success, - borderRadius: 12, - borderWidth: 2, - marginBottom: 20, - padding: 20, - }, - successIcon: { - marginBottom: 16, - }, - successTitle: { - color: colors.success, - fontSize: 20, - fontWeight: 'bold', - marginBottom: 8, - textAlign: 'center', - }, - successText: { - fontSize: 14, - lineHeight: 20, - textAlign: 'center', - }, - errorContainer: { - alignItems: 'center', - backgroundColor: colors.destructive + '10', - borderColor: colors.destructive, - borderRadius: 12, - borderWidth: 2, - marginBottom: 20, - padding: 20, - }, - errorIcon: { - marginBottom: 16, - }, - errorTitle: { - color: colors.destructive, - fontSize: 20, - fontWeight: 'bold', - marginBottom: 8, - textAlign: 'center', - }, - errorText: { - fontSize: 14, - lineHeight: 20, - textAlign: 'center', - }, }); - // Show confirmation/success/error screen after scanning - if (scannedData || successMessage || errorMessage) { + // Show loading animation after scan + if (isLoadingAfterScan) { return ( - - - {successMessage && ( - - - - {t('settings.qrScanner.mobileUnlock.successTitle')} - - - {successMessage} - - - )} - - {errorMessage && ( - - - - {t('common.error')} - - - {errorMessage} - - - )} - - {!successMessage && !errorMessage && scannedData?.type === 'MOBILE_UNLOCK' && ( - <> - - {t('settings.qrScanner.mobileUnlock.confirmTitle')} - - - {t('settings.qrScanner.mobileUnlock.confirmMessage', { username })} - - - )} - - - - {(successMessage || errorMessage) ? ( - - ) : ( - <> - - - - )} - - + + + ); } - // Show loading or permission denied screen + // Show permission request screen if (!permission || !permission.granted) { return ( - - {permission && !permission.granted && ( - <> - - {t('settings.qrScanner.cameraPermissionTitle')} - - - {t('settings.qrScanner.cameraPermissionMessage')} - - - )} - + + + ); } @@ -490,12 +222,6 @@ export default function QRScannerScreen() : React.ReactNode { return ( - router.back()} - > - - 0 && !statusText.endsWith('|'); const backgroundColor = colorScheme === 'dark' ? 'transparent' : '#fff'; const shadowColor = '#000';