mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
68acece226
commit
24f89f3967
7 files changed
+391
-157
No files matched your search
+17
-155
@@ -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
|
||||
}
|
||||
+115
@@ -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')
|
||||
})
|
||||
})
|
||||
+42
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user