340 lines
11 KiB
TypeScript
Vendored
340 lines
11 KiB
TypeScript
Vendored
import { useCallback, useMemo, useState } from 'react'
|
|
import { AlertTriangle, KeyRound, Loader2, ShieldAlert } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import dayjs from '@/lib/dayjs'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import { usePasskeyManagement } from '@/features/auth/passkey'
|
|
import {
|
|
SecureVerificationDialog,
|
|
useSecureVerification,
|
|
type VerificationMethod,
|
|
type VerificationMethods,
|
|
} from '@/features/auth/secure-verification'
|
|
|
|
interface PasskeyCardProps {
|
|
loading: boolean
|
|
}
|
|
|
|
export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
|
|
const { t } = useTranslation()
|
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
const [restrictedMethod, setRestrictedMethod] =
|
|
useState<VerificationMethod | null>(null)
|
|
|
|
const {
|
|
status,
|
|
loading,
|
|
registering,
|
|
removing,
|
|
supported,
|
|
enabled,
|
|
lastUsed,
|
|
register,
|
|
remove,
|
|
} = usePasskeyManagement()
|
|
|
|
const {
|
|
open: verificationOpen,
|
|
setOpen: setVerificationOpen,
|
|
methods: verificationMethods,
|
|
state: verificationState,
|
|
startVerification,
|
|
executeVerification,
|
|
cancel: cancelVerification,
|
|
setCode,
|
|
switchMethod,
|
|
fetchVerificationMethods,
|
|
} = useSecureVerification({
|
|
onSuccess: () => {
|
|
setRestrictedMethod(null)
|
|
},
|
|
})
|
|
|
|
const dialogMethods = useMemo<VerificationMethods>(() => {
|
|
if (!restrictedMethod) return verificationMethods
|
|
return {
|
|
...verificationMethods,
|
|
has2FA: restrictedMethod === '2fa' && verificationMethods.has2FA,
|
|
hasPasskey:
|
|
restrictedMethod === 'passkey' && verificationMethods.hasPasskey,
|
|
}
|
|
}, [restrictedMethod, verificationMethods])
|
|
|
|
const handleRegister = useCallback(async () => {
|
|
if (!supported) {
|
|
toast.info(t('This device does not support Passkey'))
|
|
return
|
|
}
|
|
|
|
const methods = await fetchVerificationMethods()
|
|
if (!methods.has2FA) {
|
|
// Without 2FA enabled, register directly. The browser-level Passkey prompt
|
|
// is itself a strong proof of presence, so no extra verification is needed.
|
|
await register()
|
|
return
|
|
}
|
|
|
|
setRestrictedMethod('2fa')
|
|
await startVerification(register, {
|
|
preferredMethod: '2fa',
|
|
title: t('Security verification'),
|
|
description: t(
|
|
'Confirm your identity with Two-factor Authentication before registering a Passkey.'
|
|
),
|
|
})
|
|
}, [fetchVerificationMethods, register, startVerification, supported, t])
|
|
|
|
const handleRemove = useCallback(async () => {
|
|
const methods = await fetchVerificationMethods()
|
|
const required: VerificationMethod | null = methods.has2FA
|
|
? '2fa'
|
|
: methods.hasPasskey
|
|
? 'passkey'
|
|
: null
|
|
|
|
if (!required) {
|
|
toast.error(
|
|
t(
|
|
'Please enable Two-factor Authentication or Passkey before proceeding'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
if (required === 'passkey' && !methods.passkeySupported) {
|
|
toast.info(t('This device does not support Passkey'))
|
|
return
|
|
}
|
|
|
|
setConfirmOpen(false)
|
|
setRestrictedMethod(required)
|
|
await startVerification(remove, {
|
|
preferredMethod: required,
|
|
title: t('Security verification'),
|
|
description: t(
|
|
'Confirm your identity before removing this Passkey from your account.'
|
|
),
|
|
})
|
|
}, [fetchVerificationMethods, remove, startVerification, t])
|
|
|
|
const handleVerificationCancel = useCallback(() => {
|
|
setRestrictedMethod(null)
|
|
cancelVerification()
|
|
}, [cancelVerification])
|
|
|
|
const handleVerificationOpenChange = useCallback(
|
|
(next: boolean) => {
|
|
if (!next) {
|
|
setRestrictedMethod(null)
|
|
}
|
|
setVerificationOpen(next)
|
|
},
|
|
[setVerificationOpen]
|
|
)
|
|
|
|
// Adapt the hook's `Promise<unknown>` return into the dialog's
|
|
// `void | Promise<void>` signature without losing error propagation
|
|
// semantics (errors are surfaced via toast inside the hook).
|
|
const handleDialogVerify = useCallback(
|
|
async (method: VerificationMethod, code?: string) => {
|
|
try {
|
|
await executeVerification(method, code)
|
|
} catch {
|
|
// Errors are already surfaced by useSecureVerification via toast.
|
|
}
|
|
},
|
|
[executeVerification]
|
|
)
|
|
|
|
if (pageLoading || loading) {
|
|
return (
|
|
<Card className='gap-0 overflow-hidden py-0'>
|
|
<CardHeader className='p-3 sm:p-5'>
|
|
<Skeleton className='h-6 w-48' />
|
|
<Skeleton className='mt-2 h-4 w-64' />
|
|
</CardHeader>
|
|
<CardContent className='p-3 sm:p-5'>
|
|
<Skeleton className='h-20 w-full' />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const formattedLastUsed =
|
|
lastUsed && !Number.isNaN(Date.parse(lastUsed))
|
|
? dayjs(lastUsed).fromNow()
|
|
: t('Not used yet')
|
|
|
|
const showUnsupportedNotice = !supported && !enabled
|
|
|
|
return (
|
|
<>
|
|
<Card className='gap-0 overflow-hidden py-0'>
|
|
<CardHeader className='p-3 sm:p-5'>
|
|
<CardTitle className='text-lg tracking-tight sm:text-xl'>
|
|
{t('Passkey Login')}
|
|
</CardTitle>
|
|
<CardDescription className='text-xs sm:text-sm'>
|
|
{t('Use Passkey to sign in without entering your password.')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className='p-3 sm:p-5'>
|
|
<div className='space-y-6'>
|
|
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
|
|
<div className='flex items-start gap-4'>
|
|
<div className='bg-muted rounded-md p-2'>
|
|
<KeyRound className='h-5 w-5' />
|
|
</div>
|
|
<div className='space-y-1'>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<p className='font-medium'>{t('Passkey Authentication')}</p>
|
|
<StatusBadge
|
|
label={enabled ? t('Enabled') : t('Disabled')}
|
|
variant={enabled ? 'success' : 'neutral'}
|
|
showDot
|
|
copyable={false}
|
|
/>
|
|
{status?.backup_eligible !== undefined && (
|
|
<StatusBadge
|
|
label={
|
|
status.backup_eligible
|
|
? status.backup_state
|
|
? t('Backed up')
|
|
: t('Not backed up')
|
|
: t('No backup')
|
|
}
|
|
variant={
|
|
status.backup_eligible
|
|
? status.backup_state
|
|
? 'success'
|
|
: 'warning'
|
|
: 'neutral'
|
|
}
|
|
showDot
|
|
copyable={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
<p className='text-muted-foreground text-sm'>
|
|
{t('Last used:')} {formattedLastUsed}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{!enabled && (
|
|
<Button
|
|
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
|
|
onClick={handleRegister}
|
|
disabled={!supported || registering}
|
|
>
|
|
{registering && (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
)}
|
|
{t('Enable Passkey')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{enabled && (
|
|
<div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
|
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant='destructive'
|
|
className='flex-1'
|
|
disabled={removing}
|
|
>
|
|
{removing ? (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
) : (
|
|
<AlertTriangle className='mr-2 h-4 w-4' />
|
|
)}
|
|
{t('Remove Passkey')}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t('Remove Passkey?')}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t(
|
|
'Removing Passkey will require you to sign in with your password next time. You can re-register anytime.'
|
|
)}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={removing}>
|
|
{t('Cancel')}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
|
disabled={removing}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
handleRemove()
|
|
}}
|
|
>
|
|
{t('Remove')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)}
|
|
|
|
{showUnsupportedNotice && (
|
|
<div className='bg-muted/60 text-muted-foreground flex items-start gap-3 rounded-md p-4 text-sm'>
|
|
<ShieldAlert className='mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500' />
|
|
<div>
|
|
<p className='text-foreground font-medium'>
|
|
{t('Passkey not supported on this device')}
|
|
</p>
|
|
<p>
|
|
{t(
|
|
'Use a compatible browser or device with biometric authentication or a security key to register a Passkey.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<SecureVerificationDialog
|
|
open={verificationOpen}
|
|
onOpenChange={handleVerificationOpenChange}
|
|
methods={dialogMethods}
|
|
state={verificationState}
|
|
onVerify={handleDialogVerify}
|
|
onCancel={handleVerificationCancel}
|
|
onCodeChange={setCode}
|
|
onMethodChange={switchMethod}
|
|
/>
|
|
</>
|
|
)
|
|
}
|