MFA Recovery codes: allow users to regenerate their codes (#50336)

## What kind of change does this PR introduce?

Once users have recovery codes generated, allow them to regenerate the
codes.

This PR also automatically check the _I have copied the codes_ after
clicking the _Copy to clipboard button_.

> [!NOTE]
> The _Delete my recovery codes_ button only appear on local and staging
environments

## How to test

- On an account that already have recovery codes generated
- You should see an admonition showing the remaining codes available and
allowing you to regenerate the codes

## Screenshots

<img width="706" height="193" alt="image"
src="https://github.com/user-attachments/assets/001bfa87-74f5-4867-8564-09cb6f91adb6"
/>

<img width="425" height="277" alt="image"
src="https://github.com/user-attachments/assets/711b139c-f806-4da2-a240-fa7e7fd8acd0"
/>

<img width="548" height="353" alt="image"
src="https://github.com/user-attachments/assets/d6521a09-3a7f-4198-b162-9effc218fee6"
/>


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

- **New Features**
- Added a recovery-code modal with copy-to-clipboard support and
confirmation before closing.
- Added an option to regenerate MFA recovery codes with a confirmation
step.
  - Recovery-code controls now appear when existing codes are available.
- Added loading, success, error, and retry states for recovery-code
generation and regeneration.

- **Bug Fixes**
- Updated the recovery-code generation error message to more accurately
describe the failed action.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-09-15 16:38:41 +02:00
1 parent 68acece226
commit 24f89f3967
7 files changed
+391 -157

No files matched your search

@@ -1,30 +1,13 @@
import { AuthMFARecoveryCodesGenerateResponseData } from '@supabase/auth-js'
import { MutationStatus, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
Button,
Checkbox,
copyToClipboard,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from 'ui'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { recoveryCodeKeys } from '@/data/recovery-codes/keys'
import { RecoveryCodesModal } from './RecoveryCodesModal'
import { useRecoveryCodesGenerateMutation } from '@/data/recovery-codes/recovery-codes-generate-mutation'
export const GenerateRecoveryCodesModal = () => {
const queryClient = useQueryClient()
const recoveryCodesGenerateMutation = useRecoveryCodesGenerateMutation()
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [copiedToClipboard, setCopiedToClipboard] = useState(false)
return (
<Admonition
@@ -33,143 +16,22 @@ export const GenerateRecoveryCodesModal = () => {
title="You haven't generated recovery codes yet"
description="Recovery codes are important to ensure you can recover your account if you loose access to your MFA."
actions={
<Dialog
open={open}
onOpenChange={(open) => {
// Prevent users from closing the dialog until they copied the codes
if (!open && !copied && recoveryCodesGenerateMutation.isSuccess) return
// Prevent users from closing the dialog while the mutation is running
if (recoveryCodesGenerateMutation.isPending) return
setOpen(open)
if (!open) {
// Reset state
setCopied(false)
setCopiedToClipboard(false)
recoveryCodesGenerateMutation.reset()
queryClient.invalidateQueries({ queryKey: recoveryCodeKeys.status() })
}
}}
>
<DialogTrigger asChild>
<Button onClick={() => recoveryCodesGenerateMutation.mutate({})}>
Generate recovery codes
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle
aria-busy={recoveryCodesGenerateMutation.isPending}
aria-live="polite"
role="status"
>
<GenerateRecoveryCodesModalTitle status={recoveryCodesGenerateMutation.status} />
</DialogTitle>
<DialogDescription asChild>
<div className="py-4">
<GenerateRecoveryCodesModalContent
status={recoveryCodesGenerateMutation.status}
codes={recoveryCodesGenerateMutation.data?.codes}
copied={copied}
onCodesCopied={(copied) => setCopied(copied)}
/>
</div>
</DialogDescription>
</DialogHeader>
{!recoveryCodesGenerateMutation.isPending ? (
<DialogFooter className="items-center">
{copiedToClipboard ? (
<span role="status" className="text-sm text-lighter">
Codes copied to your clipboard.
</span>
) : null}
{copied || recoveryCodesGenerateMutation.isError ? (
<DialogClose asChild>
<Button>Close</Button>
</DialogClose>
) : null}
<Button
variant="primary"
onClick={() =>
copyToClipboard(
recoveryCodesGenerateMutation.data?.codes.join('\n') ?? '',
() => {
setCopied(true)
setCopiedToClipboard(true)
}
)
}
>
Copy to clipboard
</Button>
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
<>
<Button
onClick={() => {
setOpen(true)
recoveryCodesGenerateMutation.mutate({})
}}
>
Generate recovery codes
</Button>
<RecoveryCodesModal
open={open}
onOpenChange={(open) => setOpen(open)}
mutation={recoveryCodesGenerateMutation}
/>
</>
}
/>
)
}
const GenerateRecoveryCodesModalTitle = ({ status }: { status: MutationStatus }) => {
if (status === 'pending') {
return 'Generating your recovery codes...'
}
if (status === 'error') {
return 'An error occurred while generating your recovery code'
}
return 'Save your recovery codes'
}
const GenerateRecoveryCodesModalContent = ({
codes,
copied,
status,
onCodesCopied,
}: {
codes: AuthMFARecoveryCodesGenerateResponseData['codes'] | undefined
copied: boolean
status: MutationStatus
onCodesCopied: (copied: boolean) => void
}) => {
if (status === 'error') {
return (
<p className="text-destructive">
We couldn't generate your recovery code. Please try again later or contact support if the
problem persists.
</p>
)
}
if (status === 'success') {
return (
<div className="flex flex-col gap-4">
<p>Save your recovery codes somewhere safe.</p>
<pre className="relative bg-muted rounded-md py-2 px-4">
<code className="flex gap-2 flex-wrap justify-between">
{codes?.map((code) => (
<span key={code}>{code}</span>
))}
</code>
</pre>
<div className="flex items-center space-x-2">
<Checkbox
id="codeCopied"
checked={copied}
onCheckedChange={(checked) => onCodesCopied(checked === true)}
/>
<label
htmlFor="codeCopied"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the codes
</label>
</div>
</div>
)
}
return null
}
@@ -0,0 +1,161 @@
import { AuthError, AuthMFARecoveryCodesGenerateResponseData } from '@supabase/auth-js'
import { MutationStatus, UseMutationResult, useQueryClient } from '@tanstack/react-query'
import { ComponentProps, useState } from 'react'
import {
Button,
Checkbox,
copyToClipboard,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from 'ui'
import { recoveryCodeKeys } from '@/data/recovery-codes/keys'
interface RecoveryCodesModalProps<T>
extends
Omit<ComponentProps<typeof Dialog>, 'onOpenChange'>,
Required<Pick<ComponentProps<typeof Dialog>, 'onOpenChange'>> {
mutation: UseMutationResult<AuthMFARecoveryCodesGenerateResponseData, AuthError, T>
}
export const RecoveryCodesModal = <T = unknown,>({
onOpenChange,
mutation,
...props
}: RecoveryCodesModalProps<T>) => {
const queryClient = useQueryClient()
const [copied, setCopied] = useState(false)
const [copiedToClipboard, setCopiedToClipboard] = useState(false)
return (
<Dialog
{...props}
onOpenChange={(open) => {
// Prevent users from closing the dialog until they copied the codes
if (!open && !copied && mutation.isSuccess) return
// Prevent users from closing the dialog while the mutation is running
if (mutation.isPending) return
onOpenChange(open)
if (!open) {
// Reset state
setCopied(false)
setCopiedToClipboard(false)
mutation.reset()
queryClient.invalidateQueries({ queryKey: recoveryCodeKeys.status() })
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle aria-busy={mutation.isPending} aria-live="polite" role="status">
<GenerateRecoveryCodesModalTitle status={mutation.status} />
</DialogTitle>
<DialogDescription asChild>
<div className="py-4">
<GenerateRecoveryCodesModalContent
status={mutation.status}
codes={mutation.data?.codes}
copied={copied}
onCodesCopied={(copied) => setCopied(copied)}
/>
</div>
</DialogDescription>
</DialogHeader>
{!mutation.isPending ? (
<DialogFooter className="items-center">
<span role="status" className="text-sm text-lighter">
{copiedToClipboard ? 'Codes copied to your clipboard.' : null}
</span>
{copied || mutation.isError ? (
<DialogClose asChild>
<Button>Close</Button>
</DialogClose>
) : null}
{mutation.isSuccess ? (
<Button
variant="primary"
onClick={() =>
copyToClipboard(mutation.data?.codes.join('\n') ?? '', () => {
setCopiedToClipboard(true)
setCopied(true)
})
}
>
Copy to clipboard
</Button>
) : null}
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
)
}
const GenerateRecoveryCodesModalTitle = ({ status }: { status: MutationStatus }) => {
if (status === 'pending') {
return 'Generating your recovery codes...'
}
if (status === 'error') {
return 'An error occurred while generating your recovery code'
}
return 'Save your recovery codes'
}
const GenerateRecoveryCodesModalContent = ({
codes,
copied,
status,
onCodesCopied,
}: {
codes: AuthMFARecoveryCodesGenerateResponseData['codes'] | undefined
copied?: boolean
status: MutationStatus
onCodesCopied: (copied: boolean) => void
}) => {
if (status === 'error') {
return (
<p className="text-destructive">
We couldn't generate your recovery code. Please try again later or contact support if the
problem persists.
</p>
)
}
if (status === 'success') {
return (
<div className="flex flex-col gap-4">
<p>Save your recovery codes somewhere safe.</p>
<pre className="relative bg-muted rounded-md py-2 px-4">
<code className="flex gap-2 flex-wrap justify-between">
{codes?.map((code) => (
<span key={code}>{code}</span>
))}
</code>
</pre>
<div className="flex items-center space-x-2">
<Checkbox
id="codeCopied"
checked={copied}
onCheckedChange={(checked) => onCodesCopied(checked === true)}
/>
<label
htmlFor="codeCopied"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the codes
</label>
</div>
</div>
)
}
return null
}
@@ -0,0 +1,115 @@
import { AuthError } from '@supabase/auth-js'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { RegenerateRecoveryCodesModal } from './RegenerateRecoveryCodesModal'
import { auth } from '@/lib/gotrue'
import { customRender } from '@/tests/lib/custom-render'
const { mockCopyToClipboard } = vi.hoisted(() => ({
mockCopyToClipboard: vi.fn((_value: string, callback: () => void) => {
return callback()
}),
}))
vi.mock('ui', async (importOriginal) => ({
...(await importOriginal<typeof import('ui')>()),
copyToClipboard: mockCopyToClipboard,
}))
const codes = Array.from(Array(10).keys()).map((i) => `code_${i}`)
describe('RegenerateRecoveryCodesModal', () => {
test('regenerate the recovery codes after confirmation and allow users to copy them', async () => {
vi.spyOn(auth.mfa.recoveryCodes, 'regenerate').mockResolvedValue({
data: {
id: 'some_id',
total: 10,
codes,
type: 'recovery_code',
},
error: null,
})
customRender(<RegenerateRecoveryCodesModal />)
fireEvent.click(await screen.findByRole('button', { name: 'Regenerate my recovery codes' }))
// Confirm regeneration
await userEvent.type(
await within(await screen.findByRole('dialog')).findByRole('textbox', {
name: /Type REGENERATE to confirm./,
}),
'REGENERATE'
)
fireEvent.click(
await within(await screen.findByRole('dialog')).findByRole('button', { name: 'Regenerate' })
)
// Codes are generated
await screen.findByText('Save your recovery codes')
await screen.findByText('code_0')
await screen.findByText('code_9')
// Users have to copy the codes to close the modal, next click should fail if they managed to close it
expect(await screen.findAllByRole('button', { name: 'Close' })).toHaveLength(1)
fireEvent.click(await screen.findByRole('button', { name: 'Close' }))
fireEvent.click(await screen.findByRole('button', { name: 'Copy to clipboard' }))
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: 'I have copied the codes' })).toBeChecked()
)
expect(mockCopyToClipboard).toHaveBeenCalledWith(codes.join('\n'), expect.any(Function))
// We should have 2 close buttons (header icon and a standard button)
expect(await screen.findAllByRole('button', { name: 'Close' })).toHaveLength(2)
fireEvent.click((await screen.findAllByRole('button', { name: 'Close' })).at(1)!)
await waitFor(() => expect(screen.queryByText('Save your recovery codes')).toBeNull())
})
test('allow users to retry in case of error', async () => {
vi.spyOn(auth.mfa.recoveryCodes, 'regenerate')
.mockRejectedValueOnce({
data: null,
error: new AuthError('boom'),
})
.mockResolvedValue({
data: {
id: 'some_id',
total: 10,
codes,
type: 'recovery_code',
},
error: null,
})
customRender(<RegenerateRecoveryCodesModal />)
fireEvent.click(await screen.findByRole('button', { name: 'Regenerate my recovery codes' }))
// Confirm regeneration
await userEvent.type(
await within(await screen.findByRole('dialog')).findByRole('textbox', {
name: /Type REGENERATE to confirm./,
}),
'REGENERATE'
)
fireEvent.click(
await within(await screen.findByRole('dialog')).findByRole('button', { name: 'Regenerate' })
)
await screen.findByText(
"We couldn't generate your recovery code. Please try again later or contact support if the problem persists."
)
// We should have 2 close buttons (header icon and a standard button)
expect(await screen.findAllByRole('button', { name: 'Close' })).toHaveLength(2)
fireEvent.click((await screen.findAllByRole('button', { name: 'Close' })).at(1)!)
await waitFor(() => expect(screen.queryByText('Save your recovery codes')).toBeNull())
// Retry
fireEvent.click(await screen.findByRole('button', { name: 'Regenerate my recovery codes' }))
fireEvent.click(
await within(await screen.findByRole('dialog')).findByRole('button', { name: 'Regenerate' })
)
// Codes are generated
await screen.findByText('Save your recovery codes')
await screen.findByText('code_0')
await screen.findByText('code_9')
})
})
@@ -0,0 +1,42 @@
import { useState } from 'react'
import { Button } from 'ui'
import { RecoveryCodesModal } from './RecoveryCodesModal'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
import { useRecoveryCodesRegenerateMutation } from '@/data/recovery-codes/recovery-codes-regenerate-mutation'
export const RegenerateRecoveryCodesModal = () => {
const [showConfirm, setShowConfirm] = useState(false)
const [open, setOpen] = useState(false)
const recoveryCodesRegenerateMutation = useRecoveryCodesRegenerateMutation()
return (
<>
<Button onClick={() => setShowConfirm(true)}>Regenerate my recovery codes</Button>
<TextConfirmModal
visible={showConfirm}
size="small"
variant="destructive"
title="Regenerate my recovery codes"
confirmPlaceholder="REGENERATE"
confirmString="REGENERATE"
confirmLabel="Regenerate"
loading={recoveryCodesRegenerateMutation.isPending}
onConfirm={() => {
setShowConfirm(false)
setOpen(true)
recoveryCodesRegenerateMutation.mutate()
}}
onCancel={() => setShowConfirm(false)}
>
<p className="text-sm">Your existing recovery codes won't work anymore.</p>
</TextConfirmModal>
<RecoveryCodesModal
open={open}
onOpenChange={(open) => setOpen(open)}
mutation={recoveryCodesRegenerateMutation}
/>
</>
)
}
@@ -18,6 +18,7 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { AddNewFactorModal } from './AddNewFactorModal'
import DeleteFactorModal from './DeleteFactorModal'
import { GenerateRecoveryCodesModal } from './GenerateRecoveryCodesModal'
import { RegenerateRecoveryCodesModal } from './RegenerateRecoveryCodesModal'
import { UnenrollRecoveryCodesModal } from './UnenrollRecoveryCodesModal'
import { AlertError } from '@/components/ui/AlertError'
import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query'
@@ -67,7 +68,12 @@ export const TOTPFactors = () => {
layout="responsive"
title={`${recoveryCodesStatus?.data?.remaining}/${recoveryCodesStatus?.data?.total} recovery codes available`}
description="Recovery codes allow you to recover your account in case you lost access to your MFA apps."
actions={IS_STAGING_OR_LOCAL && <UnenrollRecoveryCodesModal />}
actions={
<div className="flex flex-col gap-2">
<RegenerateRecoveryCodesModal />
{IS_STAGING_OR_LOCAL && <UnenrollRecoveryCodesModal />}
</div>
}
/>
)}
{shouldShowLockoutWarning && (
@@ -39,7 +39,7 @@ export const useRecoveryCodesGenerateMutation = ({
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to get recovery codes: ${data.message}`)
toast.error(`Failed to generate recovery codes: ${data.message}`)
} else {
onError(data, variables, context)
}
@@ -0,0 +1,48 @@
import type { AuthMFARecoveryCodesGenerateResponse } from '@supabase/auth-js'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { recoveryCodeKeys } from './keys'
import { captureCriticalError } from '@/lib/error-reporting'
import { auth } from '@/lib/gotrue'
import { UseCustomMutationOptions } from '@/types'
export const recoveryCodesRegenerate = async () => {
const { error, data } = await auth.mfa.recoveryCodes.regenerate()
if (error) throw error
return data
}
type RecoveryCodesRegenerateResponse = NonNullable<AuthMFARecoveryCodesGenerateResponse['data']>
type RecoveryCodesRegenerateError = NonNullable<AuthMFARecoveryCodesGenerateResponse['error']>
export const useRecoveryCodesRegenerateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<RecoveryCodesRegenerateResponse, RecoveryCodesRegenerateError>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => {
return recoveryCodesRegenerate()
},
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({ queryKey: recoveryCodeKeys.status() })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to regenerate recovery codes: ${data.message}`)
} else {
onError(data, variables, context)
}
captureCriticalError(data, 'recovery codes regenerate')
},
...options,
})
}