diff --git a/apps/deploy-web/src/components/auth/EmailCodeStart/EmailCodeStart.tsx b/apps/deploy-web/src/components/auth/EmailCodeStart/EmailCodeStart.tsx index ae6f1e68f4..c45cb6b429 100644 --- a/apps/deploy-web/src/components/auth/EmailCodeStart/EmailCodeStart.tsx +++ b/apps/deploy-web/src/components/auth/EmailCodeStart/EmailCodeStart.tsx @@ -7,6 +7,7 @@ import { useMutation } from "@tanstack/react-query"; import { z } from "zod"; import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError"; +import { SKIP_REPORTING_CAPTCHA_OUTCOME } from "@src/components/turnstile/CaptchaChallengeError"; import { useServices } from "@src/context/ServicesProvider"; import { markCodeSent } from "../PasswordlessAuth/withPersistedPasswordlessFlow"; @@ -40,6 +41,7 @@ export function EmailCodeStart({ dependencies: d = DEPENDENCIES, ...props }: Pro const { authService, analyticsService } = useServices(); const startMutation = d.useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn(input: { email: string }) { analyticsService.track("email_login_init"); const captchaToken = await props.getCaptchaToken(); diff --git a/apps/deploy-web/src/components/auth/EmailCodeVerify/EmailCodeVerify.tsx b/apps/deploy-web/src/components/auth/EmailCodeVerify/EmailCodeVerify.tsx index 41ba1d05c9..7fe9d60600 100644 --- a/apps/deploy-web/src/components/auth/EmailCodeVerify/EmailCodeVerify.tsx +++ b/apps/deploy-web/src/components/auth/EmailCodeVerify/EmailCodeVerify.tsx @@ -5,6 +5,7 @@ import { Button, Spinner } from "@akashnetwork/ui/components"; import { useMutation } from "@tanstack/react-query"; import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError"; +import { SKIP_REPORTING_CAPTCHA_OUTCOME } from "@src/components/turnstile/CaptchaChallengeError"; import { useServices } from "@src/context/ServicesProvider"; import { markCodeSent, readCodeSentAt } from "../PasswordlessAuth/withPersistedPasswordlessFlow"; import type { VerificationCodeInputRef } from "./VerificationCodeInput"; @@ -39,6 +40,7 @@ export function EmailCodeVerify({ dependencies: d = DEPENDENCIES, ...props }: Pr const [resendCooldownSec, setResendCooldownSec] = useState(() => remainingResendCooldownSec(d.readCodeSentAt())); const verifyMutation = d.useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn(input: { code: string }) { const captchaToken = await props.getCaptchaToken(); await authService.verifyEmailCode({ email: props.email, code: input.code, captchaToken }); @@ -55,6 +57,7 @@ export function EmailCodeVerify({ dependencies: d = DEPENDENCIES, ...props }: Pr }); const resendMutation = d.useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn() { const captchaToken = await props.getCaptchaToken(); await authService.startEmailCode({ email: props.email, captchaToken }); diff --git a/apps/deploy-web/src/components/auth/PasswordAuth/PasswordAuth.tsx b/apps/deploy-web/src/components/auth/PasswordAuth/PasswordAuth.tsx index 4cb21a9844..19a339041a 100644 --- a/apps/deploy-web/src/components/auth/PasswordAuth/PasswordAuth.tsx +++ b/apps/deploy-web/src/components/auth/PasswordAuth/PasswordAuth.tsx @@ -7,6 +7,7 @@ import { useSearchParams } from "next/navigation"; import { useRouter } from "next/router"; import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError"; +import { SKIP_REPORTING_CAPTCHA_OUTCOME } from "@src/components/turnstile/CaptchaChallengeError"; import type { TurnstileRef } from "@src/components/turnstile/Turnstile"; import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile"; import { useServices } from "@src/context/ServicesProvider"; @@ -53,6 +54,7 @@ export function PasswordAuth({ dependencies: d = DEPENDENCIES }: Props = {}) { const isAuthInFlight = useRef(false); const signInOrSignUp = useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn(input: Tagged<"signin", SignInFormValues> | Tagged<"signup", SignUpFormValues>) { analyticsService.track("password_auth_submit", { type: input.type }); if (!turnstileRef.current) { @@ -85,6 +87,7 @@ export function PasswordAuth({ dependencies: d = DEPENDENCIES }: Props = {}) { ); const forgotPassword = useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn(input: { email: string }) { if (!turnstileRef.current) { throw new Error("Captcha has not been rendered"); diff --git a/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.spec.tsx b/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.spec.tsx index 291ff7e365..86a2dece7d 100644 --- a/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.spec.tsx +++ b/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.spec.tsx @@ -1,6 +1,7 @@ import { AxiosError } from "axios"; import { describe, expect, it } from "vitest"; +import { CaptchaChallengeError } from "@src/components/turnstile/CaptchaChallengeError"; import { DEPENDENCIES, RemoteApiError } from "./RemoteApiError"; import { render } from "@testing-library/react"; @@ -24,6 +25,11 @@ describe(RemoteApiError.name, () => { expect(getByText(/Error message from API/i)).toBeInTheDocument(); }); + it("tells the visitor the captcha is what failed instead of blaming the request", () => { + const { getByText } = setup({ error: new CaptchaChallengeError("abandoned") }); + expect(getByText(/verification wasn't completed/i)).toBeInTheDocument(); + }); + it("does not render anything when error is null or undefined", () => { const result = setup({ error: null }); expect(result.container).toBeEmptyDOMElement(); diff --git a/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.tsx b/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.tsx index aecaf58080..dce342e62b 100644 --- a/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.tsx +++ b/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.tsx @@ -1,6 +1,8 @@ import { isHttpError } from "@akashnetwork/http-sdk"; import { Alert, AlertDescription } from "@akashnetwork/ui/components"; +import { CaptchaChallengeError } from "@src/components/turnstile/CaptchaChallengeError"; + interface Props { error: Error | null | undefined; className?: string; @@ -12,15 +14,25 @@ export const DEPENDENCIES = { AlertDescription }; +const FALLBACK_MESSAGE = "An unexpected error occurred. Please try again or contact support if the issue persists."; + +function describeError(error: Error): string { + if (error instanceof CaptchaChallengeError) { + return error.message; + } + + if (isHttpError(error) && error.response?.data.message) { + return error.response.data.message; + } + + return FALLBACK_MESSAGE; +} + export function RemoteApiError({ error, className, dependencies: d = DEPENDENCIES }: Props) { if (!error) return null; return ( - - {isHttpError(error) && error.response?.data.message - ? error.response.data.message - : "An unexpected error occurred. Please try again or contact support if the issue persists."} - + {describeError(error)} ); } diff --git a/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.spec.ts b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.spec.ts new file mode 100644 index 0000000000..f1beb3fe52 --- /dev/null +++ b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import type { CaptchaChallengeReason } from "./CaptchaChallengeError"; +import { CaptchaChallengeError, SKIP_REPORTING_CAPTCHA_OUTCOME } from "./CaptchaChallengeError"; + +describe(CaptchaChallengeError.name, () => { + it.each(["abandoned", "timeout", "dismissed", "error"])("carries a message the visitor can read for %s", reason => { + expect(new CaptchaChallengeError(reason).message).toMatch(/^Verification .+\.$/); + }); + + it("survives instanceof against both its own type and Error", () => { + const error = new CaptchaChallengeError("abandoned"); + + expect(error).toBeInstanceOf(CaptchaChallengeError); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe("CaptchaChallengeError"); + }); + + it("keeps the cloudflare error code when there is one", () => { + expect(new CaptchaChallengeError("error", "300010").code).toBe("300010"); + }); + + describe("SKIP_REPORTING_CAPTCHA_OUTCOME", () => { + it("skips reporting for a captcha outcome", () => { + expect(SKIP_REPORTING_CAPTCHA_OUTCOME.skipErrorReporting(new CaptchaChallengeError("timeout"))).toBe(true); + }); + + it("leaves every other failure reportable", () => { + expect(SKIP_REPORTING_CAPTCHA_OUTCOME.skipErrorReporting(new Error("auth api is down"))).toBe(false); + expect(SKIP_REPORTING_CAPTCHA_OUTCOME.skipErrorReporting({ reason: "timeout" })).toBe(false); + }); + }); +}); diff --git a/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts new file mode 100644 index 0000000000..20b1f5f3df --- /dev/null +++ b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts @@ -0,0 +1,22 @@ +export type CaptchaChallengeReason = "abandoned" | "timeout" | "dismissed" | "error"; + +const MESSAGES: Record = { + abandoned: "Verification wasn't completed. Please try again.", + timeout: "Verification didn't finish in time. Please try again.", + dismissed: "Verification was cancelled.", + error: "Verification failed. Please try again." +}; + +export class CaptchaChallengeError extends Error { + readonly name = "CaptchaChallengeError"; + + constructor( + readonly reason: CaptchaChallengeReason, + readonly code?: string + ) { + super(MESSAGES[reason]); + } +} + +/** The widget already tags its own anomalies and an unsolved challenge is no fault at all, so neither should reach Sentry again by way of a mutation. */ +export const SKIP_REPORTING_CAPTCHA_OUTCOME = { skipErrorReporting: (error: unknown) => error instanceof CaptchaChallengeError }; diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx index 6abfe69ac3..e7ee81887b 100644 --- a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx +++ b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx @@ -5,6 +5,7 @@ import { setTimeout as wait } from "node:timers/promises"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; +import type { AnalyticsService } from "@src/services/analytics/analytics.service"; import type { ErrorHandlerService } from "@src/services/error-handler/error-handler.service"; import type { TurnstileRef } from "./Turnstile"; import { CHALLENGE_DEADLINE_MS, COMPONENTS, Turnstile } from "./Turnstile"; @@ -237,10 +238,7 @@ describe(Turnstile.name, () => { }); await promise; - expect(rejection).toMatchObject({ - reason: "error", - error: "test-error" - }); + expect(rejection).toMatchObject({ reason: "error", code: "test-error" }); }); it("rejects the pending challenge when the widget is dismissed", async () => { @@ -342,7 +340,7 @@ describe(Turnstile.name, () => { await expect(promise).resolves.toEqual({ token: "refreshed-token" }); }); - it("rejects a challenge that never settles instead of hanging the caller", async () => { + it("reports a wedge when cloudflare goes silent without ever prompting the visitor", async () => { const { turnstileRef, errorHandler } = await setup({ enabled: true }); vi.useFakeTimers(); @@ -363,6 +361,125 @@ describe(Turnstile.name, () => { } }); + it("treats a challenge the visitor never solved as abandoned rather than wedged", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef, errorHandler, analyticsService } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + let rejection: unknown; + const promise = turnstileRef.current!.renderAndWaitResponse().catch(error => { + rejection = error; + }); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await promise; + + expect(rejection).toMatchObject({ reason: "abandoned" }); + expect(errorHandler.reportError).not.toHaveBeenCalled(); + expect(analyticsService.track).toHaveBeenCalledWith("captcha_abandoned"); + } finally { + vi.useRealTimers(); + } + }); + + it("reports a wedge again once the challenge has left interactive mode", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef, errorHandler, analyticsService } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + const promise = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + latestProps.current!.onAfterInteractive?.(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await promise; + + expect(errorHandler.reportError).toHaveBeenCalledWith(expect.objectContaining({ tags: { event: "TURNSTILE_CHALLENGE_WEDGED" } })); + expect(analyticsService.track).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not carry a previous abandonment into the next challenge", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef, errorHandler } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + const abandoned = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + }); + const retried = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await Promise.all([abandoned, retried]); + + expect(errorHandler.reportError).toHaveBeenCalledWith(expect.objectContaining({ tags: { event: "TURNSTILE_CHALLENGE_WEDGED" } })); + } finally { + vi.useRealTimers(); + } + }); + + it("takes the overlay down when the deadline passes, so the caller's error is not left behind it", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + const promise = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + }); + expect(getOverlay()).toHaveStyle({ pointerEvents: "auto" }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await promise; + + expect(getOverlay()).toHaveStyle({ pointerEvents: "none" }); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps cloudflare's own interactive timeout quiet once the challenge was abandoned", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef, errorHandler } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + const promise = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await promise; + + await act(async () => { + latestProps.current!.onTimeout?.(); + }); + + expect(errorHandler.reportError).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("drops a pending challenge on unmount without reporting it wedged 2 minutes later", async () => { const { turnstileRef, errorHandler, unmount } = await setup({ enabled: true }); vi.useFakeTimers(); @@ -414,9 +531,10 @@ describe(Turnstile.name, () => { async function setup(input?: { enabled?: boolean; siteKey?: string; onDismissed?: () => void; components?: Partial }) { const turnstileRef = { current: null as TurnstileRef | null }; const errorHandler = mock(); + const analyticsService = mock(); const result = render( - errorHandler }}> + errorHandler, analyticsService: () => analyticsService }}> { ); await act(() => wait(0)); - return { ...result, turnstileRef, errorHandler }; + return { ...result, turnstileRef, errorHandler, analyticsService }; + } + + function getOverlay() { + return screen.getByText(/we are verifying you are a human/i).closest("[style]"); } const ButtonMock = forwardRef>((props, ref) => ( diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.tsx index bf6e2874cf..8c923b9d2e 100644 --- a/apps/deploy-web/src/components/turnstile/Turnstile.tsx +++ b/apps/deploy-web/src/components/turnstile/Turnstile.tsx @@ -13,8 +13,9 @@ import dynamic from "next/dynamic"; import { useServices } from "@src/context/ServicesProvider"; import { useWhen } from "@src/hooks/useWhen"; import { getInjectedConfig } from "@src/utils/getInjectedConfig/getInjectedConfig"; +import { CaptchaChallengeError } from "./CaptchaChallengeError"; -type TurnstileStatus = "uninitialized" | "solved" | "interactive" | "expired" | "error" | "dismissed"; +type TurnstileStatus = "uninitialized" | "solved" | "interactive" | "expired" | "error" | "dismissed" | "timedout"; const VISIBILITY_STATUSES: TurnstileStatus[] = ["interactive", "error"]; @@ -56,19 +57,22 @@ export const Turnstile = forwardRef(function Turns const isVisible = useMemo(() => enabled && VISIBILITY_STATUSES.includes(status), [enabled, status]); const eventBus = useRef(new EventTarget()); const injectedConfig = getInjectedConfig(); - const { errorHandler } = useServices(); + const { errorHandler, analyticsService } = useServices(); - const hasReportedFailure = useRef(false); - /** Cloudflare keeps retrying every 8s, so only the first anomaly of a run is reported: enough to diagnose, without one Sentry event per retry per stuck visitor. A run ends at the next success or the next challenge the caller asks for. */ + /** Cloudflare keeps retrying every 8s and its own timeouts land after ours, so a run reports at most one anomaly and stops reporting altogether once it has been settled. */ + const hasSettledRun = useRef(false); const reportChallengeFailure = useCallback( (error: unknown, event: string) => { - if (hasReportedFailure.current) return; - hasReportedFailure.current = true; + if (hasSettledRun.current) return; + hasSettledRun.current = true; errorHandler.reportError({ error, severity: "warning", tags: { event } }); }, [errorHandler] ); + /** Cloudflare stops calling back once a challenge turns interactive and waits on the visitor, so a deadline reached in that state is an abandoned challenge rather than a wedged one. */ + const isAwaitingInteraction = useRef(false); + const resetWidget = useCallback(() => { turnstileRef.current?.remove(); turnstileRef.current?.render(); @@ -118,7 +122,8 @@ export const Turnstile = forwardRef(function Turns } abandonPendingChallenge.current?.(); - hasReportedFailure.current = false; + hasSettledRun.current = false; + isAwaitingInteraction.current = false; startChallenge(); return new Promise((resolve, reject) => { const stopWaiting = () => { @@ -135,19 +140,28 @@ export const Turnstile = forwardRef(function Turns }; const errorListener = (event: Event) => { stopWaiting(); - const details = (event as CustomEvent<{ reason: string; error?: string }>).detail; - reject({ status, ...details }); + const { code } = (event as CustomEvent<{ code?: string }>).detail; + reject(new CaptchaChallengeError("error", code)); }; abandonPendingChallenge.current = () => { stopWaiting(); - reject({ reason: "dismissed" }); + reject(new CaptchaChallengeError("dismissed")); }; stopWaitingForChallenge.current = stopWaiting; const deadline = setTimeout(() => { stopWaiting(); + setStatus("timedout"); + + if (isAwaitingInteraction.current) { + hasSettledRun.current = true; + analyticsService.track("captcha_abandoned"); + reject(new CaptchaChallengeError("abandoned")); + return; + } + reportChallengeFailure(new Error("Turnstile challenge never settled"), "TURNSTILE_CHALLENGE_WEDGED"); - reject({ reason: "timeout" }); + reject(new CaptchaChallengeError("timeout")); }, CHALLENGE_DEADLINE_MS); eventBus.current.addEventListener("success", successListener); @@ -155,7 +169,7 @@ export const Turnstile = forwardRef(function Turns }); } }), - [startChallenge, enabled, reportChallengeFailure] + [startChallenge, enabled, reportChallengeFailure, analyticsService] ); if (!enabled) { @@ -188,16 +202,23 @@ export const Turnstile = forwardRef(function Turns onError={error => { setStatus("error"); reportChallengeFailure(new Error(`Turnstile challenge failed with code ${error}`), "TURNSTILE_CHALLENGE_FAILED"); - eventBus.current.dispatchEvent(new CustomEvent("error", { detail: { error, reason: "error" } })); + eventBus.current.dispatchEvent(new CustomEvent("error", { detail: { code: error } })); }} onExpire={() => setStatus("expired")} onTimeout={() => reportChallengeFailure(new Error("Turnstile challenge timed out"), "TURNSTILE_CHALLENGE_TIMED_OUT")} onSuccess={token => { setStatus("solved"); - hasReportedFailure.current = false; + hasSettledRun.current = false; + isAwaitingInteraction.current = false; eventBus.current.dispatchEvent(new CustomEvent("success", { detail: { token } })); }} - onBeforeInteractive={() => setStatus("interactive")} + onBeforeInteractive={() => { + isAwaitingInteraction.current = true; + setStatus("interactive"); + }} + onAfterInteractive={() => { + isAwaitingInteraction.current = false; + }} onWidgetLoad={() => { isWidgetLoaded.current = true; const startPendingChallenge = startChallengeOnWidgetLoad.current; diff --git a/apps/deploy-web/src/services/analytics/analytics.service.ts b/apps/deploy-web/src/services/analytics/analytics.service.ts index f5ac1d5c64..6719055290 100644 --- a/apps/deploy-web/src/services/analytics/analytics.service.ts +++ b/apps/deploy-web/src/services/analytics/analytics.service.ts @@ -38,6 +38,7 @@ export type AnalyticsEvent = | "wrong_email_clk" | "resend_code_clk" | "password_auth_submit" + | "captcha_abandoned" | "connect_wallet" | "connect_managed_wallet" | "disconnect_wallet"