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"