mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## Problem 1. Recovery are displayed as returned by the backend <img width="516" height="347" alt="image" src="https://github.com/user-attachments/assets/c2599858-a65d-42d4-af3f-6bc738a810e1" /> 2. Recovery codes are not displayed even if present when more than 1 MFA is set up ## Solution 1. Format them as uppercased groups of 4 characters <img width="541" height="394" alt="image" src="https://github.com/user-attachments/assets/74c6b4eb-03c1-4de2-a772-b30ec4d7bb52" /> 3. Fix the condition check to display recovery codes ## How to test - Generate or regenerate your recovery codes: check the format is correct - If you haven't already, add a 2nd MFA: check recovery codes are still displayed <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Improvements** - Recovery codes are now displayed in uppercase with clear hyphen-separated groups. - Copied recovery codes use the same formatted presentation for easier sharing and entry. - The recovery codes section is available whenever recovery codes are enabled, regardless of the number of authenticator apps configured. - Codes that do not match the expected format remain unchanged. - **Tests** - Added coverage to verify consistent recovery code formatting and clipboard behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
169 lines
5.0 KiB
TypeScript
169 lines
5.0 KiB
TypeScript
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 { formatRecoveryCode } from './RecoveryCodesModal.utils'
|
|
import { recoveryCodeKeys } from '@/data/recovery-codes/keys'
|
|
|
|
interface RecoveryCodesModalProps<T>
|
|
extends
|
|
Omit<ComponentProps<typeof Dialog>, 'onOpenChange'>,
|
|
Required<Pick<ComponentProps<typeof Dialog>, 'onOpenChange'>> {
|
|
mutation: UseMutationResult<AuthMFARecoveryCodesGenerateResponseData, AuthError, T>
|
|
}
|
|
|
|
export const RecoveryCodesModal = <T = unknown,>({
|
|
onOpenChange,
|
|
mutation,
|
|
...props
|
|
}: RecoveryCodesModalProps<T>) => {
|
|
const queryClient = useQueryClient()
|
|
const [copied, setCopied] = useState(false)
|
|
const [copiedToClipboard, setCopiedToClipboard] = useState(false)
|
|
|
|
return (
|
|
<Dialog
|
|
{...props}
|
|
onOpenChange={(open) => {
|
|
// 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() })
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle aria-busy={mutation.isPending} aria-live="polite" role="status">
|
|
<GenerateRecoveryCodesModalTitle status={mutation.status} />
|
|
</DialogTitle>
|
|
<DialogDescription asChild>
|
|
<div className="py-4">
|
|
<GenerateRecoveryCodesModalContent
|
|
status={mutation.status}
|
|
codes={mutation.data?.codes}
|
|
copied={copied}
|
|
onCodesCopied={(copied) => setCopied(copied)}
|
|
/>
|
|
</div>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{!mutation.isPending ? (
|
|
<DialogFooter className="items-center">
|
|
<span role="status" className="text-sm text-lighter">
|
|
{copiedToClipboard ? 'Codes copied to your clipboard.' : null}
|
|
</span>
|
|
{copied || mutation.isError ? (
|
|
<DialogClose asChild>
|
|
<Button>Close</Button>
|
|
</DialogClose>
|
|
) : null}
|
|
|
|
{mutation.isSuccess ? (
|
|
<Button
|
|
variant="primary"
|
|
onClick={() =>
|
|
copyToClipboard(
|
|
mutation.data?.codes.map((code) => formatRecoveryCode(code)).join('\n') ?? '',
|
|
() => {
|
|
setCopiedToClipboard(true)
|
|
setCopied(true)
|
|
}
|
|
)
|
|
}
|
|
>
|
|
Copy to clipboard
|
|
</Button>
|
|
) : null}
|
|
</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>
|
|
Recovery codes allow you to recover your account in case you lost access to your MFA apps.
|
|
Save them 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}>{formatRecoveryCode(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
|
|
}
|