mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
MFA Recovery codes: allow users to download their recovery codes (#50267)
## What kind of change does this PR introduce? After users have set up a new MFA (first or not), we must: - check whether recovery codes have already been generated - if there are none, generate recovery codes and display them, "forcing" users to copy them - if already generated, show them how many are still available > [!NOTE] > The _Delete my recovery codes_ button in last screenshot only appear on local and staging environments ## How to test - On an account that doesn't have recovery codes generated yet and has an MFA added - You should see an admonition suggesting to generate the codes ## Screenshots <img width="729" height="306" alt="image" src="https://github.com/user-attachments/assets/79ba3870-4ef8-4571-9fd6-36eed20c9c24" /> <img width="550" height="356" alt="image" src="https://github.com/user-attachments/assets/1632611a-996a-470d-b6cd-a4693b0f4602" /> <img width="719" height="205" alt="image" src="https://github.com/user-attachments/assets/73cef611-05cf-4fac-bbd2-243f9b28e48d" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for generating, copying, and confirming MFA recovery codes. - Added recovery-code status visibility, including remaining and exhausted codes. - Added the ability to delete recovery codes with confirmation. - Added clear loading, success, and error states for recovery-code actions. - Recovery-code status refreshes after codes are generated or deleted. - **Bug Fixes** - Recovery-code notices now remain visible when all codes have been used. - Recovery-code dialogs can now be closed after generation errors. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
24f8549e41
commit
63bedef77f
12 files changed
+516
-61
No files matched your search
+88
@@ -0,0 +1,88 @@
|
||||
import { AuthError } from '@supabase/auth-js'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { GenerateRecoveryCodesModal } from './GenerateRecoveryCodesModal'
|
||||
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('GenerateRecoveryCodesModal', () => {
|
||||
test('generate the recovery codes and allow users to copy them', async () => {
|
||||
vi.spyOn(auth.mfa.recoveryCodes, 'generate').mockResolvedValue({
|
||||
data: {
|
||||
id: 'some_id',
|
||||
total: 10,
|
||||
codes,
|
||||
type: 'recovery_code',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
customRender(<GenerateRecoveryCodesModal />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate recovery codes' }))
|
||||
|
||||
// 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, 'generate')
|
||||
.mockRejectedValueOnce({
|
||||
data: null,
|
||||
error: new AuthError('boom'),
|
||||
})
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
id: 'some_id',
|
||||
total: 10,
|
||||
codes,
|
||||
type: 'recovery_code',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
customRender(<GenerateRecoveryCodesModal />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate recovery codes' }))
|
||||
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: 'Generate recovery codes' }))
|
||||
// Codes are generated
|
||||
await screen.findByText('Save your recovery codes')
|
||||
await screen.findByText('code_0')
|
||||
await screen.findByText('code_9')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
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 { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { recoveryCodeKeys } from '@/data/recovery-codes/keys'
|
||||
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
|
||||
type="danger"
|
||||
layout="horizontal"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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,36 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
||||
import { useRecoveryCodesUnenrollMutation } from '@/data/recovery-codes/recovery-codes-unenroll'
|
||||
|
||||
export const UnenrollRecoveryCodesModal = () => {
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const recoveryCodesGenerateMutation = useRecoveryCodesUnenrollMutation({
|
||||
onSuccess: () => {
|
||||
setShowConfirm(false)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setShowConfirm(true)}>Delete my recovery codes</Button>
|
||||
<TextConfirmModal
|
||||
visible={showConfirm}
|
||||
size="small"
|
||||
variant="destructive"
|
||||
title="Delete my recovery codes"
|
||||
confirmPlaceholder="DELETE"
|
||||
confirmString="DELETE"
|
||||
confirmLabel="Delete"
|
||||
loading={recoveryCodesGenerateMutation.isPending}
|
||||
onConfirm={() => recoveryCodesGenerateMutation.mutate()}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
>
|
||||
<p className="text-sm">
|
||||
Your recovery codes won't work anymore. Make sure to regenerate them if needed.
|
||||
</p>
|
||||
</TextConfirmModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useFlag } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
@@ -16,18 +17,27 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { AddNewFactorModal } from './AddNewFactorModal'
|
||||
import DeleteFactorModal from './DeleteFactorModal'
|
||||
import { GenerateRecoveryCodesModal } from './GenerateRecoveryCodesModal'
|
||||
import { UnenrollRecoveryCodesModal } from './UnenrollRecoveryCodesModal'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query'
|
||||
import { DATETIME_FORMAT } from '@/lib/constants'
|
||||
import { useRecoveryCodesStatusQuery } from '@/data/recovery-codes/recovery-codes-status-query'
|
||||
import { DATETIME_FORMAT, IS_STAGING_OR_LOCAL } from '@/lib/constants'
|
||||
|
||||
export const TOTPFactors = () => {
|
||||
const [isAddNewFactorOpen, setIsAddNewFactorOpen] = useState(false)
|
||||
const [factorToBeDeleted, setFactorToBeDeleted] = useState<string | null>(null)
|
||||
const { data, isPending: isLoading, isError, isSuccess, error } = useMfaListFactorsQuery()
|
||||
const enableAuthRecoveryCodes = useFlag('enableAuthRecoveryCodes')
|
||||
|
||||
const totpFactors = data?.totp ?? []
|
||||
const canAddApp = isSuccess && totpFactors.length < 2
|
||||
const shouldShowLockoutWarning = isSuccess && totpFactors.length === 1
|
||||
const shouldVerifyRecoveryCodes = enableAuthRecoveryCodes && totpFactors.length === 1
|
||||
|
||||
const { data: recoveryCodesStatus } = useRecoveryCodesStatusQuery({
|
||||
enabled: shouldVerifyRecoveryCodes,
|
||||
})
|
||||
|
||||
const handleAddNewApp = () => setIsAddNewFactorOpen(true)
|
||||
|
||||
@@ -51,6 +61,15 @@ export const TOTPFactors = () => {
|
||||
)}
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent className="flex flex-col gap-4">
|
||||
{recoveryCodesStatus?.status === 'unenrolled' && <GenerateRecoveryCodesModal />}
|
||||
{recoveryCodesStatus?.status === 'available' && (
|
||||
<Admonition
|
||||
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 />}
|
||||
/>
|
||||
)}
|
||||
{shouldShowLockoutWarning && (
|
||||
<Admonition
|
||||
type="danger"
|
||||
@@ -91,7 +110,7 @@ export const TOTPFactors = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Button size="tiny" onClick={() => setFactorToBeDeleted(factor.id)}>
|
||||
Delete{' '}
|
||||
Delete
|
||||
</Button>
|
||||
</CardContent>
|
||||
))}
|
||||
|
||||
@@ -25,14 +25,14 @@ export async function getOAuthCustomProviders({
|
||||
const { data, error } = await supabaseClient.auth.admin.customProviders.listProviders()
|
||||
|
||||
if (error) {
|
||||
let newError = error
|
||||
// Non-JSON responses from the API indicate custom providers aren't enabled.
|
||||
// Different browsers/SDK versions produce different JSON parse error messages,
|
||||
// so we check broadly for JSON parse indicators.
|
||||
if (/JSON\.parse|Unexpected token|unexpected.*character/i.test(newError.message)) {
|
||||
newError = new AuthError('Custom providers are not enabled for this project')
|
||||
if (/JSON\.parse|Unexpected token|unexpected.*character/i.test(error.message)) {
|
||||
handleError(new AuthError('Custom providers are not enabled for this project'))
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
handleError(newError)
|
||||
}
|
||||
return data.providers as CustomOAuthProvider[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const recoveryCodeKeys = {
|
||||
status: () => ['recovery-code-status-query'] as const,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
AuthMFARecoveryCodesGenerateResponse,
|
||||
MFARecoveryCodesGenerateParams,
|
||||
} from '@supabase/auth-js'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { captureCriticalError } from '@/lib/error-reporting'
|
||||
import { auth } from '@/lib/gotrue'
|
||||
import { UseCustomMutationOptions } from '@/types'
|
||||
|
||||
export const recoveryCodesGenerate = async (params: MFARecoveryCodesGenerateParams) => {
|
||||
const { error, data } = await auth.mfa.recoveryCodes.generate(params)
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
type RecoveryCodesGenerateResponse = NonNullable<AuthMFARecoveryCodesGenerateResponse['data']>
|
||||
type RecoveryCodesGenerateError = NonNullable<AuthMFARecoveryCodesGenerateResponse['error']>
|
||||
|
||||
export const useRecoveryCodesGenerateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<
|
||||
RecoveryCodesGenerateResponse,
|
||||
RecoveryCodesGenerateError,
|
||||
MFARecoveryCodesGenerateParams
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
return useMutation({
|
||||
mutationFn: (vars) => {
|
||||
return recoveryCodesGenerate(vars)
|
||||
},
|
||||
async onSuccess(data, variables, context) {
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to get recovery codes: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
captureCriticalError(data, 'recovery codes status')
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { recoveryCodeKeys } from './keys'
|
||||
import { handleError } from '@/data/fetchers'
|
||||
import { auth } from '@/lib/gotrue'
|
||||
import type { ResponseError, UseCustomQueryOptions } from '@/types'
|
||||
|
||||
export async function getRecoveryCodesStatus() {
|
||||
const { data, error } = await auth.mfa.recoveryCodes.getStatus()
|
||||
// If users haven't enrolled yet, just return null so that UI does not retry the request and
|
||||
// offer to enroll
|
||||
if (error?.code === 'mfa_factor_not_found') {
|
||||
return { status: 'unenrolled', data: null } as const
|
||||
}
|
||||
if (error) handleError(error)
|
||||
|
||||
return { status: 'available', data } as const
|
||||
}
|
||||
|
||||
export type RecoveryCodesStatusData = Awaited<ReturnType<typeof getRecoveryCodesStatus>>
|
||||
export type RecoveryCodesError = ResponseError
|
||||
|
||||
export const useRecoveryCodesStatusQuery = <TData = RecoveryCodesStatusData>({
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<RecoveryCodesStatusData, RecoveryCodesError, TData> = {}) =>
|
||||
useQuery<RecoveryCodesStatusData, RecoveryCodesError, TData>({
|
||||
queryKey: recoveryCodeKeys.status(),
|
||||
queryFn: () => getRecoveryCodesStatus(),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
enabled,
|
||||
...options,
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AuthMFAUnenrollResponse } 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 recoveryCodesUnenroll = async () => {
|
||||
const { error, data } = await auth.mfa.recoveryCodes.unenroll()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
type RecoveryCodesUnenrollResponse = AuthMFAUnenrollResponse['data']
|
||||
type RecoveryCodesUnenrollError = NonNullable<AuthMFAUnenrollResponse['error']>
|
||||
|
||||
export const useRecoveryCodesUnenrollMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<RecoveryCodesUnenrollResponse, RecoveryCodesUnenrollError>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => {
|
||||
return recoveryCodesUnenroll()
|
||||
},
|
||||
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 unenroll recovery codes: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
captureCriticalError(data, 'recovery codes unenroll')
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AuthClient, navigatorLock, User } from '@supabase/auth-js'
|
||||
|
||||
import { isBrowser } from './helpers'
|
||||
|
||||
export const STORAGE_KEY = process.env.NEXT_PUBLIC_STORAGE_KEY || 'supabase.dashboard.auth.token'
|
||||
@@ -185,6 +186,8 @@ export const gotrueClient = new AuthClient({
|
||||
detectSessionInUrl: shouldDetectSessionInUrl,
|
||||
debug: debug ? (persistedDebug ? logIndexedDB : true) : false,
|
||||
lock: navigatorLockEnabled ? debuggableNavigatorLock : undefined,
|
||||
// TODO: Should this be enabled with a feature flag?
|
||||
experimental: { recoveryCodes: true },
|
||||
...('localStorage' in globalThis
|
||||
? { storage: globalThis.localStorage, userStorage: globalThis.localStorage }
|
||||
: null),
|
||||
|
||||
Generated
+51
-51
@@ -16,20 +16,20 @@ catalogs:
|
||||
specifier: ^10.59.0
|
||||
version: 10.59.0
|
||||
'@supabase/auth-js':
|
||||
specifier: 2.112.4
|
||||
version: 2.112.4
|
||||
specifier: 2.116.0
|
||||
version: 2.116.0
|
||||
'@supabase/postgrest-js':
|
||||
specifier: 2.112.4
|
||||
version: 2.112.4
|
||||
specifier: 2.116.0
|
||||
version: 2.116.0
|
||||
'@supabase/realtime-js':
|
||||
specifier: 2.112.4
|
||||
version: 2.112.4
|
||||
specifier: 2.116.0
|
||||
version: 2.116.0
|
||||
'@supabase/ssr':
|
||||
specifier: 0.10.2
|
||||
version: 0.10.2
|
||||
'@supabase/supabase-js':
|
||||
specifier: 2.112.4
|
||||
version: 2.112.4
|
||||
specifier: 2.116.0
|
||||
version: 2.116.0
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.169.2
|
||||
version: 1.170.10
|
||||
@@ -379,7 +379,7 @@ importers:
|
||||
version: 10.59.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@15.5.25(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(vite@8.2.1(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
'@tanstack/react-query':
|
||||
specifier: ~5.83.0
|
||||
version: 5.83.0(react@19.2.6)
|
||||
@@ -1040,7 +1040,7 @@ importers:
|
||||
version: 1.0.32(@types/node@22.13.14)(supports-color@8.1.1)
|
||||
'@supabase/auth-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4
|
||||
version: 2.116.0
|
||||
'@supabase/mcp-server-supabase':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(@modelcontextprotocol/server@2.0.0)(zod@3.25.76)
|
||||
@@ -1049,13 +1049,13 @@ importers:
|
||||
version: link:../../packages/pg-meta
|
||||
'@supabase/realtime-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4
|
||||
version: 2.116.0
|
||||
'@supabase/shared-types':
|
||||
specifier: 0.1.95
|
||||
version: 0.1.95
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
'@tanstack/react-devtools':
|
||||
specifier: ^0.10.3
|
||||
version: 0.10.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(solid-js@1.9.13)
|
||||
@@ -1494,7 +1494,7 @@ importers:
|
||||
version: 0.1.0(@opentelemetry/api@1.9.1)
|
||||
'@supabase/postgrest-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4
|
||||
version: 2.116.0
|
||||
'@supabase/supa-mdx-lint':
|
||||
specifier: 0.2.6-alpha
|
||||
version: 0.2.6-alpha
|
||||
@@ -1636,10 +1636,10 @@ importers:
|
||||
version: 1.6.0
|
||||
'@supabase/ssr':
|
||||
specifier: 'catalog:'
|
||||
version: 0.10.2(@supabase/supabase-js@2.112.4(@opentelemetry/api@1.9.1))
|
||||
version: 0.10.2(@supabase/supabase-js@2.116.0(@opentelemetry/api@1.9.1))
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
'@tanstack/react-router':
|
||||
specifier: 'catalog:'
|
||||
version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -1786,10 +1786,10 @@ importers:
|
||||
version: 10.59.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@15.5.25(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(vite@8.2.1(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))
|
||||
'@supabase/ssr':
|
||||
specifier: 'catalog:'
|
||||
version: 0.10.2(@supabase/supabase-js@2.112.4(@opentelemetry/api@1.9.1))
|
||||
version: 0.10.2(@supabase/supabase-js@2.116.0(@opentelemetry/api@1.9.1))
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
'@typescript/native':
|
||||
specifier: 'catalog:'
|
||||
version: typescript@7.0.2
|
||||
@@ -2066,13 +2066,13 @@ importers:
|
||||
dependencies:
|
||||
'@supabase/postgrest-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4
|
||||
version: 2.116.0
|
||||
'@supabase/ssr':
|
||||
specifier: 'catalog:'
|
||||
version: 0.10.2(@supabase/supabase-js@2.112.4(@opentelemetry/api@1.9.1))
|
||||
version: 0.10.2(@supabase/supabase-js@2.116.0(@opentelemetry/api@1.9.1))
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
'@vueuse/core':
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0(vue@3.5.35(typescript@6.0.2))
|
||||
@@ -2156,7 +2156,7 @@ importers:
|
||||
version: 1.59.1
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
cross-fetch:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(encoding@0.1.13)
|
||||
@@ -2203,7 +2203,7 @@ importers:
|
||||
version: 0.18.5
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
ai:
|
||||
specifier: 5.0.52
|
||||
version: 5.0.52(zod@3.25.76)
|
||||
@@ -2300,10 +2300,10 @@ importers:
|
||||
dependencies:
|
||||
'@supabase/auth-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4
|
||||
version: 2.116.0
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
'@types/dat.gui':
|
||||
specifier: ^0.7.12
|
||||
version: 0.7.12
|
||||
@@ -2781,7 +2781,7 @@ importers:
|
||||
version: 0.1.6(encoding@0.1.13)(supports-color@8.1.1)
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.112.4(@opentelemetry/api@1.9.1)
|
||||
version: 2.116.0(@opentelemetry/api@1.9.1)
|
||||
'@tanstack/react-table':
|
||||
specifier: 'catalog:'
|
||||
version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -7958,8 +7958,8 @@ packages:
|
||||
'@supabase-labs/y-supabase@0.1.0':
|
||||
resolution: {integrity: sha512-1ItaKbcImgy7ueYfJcyVYihqCKi9RAee9MKBV8wcCqSMNsrWvw4ibhW8ai91kWsewsiWzIDXVPONgcUcxagk9g==}
|
||||
|
||||
'@supabase/auth-js@2.112.4':
|
||||
resolution: {integrity: sha512-z8DesgwLzKM5PiT0yNmJU8VJyh1zAhYi+20Z7drdJQLXg/wWW4yGt/un+He5ERYUo94Vz66t5aeyr1DIDemI5A==}
|
||||
'@supabase/auth-js@2.116.0':
|
||||
resolution: {integrity: sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@supabase/cli-darwin-arm64@2.114.0':
|
||||
@@ -8006,8 +8006,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@supabase/functions-js@2.112.4':
|
||||
resolution: {integrity: sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g==}
|
||||
'@supabase/functions-js@2.116.0':
|
||||
resolution: {integrity: sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@supabase/mcp-server-supabase@0.12.0':
|
||||
@@ -8026,12 +8026,12 @@ packages:
|
||||
'@supabase/phoenix@0.4.5':
|
||||
resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==}
|
||||
|
||||
'@supabase/postgrest-js@2.112.4':
|
||||
resolution: {integrity: sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug==}
|
||||
'@supabase/postgrest-js@2.116.0':
|
||||
resolution: {integrity: sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@supabase/realtime-js@2.112.4':
|
||||
resolution: {integrity: sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==}
|
||||
'@supabase/realtime-js@2.116.0':
|
||||
resolution: {integrity: sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@supabase/shared-types@0.1.95':
|
||||
@@ -8045,8 +8045,8 @@ packages:
|
||||
peerDependencies:
|
||||
'@supabase/supabase-js': ^2.102.1
|
||||
|
||||
'@supabase/storage-js@2.112.4':
|
||||
resolution: {integrity: sha512-lQ0JemuTlMIXVKgSci1qez8yPnM5hyDngeAfEBjZS2Om4D+Cus0EE5BE6glFobrxdyii1OF4UzWfF0zcQgDq5A==}
|
||||
'@supabase/storage-js@2.116.0':
|
||||
resolution: {integrity: sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@supabase/supa-mdx-lint-darwin@0.2.6-alpha':
|
||||
@@ -8184,8 +8184,8 @@ packages:
|
||||
resolution: {integrity: sha512-iqJHDk/ToyxFMa/um9A5gsp9jYN7iI0cIabNq9nyBpK/Yat/J9I2IIMueQwIMy3TsG6a2qdPyUkZkuPC37SdRg==}
|
||||
hasBin: true
|
||||
|
||||
'@supabase/supabase-js@2.112.4':
|
||||
resolution: {integrity: sha512-UiCX1udlFY1fQQrO7Z3GU7obQsju0w5Vk9mOOwalfo/+Gy+tahWVenSSuu5E/GTy/q//HxvGv2IrCdW66/61kw==}
|
||||
'@supabase/supabase-js@2.116.0':
|
||||
resolution: {integrity: sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0'
|
||||
@@ -24548,13 +24548,13 @@ snapshots:
|
||||
|
||||
'@supabase-labs/y-supabase@0.1.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@supabase/supabase-js': 2.112.4(@opentelemetry/api@1.9.1)
|
||||
'@supabase/supabase-js': 2.116.0(@opentelemetry/api@1.9.1)
|
||||
y-protocols: 1.0.7(yjs@13.6.29)
|
||||
yjs: 13.6.29
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@supabase/auth-js@2.112.4':
|
||||
'@supabase/auth-js@2.116.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -24582,7 +24582,7 @@ snapshots:
|
||||
'@supabase/cli-windows-x64@2.114.0':
|
||||
optional: true
|
||||
|
||||
'@supabase/functions-js@2.112.4':
|
||||
'@supabase/functions-js@2.116.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -24604,11 +24604,11 @@ snapshots:
|
||||
|
||||
'@supabase/phoenix@0.4.5': {}
|
||||
|
||||
'@supabase/postgrest-js@2.112.4':
|
||||
'@supabase/postgrest-js@2.116.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@supabase/realtime-js@2.112.4':
|
||||
'@supabase/realtime-js@2.116.0':
|
||||
dependencies:
|
||||
'@supabase/phoenix': 0.4.5
|
||||
tslib: 2.8.1
|
||||
@@ -24624,12 +24624,12 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@supabase/ssr@0.10.2(@supabase/supabase-js@2.112.4(@opentelemetry/api@1.9.1))':
|
||||
'@supabase/ssr@0.10.2(@supabase/supabase-js@2.116.0(@opentelemetry/api@1.9.1))':
|
||||
dependencies:
|
||||
'@supabase/supabase-js': 2.112.4(@opentelemetry/api@1.9.1)
|
||||
'@supabase/supabase-js': 2.116.0(@opentelemetry/api@1.9.1)
|
||||
cookie: 1.0.2
|
||||
|
||||
'@supabase/storage-js@2.112.4':
|
||||
'@supabase/storage-js@2.116.0':
|
||||
dependencies:
|
||||
iceberg-js: 0.8.1
|
||||
tslib: 2.8.1
|
||||
@@ -24730,13 +24730,13 @@ snapshots:
|
||||
'@supabase/supa-mdx-lint-win32-x64': 0.3.2
|
||||
node-pty: 1.0.0
|
||||
|
||||
'@supabase/supabase-js@2.112.4(@opentelemetry/api@1.9.1)':
|
||||
'@supabase/supabase-js@2.116.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@supabase/auth-js': 2.112.4
|
||||
'@supabase/functions-js': 2.112.4
|
||||
'@supabase/postgrest-js': 2.112.4
|
||||
'@supabase/realtime-js': 2.112.4
|
||||
'@supabase/storage-js': 2.112.4
|
||||
'@supabase/auth-js': 2.116.0
|
||||
'@supabase/functions-js': 2.116.0
|
||||
'@supabase/postgrest-js': 2.116.0
|
||||
'@supabase/realtime-js': 2.116.0
|
||||
'@supabase/storage-js': 2.116.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
|
||||
+4
-4
@@ -14,11 +14,11 @@ catalog:
|
||||
'@monaco-editor/react': 4.8.0-rc.3
|
||||
'@sentry/nextjs': ^10.59.0
|
||||
'@sentry/tanstackstart-react': ^10.59.0
|
||||
'@supabase/auth-js': 2.112.4
|
||||
'@supabase/postgrest-js': 2.112.4
|
||||
'@supabase/realtime-js': 2.112.4
|
||||
'@supabase/auth-js': 2.116.0
|
||||
'@supabase/postgrest-js': 2.116.0
|
||||
'@supabase/realtime-js': 2.116.0
|
||||
'@supabase/ssr': 0.10.2
|
||||
'@supabase/supabase-js': 2.112.4
|
||||
'@supabase/supabase-js': 2.116.0
|
||||
'@tanstack/react-router': ^1.169.2
|
||||
'@tanstack/react-start': ^1.167.65
|
||||
'@tanstack/react-table': ^8.21.3
|
||||
|
||||
Reference in new issue
Block a user