diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/GenerateRecoveryCodesModal.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/GenerateRecoveryCodesModal.tsx index c9355a62c3e..93f2a69f594 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/GenerateRecoveryCodesModal.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/GenerateRecoveryCodesModal.tsx @@ -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 ( { 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={ - { - // 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() }) - } - }} - > - - - - - - - - - -
- setCopied(copied)} - /> -
-
-
- {!recoveryCodesGenerateMutation.isPending ? ( - - {copiedToClipboard ? ( - - Codes copied to your clipboard. - - ) : null} - {copied || recoveryCodesGenerateMutation.isError ? ( - - - - ) : null} - - - - ) : null} -
-
+ <> + + 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 ( -

- We couldn't generate your recovery code. Please try again later or contact support if the - problem persists. -

- ) - } - - if (status === 'success') { - return ( -
-

Save your recovery codes somewhere safe.

-
-          
-            {codes?.map((code) => (
-              {code}
-            ))}
-          
-        
-
- onCodesCopied(checked === true)} - /> - -
-
- ) - } - - return null -} diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/RecoveryCodesModal.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/RecoveryCodesModal.tsx new file mode 100644 index 00000000000..ddfc9ee1ed8 --- /dev/null +++ b/apps/studio/components/interfaces/Account/TOTPFactors/RecoveryCodesModal.tsx @@ -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 + extends + Omit, 'onOpenChange'>, + Required, 'onOpenChange'>> { + mutation: UseMutationResult +} + +export const RecoveryCodesModal = ({ + onOpenChange, + mutation, + ...props +}: RecoveryCodesModalProps) => { + const queryClient = useQueryClient() + const [copied, setCopied] = useState(false) + const [copiedToClipboard, setCopiedToClipboard] = useState(false) + + return ( + { + // 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() }) + } + }} + > + + + + + + +
+ setCopied(copied)} + /> +
+
+
+ {!mutation.isPending ? ( + + + {copiedToClipboard ? 'Codes copied to your clipboard.' : null} + + {copied || mutation.isError ? ( + + + + ) : null} + + {mutation.isSuccess ? ( + + ) : null} + + ) : null} +
+
+ ) +} + +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 ( +

+ We couldn't generate your recovery code. Please try again later or contact support if the + problem persists. +

+ ) + } + + if (status === 'success') { + return ( +
+

Save your recovery codes somewhere safe.

+
+          
+            {codes?.map((code) => (
+              {code}
+            ))}
+          
+        
+
+ onCodesCopied(checked === true)} + /> + +
+
+ ) + } + + return null +} diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.test.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.test.tsx new file mode 100644 index 00000000000..aab69bb6209 --- /dev/null +++ b/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.test.tsx @@ -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()), + 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() + 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() + 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') + }) +}) diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.tsx new file mode 100644 index 00000000000..237442446d5 --- /dev/null +++ b/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.tsx @@ -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 ( + <> + + { + setShowConfirm(false) + setOpen(true) + recoveryCodesRegenerateMutation.mutate() + }} + onCancel={() => setShowConfirm(false)} + > +

Your existing recovery codes won't work anymore.

+
+ setOpen(open)} + mutation={recoveryCodesRegenerateMutation} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx index 8adc187289f..2f15dde1656 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx @@ -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 && } + actions={ +
+ + {IS_STAGING_OR_LOCAL && } +
+ } /> )} {shouldShowLockoutWarning && ( diff --git a/apps/studio/data/recovery-codes/recovery-codes-generate-mutation.ts b/apps/studio/data/recovery-codes/recovery-codes-generate-mutation.ts index 336ff378600..ed98d41435a 100644 --- a/apps/studio/data/recovery-codes/recovery-codes-generate-mutation.ts +++ b/apps/studio/data/recovery-codes/recovery-codes-generate-mutation.ts @@ -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) } diff --git a/apps/studio/data/recovery-codes/recovery-codes-regenerate-mutation.ts b/apps/studio/data/recovery-codes/recovery-codes-regenerate-mutation.ts new file mode 100644 index 00000000000..a511390b2b1 --- /dev/null +++ b/apps/studio/data/recovery-codes/recovery-codes-regenerate-mutation.ts @@ -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 +type RecoveryCodesRegenerateError = NonNullable + +export const useRecoveryCodesRegenerateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + '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, + }) +}