mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
233 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|