Files
supabase/apps/studio/components/interfaces/SignIn/SignInRecoveryCodeForm.tsx
T
Gildas GarciaandAli Waseem d276f75c89 Recovery codes: allow users to use recovery codes to access their account (#50569)
## What kind of change does this PR introduce?

Allow users to sign in using a recovery code after being redirected to
the MFA verification page.

## Additional context

<img width="435" height="373" alt="image"
src="https://github.com/user-attachments/assets/968fd15e-3081-4aa2-b645-4e0d2ec2637c"
/>

<img width="494" height="404" alt="image"
src="https://github.com/user-attachments/assets/fd7cee49-dca7-4f1a-873a-293e21c68faa"
/>

## How to test

- Enable MFA on your account if needed
- Generate recovery codes if needed (make sure you actually saved the
recovery codes somewhere)
- Sign out
- Sign in and when redirected to the MFA verification page, click the
_Authenticate using a recovery code_ link
- Enter one recovery code

Check that:
- you're signed in
- when on [your account security
page](https://studio-staging-git-gildasgarcia-auth-1624-dashb-177251-supabase.vercel.app/dashboard/account/security),
you have one less code available

Then:
- Disable the `enableAuthRecoveryCodes` config cat flag
- Sign out
- Sign in and wait on the MFA verification page

Check that:
- the _Authenticate using a recovery code_ link is not displayed
- Accessing [the recovery code sign in
page](https://studio-staging-git-gildasgarcia-auth-1624-dashb-177251-supabase.vercel.app/dashboard/sign-in-recovery-code)
redirects you to the MFA page

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added recovery-code authentication as an alternative MFA sign-in
method.
* Added a dedicated recovery-code sign-in page with validation,
visibility controls, cancellation, and sign-out options.
* Added a link from the MFA sign-in screen when recovery codes are
available.
* Added loading and error states while checking recovery-code
availability.

* **Bug Fixes**
* Prevented valid recovery-code sign-ins from being redirected back to
the MFA prompt.
* Limited recovery-code settings to accounts with exactly one enrolled
authenticator.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-09-21 09:23:02 +02:00

246 lines
8.3 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { useQueryClient } from '@tanstack/react-query'
import { useAuthError, useFeatureFlags, useFlag } from 'common'
import { EyeIcon, EyeOffIcon, LockIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import {
Button,
Form,
FormControl,
FormField,
FormInputGroupInput,
InputGroup,
InputGroupAddon,
InputGroupButton,
Tooltip,
TooltipContent,
TooltipTrigger,
} 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 { useRecoveryCodesStatusQuery } from '@/data/recovery-codes/recovery-codes-status-query'
import { useRecoveryCodesVerifyMutation } from '@/data/recovery-codes/recovery-codes-verify-mutation'
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, 'Recovery Code is required'),
})
const formId = 'sign-in-recovery-code-form'
const SUPPORT_EMAIL_HREF = `mailto:support@supabase.com?subject=${encodeURIComponent('Unable to sign in via MFA')}`
export const SignInRecoveryCodeForm = () => {
const router = useRouter()
const signOut = useSignOut()
const queryClient = useQueryClient()
const { hasLoaded } = useFeatureFlags()
const enableAuthRecoveryCodes = useFlag('enableAuthRecoveryCodes')
const {
data: recoveryCodesStatus,
isPending: recoveryCodesStatusIsPending,
isError: recoveryCodesStatusIsError,
error: recoveryCodesStatusError,
} = useRecoveryCodesStatusQuery({
enabled: enableAuthRecoveryCodes,
})
const track = useTrack()
const { mutate: addLoginEvent } = useAddLoginEvent()
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { code: '' },
})
const {
mutate: verifyRecoveryCode,
isPending: isVerifying,
isSuccess,
} = useRecoveryCodesVerifyMutation({
onSuccess: async () => {
track('sign_in', { category: 'account', method: 'recovery-code' })
addLoginEvent({})
await queryClient.resetQueries()
router.push(getReturnToPath())
},
})
const onClickLogout = async () => {
if (isVerifying) return
await signOut()
await router.replace('/sign-in')
}
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ code }) => {
if (isVerifying) return
verifyRecoveryCode({ code })
}
useEffect(() => {
// if users wander into this page and he has no recovery code setup or is on platform where it's not enabled
if (hasLoaded && (!enableAuthRecoveryCodes || recoveryCodesStatus?.status === 'unenrolled')) {
queryClient.resetQueries().then(() => router.push(getReturnToPath()))
}
}, [hasLoaded, enableAuthRecoveryCodes, recoveryCodesStatus, router, queryClient])
const error = useAuthError()
const [isCodeRevealed, setIsCodeRevealed] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
if (error) {
return (
<AlertError
error={error}
subject="Error while signing in"
hideContactSupport
additionalActions={
<>
<Button asChild>
<Link href="/sign-in">Back to sign in</Link>
</Button>
<Button asChild>
<a href={SUPPORT_EMAIL_HREF}>Email support</a>
</Button>
</>
}
/>
)
}
return (
<>
{recoveryCodesStatusIsPending && <GenericSkeletonLoader />}
{recoveryCodesStatusIsError && (
<AlertError
error={recoveryCodesStatusError}
subject="Failed to retrieve recovery codes status"
description="Try refreshing your browser. If the issue persists, email support@supabase.com."
hideContactSupport
/>
)}
{recoveryCodesStatus?.status === 'available' && (
<Form {...form}>
<form
ref={formRef}
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="Recovery code">
<InputGroup>
<FormControl>
<FormInputGroupInput
{...field}
className="pl-10 font-mono"
autoFocus
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck="false"
placeholder="****************"
type={isCodeRevealed ? 'text' : 'password'}
onPaste={(event) => {
// If users paste something, it's most probably a valid code so we can safely try submitting it immediately
// onPaste is triggered before the paste operation is complete so we have to emulate it to avoid using an unreliable setTimeout
event.preventDefault()
field.onChange(event.clipboardData.getData('text'))
formRef.current?.dispatchEvent(
new Event('submit', { cancelable: true, bubbles: true })
)
}}
/>
</FormControl>
<InputGroupAddon align="inline-start">
<LockIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end" className="pr-1 has-[>button]:mr-0">
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton
aria-label={isCodeRevealed ? 'Hide code' : 'Show code'}
aria-describedby={undefined}
onClick={() => setIsCodeRevealed((previous) => !previous)}
>
{isCodeRevealed ? (
<EyeIcon className="size-4" />
) : (
<EyeOffIcon className="size-4" />
)}
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>
{isCodeRevealed ? 'Hide code' : 'Show code'}
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</FormItemLayout>
)}
/>
<div className="flex items-center justify-between gap-x-2">
<Button
block
variant="outline"
size="large"
onClick={onClickLogout}
disabled={isVerifying || isSuccess}
className="opacity-80 hover:opacity-100 transition"
>
Cancel
</Button>
<Button
variant="primary"
block
form={formId}
type="submit"
size="large"
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">
<li>
<Link
href="/logout"
className="text-sm transition text-foreground-light hover:text-foreground"
>
Force sign out and clear cookies
</Link>
</li>
</ul>
</div>
</>
)
}