248 lines
7.2 KiB
TypeScript
248 lines
7.2 KiB
TypeScript
import React, { useState, useRef, useEffect } from "react";
|
|
import { authAPI } from "../services/api";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
|
|
interface VerificationCodeModalProps {
|
|
show: boolean;
|
|
onHide: () => void;
|
|
email: string;
|
|
onVerified: () => void;
|
|
}
|
|
|
|
const VerificationCodeModal: React.FC<VerificationCodeModalProps> = ({
|
|
show,
|
|
onHide,
|
|
email,
|
|
onVerified,
|
|
}) => {
|
|
const [code, setCode] = useState<string[]>(["", "", "", "", "", ""]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [resending, setResending] = useState(false);
|
|
const [resendCooldown, setResendCooldown] = useState(0);
|
|
const [resendSuccess, setResendSuccess] = useState(false);
|
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
const { checkAuth } = useAuth();
|
|
|
|
// Handle resend cooldown timer
|
|
useEffect(() => {
|
|
if (resendCooldown > 0) {
|
|
const timer = setTimeout(
|
|
() => setResendCooldown(resendCooldown - 1),
|
|
1000
|
|
);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [resendCooldown]);
|
|
|
|
// Focus first input on mount
|
|
useEffect(() => {
|
|
if (show && inputRefs.current[0]) {
|
|
inputRefs.current[0].focus();
|
|
}
|
|
}, [show]);
|
|
|
|
const handleInputChange = (index: number, value: string) => {
|
|
// Only allow digits
|
|
if (value && !/^\d$/.test(value)) return;
|
|
|
|
const newCode = [...code];
|
|
newCode[index] = value;
|
|
setCode(newCode);
|
|
setError("");
|
|
setResendSuccess(false);
|
|
|
|
// Auto-advance to next input
|
|
if (value && index < 5) {
|
|
inputRefs.current[index + 1]?.focus();
|
|
}
|
|
|
|
// Auto-submit when all 6 digits entered
|
|
if (value && index === 5 && newCode.every((d) => d !== "")) {
|
|
handleVerify(newCode.join(""));
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
|
if (e.key === "Backspace" && !code[index] && index > 0) {
|
|
inputRefs.current[index - 1]?.focus();
|
|
}
|
|
};
|
|
|
|
const handlePaste = (e: React.ClipboardEvent) => {
|
|
e.preventDefault();
|
|
const pastedData = e.clipboardData
|
|
.getData("text")
|
|
.replace(/\D/g, "")
|
|
.slice(0, 6);
|
|
if (pastedData.length === 6) {
|
|
const newCode = pastedData.split("");
|
|
setCode(newCode);
|
|
handleVerify(pastedData);
|
|
}
|
|
};
|
|
|
|
const handleVerify = async (verificationCode: string) => {
|
|
setLoading(true);
|
|
setError("");
|
|
setResendSuccess(false);
|
|
|
|
try {
|
|
await authAPI.verifyEmail(verificationCode);
|
|
await checkAuth(); // Refresh user data
|
|
onVerified();
|
|
onHide();
|
|
} catch (err: any) {
|
|
const errorData = err.response?.data;
|
|
if (errorData?.code === "TOO_MANY_ATTEMPTS") {
|
|
setError("Too many attempts. Please request a new code below.");
|
|
} else if (errorData?.code === "VERIFICATION_EXPIRED") {
|
|
setError("Code expired. Please request a new code below.");
|
|
} else if (errorData?.code === "VERIFICATION_INVALID") {
|
|
setError(
|
|
"That code didn't match. Please try again or request a new code below."
|
|
);
|
|
} else {
|
|
setError(errorData?.error || "Verification failed. Please try again.");
|
|
}
|
|
// Clear code on error
|
|
setCode(["", "", "", "", "", ""]);
|
|
inputRefs.current[0]?.focus();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResend = async () => {
|
|
setResending(true);
|
|
setError("");
|
|
setResendSuccess(false);
|
|
|
|
try {
|
|
await authAPI.resendVerification();
|
|
setResendCooldown(60); // 60 second cooldown
|
|
setResendSuccess(true);
|
|
setCode(["", "", "", "", "", ""]);
|
|
inputRefs.current[0]?.focus();
|
|
} catch (err: any) {
|
|
if (err.response?.status === 429) {
|
|
setError("Please wait before requesting another code.");
|
|
} else {
|
|
setError("Failed to resend code. Please try again.");
|
|
}
|
|
} finally {
|
|
setResending(false);
|
|
}
|
|
};
|
|
|
|
if (!show) return null;
|
|
|
|
return (
|
|
<div
|
|
className="modal show d-block"
|
|
tabIndex={-1}
|
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
|
>
|
|
<div className="modal-dialog modal-dialog-centered">
|
|
<div className="modal-content">
|
|
<div className="modal-header border-0 pb-0">
|
|
<button
|
|
type="button"
|
|
className="btn-close"
|
|
onClick={onHide}
|
|
aria-label="Close"
|
|
></button>
|
|
</div>
|
|
<div className="modal-body px-4 pb-4 text-center">
|
|
<i
|
|
className="bi bi-envelope-check text-success"
|
|
style={{ fontSize: "3rem" }}
|
|
></i>
|
|
<h4 className="mt-3">Verify Your Email</h4>
|
|
<p className="text-muted">
|
|
We sent a 6-digit code to <strong>{email}</strong>
|
|
</p>
|
|
|
|
{error && (
|
|
<div className="alert alert-danger py-2" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{resendSuccess && (
|
|
<div className="alert alert-success py-2" role="alert">
|
|
New code sent! Check your email.
|
|
</div>
|
|
)}
|
|
|
|
{/* 6-digit code input */}
|
|
<div
|
|
className="d-flex justify-content-center gap-2 my-4"
|
|
onPaste={handlePaste}
|
|
>
|
|
{code.map((digit, index) => (
|
|
<input
|
|
key={index}
|
|
ref={(el) => {
|
|
inputRefs.current[index] = el;
|
|
}}
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={1}
|
|
value={digit}
|
|
onChange={(e) => handleInputChange(index, e.target.value)}
|
|
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
className="form-control text-center"
|
|
style={{
|
|
width: "50px",
|
|
height: "60px",
|
|
fontSize: "24px",
|
|
fontWeight: "bold",
|
|
}}
|
|
disabled={loading}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
className="btn btn-success w-100 py-3"
|
|
onClick={() => handleVerify(code.join(""))}
|
|
disabled={loading || code.some((d) => d === "")}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" />
|
|
Verifying...
|
|
</>
|
|
) : (
|
|
"Verify Email"
|
|
)}
|
|
</button>
|
|
|
|
<div className="mt-4">
|
|
<p className="text-muted small mb-2">Didn't receive the code?</p>
|
|
<button
|
|
className="btn btn-link text-decoration-none p-0"
|
|
onClick={handleResend}
|
|
disabled={resending || resendCooldown > 0}
|
|
>
|
|
{resendCooldown > 0
|
|
? `Send in ${resendCooldown}s`
|
|
: resending
|
|
? "Sending..."
|
|
: "Send Code"}
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-muted small mt-3">
|
|
Check your spam folder if you don't see the email.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default VerificationCodeModal;
|