Files
rfc-app/frontend/src/components/TurnstileWidget.jsx
T

121 lines
4.7 KiB
React

// 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 <div ref={containerRef} className="turnstile-widget" />
}