improved security key management interface, also updated locales

This commit is contained in:
Adrian Astles
2025-07-05 18:27:04 +08:00
parent d5e67835aa
commit 5130071a60
17 changed files with 712 additions and 505 deletions

View File

@@ -66,6 +66,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const hasIdp = idps && idps.length > 0;
const [mfaRequested, setMfaRequested] = useState(false);
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
const t = useTranslations();
@@ -95,49 +96,63 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
});
async function initiateSecurityKeyAuth() {
setShowSecurityKeyPrompt(true);
setError(null);
await loginWithSecurityKey();
setShowSecurityKeyPrompt(false);
}
async function onSubmit(values: any) {
const { email, password } = form.getValues();
const { code } = mfaForm.getValues();
setLoading(true);
const res = await api
.post<AxiosResponse<LoginResponse>>("/auth/login", {
try {
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", {
email,
password,
code
})
.catch((e) => {
console.error(e);
setError(
formatAxiosError(e, t('loginError'))
);
});
if (res) {
setError(null);
if (res) {
setError(null);
const data = res.data.data;
const data = res.data.data;
if (data?.codeRequested) {
setMfaRequested(true);
setLoading(false);
mfaForm.reset();
return;
}
if (data?.emailVerificationRequired) {
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
} else {
router.push("/auth/verify-email");
if (data?.usePasskey) {
await initiateSecurityKeyAuth();
return;
}
if (data?.codeRequested) {
setMfaRequested(true);
setLoading(false);
mfaForm.reset();
return;
}
if (data?.emailVerificationRequired) {
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
} else {
router.push("/auth/verify-email");
}
return;
}
if (onLogin) {
await onLogin();
}
}
} catch (e) {
console.error(e);
const errorMessage = formatAxiosError(e, t('loginError'));
if (errorMessage.includes("Please use your security key")) {
await initiateSecurityKeyAuth();
return;
}
if (onLogin) {
await onLogin();
}
setError(errorMessage);
}
setLoading(false);
@@ -166,26 +181,28 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
}
async function loginWithPasskey() {
async function loginWithSecurityKey() {
try {
setLoading(true);
setError(null);
const email = form.getValues().email;
// Start passkey authentication
// Start WebAuthn authentication
const startRes = await api.post("/auth/passkey/authenticate/start", {
email: email || undefined
});
if (!startRes) {
setError(t('passkeyAuthError'));
setError(t('securityKeyAuthError', {
defaultValue: "Failed to start security key authentication"
}));
return;
}
const { tempSessionId, ...options } = startRes.data.data;
// Perform passkey authentication
// Perform WebAuthn authentication
const credential = await startAuthentication(options);
// Verify authentication
@@ -206,7 +223,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
} catch (e) {
console.error(e);
setError(formatAxiosError(e, t('passkeyAuthError')));
setError(formatAxiosError(e, t('securityKeyAuthError', {
defaultValue: "Security key authentication failed"
})));
} finally {
setLoading(false);
}
@@ -214,6 +233,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
return (
<div className="space-y-4">
{showSecurityKeyPrompt && (
<Alert>
<FingerprintIcon className="w-5 h-5 mr-2" />
<AlertDescription>
{t('securityKeyPrompt', {
defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready."
})}
</AlertDescription>
</Alert>
)}
{!mfaRequested && (
<>
<Form {...form}>
@@ -362,7 +392,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
form="form"
className="w-full"
loading={loading}
disabled={loading}
disabled={loading || showSecurityKeyPrompt}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('login')}
@@ -372,12 +402,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
type="button"
variant="outline"
className="w-full"
onClick={loginWithPasskey}
onClick={initiateSecurityKeyAuth}
loading={loading}
disabled={loading}
disabled={loading || showSecurityKeyPrompt}
>
<FingerprintIcon className="w-4 h-4 mr-2" />
{t('passkeyLogin')}
{t('securityKeyLogin', {
defaultValue: "Sign in with security key"
})}
</Button>
{hasIdp && (

View File

@@ -1,414 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { startRegistration } from "@simplewebauthn/browser";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
type PasskeyFormProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
type Passkey = {
credentialId: string;
name: string;
dateCreated: string;
lastUsed: string;
};
type DeletePasskeyData = {
credentialId: string;
name: string;
};
export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
const [loading, setLoading] = useState(false);
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [step, setStep] = useState<"list" | "register" | "delete">("list");
const [selectedPasskey, setSelectedPasskey] = useState<DeletePasskeyData | null>(null);
const { user } = useUserContext();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const registerSchema = z.object({
name: z.string().min(1, { message: t('passkeyNameRequired') }),
password: z.string().min(1, { message: t('passwordRequired') })
});
const deleteSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') })
});
const registerForm = useForm<z.infer<typeof registerSchema>>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: "",
password: ""
}
});
const deleteForm = useForm<z.infer<typeof deleteSchema>>({
resolver: zodResolver(deleteSchema),
defaultValues: {
password: ""
}
});
useEffect(() => {
if (open) {
loadPasskeys();
}
}, [open]);
const loadPasskeys = async () => {
try {
const response = await api.get("/auth/passkey/list");
setPasskeys(response.data.data);
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyLoadError')),
variant: "destructive"
});
}
};
const handleRegisterPasskey = async (values: z.infer<typeof registerSchema>) => {
try {
setLoading(true);
// Start registration
const startRes = await api.post("/auth/passkey/register/start", {
name: values.name,
password: values.password
});
// Handle 2FA if required
if (startRes.data.data.codeRequested) {
// TODO: Handle 2FA verification
toast({
title: "2FA Required",
description: "Two-factor authentication is required to register a passkey.",
variant: "destructive"
});
return;
}
const options = startRes.data.data;
// Create passkey
const credential = await startRegistration(options);
// Verify registration
await api.post("/auth/passkey/register/verify", {
credential
});
toast({
title: "Success",
description: t('passkeyRegisterSuccess')
});
// Reset form and go back to list
registerForm.reset();
setStep("list");
// Reload passkeys
await loadPasskeys();
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyRegisterError')),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleDeletePasskey = async (values: z.infer<typeof deleteSchema>) => {
if (!selectedPasskey) return;
try {
setLoading(true);
const encodedCredentialId = encodeURIComponent(selectedPasskey.credentialId);
await api.delete(`/auth/passkey/${encodedCredentialId}`, {
data: { password: values.password }
});
toast({
title: "Success",
description: t('passkeyRemoveSuccess')
});
// Reset form and go back to list
deleteForm.reset();
setStep("list");
setSelectedPasskey(null);
// Reload passkeys
await loadPasskeys();
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyRemoveError')),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
function reset() {
registerForm.reset();
deleteForm.reset();
setStep("list");
setSelectedPasskey(null);
setLoading(false);
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<CredenzaContent className="max-w-md">
<CredenzaHeader className="space-y-2 pb-4 border-b">
<CredenzaTitle className="text-2xl font-semibold tracking-tight">{t('passkeyManage')}</CredenzaTitle>
<CredenzaDescription className="text-sm text-muted-foreground">
{t('passkeyDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="py-6">
<div className="space-y-8">
{step === "list" && (
<>
<div className="space-y-4">
<h3 className="text-lg font-medium leading-none tracking-tight">{t('passkeyList')}</h3>
{passkeys.length === 0 ? (
<div className="flex h-[120px] items-center justify-center rounded-lg border border-dashed">
<p className="text-sm text-muted-foreground">
{t('passkeyNone')}
</p>
</div>
) : (
<div className="space-y-3">
{passkeys.map((passkey) => (
<div
key={passkey.credentialId}
className="flex items-center justify-between p-4 border rounded-lg bg-card hover:bg-accent/50 transition-colors"
>
<div>
<p className="font-medium">{passkey.name}</p>
<p className="text-sm text-muted-foreground mt-0.5">
{t('passkeyLastUsed', {
date: new Date(passkey.lastUsed).toLocaleDateString()
})}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedPasskey({
credentialId: passkey.credentialId,
name: passkey.name
});
setStep("delete");
}}
disabled={loading}
className="hover:bg-destructive hover:text-destructive-foreground"
>
{t('passkeyRemove')}
</Button>
</div>
))}
{passkeys.length === 1 && (
<div className="flex p-4 text-sm text-amber-600 bg-amber-50 dark:bg-amber-900/10 rounded-lg">
{t('passkeyRecommendation')}
</div>
)}
</div>
)}
</div>
<div>
<Button
onClick={() => setStep("register")}
className="w-full"
>
{t('passkeyRegister')}
</Button>
</div>
</>
)}
{step === "register" && (
<div className="space-y-4">
<h3 className="text-lg font-medium leading-none tracking-tight">{t('passkeyRegister')}</h3>
<Form {...registerForm}>
<form
onSubmit={registerForm.handleSubmit(handleRegisterPasskey)}
className="space-y-4"
>
<FormField
control={registerForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">{t('passkeyNameLabel')}</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder={t('passkeyNamePlaceholder')}
{...field}
/>
</FormControl>
<FormMessage className="text-sm" />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage className="text-sm" />
</FormItem>
)}
/>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => setStep("list")}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
loading={loading}
disabled={loading}
>
{t('passkeyRegister')}
</Button>
</div>
</form>
</Form>
</div>
)}
{step === "delete" && selectedPasskey && (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-lg font-medium leading-none tracking-tight">Remove Passkey</h3>
<p className="text-sm text-muted-foreground">
Enter your password to remove the passkey "{selectedPasskey.name}"
</p>
</div>
<Form {...deleteForm}>
<form
onSubmit={deleteForm.handleSubmit(handleDeletePasskey)}
className="space-y-4"
>
<FormField
control={deleteForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage className="text-sm" />
</FormItem>
)}
/>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setStep("list");
setSelectedPasskey(null);
}}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
loading={loading}
disabled={loading}
>
{t('passkeyRemove')}
</Button>
</div>
</form>
</Form>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter className="border-t pt-4">
<CredenzaClose asChild>
<Button variant="outline" className="w-full sm:w-auto">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -21,7 +21,7 @@ import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
import PasskeyForm from "./PasskeyForm";
import SecurityKeyForm from "./SecurityKeyForm";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher';
@@ -40,7 +40,7 @@ export default function ProfileIcon() {
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const [openPasskey, setOpenPasskey] = useState(false);
const [openSecurityKey, setOpenSecurityKey] = useState(false);
const t = useTranslations();
@@ -74,7 +74,7 @@ export default function ProfileIcon() {
<>
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<PasskeyForm open={openPasskey} setOpen={setOpenPasskey} />
<SecurityKeyForm open={openSecurityKey} setOpen={setOpenSecurityKey} />
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0">
<span className="truncate max-w-full font-medium min-w-0">
@@ -133,9 +133,9 @@ export default function ProfileIcon() {
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setOpenPasskey(true)}
onClick={() => setOpenSecurityKey(true)}
>
<span>{t('passkeyManage')}</span>
<span>{t('securityKeyManage')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>

View File

@@ -0,0 +1,409 @@
"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;
};
type FieldProps = {
field: {
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onBlur: () => void;
name: string;
ref: React.Ref<HTMLInputElement>;
};
};
export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [securityKeys, setSecurityKeys] = useState<SecurityKey[]>([]);
const [isRegistering, setIsRegistering] = useState(false);
const [showRegisterDialog, setShowRegisterDialog] = useState(false);
const [selectedSecurityKey, setSelectedSecurityKey] = useState<DeleteSecurityKeyData | null>(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') }),
});
const registerForm = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: "",
password: "",
},
});
const deleteForm = useForm<DeleteFormValues>({
resolver: zodResolver(deleteSchema),
defaultValues: {
password: "",
},
});
const loadSecurityKeys = async () => {
try {
const response = await api.get("/auth/passkey/list");
setSecurityKeys(response.data.data);
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(error, t('securityKeyLoadError')),
});
}
};
const handleRegisterSecurityKey = async (values: RegisterFormValues) => {
try {
setIsRegistering(true);
const startRes = await api.post("/auth/passkey/register/start", {
name: values.name,
password: values.password,
});
if (startRes.status === 202) {
toast({
variant: "destructive",
description: "Two-factor authentication is required to register a security key.",
});
return;
}
const options = startRes.data.data;
const credential = await startRegistration(options);
await api.post("/auth/passkey/register/verify", {
credential,
});
toast({
description: t('securityKeyRegisterSuccess')
});
registerForm.reset();
setShowRegisterDialog(false);
await loadSecurityKeys();
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(error, t('securityKeyRegisterError')),
});
} finally {
setIsRegistering(false);
}
};
const handleDeleteSecurityKey = async (values: DeleteFormValues) => {
if (!selectedSecurityKey) return;
try {
const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId);
await api.delete(`/auth/passkey/${encodedCredentialId}`, {
data: {
password: values.password,
}
});
toast({
description: t('securityKeyRemoveSuccess')
});
deleteForm.reset();
setSelectedSecurityKey(null);
await loadSecurityKeys();
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(error, t('securityKeyRemoveError')),
});
}
};
const onOpenChange = (open: boolean) => {
if (open) {
loadSecurityKeys();
} else {
registerForm.reset();
deleteForm.reset();
setSelectedSecurityKey(null);
setShowRegisterDialog(false);
}
setOpen(open);
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
{t('securityKeyManage')}
</DialogTitle>
<DialogDescription>
{t('securityKeyDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">{t('securityKeyList')}</h3>
<div className="flex items-center gap-2">
{securityKeys.length > 0 && (
<Badge className="text-xs">
{securityKeys.length} {securityKeys.length === 1 ? 'key' : 'keys'}
</Badge>
)}
<Button
className="h-8 w-8 p-0"
onClick={() => setShowRegisterDialog(true)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{securityKeys.length > 0 ? (
<div className="space-y-2">
{securityKeys.map((securityKey) => (
<Card key={securityKey.credentialId}>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
<KeyRound className="h-4 w-4 text-secondary-foreground" />
</div>
<div>
<p className="font-medium">{securityKey.name}</p>
<p className="text-xs text-muted-foreground">
{t('securityKeyLastUsed', {
date: new Date(securityKey.lastUsed).toLocaleDateString()
})}
</p>
</div>
</div>
<Button
className="h-8 w-8 p-0 text-white hover:text-white/80"
onClick={() => setSelectedSecurityKey({
credentialId: securityKey.credentialId,
name: securityKey.name
})}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Shield className="mb-2 h-12 w-12 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No security keys registered</p>
<p className="text-xs text-muted-foreground">Add a security key to enhance your account security</p>
</div>
)}
{securityKeys.length === 1 && (
<Alert variant="default">
<AlertDescription>{t('securityKeyRecommendation')}</AlertDescription>
</Alert>
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={showRegisterDialog} onOpenChange={setShowRegisterDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Register New Security Key</DialogTitle>
<DialogDescription>
Connect your security key and enter a name to identify it
</DialogDescription>
</DialogHeader>
<Form {...registerForm}>
<form onSubmit={registerForm.handleSubmit(handleRegisterSecurityKey)} className="space-y-4">
<FormField
control={registerForm.control}
name="name"
render={({ field }: FieldProps) => (
<FormItem>
<FormLabel>{t('securityKeyNameLabel')}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t('securityKeyNamePlaceholder')}
disabled={isRegistering}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="password"
render={({ field }: FieldProps) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
{...field}
type="password"
disabled={isRegistering}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
onClick={() => {
registerForm.reset();
setShowRegisterDialog(false);
}}
disabled={isRegistering}
>
{t('cancel')}
</Button>
<Button
type="submit"
disabled={isRegistering}
className={cn(
"min-w-[100px]",
isRegistering && "cursor-not-allowed opacity-50"
)}
>
{isRegistering ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('registering')}
</>
) : (
t('securityKeyRegister')
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<Dialog open={!!selectedSecurityKey} onOpenChange={(open) => !open && setSelectedSecurityKey(null)}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="h-4 w-4" />
Remove Security Key
</DialogTitle>
<DialogDescription>
Enter your password to remove the security key "{selectedSecurityKey?.name}"
</DialogDescription>
</DialogHeader>
<Form {...deleteForm}>
<form onSubmit={deleteForm.handleSubmit(handleDeleteSecurityKey)} className="space-y-4">
<FormField
control={deleteForm.control}
name="password"
render={({ field }: FieldProps) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
onClick={() => {
deleteForm.reset();
setSelectedSecurityKey(null);
}}
>
{t('cancel')}
</Button>
<Button type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{t('securityKeyRemove')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
}