Files
supabase/apps/studio/components/interfaces/Account/TOTPFactors/GenerateRecoveryCodesModal.tsx
T
Gildas Garcia 63bedef77f 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 -->
2026-09-15 11:16:47 +02:00

176 lines
5.6 KiB
TypeScript

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
}