From 3338be76f019d298e508faae0ca8f62cc5dcbcc2 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Tue, 1 Sep 2026 21:26:08 +0800 Subject: [PATCH] fix(studio): emit sign_in on totp challenge (#49755) The dashboard's `sign_in` event never fires when a user completes a TOTP challenge: `SignInForm` only tracks when no MFA challenge is needed, and the /sign-in-mfa page only tracks on mount when the assurance level is already satisfied (OAuth/SSO returns). Sign-ins that go through the actual MFA form were invisible to analytics, and the login audit event was missing on the same path. **Changed:** - **MFA-challenged sign-ins now tracked**: `SignInMfaForm` fires `sign_in` (reading the same `method` query param the page mount site reads) plus the login audit event on successful TOTP verification, in the sign-in context only. The forgot-password flow stays untracked: it is a reset, not a sign-in. - **Password+MFA sign-ins report `method: email`**: `SignInForm` now passes `?method=email` when routing to /sign-in-mfa instead of falling through to `unknown`. - **Partner TOTP sign-ins carry their provider**: `SignInPartner` now passes `?method=` when routing to /sign-in-mfa, matching the raw-provider-name convention the other entry points use. - **Join caveat documented**: the `SignInEvent` doc comment now notes the event is captured server-side and races the identify call, so it is not a valid funnel join key across the auth boundary. ## Linear - fixes GROWTH-1156 ## Summary by CodeRabbit * **New Features** * Added sign-in method details to MFA redirects for email and partner authentication, improving sign-in flow tracking. * Added telemetry and login auditing for successful MFA sign-ins while keeping forgot-password flows untracked. * **Documentation** * Clarified sign-in event tracking coverage, including OAuth providers, server-side capture, anonymous identifiers, and the sign-in page. --- .../components/interfaces/SignIn/SignInForm.tsx | 2 +- .../components/interfaces/SignIn/SignInMfaForm.tsx | 13 ++++++++++++- .../components/interfaces/SignIn/SignInPartner.tsx | 2 +- packages/common/telemetry-constants.ts | 6 ++++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/studio/components/interfaces/SignIn/SignInForm.tsx b/apps/studio/components/interfaces/SignIn/SignInForm.tsx index 6409890cdac..bf0549a81c4 100644 --- a/apps/studio/components/interfaces/SignIn/SignInForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInForm.tsx @@ -81,7 +81,7 @@ export const SignInForm = () => { if (data) { if (data.currentLevel !== data.nextLevel) { toast.success(`You need to provide your second factor authentication`, { id: toastId }) - const url = buildPathWithParams('/sign-in-mfa') + const url = buildPathWithParams('/sign-in-mfa?method=email') router.replace(url) return } diff --git a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx index 2b437c0f802..934998d089a 100644 --- a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import type { Factor } from '@supabase/supabase-js' import { useQueryClient } from '@tanstack/react-query' -import { useAuthError } from 'common' +import { useAuthError, useParams } from 'common' import { Lock } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' @@ -13,10 +13,12 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import z from 'zod' import { AlertError } from '@/components/ui/AlertError' +import { useAddLoginEvent } from '@/data/misc/audit-login-mutation' import { useMfaChallengeAndVerifyMutation } from '@/data/profile/mfa-challenge-and-verify-mutation' import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query' import { useSignOut } from '@/lib/auth' import { getReturnToPath } from '@/lib/gotrue' +import { useTrack } from '@/lib/telemetry/track' const schema = z.object({ code: z.string().min(1, 'MFA Code is required'), @@ -39,6 +41,10 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { const router = useRouter() const signOut = useSignOut() const queryClient = useQueryClient() + const { method: signInMethod = 'unknown' } = useParams() + + const track = useTrack() + const { mutate: addLoginEvent } = useAddLoginEvent() const [selectedFactor, setSelectedFactor] = useState(null) const form = useForm>({ @@ -61,6 +67,11 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { isSuccess, } = useMfaChallengeAndVerifyMutation({ onSuccess: async () => { + if (context === 'sign-in') { + track('sign_in', { category: 'account', method: signInMethod }) + addLoginEvent({}) + } + await queryClient.resetQueries() if (context === 'forgot-password') { diff --git a/apps/studio/components/interfaces/SignIn/SignInPartner.tsx b/apps/studio/components/interfaces/SignIn/SignInPartner.tsx index 6aafa0281d8..5c792b80129 100644 --- a/apps/studio/components/interfaces/SignIn/SignInPartner.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInPartner.tsx @@ -21,7 +21,7 @@ export const SignInPartner = () => { try { await auth.signInWithIdToken({ provider: partner, token }) } finally { - router.replace({ pathname: '/sign-in-mfa' }) + router.replace({ pathname: '/sign-in-mfa', query: { method: partner } }) } } else { router.replace({ pathname: '/sign-in' }) diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 12dad4e2243..2b7ff3134ac 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -46,14 +46,16 @@ export interface SignUpEvent { } /** - * Triggered when a user signs in with GitHub, Email and Password or SSO. + * Triggered when a user signs in with an OAuth provider, Email and Password, or SSO. * * 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. * * @group Events * @source studio - * @page /sign-in-mfa + * @page /sign-in, /sign-in-mfa */ export interface SignInEvent { action: 'sign_in'