Skip to content

fix(captcha): hide solved Turnstile widget so its success box leaves the form - #7770

Closed
tenderdeve wants to merge 9 commits into
cowprotocol:developfrom
tenderdeve:fix/7768-captcha-dismiss
Closed

tenderdeve wants to merge 9 commits into
cowprotocol:developfrom
tenderdeve:fix/7768-captcha-dismiss

Conversation

@tenderdeve

@tenderdeve tenderdeve commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #7768

The Cloudflare Turnstile widget only unmounted once the exchanged JWT was stored. Turnstile keeps its own "Success!" box on screen after a challenge is solved, so that box lingered on the trade form between solving and the JWT exchange resolving — and stayed there indefinitely whenever the exchange was slow or failed.

Now the widget is hidden as soon as the challenge succeeds, while the token→JWT exchange runs in the background. It stays mounted (just hidden) so an expiry or error reveals it again for a fresh challenge, and a successful exchange unmounts it entirely as before.

To Test

  1. Enable the captcha feature flag and load a trade form (swap/limit/twap).
  • Solve the Turnstile challenge — the "Success!" / Cloudflare box disappears from the form immediately.
  • With the JWT exchange failing (e.g. offline /auth/turnstile), the widget reappears instead of leaving a stale "Success!" box stuck on the form.
  • Order placement still works once the JWT is obtained.

Background

onSuccess previously gated visibility solely on the stored JWT. Extracted the exchange handler and added an isChallengeSolved flag that hides the widget on solve and resets on expiry/error/exchange-failure.

Summary by CodeRabbit

  • Bug Fixes

    • Improved captcha flow: hides the “solved” UI immediately on Turnstile success, then unmounts only after JWT exchange completes.
    • Enhanced error handling: on exchange failure, shows a failure notice, clears any stored captcha JWT, and prevents false “success” indications.
    • Refined retry and Turnstile error/expire behavior: “Try again” restores the challenge and resets the widget; error paths keep the widget visible and clear stored tokens.
  • Tests

    • Updated Turnstile mocks and expanded coverage for pending-success, exchange-failure, retry, and Turnstile error/reset behavior.

@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown

@tenderdeve is attempting to deploy a commit to the cow-dev Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 769030e6-4ca9-422b-a1ac-78ed89a5473a

📥 Commits

Reviewing files that changed from the base of the PR and between 873104d and b43a274.

📒 Files selected for processing (2)
  • apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx
  • apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx
  • apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx

Walkthrough

CaptchaWidget now hides the Turnstile widget after success, exchanges the token with stale-result protection, shows a retry state on exchange failure, and resets the widget on error, expiry, or retry. Tests were expanded to cover the updated lifecycle.

Changes

Captcha widget lifecycle handling

Layer / File(s) Summary
Challenge success handling and solved-state reset
apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx
Adds status-driven widget visibility, a stale-guarded token-to-JWT exchange helper, failure notice rendering, and reset handling for success, failure, error, and expiry flows.
Turnstile mock and lifecycle tests
apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx
Upgrades the Turnstile mock to a forwardRef implementation that captures props and exposes reset, then adds tests for hidden solved UI, failed exchange recovery, retry handling, and Turnstile error reset behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • cowprotocol/cowswap#7591: Also changes CaptchaWidget.container.tsx and its tests, adjusting when the Turnstile widget renders and how captcha state is cleared or reset.

Suggested reviewers: shoom3301, alfetopito

Poem

A bunny saw the checkbox glow,
Then hid the widget nice and slow.
If tokens fail, I tap “Try again,”
And hop back fresh to challenge lane.
🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the main captcha UI change.
Description check ✅ Passed The description follows the template sections and includes a summary, testing steps, and background.
Linked Issues check ✅ Passed The PR hides the solved Turnstile widget immediately after success, matching the linked issue's requirement to make the captcha disappear.
Out of Scope Changes check ✅ Passed The added failure and retry handling stays within the captcha flow and supports the main fix, with no clear unrelated scope creep.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@elena-zh elena-zh added the trigger-preview Add to a fork PR to trigger CF-pages preview. See https://github.com/cowprotocol/cowswap/pull/7615 label Jun 30, 2026
@github-actions

