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) <noreply@anthropic.com>
master
oabrivard 2 months ago
parent d2851c019e
commit b08d65c53c

@ -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(() => (
<Turnstile onToken={onToken} resetSignal={resetCount} />
));
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();

@ -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<number>;
}
/** Turnstile site key loaded from env, with a Cloudflare testing key as fallback. */
@ -84,6 +87,15 @@ const Turnstile: Component<TurnstileProps> = (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);

@ -35,6 +35,7 @@ const Login: Component = () => {
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(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 = () => {
</div>
</Show>
<div class="mb-4">
<Turnstile
onToken={(token) => setTurnstileToken(token)}
onExpired={() => setTurnstileToken(null)}
onError={() => setTurnstileToken(null)}
resetSignal={turnstileResetCount}
/>
</div>
<button
onClick={handleResend}
disabled={resendCooldown() > 0 || loading()}
disabled={resendCooldown() > 0 || loading() || !turnstileToken()}
class="text-sm font-medium text-indigo-600 hover:text-indigo-500 disabled:text-gray-400 disabled:cursor-not-allowed"
>
<Show

@ -35,6 +35,7 @@ const Register: Component = () => {
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [resendCooldown, setResendCooldown] = createSignal(0);
const [turnstileResetCount, setTurnstileResetCount] = createSignal(0);
// Redirect if already authenticated
createEffect(() => {
@ -85,6 +86,7 @@ const Register: Component = () => {
turnstile_token: turnstileToken()!,
});
setSubmitted(true);
setTurnstileToken(null);
setResendCooldown(60);
} catch (err) {
if (isApiError(err)) {
@ -102,7 +104,7 @@ const Register: Component = () => {
};
const handleResend = async () => {
if (resendCooldown() > 0) return;
if (resendCooldown() > 0 || !turnstileToken()) return;
setLoading(true);
setError(null);
@ -112,6 +114,8 @@ const Register: Component = () => {
display_name: displayName() || undefined,
turnstile_token: turnstileToken()!,
});
setTurnstileToken(null);
setTurnstileResetCount((c) => c + 1);
setResendCooldown(60);
} catch (err) {
if (isApiError(err)) {
@ -159,9 +163,18 @@ const Register: Component = () => {
</div>
</Show>
<div class="mb-4">
<Turnstile
onToken={(token) => setTurnstileToken(token)}
onExpired={() => setTurnstileToken(null)}
onError={() => setTurnstileToken(null)}
resetSignal={turnstileResetCount}
/>
</div>
<button
onClick={handleResend}
disabled={resendCooldown() > 0 || loading()}
disabled={resendCooldown() > 0 || loading() || !turnstileToken()}
class="text-sm font-medium text-indigo-600 hover:text-indigo-500 disabled:text-gray-400 disabled:cursor-not-allowed"
>
<Show

Loading…
Cancel
Save