"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 { Credenza, CredenzaBody, CredenzaClose, CredenzaContent, CredenzaDescription, CredenzaFooter, CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; 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, Info } 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; code?: 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 [dialogState, setDialogState] = useState< "list" | "register" | "register2fa" | "delete" | "delete2fa" >("list"); const [selectedSecurityKey, setSelectedSecurityKey] = useState(null); const [deleteInProgress, setDeleteInProgress] = useState(false); const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState< string | null >(null); const [pendingDeletePassword, setPendingDeletePassword] = useState< string | null >(null); const [pendingRegisterData, setPendingRegisterData] = useState<{ name: string; password: string; } | null>(null); const [register2FAForm, setRegister2FAForm] = useState<{ code: string }>({ code: "" }); useEffect(() => { if (open) { loadSecurityKeys(); } }, [open]); const registerSchema = z.object({ name: z.string().min(1, { message: t("securityKeyNameRequired") }), password: z.string().min(1, { message: t("passwordRequired") }), code: z.string().optional() }); 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: "", code: "" } }); 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, code: values.code } ); // If 2FA is required if (startRes.status === 202 && startRes.data.data?.codeRequested) { setPendingRegisterData({ name: values.name, password: values.password }); setDialogState("register2fa"); setIsRegistering(false); 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(); setDialogState("list"); 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); setDialogState("delete2fa"); return; } toast({ description: t("securityKeyRemoveSuccess") }); deleteForm.reset(); setSelectedSecurityKey(null); setDialogState("list"); 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); setDialogState("list"); setPendingDeleteCredentialId(null); setPendingDeletePassword(null); await loadSecurityKeys(); } catch (error) { toast({ variant: "destructive", description: formatAxiosError( error, t("securityKeyRemoveError") ) }); } finally { setDeleteInProgress(false); } }; const handleRegister2FASubmit = async (values: { code: string }) => { if (!pendingRegisterData) return; try { setIsRegistering(true); const startRes = await api.post( "/auth/security-key/register/start", { name: pendingRegisterData.name, password: pendingRegisterData.password, code: values.code } ); 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(); setDialogState("list"); setPendingRegisterData(null); setRegister2FAForm({ code: "" }); 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" }) ) }); setRegister2FAForm({ code: "" }); } finally { setIsRegistering(false); } }; const onOpenChange = (open: boolean) => { if (open) { loadSecurityKeys(); } else { registerForm.reset(); deleteForm.reset(); setSelectedSecurityKey(null); setDialogState("list"); setPendingRegisterData(null); setRegister2FAForm({ code: "" }); } setOpen(open); }; return ( <> {dialogState === "list" && ( <> {t("securityKeyManage")} {t("securityKeyDescription")}

{t("securityKeyList")}

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

{ securityKey.name }

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

))}
) : (

{t("securityKeyNoKeysRegistered")}

{t("securityKeyNoKeysDescription")}

)} {securityKeys.length === 1 && ( {t("securityKeyRecommendation")} )}
)} {dialogState === "register" && ( <> {t("securityKeyRegisterTitle")} {t("securityKeyRegisterDescription")}
( {t( "securityKeyNameLabel" )} )} /> ( {t("password")} )} />
)} {dialogState === "register2fa" && ( <> {t("securityKeyTwoFactorRequired")} {t("securityKeyTwoFactorDescription")}
setRegister2FAForm({ code: e.target.value }) } maxLength={6} disabled={isRegistering} />
)} {dialogState === "delete" && ( <> {t("securityKeyRemoveTitle")} {t("securityKeyRemoveDescription", { name: selectedSecurityKey!.name! })}
( {t("password")} )} />
)} {dialogState === "delete2fa" && ( <> {t("securityKeyTwoFactorRequired")} {t("securityKeyTwoFactorRemoveDescription")}
( {t("securityKeyTwoFactorCode")} )} />
)}
); }