Files
supabase/apps/studio/components/interfaces/Account/TOTPFactors/index.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

136 lines
5.3 KiB
TypeScript

import { useFlag } from 'common'
import dayjs from 'dayjs'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { Button, Card, CardContent } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { AddNewFactorModal } from './AddNewFactorModal'
import DeleteFactorModal from './DeleteFactorModal'
import { GenerateRecoveryCodesModal } from './GenerateRecoveryCodesModal'
import { UnenrollRecoveryCodesModal } from './UnenrollRecoveryCodesModal'
import { AlertError } from '@/components/ui/AlertError'
import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query'
import { useRecoveryCodesStatusQuery } from '@/data/recovery-codes/recovery-codes-status-query'
import { DATETIME_FORMAT, IS_STAGING_OR_LOCAL } from '@/lib/constants'
export const TOTPFactors = () => {
const [isAddNewFactorOpen, setIsAddNewFactorOpen] = useState(false)
const [factorToBeDeleted, setFactorToBeDeleted] = useState<string | null>(null)
const { data, isPending: isLoading, isError, isSuccess, error } = useMfaListFactorsQuery()
const enableAuthRecoveryCodes = useFlag('enableAuthRecoveryCodes')
const totpFactors = data?.totp ?? []
const canAddApp = isSuccess && totpFactors.length < 2
const shouldShowLockoutWarning = isSuccess && totpFactors.length === 1
const shouldVerifyRecoveryCodes = enableAuthRecoveryCodes && totpFactors.length === 1
const { data: recoveryCodesStatus } = useRecoveryCodesStatusQuery({
enabled: shouldVerifyRecoveryCodes,
})
const handleAddNewApp = () => setIsAddNewFactorOpen(true)
return (
<>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Multi-factor authentication</PageSectionTitle>
<PageSectionDescription>
Use an authenticator app (like Google Authenticator or 1Password) to protect your
account.
</PageSectionDescription>
</PageSectionSummary>
{canAddApp && (
<PageSectionAside>
<Button variant="primary" icon={<Plus />} onClick={handleAddNewApp}>
Add app
</Button>
</PageSectionAside>
)}
</PageSectionMeta>
<PageSectionContent className="flex flex-col gap-4">
{recoveryCodesStatus?.status === 'unenrolled' && <GenerateRecoveryCodesModal />}
{recoveryCodesStatus?.status === 'available' && (
<Admonition
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 && <UnenrollRecoveryCodesModal />}
/>
)}
{shouldShowLockoutWarning && (
<Admonition
type="danger"
layout="responsive"
title="Avoid being locked out"
description="Add a backup authenticator app now. Losing access to your only app will permanently lock you out of your account."
actions={
<Button icon={<Plus />} onClick={handleAddNewApp}>
Add another app
</Button>
}
/>
)}
{isLoading && (
<Card>
<CardContent>
<GenericSkeletonLoader />
</CardContent>
</Card>
)}
{isError && (
<AlertError error={error} subject="Failed to retrieve account security information" />
)}
{isSuccess && (
<Card>
{totpFactors.length === 0 ? (
<CardContent>
<p className="text-sm text-foreground-lighter">No authenticator apps yet.</p>
</CardContent>
) : (
<div className="divide-y">
{totpFactors.map((factor) => (
<CardContent key={factor.id} className="flex justify-between items-center py-4">
<div>
<p className="text-sm">{factor.friendly_name ?? 'No name provided'}</p>
<p className="text-sm text-foreground-lighter">
Added on {dayjs(factor.created_at).format(DATETIME_FORMAT)}
</p>
</div>
<Button size="tiny" onClick={() => setFactorToBeDeleted(factor.id)}>
Delete
</Button>
</CardContent>
))}
</div>
)}
</Card>
)}
</PageSectionContent>
</PageSection>
<AddNewFactorModal
visible={isAddNewFactorOpen}
onClose={() => setIsAddNewFactorOpen(false)}
/>
<DeleteFactorModal
visible={factorToBeDeleted !== null}
factorId={factorToBeDeleted}
lastFactorToBeDeleted={totpFactors.length === 1}
onClose={() => setFactorToBeDeleted(null)}
/>
</>
)
}