style consistency changes to add security key form

This commit is contained in:
miloschwartz 2025-07-14 15:43:33 -07:00
parent 0a97d91aed
commit 3505342a8d
No known key found for this signature in database
4 changed files with 645 additions and 329 deletions

View file

@ -1143,8 +1143,7 @@
"securityKeyNameRequired": "Name is required", "securityKeyNameRequired": "Name is required",
"securityKeyRemove": "Remove", "securityKeyRemove": "Remove",
"securityKeyLastUsed": "Last used: {date}", "securityKeyLastUsed": "Last used: {date}",
"securityKeyNameLabel": "Name", "securityKeyNameLabel": "Security Key Name",
"securityKeyNamePlaceholder": "Enter a name for this security key",
"securityKeyRegisterSuccess": "Security key registered successfully", "securityKeyRegisterSuccess": "Security key registered successfully",
"securityKeyRegisterError": "Failed to register security key", "securityKeyRegisterError": "Failed to register security key",
"securityKeyRemoveSuccess": "Security key removed successfully", "securityKeyRemoveSuccess": "Security key removed successfully",
@ -1152,7 +1151,7 @@
"securityKeyLoadError": "Failed to load security keys", "securityKeyLoadError": "Failed to load security keys",
"securityKeyLogin": "Sign in with security key", "securityKeyLogin": "Sign in with security key",
"securityKeyAuthError": "Failed to authenticate with security key", "securityKeyAuthError": "Failed to authenticate with security key",
"securityKeyRecommendation": "Tip: Register a backup security key on another device to ensure you always have access to your account.", "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.",
"registering": "Registering...", "registering": "Registering...",
"securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.",
"securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.",
@ -1163,5 +1162,16 @@
"twoFactorRequired": "Two-factor authentication is required to register a security key.", "twoFactorRequired": "Two-factor authentication is required to register a security key.",
"twoFactor": "Two-Factor Authentication", "twoFactor": "Two-Factor Authentication",
"adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.",
"continueToApplication": "Continue to Application" "continueToApplication": "Continue to Application",
"securityKeyAdd": "Add Security Key",
"securityKeyRegisterTitle": "Register New Security Key",
"securityKeyRegisterDescription": "Connect your security key and enter a name to identify it",
"securityKeyTwoFactorRequired": "Two-Factor Authentication Required",
"securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key",
"securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key",
"securityKeyTwoFactorCode": "Two-Factor Code",
"securityKeyRemoveTitle": "Remove Security Key",
"securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"",
"securityKeyNoKeysRegistered": "No security keys registered",
"securityKeyNoKeysDescription": "Add a security key to enhance your account security"
} }

2
package-lock.json generated
View file

@ -33,9 +33,9 @@
"@radix-ui/react-toast": "1.2.14", "@radix-ui/react-toast": "1.2.14",
"@react-email/components": "0.3.1", "@react-email/components": "0.3.1",
"@react-email/render": "^1.1.2", "@react-email/render": "^1.1.2",
"@react-email/tailwind": "1.2.1",
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/server": "^9.0.3", "@simplewebauthn/server": "^9.0.3",
"@react-email/tailwind": "1.2.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"arctic": "^3.7.0", "arctic": "^3.7.0",

View file

@ -107,7 +107,8 @@ async function clearChallenge(sessionId: string) {
export const registerSecurityKeyBody = z.object({ export const registerSecurityKeyBody = z.object({
name: z.string().min(1), name: z.string().min(1),
password: z.string().min(1) password: z.string().min(1),
code: z.string().optional()
}).strict(); }).strict();
export const verifyRegistrationBody = z.object({ export const verifyRegistrationBody = z.object({
@ -143,7 +144,7 @@ export async function startRegistration(
); );
} }
const { name, password } = parsedBody.data; const { name, password, code } = parsedBody.data;
const user = req.user as User; const user = req.user as User;
// Only allow internal users to use security keys // Only allow internal users to use security keys
@ -163,6 +164,39 @@ export async function startRegistration(
return next(unauthorized()); return next(unauthorized());
} }
// If user has 2FA enabled, require and verify the code
if (user.twoFactorEnabled) {
if (!code) {
return response<{ codeRequested: boolean }>(res, {
data: { codeRequested: true },
success: true,
error: false,
message: "Two-factor authentication required",
status: HttpCode.ACCEPTED
});
}
const validOTP = await verifyTotpCode(
code,
user.twoFactorSecret!,
user.userId
);
if (!validOTP) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"The two-factor code you entered is incorrect"
)
);
}
}
// Get existing security keys for user // Get existing security keys for user
const existingSecurityKeys = await db const existingSecurityKeys = await db
.select() .select()

