import React, { useEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { useNavigation } from "../../context/NavigationContext"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import * as Haptics from "expo-haptics"; import { copyToClipboard as sharedCopy } from "../../utils/clipboard"; import QRCode from "react-native-qrcode-svg"; import Toast from "react-native-toast-message"; import { MonoText, MutedText } from "../../components/text"; import { Button } from "../../components/button"; import { withClickTracking } from "../../services/telemetry"; import { Input } from "../../components/input"; import { Separator } from "../../components/separator"; import AuthService from "../../services/auth.service"; import { useAuth } from "../../hooks/useAuth"; import { useAppConfig } from "../../context/AppConfigContext"; import { getErrorMessage } from "../../utils/error"; import { AuthPage, type AuthPageLayout } from "./AuthPage"; type Step = "prompt" | "setup" | "verify" | "recovery" | "done"; export interface MfaEnrollScreenProps { /** * Optional promo widget rendered on the prompt step (vault advertises * its authenticator app here). When omitted, the screen renders * nothing in that slot. */ authenticatorAppPromo?: React.ReactNode; /** See `AuthPageLayout`. Defaults to the card, as it always was. */ layout?: AuthPageLayout; } export function MfaEnrollScreen({ authenticatorAppPromo, layout }: MfaEnrollScreenProps = {}) { const { refreshUser } = useAuth(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const router = useNavigation(); const { appName, routes } = useAppConfig(); const [step, setStep] = useState("prompt"); const [setupData, setSetupData] = useState<{ uri: string; secret: string; recovery_codes: string[]; } | null>(null); const [code, setCode] = useState(""); const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(false); const [copied, setCopied] = useState(null); const copiedResetTimerRef = useRef>(undefined); useEffect( () => () => { if (copiedResetTimerRef.current) { clearTimeout(copiedResetTimerRef.current); } }, [] ); // The shared `copyToClipboard` writes the value AND schedules the // 30s auto-wipe at module scope, so unmounting this screen no // longer cancels the wipe (the previous component-scoped timer // was the exact footgun the shared helper exists to avoid). const copyToClipboard = async (text: string, label: string) => { const ok = await sharedCopy(text); if (!ok) { return; } setCopied(label); Toast.show({ type: "success", text1: `${label} copied`, visibilityTime: 1500 }); if (copiedResetTimerRef.current) { clearTimeout(copiedResetTimerRef.current); } copiedResetTimerRef.current = setTimeout(() => { setCopied(null); }, 30_000); }; const handleStartSetup = async () => { setIsLoading(true); try { const data = await AuthService.setupMfa(); setSetupData(data); setStep("setup"); } catch (e: unknown) { Toast.show({ type: "error", text1: getErrorMessage(e, "Failed to start MFA setup"), visibilityTime: 3000, }); } finally { setIsLoading(false); } }; const handleVerifyCode = async () => { if (code.length !== 6) { setError("Enter the 6-digit code from your authenticator app"); return; } setIsLoading(true); try { await AuthService.verifyMfaSetup(code); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); if (setupData) { setSetupData({ ...setupData, secret: "", uri: "" }); } setStep("recovery"); } catch (e: unknown) { setError(getErrorMessage(e, "Invalid code. Make sure your authenticator is synced.")); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } finally { setIsLoading(false); } }; const handleFinish = async () => { try { await refreshUser(); setSetupData(null); Toast.show({ type: "success", text1: "MFA enabled!", text2: "Your account is now protected with two-factor authentication.", visibilityTime: 3000, }); router.replace(routes.importData); } catch (e: unknown) { Toast.show({ type: "error", text1: getErrorMessage(e, "Failed to continue"), visibilityTime: 3000, }); } }; const handleSkip = async () => { try { setSetupData(null); await AuthService.completeOnboarding(); await refreshUser(); router.replace(routes.importData); } catch (e: unknown) { Toast.show({ type: "error", text1: getErrorMessage(e, "Failed to skip"), visibilityTime: 3000, }); } }; /** * What the page says, per step. * * Held here rather than inline because the frame below takes one title and * one subtitle, and three branches each rendering their own header is what * made this screen three screens wearing the same card. */ const page = step === "prompt" ? { title: "Secure your account", subtitle: "Two-factor authentication asks for a code from an authenticator app as well as your password.", } : step === "setup" ? { title: "Scan the QR code", subtitle: "Open your authenticator app and scan this, or enter the key by hand.", } : { title: "MFA is on", subtitle: "Save these recovery codes somewhere safe." }; return ( ) } maxWidth={400} footer={ step === "prompt" ? ( <> ) : step === "setup" ? ( <> ) : ( ) } > {step === "prompt" ? authenticatorAppPromo : null} {step === "setup" && setupData ? ( <> Can't scan? Enter the key by hand copyToClipboard(setupData.secret, "Secret") )} className="rounded-lg bg-secondary p-3 active:opacity-70 dark:bg-neutral-800" accessibilityRole="button" accessibilityLabel="Copy secret key" accessibilityHint="Double tap to copy the secret key to clipboard" > {setupData.secret} Tap to copy copyToClipboard(setupData.uri, "URI") )} className="rounded-lg bg-secondary px-3 py-2 active:opacity-70 dark:bg-neutral-800" accessibilityRole="button" accessibilityLabel="Copy full OTP URI" > Copy full OTP URI Enter the 6-digit code from your authenticator { setCode(text.replace(/[^0-9]/g, "").slice(0, 6)); if (error) { setError(""); } }} error={error} placeholder="000000" keyboardType="number-pad" className="text-center font-mono text-xl tracking-widest" returnKeyType="done" onSubmitEditing={handleVerifyCode} accessibilityLabel="Authenticator code" /> ) : null} {step === "recovery" && setupData ? ( <> {setupData.recovery_codes.map((rc) => ( {rc} ))} Each code works once. ) : null} ); } export default MfaEnrollScreen;