121 lines
4.7 KiB
React
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" />
|
|
}
|