Files
mgeeky-decode-spam-headers/frontend/src/components/CaptchaChallenge.tsx
2026-02-18 04:47:43 +01:00

233 lines
7.4 KiB
TypeScript

"use client";
import { useCallback, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLock, faUnlock } from "@fortawesome/free-solid-svg-icons";
import type { CaptchaChallengeData, CaptchaVerifyPayload } from "../types/captcha";
type CaptchaChallengeProps = {
isOpen: boolean;
challenge?: CaptchaChallengeData | null;
onVerify: (payload: CaptchaVerifyPayload) => Promise<string>;
onSuccess: (bypassToken: string) => void;
onRetry: () => void | Promise<void>;
onClose: () => void;
};
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return "Verification failed. Please try again.";
};
export default function CaptchaChallenge({
isOpen,
challenge,
onVerify,
onSuccess,
onRetry,
onClose,
}: CaptchaChallengeProps) {
const [answer, setAnswer] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const titleId = useId();
const descriptionId = useId();
const inputRef = useRef<HTMLInputElement | null>(null);
const answerRef = useRef("");
const modalRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (!isOpen) {
return;
}
setAnswer("");
setError(null);
setIsSubmitting(false);
answerRef.current = "";
}, [isOpen, challenge?.challengeToken]);
useLayoutEffect(() => {
if (!isOpen) {
return;
}
inputRef.current?.focus();
}, [isOpen, challenge?.challengeToken]);
const commitAnswer = useCallback((value: string) => {
answerRef.current = value;
setAnswer(value);
}, []);
const handleSubmit = useCallback(async () => {
if (!challenge || isSubmitting) {
return;
}
setIsSubmitting(true);
setError(null);
try {
const bypassToken = await onVerify({
challengeToken: challenge.challengeToken,
answer: (inputRef.current?.value ?? answerRef.current ?? answer).trim(),
});
onSuccess(bypassToken);
await Promise.resolve(onRetry());
onClose();
} catch (err) {
setError(getErrorMessage(err));
} finally {
setIsSubmitting(false);
}
}, [answer, challenge, isSubmitting, onClose, onRetry, onSuccess, onVerify]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") {
return;
}
const container = modalRef.current;
if (!container) {
return;
}
const focusableElements = Array.from(
container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true",
);
if (focusableElements.length === 0) {
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey && activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
return;
}
if (!event.shiftKey && activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
},
[onClose],
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
void handleSubmit();
}
},
[handleSubmit],
);
if (!isOpen || !challenge) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4">
<div
className="w-full max-w-md rounded-2xl border border-info/20 bg-surface p-6 shadow-[0_0_45px_rgba(15,23,42,0.5)]"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
data-testid="captcha-challenge"
onKeyDown={handleKeyDown}
tabIndex={-1}
ref={modalRef}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="rounded-full border border-info/20 bg-background/50 p-2 text-xs text-info">
<FontAwesomeIcon icon={faLock} />
</span>
<div className="flex flex-col">
<h2 id={titleId} className="text-sm font-semibold text-text">
Security Check Required
</h2>
<p id={descriptionId} className="text-xs text-text/60">
Solve the CAPTCHA to continue analysis.
</p>
</div>
</div>
<button
type="button"
className="rounded-full border border-info/20 bg-background/40 px-3 py-2 text-xs text-text/70 transition hover:border-info/40 hover:text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info"
onClick={onClose}
data-testid="captcha-close"
aria-label="Close captcha challenge"
>
Close
</button>
</div>
<div className="mt-5 flex flex-col gap-4">
<div className="rounded-xl border border-info/20 bg-background/50 p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="CAPTCHA challenge"
className="w-full rounded-lg"
data-testid="captcha-image"
/>
</div>
<label className="flex flex-col gap-2 text-xs text-text/70">
Enter the text shown above
<input
type="text"
autoComplete="off"
value={answer}
onChange={(event) => commitAnswer(event.target.value)}
onInput={(event) => commitAnswer(event.currentTarget.value)}
onKeyDown={handleInputKeyDown}
data-testid="captcha-input"
ref={inputRef}
className="rounded-xl border border-info/20 bg-background/50 px-4 py-3 text-sm text-text outline-none transition focus:border-info/60 focus:ring-2 focus:ring-info/30"
/>
</label>
{error ? (
<p role="alert" className="text-xs text-spam" data-testid="captcha-error">
{error}
</p>
) : null}
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-xl border border-info/30 bg-info/20 px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-info transition hover:border-info/50 hover:text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleSubmit()}
disabled={isSubmitting}
data-testid="captcha-submit"
>
<FontAwesomeIcon icon={faUnlock} />
{isSubmitting ? "Verifying" : "Verify"}
</button>
</div>
</div>
</div>
);
}