From b08d65c53cb68642500cb42f68fcf0319497c3c5 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Thu, 2 Apr 2026 22:35:55 +0200 Subject: [PATCH] fix: use fresh Turnstile token for resend on Login and Register pages Turnstile tokens are single-use. The resend flow was reusing the consumed token from the initial submission, causing "timeout-or-duplicate" errors. - Add Turnstile widget to the resend view so a fresh token is obtained - Add resetSignal prop to Turnstile component to re-solve after each resend - Clear token after each successful API call to prevent stale reuse - Guard handleResend against null token - Add test for resetSignal behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/__tests__/turnstile.test.tsx | 34 +++++++++++++++++++++++ frontend/src/components/Turnstile.tsx | 14 +++++++++- frontend/src/pages/Login.tsx | 17 ++++++++++-- frontend/src/pages/Register.tsx | 17 ++++++++++-- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/turnstile.test.tsx b/frontend/src/__tests__/turnstile.test.tsx index a9cc75d..9639036 100644 --- a/frontend/src/__tests__/turnstile.test.tsx +++ b/frontend/src/__tests__/turnstile.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, cleanup } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; import Turnstile from '~/components/Turnstile'; beforeEach(() => { @@ -80,6 +81,39 @@ describe('Turnstile component', () => { expect(onError).toHaveBeenCalledTimes(1); }); + it('should reset widget when resetSignal changes', () => { + const onToken = vi.fn(); + const resetMock = vi.fn(); + const renderMock = vi.fn((_el: HTMLElement, opts: any) => { + opts.callback('token-1'); + return 'widget-reset'; + }); + + (window as any).turnstile = { + render: renderMock, + reset: resetMock, + remove: vi.fn(), + }; + + const [resetCount, setResetCount] = createSignal(0); + + render(() => ( + + )); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(resetMock).not.toHaveBeenCalled(); + + // Trigger reset + setResetCount(1); + expect(resetMock).toHaveBeenCalledTimes(1); + expect(resetMock).toHaveBeenCalledWith('widget-reset'); + + // Trigger another reset + setResetCount(2); + expect(resetMock).toHaveBeenCalledTimes(2); + }); + it('should clear interval on unmount during polling', () => { const onToken = vi.fn(); const onError = vi.fn(); diff --git a/frontend/src/components/Turnstile.tsx b/frontend/src/components/Turnstile.tsx index ca72810..fd5b6da 100644 --- a/frontend/src/components/Turnstile.tsx +++ b/frontend/src/components/Turnstile.tsx @@ -1,4 +1,5 @@ -import { type Component, onMount, onCleanup } from 'solid-js'; +import { type Component, onMount, onCleanup, createEffect, on } from 'solid-js'; +import type { Accessor } from 'solid-js'; declare global { interface Window { @@ -24,6 +25,8 @@ interface TurnstileProps { onToken: (token: string) => void; onExpired?: () => void; onError?: () => void; + /** Reactive signal — when its value changes, the widget is reset to obtain a fresh token. */ + resetSignal?: Accessor; } /** Turnstile site key loaded from env, with a Cloudflare testing key as fallback. */ @@ -84,6 +87,15 @@ const Turnstile: Component = (props) => { } }); + // Reset widget when resetSignal changes (skip the initial value) + if (props.resetSignal) { + createEffect(on(props.resetSignal, () => { + if (widgetId && window.turnstile) { + window.turnstile.reset(widgetId); + } + }, { defer: true })); + } + onCleanup(() => { if (widgetId && window.turnstile) { window.turnstile.remove(widgetId); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index ddfa5e6..39f9728 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -35,6 +35,7 @@ const Login: Component = () => { const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(null); const [resendCooldown, setResendCooldown] = createSignal(0); + const [turnstileResetCount, setTurnstileResetCount] = createSignal(0); // Redirect if already authenticated createEffect(() => { @@ -84,6 +85,7 @@ const Login: Component = () => { turnstile_token: turnstileToken()!, }); setSubmitted(true); + setTurnstileToken(null); setResendCooldown(60); } catch (err) { if (isApiError(err)) { @@ -101,7 +103,7 @@ const Login: Component = () => { }; const handleResend = async () => { - if (resendCooldown() > 0) return; + if (resendCooldown() > 0 || !turnstileToken()) return; setLoading(true); setError(null); @@ -110,6 +112,8 @@ const Login: Component = () => { email: email(), turnstile_token: turnstileToken()!, }); + setTurnstileToken(null); + setTurnstileResetCount((c) => c + 1); setResendCooldown(60); } catch (err) { if (isApiError(err)) { @@ -157,9 +161,18 @@ const Login: Component = () => { +
+ setTurnstileToken(token)} + onExpired={() => setTurnstileToken(null)} + onError={() => setTurnstileToken(null)} + resetSignal={turnstileResetCount} + /> +
+