github-actions Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Cloudflare Pages preview mirror

Preview branch URL: https://github.com/cowprotocol/cowswap/tree/cf-preview/pr-7770
Mirror PR: #7771
Cloudflare Pages preview links will be posted on the mirror PR by the Cloudflare Pages GitHub integration once builds complete.

Source fork branch: tenderdeve/cowswap:fix/7768-captcha-dismiss
Approval target SHA: 74cd5eb8562f
Last mirrored SHA: 2e4fb605abeb
Last comment update: @elena-zh at 2026-07-06T08:13:18.823Z

  • Sync Cloudflare preview to approval target commit

@github-actions github-actions Bot removed the trigger-preview Add to a fork PR to trigger CF-pages preview. See https://github.com/cowprotocol/cowswap/pull/7615 label Jun 30, 2026
@vercel
vercel Bot temporarily deployed to Preview – storybook June 30, 2026 13:31 Inactive
@vercel
vercel Bot temporarily deployed to Preview – widget-configurator June 30, 2026 13:32 Inactive
@vercel
vercel Bot temporarily deployed to Preview – cowfi June 30, 2026 13:33 Inactive

@elena-zh elena-zh left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @tenderdeve , thanks, but the fix is illogical: in case token fails, why would we show 'success' captcha again? In this case, we need to show a 'failed' status on it with a possibility to re-confirm it.
In case of success. we need to show this status, then hide the captcha block from forms.

@tenderdeve

Copy link
Copy Markdown
Contributor Author

@elena-zh good point — reworked in 694924a. Dropped the display:none/re-show approach that made the solved "Success!" box reappear on failure. Now: the solved widget stays visible while the JWT exchange runs; on success the JWT is stored and the widget unmounts (Success box leaves the form); on exchange failure (and onError) the widget is reset() so it drops the stale Success box and re-runs the challenge for the user to re-confirm. Tests updated to cover both paths.

@vercel
vercel Bot temporarily deployed to Preview – storybook July 1, 2026 11:49 Inactive
@vercel
vercel Bot temporarily deployed to Preview – widget-configurator July 1, 2026 11:50 Inactive
@vercel
vercel Bot temporarily deployed to Preview – cowfi July 1, 2026 11:51 Inactive
@elena-zh
elena-zh self-requested a review July 1, 2026 12:56

@elena-zh elena-zh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, looks good now!

@elena-zh
elena-zh requested review from alfetopito and elena-zh July 1, 2026 12:57

@elena-zh elena-zh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes because it would be great to rebase the PR against develop instead of Main

…the form

The Turnstile widget only unmounted once the exchanged JWT was stored, so
its built-in "Success!" box lingered on the trade form after the challenge
was solved — and stayed indefinitely whenever the token exchange was slow or
failed. Hide the widget as soon as the challenge succeeds while the exchange
runs in the background, and reveal it again on expiry/error so a fresh
challenge can be solved.
…success

The previous approach hid the solved widget via display:none during the JWT
exchange and re-showed it on failure, which re-rendered Cloudflare's stale
"Success!" box even though the exchange had failed.

Instead, leave the solved widget visible while the exchange runs, then:
- on success, store the JWT (the widget unmounts, so the "Success!" box leaves
  the form) — the original issue;
- on failure (or onError), reset the widget so it drops the stale "Success!"
  box and re-runs the challenge for the user to re-confirm.
@tenderdeve
tenderdeve force-pushed the fix/7768-captcha-dismiss branch from 2e4fb60 to ca50f13 Compare July 2, 2026 06:08
@tenderdeve
tenderdeve requested a review from fairlighteth as a code owner July 2, 2026 06:08
@tenderdeve
tenderdeve changed the base branch from main to develop July 2, 2026 06:08
@tenderdeve

Copy link
Copy Markdown
Contributor Author

