From 8f5a9f59aa96885f479c366c69db7ab13482f822 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 18 Aug 2026 17:15:09 +0100 Subject: [PATCH] fix(passkey): stop reporting a cancelled passkey prompt as an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useZeroDev classifies a WebAuthn failure, captures the raw error with full context, and throws a curated PasskeyError for display. For a plain user cancel it deliberately captures nothing on web — "Cancel saved no state". Four call sites undid that: - InvitesPage fired `void handleLoginClick()` with no catch, so cancelling the prompt became an unhandled rejection. Now caught, and the curated message is surfaced the way every other login entry point surfaces it. - GuestLoginModal console.error'd the wrapper, which captureConsoleIntegration turns into an event. - Landing and JoinWaitlist called Sentry.captureException on the wrapper — a second, context-free copy of an error already reported at the throw site, and the reason LOGIN_CANCELED showed up at error level at all. Landing and JoinWaitlist still report anything that isn't a PasskeyError, so an unexpected failure in the login path is not silenced. PEANUT-UI-QRW and PEANUT-UI-R20: 19 events yesterday, all expected outcomes. --- src/components/Global/GuestLoginModal/index.tsx | 5 +++-- src/components/Invites/InvitesPage.test.tsx | 4 ++++ src/components/Invites/InvitesPage.tsx | 8 +++++++- src/components/Setup/Views/JoinWaitlist.tsx | 5 ++++- src/components/Setup/Views/Landing.tsx | 5 ++++- src/utils/webauthn.utils.ts | 10 ++++++++++ 6 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/components/Global/GuestLoginModal/index.tsx b/src/components/Global/GuestLoginModal/index.tsx index a9f10f2f8c..97a54309f6 100644 --- a/src/components/Global/GuestLoginModal/index.tsx +++ b/src/components/Global/GuestLoginModal/index.tsx @@ -25,8 +25,9 @@ const GuestLoginModal = () => { onClick={() => { handleLogin() .then(closeModal) - .catch((e) => { - console.error(e) + .catch(() => { + // useZeroDev already reported the underlying failure; + // console.error here would capture the wrapper again. toast.error(t('guestLoginModal.loginError')) }) }} diff --git a/src/components/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx index df3b46729c..9309eb64a0 100644 --- a/src/components/Invites/InvitesPage.test.tsx +++ b/src/components/Invites/InvitesPage.test.tsx @@ -53,6 +53,10 @@ jest.mock('@/redux/slices/setup-slice', () => ({ }, })) jest.mock('@/hooks/useLogin', () => ({ useLogin: () => ({ handleLoginClick: mockLogin, isLoggingIn: false }) })) +jest.mock('@/components/0_Bruddle/Toast', () => ({ + ...jest.requireActual('@/components/0_Bruddle/Toast'), + useToast: () => ({ error: jest.fn(), success: jest.fn() }), +})) jest.mock('@/hooks/useGuestStoreHandoff', () => ({ useGuestStoreHandoff: (opts: { trackImpressionWhenGuest?: boolean }) => { mockUseGuestStoreHandoff(opts) diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 02d94fc305..3dfbe1225f 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -16,6 +16,7 @@ import { EInviteType } from '@/services/services.types' import { getValidRedirectUrl, saveRedirectUrl, saveToCookie } from '@/utils/general.utils' import { useGuestStoreHandoff } from '@/hooks/useGuestStoreHandoff' import { useLogin } from '@/hooks/useLogin' +import { useToast } from '@/components/0_Bruddle/Toast' import UnsupportedBrowserModal from '../Global/UnsupportedBrowserModal' import posthog from 'posthog-js' import { useTranslations } from 'next-intl' @@ -38,6 +39,7 @@ import { destinationForInviteAcquisition } from '@/services/invite-acquisition' function InvitePageContent() { const t = useTranslations('invites') const tSetup = useTranslations('setup') + const toast = useToast() const searchParams = useSearchParams() // trim trailing '?' from invite code to handle qr codes with ? at the end const inviteCode = searchParams.get('code')?.toLowerCase().replace(/\?+$/, '') @@ -292,7 +294,11 @@ function InvitePageContent() { // normal-app fallback. saveRedirectUrl() } - void handleLoginClick() + // PasskeyError carries curated user-facing copy; without this catch a + // cancelled passkey prompt becomes an unhandled rejection and a Sentry event. + handleLoginClick().catch((error: unknown) => { + toast.error((error instanceof Error && error.message) || tSetup('loginFailed')) + }) } useEffect(() => { diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index ab8b0e6532..14f1aade61 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -1,6 +1,7 @@ 'use client' import { Button } from '@/components/0_Bruddle/Button' +import { isAlreadyReported } from '@/utils/webauthn.utils' import { useToast } from '@/components/0_Bruddle/Toast' import ValidatedInput from '@/components/Global/ValidatedInput' import { useEffect, useState } from 'react' @@ -89,7 +90,9 @@ const JoinWaitlist = () => { ? t('waitlist.noPasskey') : t('waitlist.loginUnexpectedError') toast.error(errorMessage) - Sentry.captureException(error, { extra: { errorCode } }) + if (!isAlreadyReported(error)) { + Sentry.captureException(error, { extra: { errorCode } }) + } } const _onLoginClick = async () => { diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index 6845dcab6a..ab0efe20a0 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -1,6 +1,7 @@ 'use client' import { useToast } from '@/components/0_Bruddle/Toast' +import { isAlreadyReported } from '@/utils/webauthn.utils' import { useSetupFlow } from '@/hooks/useSetupFlow' import { useLogin } from '@/hooks/useLogin' import * as Sentry from '@sentry/nextjs' @@ -45,7 +46,9 @@ const LandingStep = () => { const handleError = (error: unknown) => { const errorCode = error instanceof Error && 'code' in error ? String(error.code) : undefined toast.error((error instanceof Error && error.message) || t('loginFailed')) - Sentry.captureException(error, { extra: { errorCode } }) + if (!isAlreadyReported(error)) { + Sentry.captureException(error, { extra: { errorCode } }) + } posthog.capture(ANALYTICS_EVENTS.SIGNUP_LOGIN_ERROR, { error_code: errorCode }) } diff --git a/src/utils/webauthn.utils.ts b/src/utils/webauthn.utils.ts index 24b80e6b52..14da5792c8 100644 --- a/src/utils/webauthn.utils.ts +++ b/src/utils/webauthn.utils.ts @@ -227,6 +227,16 @@ export const PASSKEY_WARNINGS = { */ const WEBAUTHN_ERROR_NAMES = new Set(Object.values(WebAuthnErrorName)) +/** + * useZeroDev captures the raw WebAuthn failure with full context before throwing + * the curated PasskeyError — and for a plain user cancel it deliberately captures + * nothing on web. Re-reporting the wrapper at a call site adds a context-free + * duplicate and undoes that silence. + */ +export function isAlreadyReported(error: unknown): boolean { + return error instanceof Error && error.name === 'PasskeyError' +} + export function capturePasskeySignFailure(error: unknown, context: string): void { if (!(error instanceof Error) || !WEBAUTHN_ERROR_NAMES.has(error.name)) return posthog.capture(ANALYTICS_EVENTS.PASSKEY_SIGN_FAILED, { error_name: error.name, context })