← back to Open Seo
src/client/features/auth/TurnstileWidget.tsx
128 lines
import { useCallback, useEffect, useRef, useState } from "react";
const TURNSTILE_SCRIPT_SRC =
"https://challenges.cloudflare.com/turnstile/v0/api.js";
// Public Turnstile site key, inlined at build time (see vite.config envPrefix).
// The widget only renders when it's set, so unconfigured / self-hosted builds
// are unaffected. The matching TURNSTILE_SECRET_KEY lives server-side only.
export const TURNSTILE_SITE_KEY = import.meta.env.TURNSTILE_SITE_KEY?.trim();
type TurnstileApi = {
render: (
element: HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
},
) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
// Captcha state for a form: the token in a ref (read at submit time, so the
// form's submit closure never sees a stale value), a boolean mirror to drive
// the submit button, and a reset (tokens are single-use — re-challenge after a
// failed submit). Wire `onToken`/`resetNonce` into <TurnstileWidget />.
export function useTurnstileCaptcha() {
const tokenRef = useRef<string | null>(null);
const [hasToken, setHasToken] = useState(false);
const [resetNonce, setResetNonce] = useState(0);
const onToken = useCallback((token: string | null) => {
tokenRef.current = token;
setHasToken(Boolean(token));
}, []);
const reset = useCallback(() => {
tokenRef.current = null;
setHasToken(false);
setResetNonce((nonce) => nonce + 1);
}, []);
return { tokenRef, hasToken, resetNonce, onToken, reset };
}
// Renders the Cloudflare Turnstile challenge and reports its token. `onToken`
// fires with the token when solved and with null when it expires/errors.
// Bump `resetNonce` to re-challenge (tokens are single-use, so reset after a
// failed submit).
export function TurnstileWidget({
onToken,
resetNonce,
}: {
onToken: (token: string | null) => void;
resetNonce: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
// Keep the latest callback in a ref so mounting the widget stays a one-time
// effect (a fresh onToken each render must not tear down and re-render it).
const onTokenRef = useRef(onToken);
onTokenRef.current = onToken;
useEffect(() => {
if (!TURNSTILE_SITE_KEY) return;
let cancelled = false;
const renderWidget = () => {
if (
cancelled ||
widgetIdRef.current !== null ||
!containerRef.current ||
!window.turnstile
) {
return;
}
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: TURNSTILE_SITE_KEY,
callback: (token) => onTokenRef.current(token),
"expired-callback": () => onTokenRef.current(null),
"error-callback": () => onTokenRef.current(null),
});
};
if (window.turnstile) {
renderWidget();
} else {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${TURNSTILE_SCRIPT_SRC}"]`,
);
const script = existing ?? document.createElement("script");
script.addEventListener("load", renderWidget);
if (!existing) {
script.src = TURNSTILE_SCRIPT_SRC;
script.async = true;
document.head.appendChild(script);
}
}
return () => {
cancelled = true;
if (widgetIdRef.current !== null && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
}
widgetIdRef.current = null;
};
}, []);
useEffect(() => {
if (resetNonce === 0) return;
if (widgetIdRef.current !== null && window.turnstile) {
window.turnstile.reset(widgetIdRef.current);
onTokenRef.current(null);
}
}, [resetNonce]);
if (!TURNSTILE_SITE_KEY) return null;
return <div ref={containerRef} className="flex justify-center" />;
}