fix(platform): Turnstile CAPTCHA reset after failed login attempts (#10056)

Users were unable to retry login attempts after a failed authentication
because the Turnstile CAPTCHA widget was not properly resetting. This
forced users to refresh the entire page to attempt login again, creating
a terrible user experience.

Root Cause: The useTurnstile hook had several critical issues:

- The reset() function only cleared state when shouldRender was true and
widget existed
- Widget ID tracking was unreliable due to intercepting
window.turnstile.render
- Token wasn't being cleared on verification failures
- State wasn't being reset consistently across error scenarios

Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->

- Fixed useTurnstile hook reset logic: Modified the reset() function to
always clear all state (token, verified, verifying, error) regardless of
shouldRender condition
- Improved widget ID synchronization: Added setWidgetId prop to the
Turnstile component interface and hook for reliable widget tracking
between component and hook
- Enhanced error handling: Updated handleVerify, handleExpire, and
handleError to properly reset tokens on failures
- Updated all auth components: Added setWidgetId prop to all Turnstile
component usages in login, signup, and password reset pages
- Removed unreliable widget tracking: Eliminated the
window.turnstile.render interception approach in favor of explicit
prop-based communication

Checklist 📋
For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- <!-- Put your test plan here: -->
- [x] Test failed login attempt - CAPTCHA resets properly without page
refresh
- [x] Test failed signup attempt - CAPTCHA resets properly without page
refresh
	- [x] Test successful login flow - CAPTCHA works normally
	- [x] Test CAPTCHA expiration - State resets correctly
	- [x] Test CAPTCHA error scenarios - Error handling works properly
This commit is contained in:
Bently
2025-05-28 16:21:27 +01:00
committed by GitHub
parent 9f2b9d08c9
commit 692f32a350
5 changed files with 57 additions and 20 deletions

View File

@@ -163,6 +163,7 @@ export default function LoginPage() {
onVerify={turnstile.handleVerify}
onExpire={turnstile.handleExpire}
onError={turnstile.handleError}
setWidgetId={turnstile.setWidgetId}
action="login"
shouldRender={turnstile.shouldRender}
/>

View File

@@ -188,6 +188,7 @@ export default function ResetPasswordPage() {
onVerify={changePasswordTurnstile.handleVerify}
onExpire={changePasswordTurnstile.handleExpire}
onError={changePasswordTurnstile.handleError}
setWidgetId={changePasswordTurnstile.setWidgetId}
action="change_password"
shouldRender={changePasswordTurnstile.shouldRender}
/>
@@ -230,6 +231,7 @@ export default function ResetPasswordPage() {
onVerify={sendEmailTurnstile.handleVerify}
onExpire={sendEmailTurnstile.handleExpire}
onError={sendEmailTurnstile.handleError}
setWidgetId={sendEmailTurnstile.setWidgetId}
action="reset_password"
shouldRender={sendEmailTurnstile.shouldRender}
/>

View File

@@ -164,6 +164,7 @@ export default function SignupPage() {
onVerify={turnstile.handleVerify}
onExpire={turnstile.handleExpire}
onError={turnstile.handleError}
setWidgetId={turnstile.setWidgetId}
action="signup"
shouldRender={turnstile.shouldRender}
/>

View File

@@ -11,6 +11,7 @@ export interface TurnstileProps {
className?: string;
id?: string;
shouldRender?: boolean;
setWidgetId?: (id: string | null) => void;
}
export function Turnstile({
@@ -22,6 +23,7 @@ export function Turnstile({
className,
id = "cf-turnstile",
shouldRender = true,
setWidgetId,
}: TurnstileProps) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
@@ -68,7 +70,11 @@ export function Turnstile({
// Reset any existing widget
if (widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current);
try {
window.turnstile.reset(widgetIdRef.current);
} catch (err) {
console.warn("Failed to reset existing Turnstile widget:", err);
}
}
// Render a new widget
@@ -86,15 +92,32 @@ export function Turnstile({
},
action,
});
// Notify the hook about the widget ID
setWidgetId?.(widgetIdRef.current);
}
return () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
try {
window.turnstile.remove(widgetIdRef.current);
} catch (err) {
console.warn("Failed to remove Turnstile widget:", err);
}
setWidgetId?.(null);
widgetIdRef.current = null;
}
};
}, [loaded, siteKey, onVerify, onExpire, onError, action, shouldRender]);
}, [
loaded,
siteKey,
onVerify,
onExpire,
onError,
action,
shouldRender,
setWidgetId,
]);
// Method to reset the widget manually
const reset = useCallback(() => {

View File

@@ -21,6 +21,7 @@ interface UseTurnstileResult {
reset: () => void;
siteKey: string;
shouldRender: boolean;
setWidgetId: (id: string | null) => void;
}
const TURNSTILE_SITE_KEY =
@@ -34,7 +35,7 @@ export function useTurnstile({
autoVerify = true,
onSuccess,
onError,
resetOnError = false,
resetOnError = true,
}: UseTurnstileOptions = {}): UseTurnstileResult {
const [token, setToken] = useState<string | null>(null);
const [verifying, setVerifying] = useState(false);
@@ -60,26 +61,30 @@ export function useTurnstile({
}
}, [token, autoVerify, shouldRender]);
useEffect(() => {
if (typeof window !== "undefined" && window.turnstile) {
const originalRender = window.turnstile.render;
window.turnstile.render = (container, options) => {
const id = originalRender(container, options);
setWidgetId(id);
return id;
};
}
const setWidgetIdCallback = useCallback((id: string | null) => {
setWidgetId(id);
}, []);
const reset = useCallback(() => {
if (shouldRender && window.turnstile && widgetId) {
window.turnstile.reset(widgetId);
// Always reset the state when reset is called, regardless of shouldRender
// This ensures users can retry CAPTCHA after failed attempts
setToken(null);
setVerified(false);
setVerifying(false);
setError(null);
// Always reset the state when reset is called
setToken(null);
setVerified(false);
setVerifying(false);
setError(null);
// Only reset the actual Turnstile widget if it exists and shouldRender is true
if (
shouldRender &&
typeof window !== "undefined" &&
window.turnstile &&
widgetId
) {
try {
window.turnstile.reset(widgetId);
} catch (err) {
console.warn("Failed to reset Turnstile widget:", err);
}
}
}, [shouldRender, widgetId]);
@@ -106,6 +111,7 @@ export function useTurnstile({
setError(newError);
if (onError) onError(newError);
if (resetOnError) {
setToken(null);
setVerified(false);
}
}
@@ -119,6 +125,7 @@ export function useTurnstile({
: new Error("Unknown error during verification");
setError(newError);
if (resetOnError) {
setToken(null);
setVerified(false);
}
setVerifying(false);
@@ -138,6 +145,7 @@ export function useTurnstile({
if (shouldRender) {
setToken(null);
setVerified(false);
setError(null);
}
}, [shouldRender]);
@@ -146,6 +154,7 @@ export function useTurnstile({
if (shouldRender) {
setError(err);
if (resetOnError) {
setToken(null);
setVerified(false);
}
if (onError) onError(err);
@@ -165,5 +174,6 @@ export function useTurnstile({
reset,
siteKey: TURNSTILE_SITE_KEY,
shouldRender,
setWidgetId: setWidgetIdCallback,
};
}