View file

@ -15,23 +15,25 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import { import {
Dialog, Credenza,
DialogContent, CredenzaBody,
DialogDescription, CredenzaClose,
DialogFooter, CredenzaContent,
DialogHeader, CredenzaDescription,
DialogTitle, CredenzaFooter,
} from "@app/components/ui/dialog"; CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { startRegistration } from "@simplewebauthn/browser"; import { startRegistration } from "@simplewebauthn/browser";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { Card, CardContent } from "@app/components/ui/card"; import { Card, CardContent } from "@app/components/ui/card";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { Loader2, KeyRound, Trash2, Plus, Shield } from "lucide-react"; import { Loader2, KeyRound, Trash2, Plus, Shield, Info } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
type SecurityKeyFormProps = { type SecurityKeyFormProps = {
@ -53,6 +55,7 @@ type DeleteSecurityKeyData = {
type RegisterFormValues = { type RegisterFormValues = {
name: string; name: string;
password: string; password: string;
code?: string;
}; };
type DeleteFormValues = { type DeleteFormValues = {
@ -70,30 +73,47 @@ type FieldProps = {
}; };
}; };
export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) { export default function SecurityKeyForm({
open,
setOpen
}: SecurityKeyFormProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const [securityKeys, setSecurityKeys] = useState<SecurityKey[]>([]); const [securityKeys, setSecurityKeys] = useState<SecurityKey[]>([]);
const [isRegistering, setIsRegistering] = useState(false); const [isRegistering, setIsRegistering] = useState(false);
const [showRegisterDialog, setShowRegisterDialog] = useState(false); const [dialogState, setDialogState] = useState<
const [selectedSecurityKey, setSelectedSecurityKey] = useState<DeleteSecurityKeyData | null>(null); "list" | "register" | "register2fa" | "delete" | "delete2fa"
const [show2FADialog, setShow2FADialog] = useState(false); >("list");
const [selectedSecurityKey, setSelectedSecurityKey] =
useState<DeleteSecurityKeyData | null>(null);
const [deleteInProgress, setDeleteInProgress] = useState(false); const [deleteInProgress, setDeleteInProgress] = useState(false);
const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState<string | null>(null); const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState<
const [pendingDeletePassword, setPendingDeletePassword] = useState<string | null>(null); 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(() => { useEffect(() => {
loadSecurityKeys(); loadSecurityKeys();
}, []); }, []);
const registerSchema = z.object({ const registerSchema = z.object({
name: z.string().min(1, { message: t('securityKeyNameRequired') }), name: z.string().min(1, { message: t("securityKeyNameRequired") }),
password: z.string().min(1, { message: t('passwordRequired') }), password: z.string().min(1, { message: t("passwordRequired") }),
code: z.string().optional()
}); });
const deleteSchema = z.object({ const deleteSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') }), password: z.string().min(1, { message: t("passwordRequired") }),
code: z.string().optional() code: z.string().optional()
}); });
@ -102,7 +122,8 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
defaultValues: { defaultValues: {
name: "", name: "",
password: "", password: "",
}, code: ""
}
}); });
const deleteForm = useForm<DeleteFormValues>({ const deleteForm = useForm<DeleteFormValues>({
@ -110,7 +131,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
defaultValues: { defaultValues: {
password: "", password: "",
code: "" code: ""
}, }
}); });
const loadSecurityKeys = async () => { const loadSecurityKeys = async () => {
@ -120,7 +141,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
description: formatAxiosError(error, t('securityKeyLoadError')), description: formatAxiosError(error, t("securityKeyLoadError"))
}); });
} }
}; };
@ -131,26 +152,32 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
if (!window.PublicKeyCredential) { if (!window.PublicKeyCredential) {
toast({ toast({
variant: "destructive", variant: "destructive",
description: t('securityKeyBrowserNotSupported', { description: t("securityKeyBrowserNotSupported", {
defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." defaultValue:
"Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari."
}) })
}); });
return; return;
} }
setIsRegistering(true); setIsRegistering(true);
const startRes = await api.post("/auth/security-key/register/start", { const startRes = await api.post(
"/auth/security-key/register/start",
{
name: values.name, name: values.name,
password: values.password, password: values.password,
}); code: values.code
}
);
if (startRes.status === 202) { // If 2FA is required
toast({ if (startRes.status === 202 && startRes.data.data?.codeRequested) {
variant: "destructive", setPendingRegisterData({
description: t('twoFactorRequired', { name: values.name,
defaultValue: "Two-factor authentication is required to register a security key." password: values.password
})
}); });
setDialogState("register2fa");
setIsRegistering(false);
return; return;
} }
@ -160,59 +187,66 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
const credential = await startRegistration(options); const credential = await startRegistration(options);
await api.post("/auth/security-key/register/verify", { await api.post("/auth/security-key/register/verify", {
credential, credential
}); });
toast({ toast({
description: t('securityKeyRegisterSuccess', { description: t("securityKeyRegisterSuccess", {
defaultValue: "Security key registered successfully" defaultValue: "Security key registered successfully"
}) })
}); });
registerForm.reset(); registerForm.reset();
setShowRegisterDialog(false); setDialogState("list");
await loadSecurityKeys(); await loadSecurityKeys();
} catch (error: any) { } catch (error: any) {
if (error.name === 'NotAllowedError') { if (error.name === "NotAllowedError") {
if (error.message.includes('denied permission')) { if (error.message.includes("denied permission")) {
toast({ toast({
variant: "destructive", variant: "destructive",
description: t('securityKeyPermissionDenied', { description: t("securityKeyPermissionDenied", {
defaultValue: "Please allow access to your security key to continue registration." defaultValue:
"Please allow access to your security key to continue registration."
}) })
}); });
} else { } else {
toast({ toast({
variant: "destructive", variant: "destructive",
description: t('securityKeyRemovedTooQuickly', { description: t("securityKeyRemovedTooQuickly", {
defaultValue: "Please keep your security key connected until the registration process completes." defaultValue:
"Please keep your security key connected until the registration process completes."
}) })
}); });
} }
} else if (error.name === 'NotSupportedError') { } else if (error.name === "NotSupportedError") {
toast({ toast({
variant: "destructive", variant: "destructive",
description: t('securityKeyNotSupported', { description: t("securityKeyNotSupported", {
defaultValue: "Your security key may not be compatible. Please try a different security key." defaultValue:
"Your security key may not be compatible. Please try a different security key."
}) })
}); });
} else { } else {
toast({ toast({
variant: "destructive", variant: "destructive",
description: t('securityKeyUnknownError', { description: t("securityKeyUnknownError", {
defaultValue: "There was a problem registering your security key. Please try again." defaultValue:
"There was a problem registering your security key. Please try again."
}) })
}); });
} }
throw error; // Re-throw to be caught by outer catch throw error; // Re-throw to be caught by outer catch
} }
} catch (error) { } catch (error) {
console.error('Security key registration error:', error); console.error("Security key registration error:", error);
toast({ toast({
variant: "destructive", variant: "destructive",
description: formatAxiosError(error, t('securityKeyRegisterError', { description: formatAxiosError(
error,
t("securityKeyRegisterError", {
defaultValue: "Failed to register security key" defaultValue: "Failed to register security key"
})) })
)
}); });
} finally { } finally {
setIsRegistering(false); setIsRegistering(false);
@ -224,33 +258,42 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
try { try {
setDeleteInProgress(true); setDeleteInProgress(true);
const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId); const encodedCredentialId = encodeURIComponent(
const response = await api.delete(`/auth/security-key/${encodedCredentialId}`, { selectedSecurityKey.credentialId
);
const response = await api.delete(
`/auth/security-key/${encodedCredentialId}`,
{
data: { data: {
password: values.password, password: values.password,
code: values.code code: values.code
} }
}); }
);
// If 2FA is required // If 2FA is required
if (response.status === 202 && response.data.data.codeRequested) { if (response.status === 202 && response.data.data.codeRequested) {
setPendingDeleteCredentialId(encodedCredentialId); setPendingDeleteCredentialId(encodedCredentialId);
setPendingDeletePassword(values.password); setPendingDeletePassword(values.password);
setShow2FADialog(true); setDialogState("delete2fa");
return; return;
} }
toast({ toast({
description: t('securityKeyRemoveSuccess') description: t("securityKeyRemoveSuccess")
}); });
deleteForm.reset(); deleteForm.reset();
setSelectedSecurityKey(null); setSelectedSecurityKey(null);
setDialogState("list");
await loadSecurityKeys(); await loadSecurityKeys();
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
description: formatAxiosError(error, t('securityKeyRemoveError')), description: formatAxiosError(
error,
t("securityKeyRemoveError")
)
}); });
} finally { } finally {
setDeleteInProgress(false); setDeleteInProgress(false);
@ -262,33 +305,128 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
try { try {
setDeleteInProgress(true); setDeleteInProgress(true);
await api.delete(`/auth/security-key/${pendingDeleteCredentialId}`, { await api.delete(
`/auth/security-key/${pendingDeleteCredentialId}`,
{
data: { data: {
password: pendingDeletePassword, password: pendingDeletePassword,
code: values.code code: values.code
} }
}); }
);
toast({ toast({
description: t('securityKeyRemoveSuccess') description: t("securityKeyRemoveSuccess")
}); });
deleteForm.reset(); deleteForm.reset();
setSelectedSecurityKey(null); setSelectedSecurityKey(null);
setShow2FADialog(false); setDialogState("list");
setPendingDeleteCredentialId(null); setPendingDeleteCredentialId(null);
setPendingDeletePassword(null); setPendingDeletePassword(null);
await loadSecurityKeys(); await loadSecurityKeys();
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
description: formatAxiosError(error, t('securityKeyRemoveError')), description: formatAxiosError(
error,
t("securityKeyRemoveError")
)
}); });
} finally { } finally {
setDeleteInProgress(false); 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) => { const onOpenChange = (open: boolean) => {
if (open) { if (open) {
loadSecurityKeys(); loadSecurityKeys();
@ -296,60 +434,89 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
registerForm.reset(); registerForm.reset();
deleteForm.reset(); deleteForm.reset();
setSelectedSecurityKey(null); setSelectedSecurityKey(null);
setShowRegisterDialog(false); setDialogState("list");
setPendingRegisterData(null);
setRegister2FAForm({ code: "" });
} }
setOpen(open); setOpen(open);
}; };
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Credenza open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <CredenzaContent>
<DialogHeader> {dialogState === "list" && (
<DialogTitle className="flex items-center gap-2"> <>
<Shield className="h-5 w-5" /> <CredenzaHeader>
{t('securityKeyManage')} <CredenzaTitle className="flex items-center gap-2">
</DialogTitle> {t("securityKeyManage")}
<DialogDescription> </CredenzaTitle>
{t('securityKeyDescription')} <CredenzaDescription>
</DialogDescription> {t("securityKeyDescription")}
</DialogHeader> </CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">{t('securityKeyList')}</h3> <h3 className="text-sm font-medium text-muted-foreground">
{t("securityKeyList")}
</h3>
<Button <Button
className="h-8 w-8 p-0" onClick={() =>
onClick={() => setShowRegisterDialog(true)} setDialogState("register")
}
className="gap-2"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{t("securityKeyAdd")}
</Button> </Button>
</div> </div>
{securityKeys.length > 0 ? ( {securityKeys.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{securityKeys.map((securityKey) => ( {securityKeys.map((securityKey) => (
<Card key={securityKey.credentialId}> <Card
key={
securityKey.credentialId
}
>
<CardContent className="flex items-center justify-between p-4"> <CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
<KeyRound className="h-4 w-4 text-secondary-foreground" /> <KeyRound className="h-4 w-4 text-secondary-foreground" />
</div> </div>
<div> <div>
<p className="font-medium">{securityKey.name}</p> <p className="font-medium">
{
securityKey.name
}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('securityKeyLastUsed', { {t(
date: new Date(securityKey.lastUsed).toLocaleDateString() "securityKeyLastUsed",
})} {
date: new Date(
securityKey.lastUsed
).toLocaleDateString()
}
)}
</p> </p>
</div> </div>
</div> </div>
<Button <Button
className="h-8 w-8 p-0 text-white hover:text-white/80" className="h-8 w-8 p-0 text-white hover:text-white/80"
onClick={() => setSelectedSecurityKey({ onClick={() => {
credentialId: securityKey.credentialId, setSelectedSecurityKey(
{
credentialId:
securityKey.credentialId,
name: securityKey.name name: securityKey.name
})} }
);
setDialogState(
"delete"
);
}}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@ -360,42 +527,66 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
) : ( ) : (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<Shield className="mb-2 h-12 w-12 text-muted-foreground" /> <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-sm text-muted-foreground">
<p className="text-xs text-muted-foreground">Add a security key to enhance your account security</p> {t("securityKeyNoKeysRegistered")}
</p>
<p className="text-xs text-muted-foreground">
{t("securityKeyNoKeysDescription")}
</p>
</div> </div>
)} )}
{securityKeys.length === 1 && ( {securityKeys.length === 1 && (
<Alert variant="default"> <Alert variant="default">
<AlertDescription>{t('securityKeyRecommendation')}</AlertDescription> <Info className="h-4 w-4" />
<AlertDescription>
{t("securityKeyRecommendation")}
</AlertDescription>
</Alert> </Alert>
)} )}
</div> </div>
</DialogContent> </CredenzaBody>
</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>
{dialogState === "register" && (
<>
<CredenzaHeader>
<CredenzaTitle>
{t("securityKeyRegisterTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("securityKeyRegisterDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...registerForm}> <Form {...registerForm}>
<form onSubmit={registerForm.handleSubmit(handleRegisterSecurityKey)} className="space-y-4"> <form
onSubmit={registerForm.handleSubmit(
handleRegisterSecurityKey
)}
className="space-y-4"
id="form"
>
<FormField <FormField
control={registerForm.control} control={registerForm.control}
name="name" name="name"
render={({ field }: FieldProps) => ( render={({ field }: FieldProps) => (
<FormItem> <FormItem>
<FormLabel>{t('securityKeyNameLabel')}</FormLabel> <FormLabel>
{t(
"securityKeyNameLabel"
)}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
placeholder={t('securityKeyNamePlaceholder')} placeholder={t(
disabled={isRegistering} "securityKeyNamePlaceholder"
)}
disabled={
isRegistering
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -407,181 +598,262 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
name="password" name="password"
render={({ field }: FieldProps) => ( render={({ field }: FieldProps) => (
<FormItem> <FormItem>
<FormLabel>{t('password')}</FormLabel> <FormLabel>
{t("password")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
type="password" type="password"
disabled={isRegistering} disabled={
isRegistering
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</form>
<DialogFooter> </Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button <Button
type="button" variant="outline"
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
onClick={() => { onClick={() => {
registerForm.reset(); registerForm.reset();
setShowRegisterDialog(false); setDialogState("list");
}} }}
disabled={isRegistering} disabled={isRegistering}
> >
{t('cancel')} {t("cancel")}
</Button> </Button>
</CredenzaClose>
<Button <Button
type="submit" type="submit"
form="form"
disabled={isRegistering} disabled={isRegistering}
className={cn( className={cn(
"min-w-[100px]", "min-w-[100px]",
isRegistering && "cursor-not-allowed opacity-50" isRegistering &&
"cursor-not-allowed opacity-50"
)} )}
loading={isRegistering}
> >
{isRegistering ? ( {t("securityKeyRegister")}
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('registering')}
</>
) : (
t('securityKeyRegister')
)}
</Button> </Button>
</DialogFooter> </CredenzaFooter>
</form> </>
</Form> )}
</DialogContent>
</Dialog>
<Dialog open={!!selectedSecurityKey} onOpenChange={(open) => !open && setSelectedSecurityKey(null)}> {dialogState === "register2fa" && (
<DialogContent className="sm:max-w-[400px]"> <>
<DialogHeader> <CredenzaHeader>
<DialogTitle className="flex items-center gap-2 text-destructive"> <CredenzaTitle>
<Trash2 className="h-4 w-4" /> {t("securityKeyTwoFactorRequired")}
Remove Security Key </CredenzaTitle>
</DialogTitle> <CredenzaDescription>
<DialogDescription> {t("securityKeyTwoFactorDescription")}
Enter your password to remove the security key "{selectedSecurityKey?.name}" </CredenzaDescription>
</DialogDescription> </CredenzaHeader>
</DialogHeader> <CredenzaBody>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t("securityKeyTwoFactorCode")}
</label>
<Input
type="text"
value={register2FAForm.code}
onChange={(e) =>
setRegister2FAForm({
code: e.target.value
})
}
maxLength={6}
disabled={isRegistering}
/>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => {
setRegister2FAForm({ code: "" });
setDialogState("list");
setPendingRegisterData(null);
}}
disabled={isRegistering}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="button"
className="min-w-[100px]"
disabled={
isRegistering ||
register2FAForm.code.length !== 6
}
loading={isRegistering}
onClick={() =>
handleRegister2FASubmit({
code: register2FAForm.code
})
}
>
{t("securityKeyRegister")}
</Button>
</CredenzaFooter>
</>
)}
{dialogState === "delete" && (
<>
<CredenzaHeader>
<CredenzaTitle className="flex items-center gap-2">
{t("securityKeyRemoveTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("securityKeyRemoveDescription", { name: selectedSecurityKey!.name! })}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...deleteForm}> <Form {...deleteForm}>
<form onSubmit={deleteForm.handleSubmit(handleDeleteSecurityKey)} className="space-y-4"> <form
onSubmit={deleteForm.handleSubmit(
handleDeleteSecurityKey
)}
className="space-y-4"
id="delete-form"
>
<FormField <FormField
control={deleteForm.control} control={deleteForm.control}
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('password')}</FormLabel> <FormLabel>
{t("password")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
type="password" type="password"
disabled={deleteInProgress} disabled={
deleteInProgress
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</form>
<DialogFooter> </Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button <Button
type="button" variant="outline"
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
onClick={() => { onClick={() => {
deleteForm.reset(); deleteForm.reset();
setSelectedSecurityKey(null); setSelectedSecurityKey(null);
setDialogState("list");
}} }}
disabled={deleteInProgress} disabled={deleteInProgress}
> >
{t('cancel')} {t("cancel")}
</Button> </Button>
</CredenzaClose>
<Button <Button
type="submit" type="submit"
form="delete-form"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteInProgress} disabled={deleteInProgress}
loading={deleteInProgress}
> >
{deleteInProgress ? ( {t("securityKeyRemove")}
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('securityKeyRemoving')}
</>
) : (
t('securityKeyRemove')
)}
</Button> </Button>
</DialogFooter> </CredenzaFooter>
</form> </>
</Form> )}
</DialogContent>
</Dialog>
<Dialog open={show2FADialog} onOpenChange={(open) => !open && setShow2FADialog(false)}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Two-Factor Authentication Required</DialogTitle>
<DialogDescription>
Please enter your two-factor authentication code to remove the security key
</DialogDescription>
</DialogHeader>
{dialogState === "delete2fa" && (
<>
<CredenzaHeader>
<CredenzaTitle>
{t("securityKeyTwoFactorRequired")}
</CredenzaTitle>
<CredenzaDescription>
{t("securityKeyTwoFactorRemoveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...deleteForm}> <Form {...deleteForm}>
<form onSubmit={deleteForm.handleSubmit(handle2FASubmit)} className="space-y-4"> <form
onSubmit={deleteForm.handleSubmit(
handle2FASubmit
)}
className="space-y-4"
id="delete2fa-form"
>
<FormField <FormField
control={deleteForm.control} control={deleteForm.control}
name="code" name="code"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Two-Factor Code</FormLabel> <FormLabel>
{t("securityKeyTwoFactorCode")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
type="text" type="text"
placeholder="Enter your 6-digit code" maxLength={6}
disabled={deleteInProgress} disabled={
deleteInProgress
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</form>
<DialogFooter> </Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button <Button
type="button" variant="outline"
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
onClick={() => { onClick={() => {
deleteForm.reset(); deleteForm.reset();
setShow2FADialog(false); setDialogState("list");
setPendingDeleteCredentialId(null); setPendingDeleteCredentialId(null);
setPendingDeletePassword(null); setPendingDeletePassword(null);
}} }}
disabled={deleteInProgress} disabled={deleteInProgress}
> >
{t('cancel')} {t("cancel")}
</Button> </Button>
</CredenzaClose>
<Button <Button
type="submit" type="submit"
form="delete2fa-form"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteInProgress} disabled={deleteInProgress}
loading={deleteInProgress}
> >
{deleteInProgress ? ( {t("securityKeyRemove")}
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('securityKeyRemoving')}
</>
) : (
t('securityKeyRemove')
)}
</Button> </Button>
</DialogFooter> </CredenzaFooter>
</form> </>
</Form> )}
</DialogContent> </CredenzaContent>
</Dialog> </Credenza>
</> </>
); );
} }