@elena-zh rebased onto develop and retargeted the PR base — dropped the merge-from-main so it's now just the two captcha commits on top of develop. Tests green (6/6).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx (1)

26-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add direct coverage for the new onError reset path.

The production callback now resets the widget on Turnstile errors, but the mock only exposes onSuccess, so this behavior can regress without a focused test.

🧪 Proposed test coverage
-let mockTurnstileProps: { style?: CSSProperties; onSuccess?: (token: string) => void } = {}
+let mockTurnstileProps: {
+  style?: CSSProperties
+  onSuccess?: (token: string) => void | Promise<void>
+  onError?: (errorCode: string) => void
+} = {}
 const mockReset = jest.fn()
@@
-      (props: { style?: CSSProperties; onSuccess?: (token: string) => void }, ref: unknown) => {
+      (props: typeof mockTurnstileProps, ref: unknown) => {
         mockTurnstileProps = props
         react.useImperativeHandle(ref, () => ({ reset: mockReset, execute: jest.fn() }))
         return <div data-testid="turnstile" style={props.style} />
       },
@@
   it('resets the widget so the user can re-confirm when the JWT exchange fails', async () => {
@@
     expect(store.get(captchaJwtAtom)).toBeNull()
   })
+
+  it('resets the widget when Turnstile reports an error', async () => {
+    const store = createStore()
+    store.set(featureFlagsAtom, { isCaptchaEnabled: true })
+
+    renderWithStore(store)
+
+    act(() => {
+      mockTurnstileProps.onError?.('mock-error')
+    })
+
+    expect(mockReset).toHaveBeenCalled()
+    expect(store.get(captchaJwtAtom)).toBeNull()
+  })

Also applies to: 146-162

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx`
around lines 26 - 42, The CaptchaWidget test mock only captures onSuccess, so
the new Turnstile error reset behavior is not directly covered. Update the
`@marsidev/react-turnstile` mock in CaptchaWidget.container.test.tsx to expose
onError alongside onSuccess via mockTurnstileProps, then add a focused test
against CaptchaWidget that invokes the captured onError callback and asserts
mockReset is called on the mocked Turnstile ref. Use the existing
mockTurnstileProps and mockReset setup to locate the relevant test block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx`:
- Around line 26-42: The CaptchaWidget test mock only captures onSuccess, so the
new Turnstile error reset behavior is not directly covered. Update the
`@marsidev/react-turnstile` mock in CaptchaWidget.container.test.tsx to expose
onError alongside onSuccess via mockTurnstileProps, then add a focused test
against CaptchaWidget that invokes the captured onError callback and asserts
mockReset is called on the mocked Turnstile ref. Use the existing
mockTurnstileProps and mockReset setup to locate the relevant test block.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2245651c-aa25-46a2-ac8d-ddde68f8b457

📥 Commits

Reviewing files that changed from the base of the PR and between 754e88d and ca50f13.

📒 Files selected for processing (2)
  • apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx
  • apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx

@vercel

vercel Bot commented Jul 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
swap-dev Ready Ready Preview Jul 3, 2026 8:19am

Request Review

@vercel
vercel Bot temporarily deployed to Preview – swap-dev July 2, 2026 10:00 Inactive

@fairlighteth fairlighteth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ AI Review (Codex GPT-5, worked 11m): pending exchange still leaves the Turnstile success box visible

Finding: [BLOCKING] Pending JWT exchange still shows the solved captcha

  • Location: apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx:134
  • This one is important: the linked issue asks for the captcha message to disappear when the box is checked, and the PR body still says the widget hides as soon as the challenge succeeds.
  • Current head now asserts the opposite: after onSuccess, the widget remains display: block until exchangeTurnstileToken() resolves. If /auth/turnstile is slow or never settles, the “Success!” box can still linger on the form for the whole pending exchange.
  • The failure reset path is better now, but it does not cover the slow/pending case.

Suggested fix

  • Keep the immediate hide-on-success state for the pending exchange, and on exchange failure call captchaRef.current?.reset() before showing/re-enabling the widget for a fresh challenge.
  • Update the pending-exchange test to assert the solved widget is hidden immediately, plus keep the failure test asserting reset() is called.
Review scope and related context
  • This is separate from CodeRabbit’s existing nit, which only asks for direct onError reset test coverage.
  • I also rechecked the earlier human review request about not re-showing a stale success state after exchange failure; current code does reset on exchange failure, so I’m not repeating that as a finding.
  • The PR body/checklist should be updated if the intended UX is no longer “hide immediately after solve,” because it currently conflicts with the implementation and test.
🤖 Prompt for AI agents
Verify this finding against current PR head. The linked issue #7768 expects the Turnstile success message to disappear when the checkbox is checked, but current tests assert the widget remains display:block while exchangeTurnstileToken is pending.

Fix only if still valid:
- Hide the solved Turnstile widget immediately after onSuccess while the JWT exchange is pending.
- If the exchange fails, reset the Turnstile ref so the stale Success state is cleared before showing/retrying.
- Preserve stale request guards.
- Add/update tests for pending exchange hidden immediately, success unmount, exchange failure reset/retry, and optionally onError reset.

Generated using the pr-review skill from the CoW Protocol skills repo.

@elena-zh

elena-zh commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Hey @fairlighteth , just FYI: we have another task to fix a failed captcha state if JWT token verification failed #7801

The solved Turnstile widget stayed visible until exchangeTurnstileToken
resolved, so a slow /auth/turnstile left the "Success!" box lingering on
the form. Hide it as soon as the challenge is solved (display:none, kept
mounted) and reveal + reset it only if the exchange fails.
@tenderdeve

Copy link
Copy Markdown
Contributor Author

@fairlighteth good catch — updated in a7f0ed5. The solved widget now hides immediately on onSuccess (display:none, kept mounted) so a slow /auth/turnstile no longer leaves the "Success!" box on the form. If the exchange fails it reveals + resets the widget for a fresh challenge; stale-request guard preserved. Added a pending-exchange test (asserts hidden immediately) and a direct onError reset test (also covers @coderabbitai's nit).

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd CaptchaWidget.container.tsx
echo "---"
cat -n apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx 2>/dev/null | sed -n '1,200p'

Length of output: 7554


@tenderdeve Thanks for the update — I pulled the latest code and confirmed commit a7f0ed5 matches what you described:

  • onSuccess now sets isSolved(true) immediately, which drives style={{ display: isSolved ? 'none' : 'block' }} on the Turnstile widget, so the "Success!" box disappears right away without unmounting the widget.
  • handleChallengeSuccess keeps the stale-request guard via exchangeRequestIdRef, and on a successful exchange it stores the JWT (which unmounts the widget entirely via the if (... || captchaJwt) return null early return) and resets isSolved back to false so a future re-mount starts visible.
  • On exchange failure, it clears the JWT, resets isSolved to false, and calls captchaRef.current?.reset() — so the stale Cloudflare "Success!" state is dropped and a fresh challenge is presented.
  • onError mirrors that same reset behavior.

This looks like a solid resolution for both the original issue (#7768) and the follow-up nit about the failed-exchange case. Nice work getting the tests to cover both the pending-exchange (hidden) and exchange-failure (reset) paths.

ヽ(・∀・)ノ

[resolve]

@elena-zh
elena-zh self-requested a review July 2, 2026 13:07

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx (1)

164-176: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise onError from the hidden solved state.

This currently starts from a visible widget, so it does not prove onError clears the new isSolved state. Trigger onSuccess first, assert display: none, then fire onError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx`
around lines 164 - 176, The test for CaptchaWidget.container’s Turnstile error
handling should start from the hidden solved state instead of a visible widget.
In the CaptchaWidget.container.test.tsx case that calls
mockTurnstileProps.onError, first trigger mockTurnstileProps.onSuccess to put
the widget into the solved state and assert the element is hidden with display
none, then fire onError and verify mockReset is called and the widget becomes
visible again. This should exercise the isSolved state transition in the
CaptchaWidget container behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx`:
- Around line 66-69: The CaptchaWidget.container state can get stuck with
isSolved=true when captcha is disabled during a pending exchange, so the widget
remounts hidden after re-enable. Update the CaptchaWidget.container logic to
clear isSolved whenever captcha is disabled or the pending exchange is
invalidated, using the existing isSolved state and exchangeRequestIdRef flow so
the widget can render normally again when re-enabled.

---

Nitpick comments:
In
`@apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx`:
- Around line 164-176: The test for CaptchaWidget.container’s Turnstile error
handling should start from the hidden solved state instead of a visible widget.
In the CaptchaWidget.container.test.tsx case that calls
mockTurnstileProps.onError, first trigger mockTurnstileProps.onSuccess to put
the widget into the solved state and assert the element is hidden with display
none, then fire onError and verify mockReset is called and the widget becomes
visible again. This should exercise the isSolved state transition in the
CaptchaWidget container behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 571852b2-e411-4de0-81c9-47ce7bf0cf27

📥 Commits

Reviewing files that changed from the base of the PR and between ca50f13 and a7f0ed5.

📒 Files selected for processing (2)
  • apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.test.tsx
  • apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx

Comment thread apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx Outdated
@vercel
vercel Bot temporarily deployed to Preview – swap-dev July 2, 2026 13:13 Inactive
@elena-zh
elena-zh requested a review from fairlighteth July 2, 2026 13:15

@fairlighteth fairlighteth left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review (Codex GPT-5, worked 2m): original finding addressed; failure-state UX still open

Rechecked

  • Author response: a7f0ed5fd claims the solved widget now hides immediately on onSuccess, then reveals + resets on exchange failure.
  • Code path: apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx:onSuccess.
  • Test coverage: CaptchaWidget.container.test.tsx now asserts the widget is display: none while the JWT exchange is still pending, and display: block again after exchange failure/reset.

Result: Fixed for the issue I raised. The prior slow /auth/turnstile failure mode no longer applies: onSuccess sets isSolved before starting the JWT exchange, so the Turnstile stays mounted but hidden while the exchange is pending.

Follow-up context: Elena raised a newer review comment, and #7801 adds a separate UX requirement: failed JWT verification should show a failure state, not just reset/reveal the widget. I would keep my original finding marked addressed, but I would not treat the PR as fully clear if #7801 is intended to be solved here.

Verification notes for AI agents
Prior finding:
- After onSuccess, the Turnstile success box stayed visible until exchangeTurnstileToken resolved.

Current verification:
- onSuccess sets isSolved(true) before calling handleChallengeSuccess.
- Turnstile style uses display: isSolved ? "none" : "block".
- Exchange failure sets isSolved(false) and calls captchaRef.current?.reset().
- Tests cover pending exchange hidden immediately and exchange failure reset/reveal.

Remaining separate UX question:
- #7801 expects a visible failed state when JWT verification fails. Current behavior resets/reveals for retry; it does not implement a failed-state UI.

Generated using the pr-review skill from the CoW Protocol skills repo.

@elena-zh elena-zh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @tenderdeve , now I don't like when the captcha just disappears if token verification is failed. As I've described in #7801 , it should show 'failure' instead

On a failed token exchange the solved widget was silently reset, briefly
implying success on a rejected token. Introduce an explicit status
('challenge' | 'solved' | 'failed'): on exchange failure show a 'Verification
failed' notice with a 'Try again' button that resets and re-runs the challenge,
instead of auto-resetting. Resolves cowprotocol#7801.

Addresses @elena-zh's review feedback.
@tenderdeve

Copy link
Copy Markdown
Contributor Author

@elena-zh @fairlighteth updated in 873104d — the widget no longer silently resets when JWT verification fails. Introduced an explicit status ('challenge' | 'solved' | 'failed'): on a failed exchange it now shows a Verification failed notice with a Try again button that resets and re-runs the challenge. Covers #7801. Tests updated (pending-hidden, failed-notice-shown-not-reset, retry-resets-and-reveals) — 8/8 green.

@elena-zh
elena-zh requested review from elena-zh and fairlighteth July 3, 2026 08:15
@vercel
vercel Bot temporarily deployed to Preview – swap-dev July 3, 2026 08:19 Inactive
@elena-zh

elena-zh commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@fairlighteth , could you please review this PR and let us know if this 'failed verification' widget looks good for you?
image

@fairlighteth fairlighteth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ AI Review (Codex GPT-5, worked 5m): failed state addressed, pending exchange still needs a fallback

Rechecked

  • Author response: 873104d81 / #7770 (comment).
  • Code path: apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx:41.
  • Test coverage: current tests cover hidden-pending, rejected-exchange failed notice, retry reset/reveal, and Turnstile error reset.

Result: Addressed for rejected JWT verification. The failed notice shown in Elena's screenshot matches #7801's expected state and looks fine to me.

Finding: [BLOCKING] Hidden pending exchange still has no timeout/fallback

  • Location: apps/cowswap-frontend/src/modules/captcha/containers/CaptchaWidget.container.tsx:171 and apps/cowswap-frontend/src/modules/captcha/api/captchaApi.ts:9
  • This one is important: onSuccess now hides Turnstile immediately with status = 'solved', but exchangeTurnstileToken() is a bare fetch() without an abort timeout.
  • If /auth/turnstile never settles, the catch path never runs, so the user gets no failed notice, no Try again button, no JWT, and the captcha area stays hidden indefinitely.

Suggested fix

  • Add a timeout/abort fallback to the JWT exchange request and route timeout/abort into the same status = 'failed' UI.
  • Add a focused test for a non-settling exchange becoming retryable after the timeout.
Review scope and related context
  • The prior pending-success-box finding is fixed: the widget is display: none while the JWT exchange is pending.
  • Elena's failed-state request is fixed for rejected exchanges: the code shows Verification failed. Please try again. and only resets Turnstile after the user clicks Try again.
  • CodeRabbit's resolved feature-flag edge still looks equivalent in current code: disabling captcha while status === 'solved' invalidates the request but does not reset status to challenge, so re-enabling can remount Turnstile hidden. I'm not making that the main blocker because runtime flag toggling is lower-risk than the auth request fallback.

CI context checked:

  • Lint, Test, Typecheck, Agent Harness: passing.
  • Cypress is red because INTEGRATION_TEST_PRIVATE_KEY env missing, which looks environment-related rather than captcha-specific.
🤖 Prompt for AI agents
Verify this follow-up against current PR head.

Context:
- CaptchaWidget sets status to "solved" in onSuccess and hides Turnstile while exchangeTurnstileToken is pending.
- exchangeTurnstileToken uses fetch(TURNSTILE_AUTH_URL) without an AbortController or request timeout.
- Rejected exchanges show the failed notice correctly, but a never-settling exchange never reaches that path.

Fix:
- Add a bounded timeout/abort fallback for the JWT exchange.
- Ensure timeout/abort shows the same failed verification notice with Try again.
- Add/update tests for non-settling exchange timeout, rejected exchange failed notice, and retry reset/reveal.

Generated using the pr-review skill from the CoW Protocol skills repo.

@fairlighteth

Copy link
Copy Markdown
Contributor
🧪 Scoped browser QA passed: disconnected captcha solved/failed states verified; live Turnstile + signing not checked

Outcome

  • Checked scenarios:
    • ✅ Solved challenge: the Turnstile widget hides while JWT exchange is pending.
    • ✅ JWT exchange success: the captcha area unmounts and the disconnected-wallet Connect Wallet CTA remains visible.
    • ✅ JWT exchange failure: the form shows Verification failed. Please try again. plus the Try again CTA, with the Turnstile widget hidden.
    • ✅ Retry flow: clicking Try again clears the failed state and reveals/resets the Turnstile widget.
  • Primary evidence: structured DOM/network capture plus inspected Chromium screenshots/recording from the PR preview.

Run details

Artifacts

How to retest

  • Open the preview route above with the captcha flag enabled and the cross-chain promo dismissed.
  • For the solved path, complete the Turnstile challenge and let /auth/turnstile return a JWT. Expected: the widget hides during exchange, then the captcha area unmounts.
  • For the failed path, force /auth/turnstile to reject. Expected: Verification failed. Please try again. and Try again appear; clicking Try again resets/reveals the challenge.

Not checked / follow-up

  • ⚠️ Live Cloudflare Turnstile and live backend JWT exchange were not checked.
  • ⚠️ Provider-injected, wallet-connected UI, signing-capable flows, and real order placement were not checked.
  • ⚠️ Limit/TWAP forms and non-Chromium browsers were not checked.

Generated using the pr-qa skill from the CoW Protocol skills repo.

@fairlighteth

Copy link
Copy Markdown
Contributor

@tenderdeve Functionally this looks good to me: the failed JWT exchange no longer leaves the solved Turnstile success state hanging around, and the user gets a clear retry path.

Small UI polish suggestion: I’d avoid rendering this as a loose red text line plus a full-width Try again button. Since Connect Wallet is also a full-width primary CTA right below it, the two buttons visually compete.

Current state:
Screenshot 2026-07-03 at 14 50 33

Here's a suggested render example:
Screenshot 2026-07-03 at 14 44 41

I think this would fit better as a compact inline error state using the existing shared banner pattern:

  • InlineBanner from @cowprotocol/ui
  • StatusColorVariant.Danger
  • compact ButtonSecondary for Try again
  • keep Connect Wallet as the only large primary CTA in this area

Rough shape:

<InlineBanner
  bannerType={StatusColorVariant.Danger}
  borderRadius="12px"
  iconSize={18}
  orientation={BannerOrientation.Horizontal}
  padding="10px 12px"
  noWrapContent
>
  <CaptchaFailureContent>
    <span>Verification failed. Please try again.</span>
    <CaptchaRetryButton
      $borderRadius="12px"
      $fontSize="12px"
      $minHeight="28px"
      onClick={onRetry}
      padding="0 12px"
      width="fit-content"
    >
      Try again
    </CaptchaRetryButton>
  </CaptchaFailureContent>
</InlineBanner>

const CaptchaFailureContent = styled.div`
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  min-width: 0;

  > span {
    min-width: 0;
    text-align: left;
    font-weight: 500;
  }
`

const CaptchaRetryButton = styled(ButtonSecondary)`
  flex: 0 0 auto;
  white-space: nowrap;
`

This is roughly the same composition we already use in places like CustomRecipientWarningBanner: shared inline banner, short warning copy, compact secondary action. I would not use WarningCard or TradeWarning here, since those feel either too card-like or too tied to trade acknowledgement behavior.

So my take is: behavior is good, but I’d polish the presentation by grouping the error and retry action into a single danger InlineBanner, and keep the retry CTA visually secondary to Connect Wallet.

fairlighteth and others added 2 commits July 3, 2026 15:07
Group the failed-verification message and its "Try again" action into a
single horizontal danger InlineBanner instead of a loose red text line
plus a full-width button. Keeps the retry CTA visually secondary to the
form's primary "Connect Wallet" button so the two no longer compete.

Addresses @fairlighteth's UI polish feedback.
@tenderdeve

Copy link
Copy Markdown
Contributor Author

@fairlighteth updated in b43a274 — reworked the failed-verification UI into a single compact horizontal danger InlineBanner (StatusColorVariant.Danger) with a secondary Try again button, so the retry action stays visually below/secondary to the primary Connect Wallet CTA instead of competing as a second full-width button. Same shared inline-banner pattern as the recipient warnings you pointed at.

@kernelwhisperer

Copy link
Copy Markdown
Contributor

Closing in favor of #7812, really sorry @tenderdeve, thank you for your effort!

@github-actions github-actions Bot locked and limited conversation to collaborators Jul 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Captcha does not disappear on a form after checking the box

4 participants