import React, { useEffect, useMemo, useState } from "react"; import { Platform, ScrollView, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useLocalSearchParams } 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 { 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 { Input, PasswordInput } from "../../components/input"; import PasswordStrengthMeter from "../../components/auth/PasswordStrengthMeter"; import PwnedPasswordWarning from "../../components/auth/PwnedPasswordWarning"; import ShakeField from "../../forms/shake-field"; import FormError from "../../forms/form-error"; import { useShakeAnimation } from "../../hooks/useShakeAnimation"; import AuthService from "../../services/auth.service"; import { checkPasswordPwned, debounce, getPwnedMessage } from "../../services/hibp"; import { getCachedPolicy } from "../../services/hibp-cache"; import { useAppConfig } from "../../context/AppConfigContext"; import { getErrorMessage } from "../../utils/error"; import { ApiError } from "../../utils/http-error"; type Step = "code" | "password"; const DEFAULT_POLICY = { enabled: false, enforce: false, block_threshold: 0 }; export interface ResetPasswordScreenProps { /** * Optional warning shown above the new-password field. Vault uses this * to remind users that data encrypted under the previous password may * become inaccessible. Default: none. */ encryptionWarning?: string; } export function ResetPasswordScreen({ encryptionWarning }: ResetPasswordScreenProps = {}) { const params = useLocalSearchParams<{ code?: string }>(); const router = useNavigation(); const { routes } = useAppConfig(); const [step, setStep] = useState("code"); const [code, setCode] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [errors, setErrors] = useState<{ code?: string; password?: string; confirmPassword?: string; }>({}); const [isLoading, setIsLoading] = useState(false); const [pwnedInfo, setPwnedInfo] = useState<{ title: string; message: string; severity: "error" | "warning" | "info"; count: number; } | null>(null); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const codeShake = useShakeAnimation(); const passwordShake = useShakeAnimation(); const confirmShake = useShakeAnimation(); const firstError = errors.code ?? errors.password ?? errors.confirmPassword; const checkPasswordDebounced = useMemo( () => debounce(async (pass: string) => { const policy = getCachedPolicy() ?? DEFAULT_POLICY; if (!policy.enabled || pass.length < 8) { setPwnedInfo(null); return; } const result = await checkPasswordPwned(pass); if (result.isPwned) { const message = getPwnedMessage(result); setPwnedInfo({ ...message, count: result.count }); } else { setPwnedInfo(null); } }, 1000), [] ); useEffect(() => () => checkPasswordDebounced.cancel(), [checkPasswordDebounced]); useEffect(() => { let urlCode: string | null = null; if (params.code) { urlCode = params.code; } else if (Platform.OS === "web") { const urlParams = new URLSearchParams(window.location.search); urlCode = urlParams.get("code"); } if (urlCode) { const captured = urlCode; AuthService.validateResetCode(captured.trim()) .then(() => { setCode(captured); setStep("password"); }) .catch(() => setErrors({ code: "Invalid or expired code" })); } }, [params.code]); const handleCodeSubmit = async () => { // Re-entry guard against double-submit before isLoading lands. if (isLoading) { return; } if (!code.trim()) { setErrors({ code: "Reset code is required" }); codeShake.shake(); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } setIsLoading(true); try { await AuthService.validateResetCode(code.trim()); setErrors({}); setStep("password"); } catch (e: unknown) { setErrors({ code: getErrorMessage(e, "Invalid or expired code") }); codeShake.shake(); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } finally { setIsLoading(false); } }; const handlePasswordSubmit = async () => { // Re-entry guard. The HIBP probe below is itself a network // round-trip; without this guard, a double-submit before // `setIsLoading(true)` fires would double-issue HIBP fetch + the // resetPassword POST. if (isLoading) { return; } const newErrors: typeof errors = {}; if (!newPassword) { newErrors.password = "Password is required"; } else if (newPassword.length < 8) { newErrors.password = "Password must be at least 8 characters"; } if (!confirmPassword) { newErrors.confirmPassword = "Please confirm your password"; } else if (newPassword !== confirmPassword) { newErrors.confirmPassword = "Passwords do not match"; } if (Object.keys(newErrors).length > 0) { setErrors(newErrors); if (newErrors.password) { passwordShake.shake(); } if (newErrors.confirmPassword) { confirmShake.shake(); } await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); return; } setErrors({}); const hibpPolicy = getCachedPolicy() ?? DEFAULT_POLICY; if (hibpPolicy.enabled) { const pwnedResult = await checkPasswordPwned(newPassword); if (pwnedResult.isPwned) { const message = getPwnedMessage(pwnedResult); setPwnedInfo({ ...message, count: pwnedResult.count }); if (hibpPolicy.enforce && pwnedResult.count > hibpPolicy.block_threshold) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); passwordShake.shake(); confirmShake.shake(); return; } } else { setPwnedInfo(null); } } setIsLoading(true); try { await AuthService.resetPassword(code, newPassword); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); router.replace(routes.login); } catch (e: unknown) { const msg = getErrorMessage(e, "Failed to reset password"); // Branch on the server's machine-readable `error.code` rather // than English-substring matching the message — i18n / wording // tweaks on the server would silently break the old behaviour. const code = e instanceof ApiError ? e.code : undefined; if (code === "INVALID_CODE") { setErrors({ code: msg }); codeShake.shake(); setStep("code"); } else { setErrors({ password: msg }); passwordShake.shake(); } await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } finally { setIsLoading(false); } }; return ( (step === "password" ? setStep("code") : router.back())} accessibilityLabel="Go back" accessibilityRole="button" > {step === "code" ? ( <> Reset password Enter the reset code from your email { setCode(text); if (errors.code) { setErrors({ ...errors, code: undefined }); } }} invalid={!!errors.code} placeholder="Paste your reset code" autoCapitalize="none" autoFocus returnKeyType="done" onSubmitEditing={handleCodeSubmit} accessibilityLabel="Reset code" /> Back to{" "} router.push(routes.login) )} accessibilityRole="link" accessibilityLabel="Sign in" > Sign In ) : ( <> New password Choose a strong password for your account. {encryptionWarning ? `\n${encryptionWarning}` : ""} { setNewPassword(text); if (errors.password) { setErrors({ ...errors, password: undefined }); } checkPasswordDebounced(text); }} invalid={!!errors.password} placeholder="New password" autoFocus accessibilityLabel="New password" /> { setConfirmPassword(text); if (errors.confirmPassword) { setErrors({ ...errors, confirmPassword: undefined }); } }} invalid={!!errors.confirmPassword} placeholder="Confirm new password" returnKeyType="done" onSubmitEditing={handlePasswordSubmit} accessibilityLabel="Confirm new password" /> setStep("code"))} accessibilityRole="link" accessibilityLabel="Use a different code" > Use a different code )} ); } export default ResetPasswordScreen;