import React, { useEffect, useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import Toast from "react-native-toast-message"; import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from "react-native-reanimated"; import { useColorScheme } from "nativewind"; import { Button } from "../../components/button"; import { PasswordInput } from "../../components/input"; import { MutedText, Text } from "../../components/text"; import { useAppConfig } from "../../context/AppConfigContext"; interface LockOverlayProps { /** * Validates the password and unlocks the session. Throw on bad * password — the overlay catches and surfaces a generic error. */ onUnlock: (password: string) => Promise | void; /** Optional success-toast message. Defaults to `${appName} unlocked`. */ successMessage?: string; /** * Optional biometric/passkey unlock. When provided, this fires * AUTOMATICALLY once on mount, and the password field/Unlock button * are not rendered at all until this settles — see the mode-switching * comment below for why. This overlay has no opinion on *how* unlock * happens (passkey PRF, platform secure-storage, etc.) — the caller * owns the whole ceremony and is expected to swallow expected failure * modes (user cancel, nothing enrolled) rather than throw; a throw * here surfaces the same generic "unlock failed" error the password * path does, which is the wrong message for "you tapped cancel on the * Face ID prompt". */ onBiometricUnlock?: () => Promise | void; /** * Whether `onBiometricUnlock` fires automatically on mount. Defaults * to true (the original behavior) — set false for a re-lock triggered * by idle timeout rather than a fresh app launch, so the OS biometric * sensor doesn't pop up unprompted every time the session times out * from inactivity. The manual "Unlock with Biometrics Instead" button * is unaffected by this — it's still shown (and still works) whenever * `onBiometricUnlock` is provided, regardless of this flag; only the * automatic first attempt is what this suppresses. */ autoPromptBiometric?: boolean; } /** * Unlock form for session-locked flows (e.g. vault re-lock after idle). * The typed password never leaves this component — it is captured into a * local closure variable and wiped from React state before the async * unlock work begins so the fiber tree no longer references the * plaintext during PBKDF2 / WebCrypto derivation. */ export function LockOverlay({ onUnlock, successMessage, onBiometricUnlock, autoPromptBiometric = true, }: LockOverlayProps) { const { appName, Logo } = useAppConfig(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [biometricState, setBiometricState] = useState<"idle" | "prompting">("idle"); const [error, setError] = useState(""); const pulse = useSharedValue(1); const hasAutoPrompted = useRef(false); // Two distinct screens, not one screen with an extra button: // "biometric" — fingerprint icon only. No password field, no Unlock // button anywhere in the tree. // "password" — the normal password form, shown once biometric // unlock isn't offered at all, OR once an attempt has // settled (succeeded or failed — a real success // unmounts this whole overlay from the parent almost // immediately, so briefly being in "password" mode in // that case is never visible). // The password field being ABSENT from the tree during "biometric" // mode is load-bearing, not cosmetic: Android's Autofill Framework // triggers independently of the passkey ceremony, purely from a // password-type field being mounted and focusable on screen. With // both mounted together, the passkey ceremony's own Credential // Manager UI and Bitwarden's separately-triggered autofill prompt // fought each other — dismiss one, the other reopens immediately, // neither settling — reported as a black screen the app never // recovered from, root-caused on 2026-08-17. There is no such field // for Android to react to until biometric unlock has actually failed. const [mode, setMode] = useState<"biometric" | "password">( onBiometricUnlock && autoPromptBiometric ? "biometric" : "password" ); const triggerBiometricUnlock = React.useCallback(async () => { if (!onBiometricUnlock) { return; } setBiometricState("prompting"); try { await onBiometricUnlock(); } finally { setBiometricState("idle"); setMode("password"); } }, [onBiometricUnlock]); useEffect(() => { if (onBiometricUnlock && autoPromptBiometric && !hasAutoPrompted.current) { hasAutoPrompted.current = true; void triggerBiometricUnlock(); } }, [onBiometricUnlock, autoPromptBiometric, triggerBiometricUnlock]); useEffect(() => { if (biometricState === "prompting") { pulse.value = withRepeat( withTiming(1.12, { duration: 650, easing: Easing.inOut(Easing.ease) }), -1, true ); } else { pulse.value = withTiming(1, { duration: 200 }); } }, [biometricState, pulse]); const pulseStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }], })); const handleUnlock = async () => { const captured = password; if (!captured.trim()) { setError("Password is required"); return; } setPassword(""); setLoading(true); setError(""); try { await onUnlock(captured); Toast.show({ type: "success", text1: successMessage ?? `${appName} unlocked`, visibilityTime: 1500, }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (__DEV__) { console.error("Unlock failed:", msg); } // Distinguish "wrong password" (the common case) from a real // crypto/transport failure. The vault key-derivation pipeline // surfaces wrong-password as a decrypt / authentication-tag // mismatch from AES-GCM; anything else means the unlock path // itself broke and the user can't recover by retyping. const looksLikeWrongPassword = /decrypt|auth(entication)? tag|bad ?decrypt|invalid|unwrap/i.test(msg); setError( looksLikeWrongPassword ? "Incorrect password. Please try again." : `Unlock failed: ${msg}` ); } finally { setLoading(false); } }; if (mode === "biometric") { return ( {appName} {biometricState === "prompting" ? "Authenticating…" : "Tap to unlock"} ); } return ( {appName} Enter your master password to unlock { setPassword(text); if (error) { setError(""); } }} placeholder="Master password" error={error} // Web: autofocus on a login-style page is expected desktop // UX. Native: autofocus here pops the on-screen keyboard // unprompted the moment the password fallback appears — // reported as a real annoyance previously. Wait for an // explicit tap on the field. autoFocus={Platform.OS === "web"} returnKeyType="done" onSubmitEditing={handleUnlock} accessibilityLabel="Master password" testID="lock-password-input" /> {onBiometricUnlock ? ( ) : null} ); }