mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +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 -->
136 lines
5.3 KiB
TypeScript
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)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|