// TurnstileWidget.jsx — v0.12.0 / roadmap item #10. // // Renders the CloudFlare Turnstile JS widget on the email-entry step of // `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY` // (Vite convention — VITE_* prefix is build-time embedded). When the // site key is unset/empty, this component renders nothing and reports // a `null` token through `onToken` so the parent form can still submit. // The backend's `TURNSTILE_REQUIRED` policy decides what happens to a // request that arrives without a token; the frontend is intentionally // not in that loop. See `backend/app/turnstile.py` for the matrix. // // The CloudFlare script is loaded once per page on first widget mount. // Subsequent mounts (e.g. user goes back to email-entry after a failed // OTC request) reuse the script tag and re-render the widget on the // fresh container `div`. Unmounting removes the widget instance via // `turnstile.remove(widgetId)` so a remount produces a new challenge // rather than reusing a stale, already-consumed token. // // Turnstile contract: // * `data-callback` fires with the token string on a successful // challenge; the token is single-use and expires after ~5 minutes. // * `data-error-callback` fires on a failed challenge (network, // blocked, etc.); we surface a `null` token so the parent shows // a retry hint. // * `data-expired-callback` fires when the token times out before // submission; we also drop to `null` and re-render so the user // gets a fresh challenge on retry. // // We do **not** import the CloudFlare script at build time; loading it // dynamically here keeps the bundle clean of an external request the // page may not need (anonymous viewers reading RFCs never see Login). import { useEffect, useRef } from 'react' const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js' const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' // Promise-keyed: only one script tag, only one resolution chain. let scriptLoadPromise = null function loadTurnstileScript() { if (typeof window === 'undefined') return Promise.resolve(null) if (window.turnstile) return Promise.resolve(window.turnstile) if (scriptLoadPromise) return scriptLoadPromise scriptLoadPromise = new Promise((resolve, reject) => { const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`) if (existing) { existing.addEventListener('load', () => resolve(window.turnstile)) existing.addEventListener('error', reject) return } const script = document.createElement('script') script.src = TURNSTILE_SCRIPT_URL script.async = true script.defer = true script.addEventListener('load', () => resolve(window.turnstile)) script.addEventListener('error', reject) document.head.appendChild(script) }) return scriptLoadPromise } export function turnstileEnabled() { return !!SITE_KEY } export default function TurnstileWidget({ onToken, theme = 'auto' }) { const containerRef = useRef(null) const widgetIdRef = useRef(null) useEffect(() => { if (!SITE_KEY) { // No site key configured → surface a null token immediately so // the parent form's submit-disabled gate doesn't lock up // waiting on a challenge that will never arrive. The backend // decides whether a tokenless request is admitted. onToken?.(null) return undefined } let cancelled = false loadTurnstileScript() .then(turnstile => { if (cancelled || !turnstile || !containerRef.current) return widgetIdRef.current = turnstile.render(containerRef.current, { sitekey: SITE_KEY, theme, callback: token => onToken?.(token), 'error-callback': () => onToken?.(null), 'expired-callback': () => onToken?.(null), }) }) .catch(() => { // Script load failure — surface null so the parent can decide // what to do (today: still let submit through; the backend // policy decides admission). if (!cancelled) onToken?.(null) }) return () => { cancelled = true if (widgetIdRef.current && window.turnstile) { try { window.turnstile.remove(widgetIdRef.current) } catch (_) { // Already gone or never registered — nothing to clean up. } widgetIdRef.current = null } } // We intentionally do not list `onToken` in the dependency array; // a parent re-rendering with a fresh closure should not tear down // and rebuild the widget (which would consume a fresh challenge). // eslint-disable-next-line react-hooks/exhaustive-deps }, []) if (!SITE_KEY) return null return
}