All files / src/components/auth ForgotPasswordForm.tsx

100% Statements 30/30
100% Branches 28/28
100% Functions 5/5
100% Lines 29/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124            36x 36x 36x 36x 36x   36x           36x 9x 9x 9x   9x   7x 7x 7x 3x         2x   2x 1x   1x     5x       36x 4x                       1x 1x 1x 1x                                   32x                   8x 1x                                                                  
import { useState } from "react";
import { Link } from "@tanstack/react-router";
import { authApi } from "../../lib/api";
import { isValidEmail } from "../../lib/validation";
 
export function ForgotPasswordForm() {
	const [email, setEmail] = useState("");
	const [isLoading, setIsLoading] = useState(false);
	const [error, setError] = useState<string | null>(null);
	const [success, setSuccess] = useState(false);
	const [touched, setTouched] = useState(false);
 
	const emailError = touched && !email.trim()
		? "Email is required"
		: touched && email.trim() && !isValidEmail(email.trim())
			? "Enter a valid email"
			: undefined;
 
	const handleSubmit = async (e: React.FormEvent) => {
		e.preventDefault();
		setTouched(true);
		setError(null);
 
		if (!email.trim() || !isValidEmail(email.trim())) return;
 
		setIsLoading(true);
		try {
			await authApi.forgotPassword(email.trim());
			setSuccess(true);
		} catch (err) {
			// Distinguish network errors from API errors.
			// Network failures should show a retry message.
			// API errors (email not found, etc.) show success to prevent enumeration.
			const isNetworkError = err instanceof TypeError ||
				(err instanceof Error && err.message === "Failed to fetch");
			if (isNetworkError) {
				setError("Unable to connect. Please check your internet and try again.");
			} else {
				setSuccess(true);
			}
		} finally {
			setIsLoading(false);
		}
	};
 
	if (success) {
		return (
			<div className="space-y-4">
				<div className="rounded-md bg-green-50 border border-green-200 p-4">
					<h3 className="text-sm font-medium text-green-800">Check your email</h3>
					<p className="mt-1 text-sm text-green-700">
						If an account exists for <span className="font-medium">{email}</span>,
						we&apos;ve sent a password reset link. Please check your inbox and spam folder.
					</p>
				</div>
				<button
					type="button"
					onClick={() => {
						setSuccess(false);
						setError(null);
						setEmail("");
						setTouched(false);
					}}
					className="w-full h-10 px-4 border border-input rounded-md bg-background text-foreground text-sm font-medium hover:bg-muted transition-colors"
				>
					Try a different email
				</button>
				<div className="text-center">
					<Link
						to="/auth/sign-in"
						className="text-sm text-primary hover:underline"
					>
						Back to sign in
					</Link>
				</div>
			</div>
		);
	}
 
	return (
		<form onSubmit={handleSubmit} className="space-y-4" noValidate>
			<div>
				<label htmlFor="forgot-email" className="block text-sm font-medium text-foreground mb-1">
					Email
				</label>
				<input
					id="forgot-email"
					type="email"
					value={email}
					onChange={(e) => setEmail(e.target.value)}
					onBlur={() => setTouched(true)}
					placeholder="you@example.com"
					disabled={isLoading}
					className={`w-full h-10 px-3 border rounded-md bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50 ${
						emailError ? "border-red-500 focus:ring-red-500" : "border-input"
					}`}
				/>
				{emailError && <p className="mt-1 text-xs text-destructive">{emailError}</p>}
			</div>
 
			{error && (
				<p role="alert" className="text-sm text-destructive">{error}</p>
			)}
 
			<button
				type="submit"
				disabled={isLoading}
				className="w-full h-10 px-4 bg-primary text-primary-foreground text-sm font-medium rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
			>
				{isLoading ? "Sending..." : "Send reset link"}
			</button>
 
			<div className="text-center">
				<Link
					to="/auth/sign-in"
					className="text-sm text-muted-foreground hover:underline"
				>
					&larr; Back to sign in
				</Link>
			</div>
		</form>
	);
}