diff --git a/apps/studio/components/interfaces/SignIn/SignIn.utils.ts b/apps/studio/components/interfaces/SignIn/SignIn.utils.ts new file mode 100644 index 00000000000..e9229aa3aec --- /dev/null +++ b/apps/studio/components/interfaces/SignIn/SignIn.utils.ts @@ -0,0 +1,32 @@ +import type HCaptcha from '@hcaptcha/react-hcaptcha' +import type { RefObject } from 'react' +import { toast } from 'sonner' + +import { captureCriticalError } from '@/lib/error-reporting' +import type { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' + +type TrackFunnelError = ReturnType + +export async function resolveCaptchaToken( + captchaRef: RefObject, + trackFunnelError: TrackFunnelError, + toastId: string | number +): Promise<{ ok: true; token: string | null } | { ok: false }> { + try { + const captchaResponse = await captchaRef.current?.execute({ async: true }) + return { ok: true, token: captchaResponse?.response ?? null } + } catch (error) { + toast.error('Could not complete the security check. Please try again.', { id: toastId }) + trackFunnelError( + 'signin', + { errorCategory: 'unknown', errorReason: 'captcha_challenge_failed' }, + 'toast', + toastId + ) + captureCriticalError( + error instanceof Error ? error : new Error(String(error)), + 'sign in captcha challenge' + ) + return { ok: false } + } +} diff --git a/apps/studio/components/interfaces/SignIn/SignInForm.tsx b/apps/studio/components/interfaces/SignIn/SignInForm.tsx index bf0549a81c4..258af90808a 100644 --- a/apps/studio/components/interfaces/SignIn/SignInForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInForm.tsx @@ -14,13 +14,16 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import { LastSignInWrapper } from './LastSignInWrapper' +import { resolveCaptchaToken } from './SignIn.utils' import { AlertError } from '@/components/ui/AlertError' import { useAddLoginEvent } from '@/data/misc/audit-login-mutation' import { getMfaAuthenticatorAssuranceLevel } from '@/data/profile/mfa-authenticator-assurance-level-query' import { useLastSignIn } from '@/hooks/misc/useLastSignIn' import { captureCriticalError } from '@/lib/error-reporting' import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue' +import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors' import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' const schema = z.object({ email: z.string().min(1, 'Email is required').email('Must be a valid email'), @@ -51,6 +54,7 @@ export const SignInForm = () => { }, []) const track = useTrack() + const trackFunnelError = useTrackFunnelError() const { mutate: addLoginEvent } = useAddLoginEvent() let forgotPasswordUrl = `/forgot-password` @@ -64,8 +68,13 @@ export const SignInForm = () => { let token = captchaToken if (!token) { - const captchaResponse = await captchaRef.current?.execute({ async: true }) - token = captchaResponse?.response ?? null + const captcha = await resolveCaptchaToken(captchaRef, trackFunnelError, toastId) + if (!captcha.ok) { + setCaptchaToken(null) + captchaRef.current?.resetCaptcha() + return + } + token = captcha.token } const { error } = await auth.signInWithPassword({ @@ -100,6 +109,7 @@ export const SignInForm = () => { router.push(redirectPath) } catch (error: any) { toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, 'sign in via EP') } } else { @@ -107,13 +117,16 @@ export const SignInForm = () => { captchaRef.current?.resetCaptcha() if (error.message.toLowerCase() === 'email not confirmed') { - return toast.error( + toast.error( 'Your account has not been verified. Please check the verification link sent to your email. If you have not received the email or the link has expired, please sign up again to request a new verification link.', { id: toastId } ) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) + return } toast.error(error.message, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) } } @@ -125,7 +138,12 @@ export const SignInForm = () => { id={formId} method="POST" className="flex flex-col gap-4" - onSubmit={form.handleSubmit(onSubmit)} + onSubmit={(e) => { + track('sign_in_submitted', { category: 'account', method: 'email' }) + return form.handleSubmit(onSubmit, (errors) => + trackFunnelError('signin', classifyValidationError('signin', errors), 'form') + )(e) + }} > {authError && } { const router = useRouter() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() useEffect(() => { ;(async () => { @@ -18,10 +24,21 @@ export const SignInPartner = () => { const { data } = await auth.getSession() if (!data.session && partner && token) { + // partner comes from the URL hash unauthenticated; only registry-known values may + // enter the method vocabulary, anything else would let a crafted link poison it + const knownPartner = getIdentityProviderConfig(partner) + const method = knownPartner?.id ?? 'unregistered_partner' + track('sign_in_submitted', { + category: 'account', + method, + }) try { - await auth.signInWithIdToken({ provider: partner, token }) + const { error } = await auth.signInWithIdToken({ provider: partner, token }) + if (error) { + trackFunnelError('signin', classifyApiError('signin', error), 'form') + } } finally { - router.replace({ pathname: '/sign-in-mfa', query: { method: partner } }) + router.replace({ pathname: '/sign-in-mfa', query: { method } }) } } else { router.replace({ pathname: '/sign-in' }) diff --git a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx index f6e42e82ce4..908f42e204c 100644 --- a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx @@ -12,10 +12,14 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import { LastSignInWrapper } from './LastSignInWrapper' +import { resolveCaptchaToken } from './SignIn.utils' import { useLastSignIn } from '@/hooks/misc/useLastSignIn' import { BASE_PATH } from '@/lib/constants' import { captureCriticalError } from '@/lib/error-reporting' import { auth, buildPathWithParams } from '@/lib/gotrue' +import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors' +import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' const schema = z.object({ email: z.string().min(1, 'Email is required').email('Must be a valid email'), @@ -28,6 +32,8 @@ export const SignInSSOForm = () => { const captchaRef = useRef(null) const [captchaToken, setCaptchaToken] = useState(null) const [_, setLastSignInUsed] = useLastSignIn() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() const form = useForm>({ resolver: zodResolver(schema), defaultValues: { email: '' }, @@ -39,8 +45,13 @@ export const SignInSSOForm = () => { let token = captchaToken if (!token) { - const captchaResponse = await captchaRef.current?.execute({ async: true }) - token = captchaResponse?.response ?? null + const captcha = await resolveCaptchaToken(captchaRef, trackFunnelError, toastId) + if (!captcha.ok) { + setCaptchaToken(null) + captchaRef.current?.resetCaptcha() + return + } + token = captcha.token } // redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx) @@ -71,6 +82,7 @@ export const SignInSSOForm = () => { setCaptchaToken(null) captchaRef.current?.resetCaptcha() toast.error(`Failed to sign in: ${error.message}`, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, 'sign in via SSO') } } @@ -81,7 +93,12 @@ export const SignInSSOForm = () => { id={formId} method="POST" className="flex flex-col gap-4" - onSubmit={form.handleSubmit(onSubmit)} + onSubmit={(e) => { + track('sign_in_submitted', { category: 'account', method: 'sso' }) + return form.handleSubmit(onSubmit, (errors) => + trackFunnelError('signin', classifyValidationError('signin', errors), 'form') + )(e) + }} > { const [loading, setLoading] = useState(false) const displayName = getProviderDisplay(providerName).displayName + const track = useTrack() + const trackFunnelError = useTrackFunnelError() async function handleCustomSignIn() { setLoading(true) + track('sign_in_submitted', { category: 'account', method: providerName.toLowerCase() }) try { // redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx) @@ -36,7 +42,8 @@ export const SignInWithCustom = ({ providerName }: SignInWithCustomProps) => { if (error) throw error } catch (error: any) { - toast.error(`Failed to sign in via ${displayName}: ${error.message}`) + const toastId = toast.error(`Failed to sign in via ${displayName}: ${error.message}`) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, `sign in via ${providerName}`) setLoading(false) } diff --git a/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx b/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx index 51ed1debe9a..bf3ee81a69c 100644 --- a/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx @@ -14,6 +14,9 @@ import { } from '@/lib/external-identity-providers' import { getErrorMessage } from '@/lib/get-error-message' import { auth, buildPathWithParams } from '@/lib/gotrue' +import { classifyApiError } from '@/lib/telemetry/funnel-errors' +import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' interface SignInWithExternalProviderProps { provider: ExternalIdentityProviderConfig @@ -22,9 +25,12 @@ interface SignInWithExternalProviderProps { export const SignInWithExternalProvider = ({ provider }: SignInWithExternalProviderProps) => { const [loading, setLoading] = useState(false) const [, setLastSignInUsed] = useLastSignIn() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() async function handleSignIn() { setLoading(true) + track('sign_in_submitted', { category: 'account', method: provider.id }) try { // Redirects to /sign-in-mfa to check if the user has MFA set up before entering the dashboard @@ -40,7 +46,8 @@ export const SignInWithExternalProvider = ({ provider }: SignInWithExternalProvi setLastSignInUsed(provider.id) } catch (error: unknown) { const message = getErrorMessage(error) ?? 'Unknown error' - toast.error(`Failed to sign in via ${provider.displayName}: ${message}`) + const toastId = toast.error(`Failed to sign in via ${provider.displayName}: ${message}`) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError( error instanceof Error ? error : new Error(message), `sign in via ${provider.displayName}` diff --git a/apps/studio/lib/telemetry/funnel-errors.test.ts b/apps/studio/lib/telemetry/funnel-errors.test.ts index 6abe0049b6d..161a1c79df0 100644 --- a/apps/studio/lib/telemetry/funnel-errors.test.ts +++ b/apps/studio/lib/telemetry/funnel-errors.test.ts @@ -58,6 +58,84 @@ describe('classifyApiError', () => { errorCode: 400, }) }) + + describe('signin', () => { + it('reads a GoTrue AuthError status as the code', () => { + expect( + classifyApiError('signin', { status: 400, message: 'Invalid login credentials' }) + ).toEqual({ + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + }) + + it('classifies an unconfirmed email', () => { + expect(classifyApiError('signin', { status: 400, message: 'Email not confirmed' })).toEqual({ + errorCategory: 'api', + errorReason: 'email_not_confirmed', + errorCode: 400, + }) + }) + + it('classifies 429 via status as rate_limited', () => { + expect(classifyApiError('signin', { status: 429, message: 'Rate limit exceeded' })).toEqual({ + errorCategory: 'api', + errorReason: 'rate_limited', + errorCode: 429, + }) + }) + + it('classifies a captcha failure', () => { + expect( + classifyApiError('signin', { status: 400, message: 'captcha verification process failed' }) + ).toEqual({ errorCategory: 'api', errorReason: 'captcha_failed', errorCode: 400 }) + }) + + it('matches the SSO pattern before the 404 status map', () => { + expect( + classifyApiError('signin', { + status: 404, + message: 'No SSO provider assigned for this domain', + }) + ).toEqual({ errorCategory: 'api', errorReason: 'sso_provider_not_found', errorCode: 404 }) + }) + + it('classifies a redirect allow-list rejection', () => { + expect(classifyApiError('signin', { status: 400, message: 'Invalid redirect URL' })).toEqual({ + errorCategory: 'api', + errorReason: 'redirect_not_allowed', + errorCode: 400, + }) + }) + + it('classifies a disabled provider', () => { + expect( + classifyApiError('signin', { + status: 400, + message: 'Unsupported provider: provider is not enabled', + }) + ).toEqual({ errorCategory: 'api', errorReason: 'provider_not_enabled', errorCode: 400 }) + }) + + it('classifies a GoTrue transport failure (status 0) as network_error, never api/other', () => { + expect( + classifyApiError('signin', { + name: 'AuthRetryableFetchError', + status: 0, + message: 'Failed to fetch', + }) + ).toEqual({ errorCategory: 'network', errorReason: 'network_error' }) + }) + + it('classifies a retryable 5xx via status as server_error', () => { + expect(classifyApiError('signin', { status: 503, message: 'Service unavailable' })).toEqual({ + errorCategory: 'api', + errorReason: 'server_error', + errorCode: 503, + }) + }) + }) }) describe('classifyValidationError', () => { @@ -79,6 +157,15 @@ describe('classifyValidationError', () => { ).toEqual({ errorCategory: 'validation', errorReason: 'email_invalid' }) }) + it('maps a signin password error to password_invalid', () => { + expect( + classifyValidationError('signin', { password: { type: 'too_small' } } as FieldErrors) + ).toEqual({ + errorCategory: 'validation', + errorReason: 'password_invalid', + }) + }) + it('maps an org name error to org_name_missing', () => { expect( classifyValidationError('org_creation', { name: { type: 'too_small' } } as FieldErrors) diff --git a/apps/studio/lib/telemetry/funnel-errors.ts b/apps/studio/lib/telemetry/funnel-errors.ts index 67c92592fed..d4873ae2039 100644 --- a/apps/studio/lib/telemetry/funnel-errors.ts +++ b/apps/studio/lib/telemetry/funnel-errors.ts @@ -1,6 +1,6 @@ import type { FieldErrors } from 'react-hook-form' -export type FunnelOrigin = 'signup' | 'project_creation' | 'org_creation' +export type FunnelOrigin = 'signup' | 'signin' | 'project_creation' | 'org_creation' export type ErrorCategory = 'validation' | 'api' | 'network' | 'payment' | 'unknown' export interface FunnelErrorClassification { @@ -19,6 +19,16 @@ const API_REASON_PATTERNS = { [/password/i, 'password_rejected'], [/valid email|invalid email|email address/i, 'email_invalid'], ], + signin: [ + [/invalid login credentials/i, 'invalid_credentials'], + [/email not confirmed/i, 'email_not_confirmed'], + [/rate limit|too many requests|after \d+ second/i, 'rate_limited'], + [/captcha/i, 'captcha_failed'], + [/sso provider/i, 'sso_provider_not_found'], + [/redirect|requested path is invalid/i, 'redirect_not_allowed'], + [/provider is not enabled|unsupported provider/i, 'provider_not_enabled'], + [/valid email|invalid email|email address/i, 'email_invalid'], + ], project_creation: [ [/already exists/i, 'project_name_taken'], [/free plan|free tier/i, 'free_tier_limit'], @@ -39,6 +49,10 @@ const VALIDATION_FIELD_REASONS = { email: 'email_invalid', password: 'password_invalid', }, + signin: { + email: 'email_invalid', + password: 'password_invalid', + }, project_creation: { organization: 'organization_missing', projectName: 'project_name_invalid', @@ -67,6 +81,7 @@ const STRIPE_DECLINE_REASONS = { } as const satisfies Record const GENERIC_REASONS = [ + 'captcha_challenge_failed', 'rate_limited', 'server_error', 'connection_timeout', @@ -95,8 +110,16 @@ const STATUS_REASONS: Readonly>> = { } export function classifyApiError(origin: FunnelOrigin, error: unknown): FunnelErrorClassification { - const err = error as { code?: unknown; errorType?: unknown; message?: unknown } - const code = typeof err?.code === 'number' ? err.code : undefined + const err = error as { code?: unknown; status?: unknown; errorType?: unknown; message?: unknown } + // GoTrue AuthErrors carry a numeric `status` and a string `code` slug; auth-js uses + // status 0 for transport failures (AuthRetryableFetchError), which must classify as + // network_error, so the fallback only accepts positive statuses. + const code = + typeof err?.code === 'number' + ? err.code + : typeof err?.status === 'number' && err.status > 0 + ? err.status + : undefined const message = typeof err?.message === 'string' ? err.message : '' if (err?.errorType === 'connection-timeout') { diff --git a/apps/studio/lib/toast-errors.test.tsx b/apps/studio/lib/toast-errors.test.tsx index 07e0971d3ad..60f0141b9d1 100644 --- a/apps/studio/lib/toast-errors.test.tsx +++ b/apps/studio/lib/toast-errors.test.tsx @@ -76,6 +76,33 @@ describe('ToastErrorTracker', () => { expect(mockTrack).toHaveBeenCalledTimes(1) }) + it('tracks a loading toast updated to an error exactly once (sign-in reuses the loading toast id)', async () => { + render() + let toastId: string | number + act(() => { + toastId = toast.loading('Signing in...') + }) + act(() => { + toast.error('Invalid login credentials', { id: toastId }) + registerFunnelErrorToast(toastId, { + origin: 'signin', + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + }) + await waitFor(() => + expect(mockTrack).toHaveBeenCalledWith('dashboard_error_created', { + source: 'toast', + origin: 'signin', + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + ) + expect(mockTrack).toHaveBeenCalledTimes(1) + }) + it('ignores non-error toasts', async () => { render() act(() => { diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 2b7ff3134ac..0e4365ed54e 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -50,8 +50,7 @@ export interface SignUpEvent { * * Some unintuitive behavior: * - If signing up with GitHub the SignInEvent gets triggered first before the SignUpEvent. - * - Captured server-side; the distinct_id often resolves to the anonymous cookie because - * the event races identify, so don't use it as a funnel join key across the auth boundary. + * - distinct_id often resolves to the anonymous cookie (races identify); not a person-level join key. * * @group Events * @source studio @@ -68,6 +67,26 @@ export interface SignInEvent { } } +/** + * Triggered when a user initiates a sign-in (form submit including client-side validation + * failures, OAuth or custom-provider click, partner token exchange), before auth resolves. + * Pre-auth, so distinct_id is the anonymous cookie: not a person-level join key. + * + * @group Events + * @source studio + * @page /sign-in, /sign-in-sso, /sign-in-partner + */ +export interface SignInSubmittedEvent { + action: 'sign_in_submitted' + properties: { + category: 'account' + /** + * Matches the sign_in event's method vocabulary, e.g. email (password path), github, sso + */ + method: string + } +} + /** * User copied the database connection string. * @@ -3176,7 +3195,7 @@ export interface DashboardErrorCreatedEvent { /** * Funnel the error occurred in (set only for instrumented funnel errors) */ - origin?: 'signup' | 'project_creation' | 'org_creation' + origin?: 'signup' | 'signin' | 'project_creation' | 'org_creation' /** * Coarse classification of the funnel error */ @@ -3772,6 +3791,7 @@ export interface HeaderLocalVersionPopoverOpenedEvent { export type TelemetryEvent = | SignUpEvent | SignInEvent + | SignInSubmittedEvent | ConnectionStringCopiedEvent | McpInstallButtonClickedEvent | ApiDocsOpenedEvent diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 4e8e13aa9f8..f3cce2f6286 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -428,8 +428,16 @@ export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathn } } + // keepalive lets the request survive the same-tick OAuth redirect after + // sign_in_submitted, but keepalive requests share a ~64KB in-flight quota + // page-wide, so it stays scoped to that event. Callers like useTrack + // fire-and-forget, so rejections are handled here rather than surfacing + // as unhandled promise rejections. return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, { headers: { Version: '2' }, + keepalive: event.action === 'sign_in_submitted', + }).catch((error) => { + console.error('Problem sending telemetry event:', error) }) }