File
Blob: src/client/components/turnstile-widget.tsx
| 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | import { Loader2, RefreshCw } from "lucide-react"; |
| 3 | import { getErrorMessage, getPublicConfig } from "@/client/lib/api"; |
| 4 | |
| 5 | interface TurnstileRenderOptions { |
| 6 | action: string; |
| 7 | appearance?: "always" | "execute" | "interaction-only"; |
| 8 | callback: (token: string) => void; |
| 9 | "error-callback": () => void; |
| 10 | "expired-callback": () => void; |
| 11 | sitekey: string; |
| 12 | size?: "normal" | "compact" | "flexible"; |
| 13 | theme?: "auto" | "light" | "dark"; |
| 14 | } |
| 15 | |
| 16 | interface TurnstileApi { |
| 17 | remove(widgetId: string): void; |
| 18 | render(container: HTMLElement, options: TurnstileRenderOptions): string; |
| 19 | reset(widgetId: string): void; |
| 20 | } |
| 21 | |
| 22 | declare global { |
| 23 | interface Window { |
| 24 | turnstile?: TurnstileApi; |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | let turnstileScriptPromise: Promise<TurnstileApi> | null = null; |
| 29 | |
| 30 | function loadTurnstileScript() { |
| 31 | if (window.turnstile) { |
| 32 | return Promise.resolve(window.turnstile); |
| 33 | } |
| 34 | |
| 35 | if (turnstileScriptPromise) { |
| 36 | return turnstileScriptPromise; |
| 37 | } |
| 38 | |
| 39 | turnstileScriptPromise = new Promise<TurnstileApi>((resolve, reject) => { |
| 40 | const existingScript = document.querySelector<HTMLScriptElement>( |
| 41 | 'script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]', |
| 42 | ); |
| 43 | |
| 44 | const handleLoad = () => { |
| 45 | if (window.turnstile) { |
| 46 | resolve(window.turnstile); |
| 47 | return; |
| 48 | } |
| 49 | |
| 50 | turnstileScriptPromise = null; |
| 51 | reject(new Error("Human verification is temporarily unavailable.")); |
| 52 | }; |
| 53 | |
| 54 | const handleError = () => { |
| 55 | turnstileScriptPromise = null; |
| 56 | reject(new Error("Human verification is temporarily unavailable.")); |
| 57 | }; |
| 58 | |
| 59 | if (existingScript) { |
| 60 | existingScript.addEventListener("load", handleLoad, { once: true }); |
| 61 | existingScript.addEventListener("error", handleError, { once: true }); |
| 62 | return; |
| 63 | } |
| 64 | |
| 65 | const script = document.createElement("script"); |
| 66 | script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; |
| 67 | script.async = true; |
| 68 | script.defer = true; |
| 69 | script.addEventListener("load", handleLoad, { once: true }); |
| 70 | script.addEventListener("error", handleError, { once: true }); |
| 71 | document.head.appendChild(script); |
| 72 | }); |
| 73 | |
| 74 | return turnstileScriptPromise; |
| 75 | } |
| 76 | |
| 77 | interface TurnstileWidgetProps { |
| 78 | action: string; |
| 79 | onError?: (error: string | null) => void; |
| 80 | onTokenChange: (token: string | null) => void; |
| 81 | resetKey: number; |
| 82 | } |
| 83 | |
| 84 | export function TurnstileWidget({ action, onError, onTokenChange, resetKey }: TurnstileWidgetProps) { |
| 85 | const containerRef = useRef<HTMLDivElement | null>(null); |
| 86 | const widgetIdRef = useRef<string | null>(null); |
| 87 | const previousResetKeyRef = useRef(resetKey); |
| 88 | const [turnstile, setTurnstile] = useState<TurnstileApi | null>(null); |
| 89 | const [siteKey, setSiteKey] = useState<string | null>(null); |
| 90 | const [loading, setLoading] = useState(true); |
| 91 | const [error, setError] = useState<string | null>(null); |
| 92 | const [loadError, setLoadError] = useState(false); |
| 93 | const [loadKey, setLoadKey] = useState(0); |
| 94 | |
| 95 | useEffect(() => { |
| 96 | let active = true; |
| 97 | |
| 98 | const load = async () => { |
| 99 | setLoading(true); |
| 100 | setError(null); |
| 101 | setLoadError(false); |
| 102 | |
| 103 | try { |
| 104 | const [config, api] = await Promise.all([getPublicConfig(), loadTurnstileScript()]); |
| 105 | if (!active) { |
| 106 | return; |
| 107 | } |
| 108 | |
| 109 | setSiteKey(config.turnstileSiteKey); |
| 110 | setTurnstile(api); |
| 111 | } catch (nextError) { |
| 112 | if (!active) { |
| 113 | return; |
| 114 | } |
| 115 | |
| 116 | onTokenChange(null); |
| 117 | const message = getErrorMessage(nextError); |
| 118 | setError(message); |
| 119 | setLoadError(true); |
| 120 | onError?.(message); |
| 121 | } finally { |
| 122 | if (active) { |
| 123 | setLoading(false); |
| 124 | } |
| 125 | } |
| 126 | }; |
| 127 | |
| 128 | void load(); |
| 129 | |
| 130 | return () => { |
| 131 | active = false; |
| 132 | }; |
| 133 | }, [loadKey, onError, onTokenChange]); |
| 134 | |
| 135 | useEffect(() => { |
| 136 | if (!containerRef.current || !turnstile || !siteKey || widgetIdRef.current) { |
| 137 | return; |
| 138 | } |
| 139 | |
| 140 | widgetIdRef.current = turnstile.render(containerRef.current, { |
| 141 | sitekey: siteKey, |
| 142 | action, |
| 143 | theme: "dark", |
| 144 | size: "flexible", |
| 145 | appearance: "interaction-only", |
| 146 | callback: (token) => { |
| 147 | setError(null); |
| 148 | onError?.(null); |
| 149 | onTokenChange(token); |
| 150 | }, |
| 151 | "error-callback": () => { |
| 152 | const message = "Human verification failed. Please try again."; |
| 153 | onTokenChange(null); |
| 154 | setError(message); |
| 155 | onError?.(message); |
| 156 | }, |
| 157 | "expired-callback": () => { |
| 158 | const message = "Human verification expired. Please try again."; |
| 159 | onTokenChange(null); |
| 160 | setError(message); |
| 161 | onError?.(message); |
| 162 | }, |
| 163 | }); |
| 164 | |
| 165 | // Cloudflare Turnstile can render its host on a fractional Y position |
| 166 | // (for example, on 1.5 DPR displays), which causes the widget to lose its |
| 167 | // bottom border by 1px. Snap the host upward by the fractional bottom |
| 168 | // offset after render and on resize to keep the border visible. |
| 169 | const alignWidgetHost = () => { |
| 170 | const container = containerRef.current; |
| 171 | const host = container?.firstElementChild; |
| 172 | if (!container || !(host instanceof HTMLElement)) { |
| 173 | return; |
| 174 | } |
| 175 | |
| 176 | const bottom = container.getBoundingClientRect().bottom; |
| 177 | const bottomFraction = bottom - Math.floor(bottom); |
| 178 | host.style.transform = bottomFraction > 0.001 ? `translateY(${-bottomFraction}px)` : ""; |
| 179 | }; |
| 180 | |
| 181 | const frameId = window.requestAnimationFrame(alignWidgetHost); |
| 182 | window.addEventListener("resize", alignWidgetHost); |
| 183 | |
| 184 | return () => { |
| 185 | window.cancelAnimationFrame(frameId); |
| 186 | window.removeEventListener("resize", alignWidgetHost); |
| 187 | if (widgetIdRef.current) { |
| 188 | turnstile.remove(widgetIdRef.current); |
| 189 | widgetIdRef.current = null; |
| 190 | } |
| 191 | }; |
| 192 | }, [action, onError, onTokenChange, siteKey, turnstile]); |
| 193 | |
| 194 | useEffect(() => { |
| 195 | if (!turnstile || !widgetIdRef.current) { |
| 196 | previousResetKeyRef.current = resetKey; |
| 197 | return; |
| 198 | } |
| 199 | |
| 200 | if (resetKey === previousResetKeyRef.current) { |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | previousResetKeyRef.current = resetKey; |
| 205 | onTokenChange(null); |
| 206 | setError(null); |
| 207 | turnstile.reset(widgetIdRef.current); |
| 208 | }, [onTokenChange, resetKey, turnstile]); |
| 209 | |
| 210 | return ( |
| 211 | <div className="space-y-2"> |
| 212 | <div ref={containerRef} /> |
| 213 | {loading ? ( |
| 214 | <p className="flex items-center gap-2 text-sm text-zinc-400"> |
| 215 | <Loader2 className="h-3.5 w-3.5 animate-spin" /> |
| 216 | Loading human verification... |
| 217 | </p> |
| 218 | ) : null} |
| 219 | {error ? ( |
| 220 | <div className="flex items-center gap-3"> |
| 221 | <p className="text-sm text-red-400">{error}</p> |
| 222 | {loadError ? ( |
| 223 | <button |
| 224 | type="button" |
| 225 | onClick={() => setLoadKey((value) => value + 1)} |
| 226 | className="flex shrink-0 items-center gap-1.5 rounded-lg border border-zinc-700/60 bg-zinc-800/60 px-2.5 py-1 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-700/60" |
| 227 | > |
| 228 | <RefreshCw className="h-3 w-3" /> |
| 229 | Retry |
| 230 | </button> |
| 231 | ) : null} |
| 232 | </div> |
| 233 | ) : null} |
| 234 | </div> |
| 235 | ); |
| 236 | } |