mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 04:45:08 +03:00
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=<partner>` 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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
259 lines
8.1 KiB
TypeScript
259 lines
8.1 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import type { Factor } from '@supabase/supabase-js'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { useAuthError, useParams } from 'common'
|
|
import { Lock } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import { useEffect, useState } from 'react'
|
|
import { SubmitHandler, useForm, useWatch } from 'react-hook-form'
|
|
import { Button, Form, FormControl, FormField, Input } from 'ui'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
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'),
|
|
})
|
|
|
|
const formId = 'sign-in-mfa-form'
|
|
|
|
const SUPPORT_EMAIL_HREF = `mailto:support@supabase.com?subject=${encodeURIComponent('Unable to sign in via MFA')}`
|
|
|
|
function getFactorDisplayName(factor: Pick<Factor, 'friendly_name'> | null | undefined): string {
|
|
const name = factor?.friendly_name?.trim()
|
|
return name && name.length > 0 ? name : 'your authenticator app'
|
|
}
|
|
|
|
interface SignInMfaFormProps {
|
|
context?: 'forgot-password' | 'sign-in'
|
|
}
|
|
|
|
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<Factor | null>(null)
|
|
const form = useForm<z.infer<typeof schema>>({
|
|
resolver: zodResolver(schema),
|
|
defaultValues: { code: '' },
|
|
})
|
|
|
|
const code = useWatch({ control: form.control, name: 'code' })
|
|
|
|
const {
|
|
data: factors,
|
|
error: factorsError,
|
|
isError: isErrorFactors,
|
|
isSuccess: isSuccessFactors,
|
|
isPending: isLoadingFactors,
|
|
} = useMfaListFactorsQuery()
|
|
const {
|
|
mutate: mfaChallengeAndVerify,
|
|
isPending: isVerifying,
|
|
isSuccess,
|
|
} = useMfaChallengeAndVerifyMutation({
|
|
onSuccess: async () => {
|
|
if (context === 'sign-in') {
|
|
track('sign_in', { category: 'account', method: signInMethod })
|
|
addLoginEvent({})
|
|
}
|
|
|
|
await queryClient.resetQueries()
|
|
|
|
if (context === 'forgot-password') {
|
|
router.push({
|
|
pathname: '/reset-password',
|
|
query: router.query,
|
|
})
|
|
} else {
|
|
router.push(getReturnToPath())
|
|
}
|
|
},
|
|
})
|
|
|
|
const onClickLogout = async () => {
|
|
await signOut()
|
|
await router.replace('/sign-in')
|
|
}
|
|
|
|
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ code }) => {
|
|
if (selectedFactor) {
|
|
mfaChallengeAndVerify({ factorId: selectedFactor.id, code, refreshFactors: false })
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (isSuccessFactors) {
|
|
// if the user wanders into this page and he has no MFA setup, send the user to the next screen
|
|
if (factors.totp.length === 0) {
|
|
queryClient.resetQueries().then(() => router.push(getReturnToPath()))
|
|
}
|
|
if (factors.totp.length > 0) {
|
|
setSelectedFactor(factors.totp[0])
|
|
}
|
|
}
|
|
}, [factors?.totp, isSuccessFactors, router, queryClient])
|
|
|
|
useEffect(() => {
|
|
if (code.length === 6) form.handleSubmit(onSubmit)()
|
|
}, [code])
|
|
|
|
const error = useAuthError()
|
|
|
|
if (error) {
|
|
return (
|
|
<AlertError
|
|
error={error}
|
|
subject="Error while signing in"
|
|
hideContactSupport
|
|
additionalActions={
|
|
<>
|
|
<Button asChild variant="default">
|
|
<Link href="/sign-in">Back to sign in</Link>
|
|
</Button>
|
|
<Button asChild variant="default">
|
|
<a href={SUPPORT_EMAIL_HREF}>Email support</a>
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{isLoadingFactors && <GenericSkeletonLoader />}
|
|
|
|
{isErrorFactors && (
|
|
<AlertError
|
|
error={factorsError}
|
|
subject="Failed to retrieve factors"
|
|
description="Try refreshing your browser. If the issue persists, email support@supabase.com."
|
|
hideContactSupport
|
|
/>
|
|
)}
|
|
|
|
{isSuccessFactors && (
|
|
<Form {...form}>
|
|
<form
|
|
id={formId}
|
|
method="POST"
|
|
className="flex flex-col gap-4"
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
>
|
|
<FormField
|
|
key="code"
|
|
name="code"
|
|
control={form.control}
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
name="code"
|
|
label={
|
|
selectedFactor ? (
|
|
<>
|
|
Code generated by{' '}
|
|
<strong className="text-foreground">
|
|
{getFactorDisplayName(selectedFactor)}
|
|
</strong>
|
|
</>
|
|
) : null
|
|
}
|
|
>
|
|
<FormControl>
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-light [&_svg]:stroke-[1.5] [&_svg]:h-[20px] [&_svg]:w-[20px]">
|
|
<Lock />
|
|
</div>
|
|
<Input
|
|
id="code"
|
|
className="pl-10 font-mono"
|
|
{...field}
|
|
autoFocus
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="none"
|
|
spellCheck="false"
|
|
placeholder="XXXXXX"
|
|
disabled={isVerifying}
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex items-center justify-between gap-x-2">
|
|
<Button
|
|
block
|
|
variant="outline"
|
|
size="large"
|
|
disabled={isVerifying || isSuccess}
|
|
onClick={onClickLogout}
|
|
className="opacity-80 hover:opacity-100 transition"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
block
|
|
form={formId}
|
|
type="submit"
|
|
size="large"
|
|
disabled={isVerifying || isSuccess}
|
|
loading={isVerifying || isSuccess}
|
|
>
|
|
{isVerifying ? 'Verifying' : isSuccess ? 'Signing in' : 'Verify'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
)}
|
|
|
|
<div className="my-8">
|
|
<div className="text-sm">
|
|
<span className="text-foreground-light">Unable to sign in?</span>{' '}
|
|
</div>
|
|
<ul className="list-disc pl-6">
|
|
{factors?.totp.length === 2 && (
|
|
<li>
|
|
<a
|
|
className="text-sm text-foreground-light hover:text-foreground cursor-pointer"
|
|
onClick={() =>
|
|
setSelectedFactor(factors.totp.find((f) => f.id !== selectedFactor?.id)!)
|
|
}
|
|
>
|
|
Authenticate using{' '}
|
|
<strong className="text-foreground">
|
|
{getFactorDisplayName(factors.totp.find((f) => f.id !== selectedFactor?.id))}
|
|
</strong>
|
|
?
|
|
</a>
|
|
</li>
|
|
)}
|
|
<li>
|
|
<Link
|
|
href="/logout"
|
|
className="text-sm transition text-foreground-light hover:text-foreground"
|
|
>
|
|
Force sign out and clear cookies
|
|
</Link>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|