Update qr-scanner.tsx UX flow (#1347)

This commit is contained in:
Leendert de Borst
2025-11-16 22:18:52 +01:00
parent fb33e688df
commit 09d4ba46fa
6 changed files with 424 additions and 329 deletions

View File

@@ -133,6 +133,20 @@ export default function SettingsLayout(): React.ReactNode {
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="qr-confirm"
options={{
title: t('settings.qrScanner.mobileUnlock.confirmTitle'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="qr-result"
options={{
title: t('settings.qrScanner.mobileUnlock.successTitle'),
...defaultHeaderOptions,
}}
/>
</Stack>
);
}

View File

@@ -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<void> => {
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<void> => {
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 (
<ThemedContainer>
<View style={styles.confirmationContainer}>
<LoadingIndicator />
</View>
</ThemedContainer>
);
}
// Show confirmation screen
return (
<ThemedContainer>
<ThemedScrollView contentContainerStyle={{ flexGrow: 1 }}>
<View style={styles.confirmationContainer}>
<ThemedText style={styles.confirmationTitle}>
{t('settings.qrScanner.mobileUnlock.confirmTitle')}
</ThemedText>
<ThemedText style={styles.confirmationText}>
{t('settings.qrScanner.mobileUnlock.confirmMessage', { username })}
</ThemedText>
</View>
<View style={styles.buttonContainer}>
<ThemedButton
title={t('common.confirm')}
onPress={handleConfirm}
style={styles.button}
/>
<ThemedButton
title={t('common.cancel')}
onPress={handleDismiss}
style={StyleSheet.flatten([styles.button, styles.cancelButton])}
/>
</View>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -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 (
<ThemedContainer>
<ThemedScrollView contentContainerStyle={{ flexGrow: 1 }}>
<View style={styles.container}>
<View style={styles.resultContainer}>
<Ionicons
name={isSuccess ? 'checkmark-circle' : 'alert-circle'}
size={64}
color={isSuccess ? colors.success : colors.destructive}
style={styles.icon}
/>
<ThemedText style={styles.title}>
{isSuccess
? t('settings.qrScanner.mobileUnlock.successTitle')
: t('common.error')}
</ThemedText>
<ThemedText style={styles.message}>
{message || (isSuccess
? t('settings.qrScanner.mobileUnlock.successDescription')
: t('settings.qrScanner.mobileUnlock.genericError'))}
</ThemedText>
</View>
</View>
<View style={styles.buttonContainer}>
<ThemedButton
title={t('common.close')}
onPress={handleDismiss}
style={styles.button}
/>
</View>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -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<ScannedQRCode | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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<void> => {
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<void> => {
if (!scannedData) {
return;
}
setIsProcessing(true);
const validateAndNavigate = async (parsedData: ScannedQRCode) : Promise<void> => {
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 (
<ThemedContainer>
<ThemedScrollView contentContainerStyle={{ flexGrow: 1 }}>
<View style={styles.confirmationContainer}>
{successMessage && (
<View style={styles.successContainer}>
<Ionicons
name="checkmark-circle"
size={64}
color={colors.success}
style={styles.successIcon}
/>
<ThemedText style={styles.successTitle}>
{t('settings.qrScanner.mobileUnlock.successTitle')}
</ThemedText>
<ThemedText style={styles.successText}>
{successMessage}
</ThemedText>
</View>
)}
{errorMessage && (
<View style={styles.errorContainer}>
<Ionicons
name="alert-circle"
size={64}
color={colors.destructive}
style={styles.errorIcon}
/>
<ThemedText style={styles.errorTitle}>
{t('common.error')}
</ThemedText>
<ThemedText style={styles.errorText}>
{errorMessage}
</ThemedText>
</View>
)}
{!successMessage && !errorMessage && scannedData?.type === 'MOBILE_UNLOCK' && (
<>
<ThemedText style={styles.confirmationTitle}>
{t('settings.qrScanner.mobileUnlock.confirmTitle')}
</ThemedText>
<ThemedText style={styles.confirmationText}>
{t('settings.qrScanner.mobileUnlock.confirmMessage', { username })}
</ThemedText>
</>
)}
</View>
<View style={styles.buttonContainer}>
{(successMessage || errorMessage) ? (
<ThemedButton
title={t('common.close')}
onPress={handleDismiss}
style={styles.button}
/>
) : (
<>
<ThemedButton
title={t('common.confirm')}
onPress={handleConfirm}
loading={isProcessing}
disabled={isProcessing}
style={styles.button}
/>
<ThemedButton
title={t('common.cancel')}
onPress={handleCancel}
disabled={isProcessing}
style={StyleSheet.flatten([styles.button, styles.cancelButton])}
/>
</>
)}
</View>
</ThemedScrollView>
<View style={styles.loadingContainer}>
<LoadingIndicator />
</View>
</ThemedContainer>
);
}
// Show loading or permission denied screen
// Show permission request screen
if (!permission || !permission.granted) {
return (
<ThemedContainer>
<ThemedView style={styles.confirmationContainer}>
{permission && !permission.granted && (
<>
<ThemedText style={styles.confirmationTitle}>
{t('settings.qrScanner.cameraPermissionTitle')}
</ThemedText>
<ThemedText style={styles.confirmationText}>
{t('settings.qrScanner.cameraPermissionMessage')}
</ThemedText>
</>
)}
</ThemedView>
<View style={styles.loadingContainer}>
<LoadingIndicator />
</View>
</ThemedContainer>
);
}
@@ -490,12 +222,6 @@ export default function QRScannerScreen() : React.ReactNode {
return (
<ThemedContainer>
<View style={styles.cameraContainer}>
<TouchableOpacity
style={styles.closeButton}
onPress={() => router.back()}
>
<Ionicons name="close-circle" size={32} color={colors.white} />
</TouchableOpacity>
<CameraView
style={styles.camera}
facing="back"

View File

@@ -71,8 +71,7 @@ function RootLayoutNav() : React.ReactNode {
// Handle mobile unlock QR code scans from native camera
if (path.startsWith('mobile-unlock/')) {
// Process the QR code directly by simulating what the scanner would do
// Since we already have the URL from the camera, we can process it immediately
// Process the QR code
router.push(`/(tabs)/settings/qr-scanner?url=${encodeURIComponent(`aliasvault://${path}`)}` as Href);
return;
}

View File

@@ -4,13 +4,13 @@ import { StyleSheet, View, Text, Animated, useColorScheme } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
type LoadingIndicatorProps = {
status: string;
status?: string | null;
};
/**
* Loading indicator component.
*/
export default function LoadingIndicator({ status }: LoadingIndicatorProps): React.ReactNode {
export default function LoadingIndicator({ status = '' }: LoadingIndicatorProps): React.ReactNode {
const colors = useColors();
const dot1Anim = useRef(new Animated.Value(0)).current;
const dot2Anim = useRef(new Animated.Value(0)).current;
@@ -89,8 +89,9 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea
* If the status ends with a pipe character (|), don't show any dots
* This provides an explicit way to disable the loading dots animation
*/
const statusTrimmed = status.endsWith('|') ? status.slice(0, -1) : status;
const shouldShowDots = !status.endsWith('|');
const statusText = status || '';
const statusTrimmed = statusText.endsWith('|') ? statusText.slice(0, -1) : statusText;
const shouldShowDots = statusText.length > 0 && !statusText.endsWith('|');
const backgroundColor = colorScheme === 'dark' ? 'transparent' : '#fff';
const shadowColor = '#000';