// Stateful core of PasswordGenerator (components/password-generator.tsx), // extracted so a platform-specific presentational layer can consume the // same state/logic without re-implementing generate/copy/breach-check. // Native's own UI (the vault mobile app's own screen) needed a genuinely // different, more native-feeling layout rather than the web card/checkbox // layout — see components/password-generator.tsx's own doc comment for // why that stayed a separate render rather than one component branching // on Platform.OS internally. User-requested, 2026-08-17. import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState, } from "react"; import { Platform } from "react-native"; import * as Haptics from "expo-haptics"; import Toast from "react-native-toast-message"; import { copyToClipboard } from "../utils/clipboard"; import { generatePassword } from "../utils/password"; import { checkPasswordPwned } from "../services/hibp"; // A randomly drawn password being in a breach corpus is astronomically // unlikely, but if it ever hits, regenerate until clean — capped so a // misbehaving API can't loop forever. const MAX_BREACH_REGEN = 5; export type BreachState = "idle" | "checking" | "safe" | "unverified"; const isWeb = Platform.OS === "web"; export interface PasswordStrength { label: string; color: string; } export interface UsePasswordGeneratorResult { password: string; length: number; setLength: Dispatch>; includeUppercase: boolean; setIncludeUppercase: Dispatch>; includeLowercase: boolean; setIncludeLowercase: Dispatch>; includeNumbers: boolean; setIncludeNumbers: Dispatch>; includeSymbols: boolean; setIncludeSymbols: Dispatch>; excludeSimilar: boolean; setExcludeSimilar: Dispatch>; breach: BreachState; strength: PasswordStrength; handleGenerate: () => void; handleCopy: () => Promise; } export function usePasswordGenerator(): UsePasswordGeneratorResult { const [password, setPassword] = useState(""); const [length, setLength] = useState(16); const [includeUppercase, setIncludeUppercase] = useState(true); const [includeLowercase, setIncludeLowercase] = useState(true); const [includeNumbers, setIncludeNumbers] = useState(true); const [includeSymbols, setIncludeSymbols] = useState(true); const [excludeSimilar, setExcludeSimilar] = useState(false); const [breach, setBreach] = useState("idle"); // Latest options, read by the breach-regen path without re-subscribing // the check effect on every option change. const optsRef = useRef({ length, includeUppercase, includeLowercase, includeNumbers, includeSymbols, excludeSimilar, }); optsRef.current = { length, includeUppercase, includeLowercase, includeNumbers, includeSymbols, excludeSimilar, }; // Ignore stale async HIBP results once a newer password supersedes them. const breachTokenRef = useRef(0); const breachAttemptRef = useRef(0); // 150 ms throttle on Generate. The CSPRNG draw itself is cheap // (synchronous bytes), but a held-down Enter key or a stuck // accessibility-keyboard repeat could fire many calls per second // and the rejection-sampling loop inside `generatePassword` does // a small amount of work per call. The throttle also gates the // haptic so the user isn't getting buzzed at keypress rate. const lastGenerateRef = useRef(0); const handleGenerate = useCallback(() => { const now = Date.now(); if (now - lastGenerateRef.current < 150) { return; } lastGenerateRef.current = now; try { const next = generatePassword({ length, includeUppercase, includeLowercase, includeNumbers, includeSymbols, excludeSimilar, }); setPassword(next); if (!isWeb) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } } catch (e) { Toast.show({ type: "error", text1: e instanceof Error ? e.message : "Please select at least one character type", visibilityTime: 3000, }); } }, [length, includeUppercase, includeLowercase, includeNumbers, includeSymbols, excludeSimilar]); const handleCopy = useCallback(async () => { if (!password) { return; } const ok = await copyToClipboard(password); if (!ok) { return; } if (!isWeb) { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } Toast.show({ type: "success", text1: "Copied to clipboard", visibilityTime: 2000 }); }, [password]); // Generate on mount and when character-class options change. useEffect(() => { handleGenerate(); }, [handleGenerate]); // Verify each generated password against HIBP (k-anonymity — only the SHA-1 // prefix leaves the device). Debounced so spamming Generate only checks the // final password. On the (near-impossible) breach hit, silently regenerate; // a failed/offline check surfaces as "unverified" rather than blocking. useEffect(() => { if (!password) { setBreach("idle"); return; } const token = ++breachTokenRef.current; setBreach("checking"); const timer = setTimeout(async () => { const res = await checkPasswordPwned(password); if (token !== breachTokenRef.current) { return; } // superseded if (res.isPwned === true && breachAttemptRef.current < MAX_BREACH_REGEN) { breachAttemptRef.current += 1; try { setPassword(generatePassword(optsRef.current)); } catch { setBreach("unverified"); } return; } breachAttemptRef.current = 0; setBreach(res.isPwned === null ? "unverified" : "safe"); }, 400); return () => clearTimeout(timer); }, [password]); const strength = getPasswordStrength(password); return { password, length, setLength, includeUppercase, setIncludeUppercase, includeLowercase, setIncludeLowercase, includeNumbers, setIncludeNumbers, includeSymbols, setIncludeSymbols, excludeSimilar, setExcludeSimilar, breach, strength, handleGenerate, handleCopy, }; } /** Coarse strength indicator — purely UX, server doesn't see it. */ export function getPasswordStrength(password: string): PasswordStrength { if (!password) { return { label: "Generate a password", color: "gray" }; } let strength = 0; if (password.length >= 12) { strength++; } if (password.length >= 16) { strength++; } if (/[a-z]/.test(password)) { strength++; } if (/[A-Z]/.test(password)) { strength++; } if (/[0-9]/.test(password)) { strength++; } if (/[^A-Za-z0-9]/.test(password)) { strength++; } if (strength <= 2) { return { label: "Weak", color: "#ef4444" }; } if (strength <= 4) { return { label: "Medium", color: "#f59e0b" }; } return { label: "Strong", color: "#22c55e" }; }