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 -->
176 lines
5.6 KiB
TypeScript
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
|
|
}
|