From 43385ef4422a8640a82a19b0e92bb2fa7889ad55 Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:41:09 +0000 Subject: [PATCH] fix(login): recover Turnstile widget from transient errors The login captcha marked the widget permanently unavailable on the first Turnstile error and never reset it. Error 300010 and the rest of the 3xxxxx/6xxxxx families are transient (network, WebView, ad-blocker), so a single hiccup stranded the user on a dead captcha and logged an error each time. - Reset and retry the widget a bounded number of times on a recoverable error; give up (and log at error level) only after the budget is spent. - Handle the expired-token callback: Cloudflare does not reset the widget on expiry, so clear the stale token and reset for a fresh challenge. - Show the existing "Captcha not available" message when the widget is unavailable so a blocked user sees why. - Add unit tests for error recoverability and the retry budget. Generated-By: PostHog Desktop Task-Id: 59e9c12b-6ee3-4205-80fe-17c5ff529793 --- src/pages/login.vue | 42 +++++++++++++++++++++++++++++++++--- src/utils/turnstile.ts | 24 +++++++++++++++++++++ tests/turnstile.unit.test.ts | 35 +++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/pages/login.vue b/src/pages/login.vue index 879532468d..6d24dc6e70 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -21,7 +21,7 @@ import { openSupport } from '~/services/support' import { isCapgoDomainReferrer, isDirectLoginLanding } from '~/utils/capgoReferrer' import { getLoginActionVisibility } from '~/utils/loginActions' import { validateRedirectPath } from '~/utils/safeRedirect' -import { safeResetTurnstile } from '~/utils/turnstile' +import { safeResetTurnstile, shouldRetryTurnstile, TURNSTILE_MAX_RETRIES } from '~/utils/turnstile' const route = useRoute('/login') const supabase = useSupabase() @@ -52,6 +52,7 @@ const domainCheckTimeoutMs = 5000 const domainCheckDebounceMs = 350 const isCheckingSavedSession = ref(true) const captchaStatus = ref<'disabled' | 'loading' | 'ready' | 'unavailable'>(captchaKey.value ? 'loading' : 'disabled') +const captchaRetries = ref(0) let captchaInitTimeout: ReturnType | null = null let domainCheckTimer: ReturnType | null = null let domainCheckSeq = 0 @@ -170,7 +171,33 @@ function scheduleCaptchaInitTimeout() { function handleCaptchaUnavailable(reason: string, error?: unknown) { captchaStatus.value = 'unavailable' clearCaptchaInitTimeout() - console.error(reason, error) + if (error !== undefined) + console.error(reason, error) + else + console.error(reason) +} + +// Turnstile error 300010 and the rest of the 3xxxxx/6xxxxx families are +// transient (network, WebView, ad-blocker). Reset the widget and let the +// challenge run again instead of stranding the user on a dead captcha. Log at +// error level only once the retry budget is spent. +function handleCaptchaError(code: string) { + // Clearing the token drives captchaStatus back to 'loading' through its watcher. + turnstileToken.value = '' + if (isLoginStep.value && shouldRetryTurnstile(code, captchaRetries.value)) { + captchaRetries.value += 1 + console.warn(`Turnstile error ${code}, retrying (${captchaRetries.value}/${TURNSTILE_MAX_RETRIES})`) + safeResetTurnstile(captchaComponent.value) + return + } + handleCaptchaUnavailable(`Turnstile error ${code}`) +} + +// Cloudflare does not reset the widget when a token expires, so the stale token +// stays bound. Clear it and reset so the next submit carries a fresh token. +function handleCaptchaExpired() { + turnstileToken.value = '' + safeResetTurnstile(captchaComponent.value) } watch(turnstileToken, (token) => { @@ -181,6 +208,7 @@ watch(turnstileToken, (token) => { if (token) { captchaStatus.value = 'ready' + captchaRetries.value = 0 clearCaptchaInitTimeout() } else if (isLoginStep.value) { @@ -990,9 +1018,17 @@ onMounted(checkLogin) v-model="turnstileToken" size="flexible" :site-key="captchaKey" - @error="handleCaptchaUnavailable('Turnstile error', $event)" + @error="handleCaptchaError($event)" + @expired="handleCaptchaExpired" @unsupported="handleCaptchaUnavailable('Turnstile unsupported')" /> +

+ {{ t('captcha-not-available') }} +

diff --git a/src/utils/turnstile.ts b/src/utils/turnstile.ts index aeb5b44e9f..59d9834220 100644 --- a/src/utils/turnstile.ts +++ b/src/utils/turnstile.ts @@ -1,6 +1,9 @@ /** VueTurnstile instance ref shape — only reset() is needed for safe cleanup. */ export type TurnstileComponentRef = { reset?: () => void } | null | undefined +/** Reset attempts allowed before we declare Turnstile unavailable. */ +export const TURNSTILE_MAX_RETRIES = 2 + /** * Reset a Cloudflare Turnstile widget without throwing when the container * was destroyed (v-if step change, remount, or failed init). @@ -16,3 +19,24 @@ export function safeResetTurnstile(component: TurnstileComponentRef): void { // Cloudflare Turnstile throws TurnstileError when nothing to reset. } } + +/** + * Report whether a Turnstile error code clears after a widget reset. + * + * The 3xxxxx (generic client execution) and 6xxxxx (challenge / timeout) + * families are transient — a slow network, a WebView, or an ad-blocker — and a + * reset lets the challenge run again. Other families (bad sitekey, unsupported + * browser, wrong domain) do not clear on retry. + * See https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/ + */ +export function isRecoverableTurnstileError(code: string): boolean { + return /^[36]\d+$/.test(code) +} + +/** + * Decide whether to reset and retry the widget after a Turnstile error. Retry + * only transient errors, and only while under the retry budget. + */ +export function shouldRetryTurnstile(code: string, retries: number, maxRetries: number = TURNSTILE_MAX_RETRIES): boolean { + return isRecoverableTurnstileError(code) && retries < maxRetries +} diff --git a/tests/turnstile.unit.test.ts b/tests/turnstile.unit.test.ts index fe008808aa..f61a696e00 100644 --- a/tests/turnstile.unit.test.ts +++ b/tests/turnstile.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { safeResetTurnstile } from '../src/utils/turnstile.ts' +import { isRecoverableTurnstileError, safeResetTurnstile, shouldRetryTurnstile, TURNSTILE_MAX_RETRIES } from '../src/utils/turnstile.ts' describe('safeResetTurnstile', () => { it('calls reset when the component is present', () => { @@ -21,3 +21,36 @@ describe('safeResetTurnstile', () => { expect(reset).toHaveBeenCalledOnce() }) }) + +describe('isRecoverableTurnstileError', () => { + it('treats the reported 300010 login failure as recoverable', () => { + expect(isRecoverableTurnstileError('300010')).toBe(true) + }) + + it('treats other 3xxxxx and 6xxxxx codes as recoverable', () => { + expect(isRecoverableTurnstileError('300030')).toBe(true) + expect(isRecoverableTurnstileError('600010')).toBe(true) + }) + + it('treats config and browser codes as not recoverable', () => { + // 110xxx bad sitekey/domain, 100xxx init, unsupported browser. + expect(isRecoverableTurnstileError('110200')).toBe(false) + expect(isRecoverableTurnstileError('100000')).toBe(false) + expect(isRecoverableTurnstileError('')).toBe(false) + }) +}) + +describe('shouldRetryTurnstile', () => { + it('retries a transient error while under the budget', () => { + expect(shouldRetryTurnstile('300010', 0)).toBe(true) + expect(shouldRetryTurnstile('300010', TURNSTILE_MAX_RETRIES - 1)).toBe(true) + }) + + it('stops retrying once the budget is spent', () => { + expect(shouldRetryTurnstile('300010', TURNSTILE_MAX_RETRIES)).toBe(false) + }) + + it('never retries a non-recoverable error', () => { + expect(shouldRetryTurnstile('110200', 0)).toBe(false) + }) +})