import React, { useEffect, useRef, useState } from "react"; import { ScrollView, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { type Href } from "expo-router"; 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 { Heading, MutedText, Text } from "../../components/text"; import { Button, IconButton } from "../../components/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "../../components/card"; import { withClickTracking } from "../../services/telemetry"; import { EmailInput, PasswordInput } from "../../components/input"; import { Dialog, DialogButton, DialogFooter } from "../../components/dialog"; import { SeparatorWithText } from "../../components/separator"; import { useAuth } from "../../hooks/useAuth"; import { ApiError } from "../../utils/http-error"; import { validateEmail } from "../../utils/validation"; import { useShakeAnimation } from "../../hooks/useShakeAnimation"; import ShakeField from "../../forms/shake-field"; import FormError from "../../forms/form-error"; import { useAppConfig } from "../../context/AppConfigContext"; const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const; export interface LoginScreenProps { /** Optional widget rendered top-right (vault wires its admin cog here). */ topAccessory?: React.ReactNode; /** * Optional social-auth block. When provided, the screen renders a * separator above it. */ socialAuth?: React.ReactNode; /** Master-password field placeholder. */ passwordPlaceholder?: string; /** Master-password field label. */ passwordLabel?: string; /** * Render the "Sign in with passkey" button + ceremony flow. Set to * `true` only when the platform reports a usable WebAuthn implementation * — pass the result of `isPasskeySupported()` or equivalent. * * The button drives `useAuth().loginWithPasskey`, which the consumer * wires through ``. A non-PRF * outcome opens an inline unlock dialog and calls * `useAuth().unlockWithPassword(password)` after submit. */ passkeyEnabled?: boolean; /** Master-password placeholder shown inside the passkey unlock dialog. */ passkeyUnlockPlaceholder?: string; /** Title shown inside the passkey unlock dialog. */ passkeyUnlockTitle?: string; /** * Description rendered above the unlock input in the passkey dialog. * Defaults to "Signed in successfully. This passkey can't unlock the * vault on its own — enter your master password to decrypt your data." */ passkeyUnlockDescription?: string; } export function LoginScreen({ topAccessory, socialAuth, passwordPlaceholder = "Enter your password", passwordLabel = "Password", passkeyEnabled = false, passkeyUnlockPlaceholder = "Enter your master password", passkeyUnlockTitle = "Unlock your vault", passkeyUnlockDescription = "Signed in successfully. This passkey can't unlock the vault on its own — enter your master password to decrypt your data.", }: LoginScreenProps = {}) { const { login: authLogin, loginWithPasskey, unlockWithPassword } = useAuth(); const router = useNavigation(); const { appName, Logo, routes } = useAppConfig(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [errors, setErrors] = useState<{ email?: string; password?: string }>({}); // Passkey ceremony state machine. // "idle" — default; primary CTA is the email/password form. // "passkey-loading" — passkey ceremony is in flight. // "passkey-unlock" — server accepted the assertion but the // authenticator did not honor PRF; collect the // master password to complete local unlock. const [passkeyStep, setPasskeyStep] = useState<"idle" | "passkey-loading" | "passkey-unlock">( "idle" ); const [unlockPassword, setUnlockPassword] = useState(""); const [unlockError, setUnlockError] = useState(""); const [pendingOnboardingStep, setPendingOnboardingStep] = useState(null); // Abort an in-flight passkey ceremony on unmount so the OS prompt // tears down and the resolved credential doesn't land on a stale // setState. Ref + cleanup so navigating away mid-ceremony is clean. const passkeyAbortRef = useRef(null); useEffect( () => () => { passkeyAbortRef.current?.abort(); }, [] ); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const emailShake = useShakeAnimation(); const passwordShake = useShakeAnimation(); const firstError = errors.email ?? errors.password; const validateForm = () => { const newErrors: typeof errors = {}; if (!email) { newErrors.email = "Email is required"; } else if (!validateEmail(email)) { newErrors.email = "Please enter a valid email"; } if (!password) { newErrors.password = "Password is required"; } else if (password.length < 8) { newErrors.password = "Password must be at least 8 characters"; } return newErrors; }; const performLogin = async () => { setIsLoading(true); try { await authLogin(email.trim().toLowerCase(), password); setPassword(""); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (error) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); const errorMessage = error instanceof Error ? error.message : "Login failed"; // Prefer the server's machine-readable `error.code` over // substring-matching the English message — i18n on the server // (or a wording tweak) silently broke the old branches. const code = error instanceof ApiError ? error.code : undefined; // Codes the server emits today. Unknown / missing codes fall // through to the generic "show the server's message" branch. if (code === "INVALID_CREDENTIALS") { setErrors({ password: "Invalid email or password" }); emailShake.shake(); passwordShake.shake(); } else if (code === "ACCOUNT_LOCKED") { setErrors({ email: "Account is locked. Please contact support." }); emailShake.shake(); } else { setErrors({ password: errorMessage }); passwordShake.shake(); } } finally { setIsLoading(false); } }; const handleLogin = async () => { const newErrors = validateForm(); if (Object.keys(newErrors).length > 0) { setErrors(newErrors); if (newErrors.email) { emailShake.shake(); } if (newErrors.password) { passwordShake.shake(); } await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } setErrors({}); await performLogin(); }; const routeAfterUnlock = (onboardingStep?: string | null) => { if (onboardingStep === "email_verification") { router.replace({ pathname: routes.verifyEmail, params: { email } } as Href); } else if (onboardingStep === "mfa_enrollment") { router.replace(routes.mfaEnroll); } else if (onboardingStep === "import") { router.replace(routes.importData); } else { // `complete`, null, undefined, or any unknown step the client // doesn't recognise — the consumer's route guard re-evaluates // and bounces to welcome / the right step if the server says // onboarding isn't done. Default to home rather than stranding // the user. router.replace(routes.home); } }; const handlePasskeyLogin = async () => { setPasskeyStep("passkey-loading"); setErrors({}); // Abort any prior ceremony before starting a new one so a // double-tap doesn't race two outstanding credentials.get() calls. passkeyAbortRef.current?.abort(); const controller = new AbortController(); passkeyAbortRef.current = controller; try { const trimmedEmail = email.trim().toLowerCase(); const result = await loginWithPasskey( trimmedEmail.length > 0 ? trimmedEmail : undefined, controller.signal ); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); if (result.unlocked) { setPasskeyStep("idle"); routeAfterUnlock(result.user.onboarding_step); return; } setPendingOnboardingStep(result.user.onboarding_step ?? null); setPasskeyStep("passkey-unlock"); } catch (error) { // Distinguish user-cancel / programmatic-abort from real // failures. `NotAllowedError` (user cancel, timeout) and // `AbortError` (signal abort) shouldn't paint a destructive // error on the password field — the user knows they cancelled. // Real failures (`SecurityError`, `UnknownError`, network) do // want the inline message + shake so the user falls back to // password. const name = error instanceof Error ? error.name : undefined; const isBenign = name === "NotAllowedError" || name === "AbortError"; if (!isBenign) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); const message = error instanceof Error ? error.message : "Passkey sign-in failed"; setErrors({ password: message }); } setPasskeyStep("idle"); } }; const handleUnlockAfterPasskey = async () => { if (!unlockPassword) { setUnlockError("Master password is required"); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } setIsLoading(true); try { await unlockWithPassword(unlockPassword); setUnlockPassword(""); setUnlockError(""); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); routeAfterUnlock(pendingOnboardingStep ?? "complete"); setPasskeyStep("idle"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to unlock the vault. Check your password."; setUnlockError(message); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } finally { setIsLoading(false); } }; return ( <> router.push(routes.welcome)} className="self-start" accessibilityLabel="Go back" accessibilityRole="button" > {topAccessory} {appName} Sign In Enter your credentials to sign in to {appName} {/* Passkey CTA above email/password — passkeys are the preferred primary factor when the device has one enrolled. Email is optional; when blank the server emits a discoverable-credential challenge and the platform UI picks the account. */} {passkeyEnabled ? ( <> ) : null} { setEmail(text); if (errors.email) { setErrors({ ...errors, email: undefined }); } }} autoFocus invalid={!!errors.email} autoComplete="email" accessibilityLabel="Email address" testID="login-email-input" /> { setPassword(text); if (errors.password) { setErrors({ ...errors, password: undefined }); } }} placeholder={passwordPlaceholder} invalid={!!errors.password} autoComplete="password" returnKeyType="done" onSubmitEditing={handleLogin} accessibilityLabel={passwordLabel} testID="login-password-input" /> router.push(routes.forgotPassword) )} accessibilityRole="link" accessibilityLabel="Forgot password" > Forgot password? Don't have an account?{" "} router.push(routes.register) )} accessibilityRole="link" accessibilityLabel="Sign up" > Sign Up {socialAuth ? ( <> {socialAuth} ) : null} {/* Passkey unlock dialog — shown only when the authenticator did NOT honor the PRF extension. The session is already established server-side; this prompt gathers the master password so the consumer's `unlockWithPassword` handler can derive the symmetric key locally before routing. */} { setPasskeyStep("idle"); setUnlockPassword(""); setUnlockError(""); }} draggable dismissOnBackdrop={false} > {passkeyUnlockTitle} {passkeyUnlockDescription} { setUnlockPassword(text); if (unlockError) { setUnlockError(""); } }} placeholder={passkeyUnlockPlaceholder} error={unlockError} autoComplete="password" returnKeyType="done" onSubmitEditing={handleUnlockAfterPasskey} accessibilityLabel="Master password" testID="login-passkey-unlock-input" /> {} : handleUnlockAfterPasskey )} accessibilityLabel="Unlock" accessibilityRole="button" > {isLoading ? "Unlocking…" : "Unlock"} ); } export default LoginScreen;