"use client"; import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; const SCRIPT_ID = "cf-turnstile-script"; const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; type TurnstileApi = { render: (el: HTMLElement, opts: Record) => string; reset: (id: string) => void; remove: (id: string) => void; }; declare global { // eslint-disable-next-line no-var var turnstile: TurnstileApi | undefined; } /** * Load Cloudflare's script once per page, no matter how many widgets mount. * `render=explicit` keeps control in our hands so the widget can be reset — * Turnstile tokens are single-use, so every failed submit needs a fresh one. */ let scriptPromise: Promise | null = null; function loadTurnstileScript(): Promise { if (typeof window === "undefined") return Promise.resolve(); if (window.turnstile) return Promise.resolve(); if (scriptPromise) return scriptPromise; scriptPromise = new Promise((resolve, reject) => { const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null; if (existing) { existing.addEventListener("load", () => resolve()); existing.addEventListener("error", () => reject(new Error("Turnstile failed to load"))); return; } const script = document.createElement("script"); script.id = SCRIPT_ID; script.src = SCRIPT_SRC; script.async = true; script.defer = true; script.onload = () => resolve(); script.onerror = () => { // Allow a later mount to retry (e.g. the user was briefly offline). scriptPromise = null; reject(new Error("Turnstile failed to load")); }; document.head.appendChild(script); }); return scriptPromise; } /** * `script.onload` does not guarantee `window.turnstile` is assigned yet, and the * original code silently gave up forever when it wasn't — the widget would just * never appear. Poll briefly instead so a slow parse still resolves. */ async function waitForTurnstileApi(timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs; while (!window.turnstile && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 50)); } } export type TurnstileHandle = { reset: () => void }; type TurnstileProps = { siteKey: string; /** Fires with the solved token, or "" whenever the token becomes unusable. */ onToken: (token: string) => void; onError?: () => void; className?: string; }; /** * Renders the Turnstile challenge. The parent owns the token and must send it * as the `x-captcha-response` header; verification happens server-side in * `lib/auth/auth.ts`. */ export const Turnstile = forwardRef(function Turnstile( { siteKey, onToken, onError, className }, ref ) { const containerRef = useRef(null); const widgetIdRef = useRef(null); // Hold the callbacks in refs so re-renders never tear down the widget — // re-rendering it would drop a token the user already solved. const onTokenRef = useRef(onToken); const onErrorRef = useRef(onError); onTokenRef.current = onToken; onErrorRef.current = onError; useImperativeHandle( ref, () => ({ reset() { if (widgetIdRef.current && window.turnstile) { window.turnstile.reset(widgetIdRef.current); onTokenRef.current(""); } }, }), [] ); useEffect(() => { let cancelled = false; loadTurnstileScript() .then(() => waitForTurnstileApi()) .then(() => { if (cancelled || widgetIdRef.current || !containerRef.current || !window.turnstile) return; widgetIdRef.current = window.turnstile.render(containerRef.current, { sitekey: siteKey, // The auth screens render outside next-themes' provider, so let the // widget follow the OS colour scheme instead of the app's theme. theme: "auto", callback: (token: string) => onTokenRef.current(token), // Any of these means we no longer hold a usable token. "error-callback": () => { onTokenRef.current(""); onErrorRef.current?.(); }, "expired-callback": () => onTokenRef.current(""), "timeout-callback": () => onTokenRef.current(""), }); }) .catch(() => onErrorRef.current?.()); return () => { cancelled = true; const id = widgetIdRef.current; widgetIdRef.current = null; if (id && window.turnstile) { try { window.turnstile.remove(id); } catch { // Widget already gone (e.g. React 18/19 StrictMode double-invoke). } } }; }, [siteKey]); return
; });