mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## 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 -->
89 lines
3.5 KiB
TypeScript
89 lines
3.5 KiB
TypeScript
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')
|
|
})
|
|
})
|