import { withClickTracking } from "../../services/telemetry"; import React, { useState } from "react"; import { ActivityIndicator, Platform, Pressable, Text, TextInput, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useNavigation } from "../../context/NavigationContext"; import { StatusBar } from "expo-status-bar"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import * as Haptics from "expo-haptics"; import AuthService from "../../services/auth.service"; import { useAppConfig } from "../../context/AppConfigContext"; import { getErrorMessage } from "../../utils/error"; export function MfaChallengeScreen() { const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const router = useNavigation(); const { routes } = useAppConfig(); const [code, setCode] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); const [isRecoveryMode, setIsRecoveryMode] = useState(false); const handleVerify = async () => { // Re-entry guard: a fast double-tap (button + onSubmitEditing) // would otherwise re-issue verifyMfa before `setIsLoading(true)` // had time to flow through React and disable the submit. Each // verify burns a one-shot TOTP attempt server-side; cheaper to // gate at the client. if (isLoading) { return; } const trimmed = code.trim(); if (!isRecoveryMode && trimmed.length !== 6) { setError("Please enter a 6-digit code"); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } if (isRecoveryMode && trimmed.length === 0) { setError("Please enter a recovery code"); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } setIsLoading(true); setError(""); try { await AuthService.verifyMfa(trimmed); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); router.replace(routes.home); } catch (err) { setError(getErrorMessage(err, "Invalid code")); setCode(""); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } finally { setIsLoading(false); } }; const toggleRecoveryMode = () => { setIsRecoveryMode(!isRecoveryMode); setCode(""); setError(""); }; const inputStyle = { borderWidth: 1, borderColor: error ? "#EF4444" : isDark ? "#333333" : "#E5E5E5", borderRadius: 8, paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 12 : 8, backgroundColor: isDark ? "#0A0A0A" : "#FAFAFA", }; return ( Two-Factor Authentication {isRecoveryMode ? "Enter one of your recovery codes" : "Enter the 6-digit code from your authenticator app"} {error ? ( {error} ) : null} {isRecoveryMode ? ( { setCode(text); if (error) { setError(""); } }} placeholder="Enter recovery code" placeholderTextColor={isDark ? "#999999" : "#666666"} className="font-mono text-lg text-center tracking-wide" style={{ ...inputStyle, color: isDark ? "#FFFFFF" : "#000000", }} autoCapitalize="none" autoCorrect={false} autoFocus returnKeyType="done" onSubmitEditing={handleVerify} accessibilityLabel="Recovery code" /> ) : ( { const digits = text.replace(/[^0-9]/g, "").slice(0, 6); setCode(digits); if (error) { setError(""); } }} placeholder="000000" placeholderTextColor={isDark ? "#999999" : "#666666"} className="font-mono text-center" style={{ ...inputStyle, fontSize: 32, color: isDark ? "#FFFFFF" : "#000000", letterSpacing: 12, }} keyboardType="number-pad" maxLength={6} autoFocus returnKeyType="done" onSubmitEditing={handleVerify} accessibilityLabel="Authenticator code" /> )} ({ backgroundColor: isDark ? "#FFFFFF" : "#000000", paddingVertical: 14, opacity: pressed || isLoading ? 0.7 : 1, })} accessibilityRole="button" accessibilityLabel="Verify" accessibilityHint="Double tap to verify your code" > {isLoading ? ( ) : ( Verify )} {isRecoveryMode ? "Use authenticator code" : "Use recovery code"} ); } export default MfaChallengeScreen;