"use client"; import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api"; import { toast } from "@app/hooks/useToast"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@app/components/ui/dialog"; import { startRegistration } from "@simplewebauthn/browser"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Card, CardContent } from "@app/components/ui/card"; import { Badge } from "@app/components/ui/badge"; import { Loader2, KeyRound, Trash2, Plus, Shield } from "lucide-react"; import { cn } from "@app/lib/cn"; type SecurityKeyFormProps = { open: boolean; setOpen: (open: boolean) => void; }; type SecurityKey = { credentialId: string; name: string; lastUsed: string; }; type DeleteSecurityKeyData = { credentialId: string; name: string; }; type RegisterFormValues = { name: string; password: string; }; type DeleteFormValues = { password: string; code?: string; }; type FieldProps = { field: { value: string; onChange: (event: React.ChangeEvent) => void; onBlur: () => void; name: string; ref: React.Ref; }; }; export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) { const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const [securityKeys, setSecurityKeys] = useState([]); const [isRegistering, setIsRegistering] = useState(false); const [showRegisterDialog, setShowRegisterDialog] = useState(false); const [selectedSecurityKey, setSelectedSecurityKey] = useState(null); const [show2FADialog, setShow2FADialog] = useState(false); const [deleteInProgress, setDeleteInProgress] = useState(false); const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState(null); const [pendingDeletePassword, setPendingDeletePassword] = useState(null); useEffect(() => { loadSecurityKeys(); }, []); const registerSchema = z.object({ name: z.string().min(1, { message: t('securityKeyNameRequired') }), password: z.string().min(1, { message: t('passwordRequired') }), }); const deleteSchema = z.object({ password: z.string().min(1, { message: t('passwordRequired') }), code: z.string().optional() }); const registerForm = useForm({ resolver: zodResolver(registerSchema), defaultValues: { name: "", password: "", }, }); const deleteForm = useForm({ resolver: zodResolver(deleteSchema), defaultValues: { password: "", code: "" }, }); const loadSecurityKeys = async () => { try { const response = await api.get("/auth/security-key/list"); setSecurityKeys(response.data.data); } catch (error) { toast({ variant: "destructive", description: formatAxiosError(error, t('securityKeyLoadError')), }); } }; const handleRegisterSecurityKey = async (values: RegisterFormValues) => { try { // Check browser compatibility first if (!window.PublicKeyCredential) { toast({ variant: "destructive", description: t('securityKeyBrowserNotSupported', { defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." }) }); return; } setIsRegistering(true); const startRes = await api.post("/auth/security-key/register/start", { name: values.name, password: values.password, }); if (startRes.status === 202) { toast({ variant: "destructive", description: t('twoFactorRequired', { defaultValue: "Two-factor authentication is required to register a security key." }) }); return; } const options = startRes.data.data; try { const credential = await startRegistration(options); await api.post("/auth/security-key/register/verify", { credential, }); toast({ description: t('securityKeyRegisterSuccess', { defaultValue: "Security key registered successfully" }) }); registerForm.reset(); setShowRegisterDialog(false); await loadSecurityKeys(); } catch (error: any) { if (error.name === 'NotAllowedError') { if (error.message.includes('denied permission')) { toast({ variant: "destructive", description: t('securityKeyPermissionDenied', { defaultValue: "Please allow access to your security key to continue registration." }) }); } else { toast({ variant: "destructive", description: t('securityKeyRemovedTooQuickly', { defaultValue: "Please keep your security key connected until the registration process completes." }) }); } } else if (error.name === 'NotSupportedError') { toast({ variant: "destructive", description: t('securityKeyNotSupported', { defaultValue: "Your security key may not be compatible. Please try a different security key." }) }); } else { toast({ variant: "destructive", description: t('securityKeyUnknownError', { defaultValue: "There was a problem registering your security key. Please try again." }) }); } throw error; // Re-throw to be caught by outer catch } } catch (error) { console.error('Security key registration error:', error); toast({ variant: "destructive", description: formatAxiosError(error, t('securityKeyRegisterError', { defaultValue: "Failed to register security key" })) }); } finally { setIsRegistering(false); } }; const handleDeleteSecurityKey = async (values: DeleteFormValues) => { if (!selectedSecurityKey) return; try { setDeleteInProgress(true); const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId); const response = await api.delete(`/auth/security-key/${encodedCredentialId}`, { data: { password: values.password, code: values.code } }); // If 2FA is required if (response.status === 202 && response.data.data.codeRequested) { setPendingDeleteCredentialId(encodedCredentialId); setPendingDeletePassword(values.password); setShow2FADialog(true); return; } toast({ description: t('securityKeyRemoveSuccess') }); deleteForm.reset(); setSelectedSecurityKey(null); await loadSecurityKeys(); } catch (error) { toast({ variant: "destructive", description: formatAxiosError(error, t('securityKeyRemoveError')), }); } finally { setDeleteInProgress(false); } }; const handle2FASubmit = async (values: DeleteFormValues) => { if (!pendingDeleteCredentialId || !pendingDeletePassword) return; try { setDeleteInProgress(true); await api.delete(`/auth/security-key/${pendingDeleteCredentialId}`, { data: { password: pendingDeletePassword, code: values.code } }); toast({ description: t('securityKeyRemoveSuccess') }); deleteForm.reset(); setSelectedSecurityKey(null); setShow2FADialog(false); setPendingDeleteCredentialId(null); setPendingDeletePassword(null); await loadSecurityKeys(); } catch (error) { toast({ variant: "destructive", description: formatAxiosError(error, t('securityKeyRemoveError')), }); } finally { setDeleteInProgress(false); } }; const onOpenChange = (open: boolean) => { if (open) { loadSecurityKeys(); } else { registerForm.reset(); deleteForm.reset(); setSelectedSecurityKey(null); setShowRegisterDialog(false); } setOpen(open); }; return ( <> {t('securityKeyManage')} {t('securityKeyDescription')}

{t('securityKeyList')}

{securityKeys.length > 0 ? (
{securityKeys.map((securityKey) => (

{securityKey.name}

{t('securityKeyLastUsed', { date: new Date(securityKey.lastUsed).toLocaleDateString() })}

))}
) : (

No security keys registered

Add a security key to enhance your account security

)} {securityKeys.length === 1 && ( {t('securityKeyRecommendation')} )}
Register New Security Key Connect your security key and enter a name to identify it
( {t('securityKeyNameLabel')} )} /> ( {t('password')} )} />
!open && setSelectedSecurityKey(null)}> Remove Security Key Enter your password to remove the security key "{selectedSecurityKey?.name}"
( {t('password')} )} />
!open && setShow2FADialog(false)}> Two-Factor Authentication Required Please enter your two-factor authentication code to remove the security key
( Two-Factor Code )} />
); }