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

@ -1132,5 +1132,23 @@
"initialSetupTitle": "Initial Einrichtung des Servers",
"initialSetupDescription": "Erstellen Sie das initiale Server-Admin-Konto. Es kann nur einen Server-Admin geben. Sie können diese Anmeldedaten später immer ändern.",
"createAdminAccount": "Admin-Konto erstellen",
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten."
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.",
"securityKeyManage": "Sicherheitsschlüssel verwalten",
"securityKeyDescription": "Sicherheitsschlüssel für passwortlose Authentifizierung hinzufügen oder entfernen",
"securityKeyRegister": "Neuen Sicherheitsschlüssel registrieren",
"securityKeyList": "Ihre Sicherheitsschlüssel",
"securityKeyNone": "Noch keine Sicherheitsschlüssel registriert",
"securityKeyNameRequired": "Name ist erforderlich",
"securityKeyRemove": "Entfernen",
"securityKeyLastUsed": "Zuletzt verwendet: {date}",
"securityKeyNameLabel": "Name",
"securityKeyNamePlaceholder": "Geben Sie einen Namen für diesen Sicherheitsschlüssel ein",
"securityKeyRegisterSuccess": "Sicherheitsschlüssel erfolgreich registriert",
"securityKeyRegisterError": "Fehler beim Registrieren des Sicherheitsschlüssels",
"securityKeyRemoveSuccess": "Sicherheitsschlüssel erfolgreich entfernt",
"securityKeyRemoveError": "Fehler beim Entfernen des Sicherheitsschlüssels",
"securityKeyLoadError": "Fehler beim Laden der Sicherheitsschlüssel",
"securityKeyLogin": "Mit Sicherheitsschlüssel anmelden",
"securityKeyAuthError": "Fehler bei der Authentifizierung mit Sicherheitsschlüssel",
"securityKeyRecommendation": "Erwägen Sie die Registrierung eines weiteren Sicherheitsschlüssels auf einem anderen Gerät, um sicherzustellen, dass Sie sich nicht aus Ihrem Konto aussperren."
}

View file

@ -1133,22 +1133,24 @@
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
"createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"passkeyManage": "Manage Passkeys",
"passkeyDescription": "Add or remove passkeys for passwordless authentication",
"passkeyRegister": "Register New Passkey",
"passkeyList": "Your Passkeys",
"passkeyNone": "No passkeys registered yet",
"passkeyNameRequired": "Name is required",
"passkeyRemove": "Remove",
"passkeyLastUsed": "Last used: {date}",
"passkeyNameLabel": "Name",
"passkeyNamePlaceholder": "Enter a name for this passkey",
"passkeyRegisterSuccess": "Passkey registered successfully",
"passkeyRegisterError": "Failed to register passkey",
"passkeyRemoveSuccess": "Passkey removed successfully",
"passkeyRemoveError": "Failed to remove passkey",
"passkeyLoadError": "Failed to load passkeys",
"passkeyLogin": "Login with Passkey",
"passkeyAuthError": "Failed to authenticate with passkey",
"passkeyRecommendation": "Consider registering another passkey on a different device to ensure you don't get locked out of your account."
"securityKeyManage": "Manage Security Keys",
"securityKeyDescription": "Add or remove security keys for passwordless authentication",
"securityKeyRegister": "Register New Security Key",
"securityKeyList": "Your Security Keys",
"securityKeyNone": "No security keys registered yet",
"securityKeyNameRequired": "Name is required",
"securityKeyRemove": "Remove",
"securityKeyLastUsed": "Last used: {date}",
"securityKeyNameLabel": "Name",
"securityKeyNamePlaceholder": "Enter a name for this security key",
"securityKeyRegisterSuccess": "Security key registered successfully",
"securityKeyRegisterError": "Failed to register security key",
"securityKeyRemoveSuccess": "Security key removed successfully",
"securityKeyRemoveError": "Failed to remove security key",
"securityKeyLoadError": "Failed to load security keys",
"securityKeyLogin": "Sign in with security key",
"securityKeyAuthError": "Failed to authenticate with security key",
"securityKeyRecommendation": "Consider registering another security key on a different device to ensure you don't get locked out of your account.",
"registering": "Registering...",
"securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready."
}

View file

@ -1132,5 +1132,23 @@
"initialSetupTitle": "Configuración inicial del servidor",
"initialSetupDescription": "Cree la cuenta de administrador del servidor inicial. Solo puede existir un administrador del servidor. Siempre puede cambiar estas credenciales más tarde.",
"createAdminAccount": "Crear cuenta de administrador",
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor."
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
"securityKeyManage": "Gestionar llaves de seguridad",
"securityKeyDescription": "Agregar o eliminar llaves de seguridad para autenticación sin contraseña",
"securityKeyRegister": "Registrar nueva llave de seguridad",
"securityKeyList": "Tus llaves de seguridad",
"securityKeyNone": "No hay llaves de seguridad registradas",
"securityKeyNameRequired": "El nombre es requerido",
"securityKeyRemove": "Eliminar",
"securityKeyLastUsed": "Último uso: {date}",
"securityKeyNameLabel": "Nombre",
"securityKeyNamePlaceholder": "Ingrese un nombre para esta llave de seguridad",
"securityKeyRegisterSuccess": "Llave de seguridad registrada exitosamente",
"securityKeyRegisterError": "Error al registrar la llave de seguridad",
"securityKeyRemoveSuccess": "Llave de seguridad eliminada exitosamente",
"securityKeyRemoveError": "Error al eliminar la llave de seguridad",
"securityKeyLoadError": "Error al cargar las llaves de seguridad",
"securityKeyLogin": "Iniciar sesión con llave de seguridad",
"securityKeyAuthError": "Error al autenticar con llave de seguridad",
"securityKeyRecommendation": "Considere registrar otra llave de seguridad en un dispositivo diferente para asegurarse de no quedar bloqueado de su cuenta."
}

View file

@ -184,7 +184,7 @@
"cancel": "Abandonner",
"resourceConfig": "Snippets de configuration",
"resourceConfigDescription": "Copiez et collez ces modules de configuration pour configurer votre ressource TCP/UDP",
"resourceAddEntrypoints": "Traefik: Ajouter des points dentrée",
"resourceAddEntrypoints": "Traefik: Ajouter des points d'entrée",
"resourceExposePorts": "Gerbil: Exposer des ports dans Docker Compose",
"resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP",
"resourceBack": "Retour aux ressources",
@ -1132,5 +1132,23 @@
"initialSetupTitle": "Configuration initiale du serveur",
"initialSetupDescription": "Créer le compte administrateur du serveur initial. Un seul administrateur serveur peut exister. Vous pouvez toujours changer ces informations d'identification plus tard.",
"createAdminAccount": "Créer un compte administrateur",
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur."
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.",
"securityKeyManage": "Gérer les clés de sécurité",
"securityKeyDescription": "Ajouter ou supprimer des clés de sécurité pour l'authentification sans mot de passe",
"securityKeyRegister": "Enregistrer une nouvelle clé de sécurité",
"securityKeyList": "Vos clés de sécurité",
"securityKeyNone": "Aucune clé de sécurité enregistrée",
"securityKeyNameRequired": "Le nom est requis",
"securityKeyRemove": "Supprimer",
"securityKeyLastUsed": "Dernière utilisation : {date}",
"securityKeyNameLabel": "Nom",
"securityKeyNamePlaceholder": "Entrez un nom pour cette clé de sécurité",
"securityKeyRegisterSuccess": "Clé de sécurité enregistrée avec succès",
"securityKeyRegisterError": "Échec de l'enregistrement de la clé de sécurité",
"securityKeyRemoveSuccess": "Clé de sécurité supprimée avec succès",
"securityKeyRemoveError": "Échec de la suppression de la clé de sécurité",
"securityKeyLoadError": "Échec du chargement des clés de sécurité",
"securityKeyLogin": "Se connecter avec une clé de sécurité",
"securityKeyAuthError": "Échec de l'authentification avec la clé de sécurité",
"securityKeyRecommendation": "Envisagez d'enregistrer une autre clé de sécurité sur un appareil différent pour vous assurer de ne pas être bloqué de votre compte."
}

View file

@ -1132,5 +1132,23 @@
"initialSetupTitle": "Impostazione Iniziale del Server",
"initialSetupDescription": "Crea l'account amministratore del server iniziale. Può esistere solo un amministratore del server. È sempre possibile modificare queste credenziali in seguito.",
"createAdminAccount": "Crea Account Admin",
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server."
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.",
"securityKeyManage": "Gestisci chiavi di sicurezza",
"securityKeyDescription": "Aggiungi o rimuovi chiavi di sicurezza per l'autenticazione senza password",
"securityKeyRegister": "Registra nuova chiave di sicurezza",
"securityKeyList": "Le tue chiavi di sicurezza",
"securityKeyNone": "Nessuna chiave di sicurezza registrata",
"securityKeyNameRequired": "Il nome è obbligatorio",
"securityKeyRemove": "Rimuovi",
"securityKeyLastUsed": "Ultimo utilizzo: {date}",
"securityKeyNameLabel": "Nome",
"securityKeyNamePlaceholder": "Inserisci un nome per questa chiave di sicurezza",
"securityKeyRegisterSuccess": "Chiave di sicurezza registrata con successo",
"securityKeyRegisterError": "Errore durante la registrazione della chiave di sicurezza",
"securityKeyRemoveSuccess": "Chiave di sicurezza rimossa con successo",
"securityKeyRemoveError": "Errore durante la rimozione della chiave di sicurezza",
"securityKeyLoadError": "Errore durante il caricamento delle chiavi di sicurezza",
"securityKeyLogin": "Accedi con chiave di sicurezza",
"securityKeyAuthError": "Errore durante l'autenticazione con chiave di sicurezza",
"securityKeyRecommendation": "Considera di registrare un'altra chiave di sicurezza su un dispositivo diverso per assicurarti di non rimanere bloccato fuori dal tuo account."
}

View file

@ -1132,5 +1132,23 @@
"initialSetupTitle": "Initiële serverconfiguratie",
"initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.",
"createAdminAccount": "Maak een beheeraccount aan",
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount."
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.",
"securityKeyManage": "Beveiligingssleutels beheren",
"securityKeyDescription": "Voeg beveiligingssleutels toe of verwijder ze voor wachtwoordloze authenticatie",
"securityKeyRegister": "Nieuwe beveiligingssleutel registreren",
"securityKeyList": "Uw beveiligingssleutels",
"securityKeyNone": "Nog geen beveiligingssleutels geregistreerd",
"securityKeyNameRequired": "Naam is verplicht",
"securityKeyRemove": "Verwijderen",
"securityKeyLastUsed": "Laatst gebruikt: {date}",
"securityKeyNameLabel": "Naam",
"securityKeyNamePlaceholder": "Voer een naam in voor deze beveiligingssleutel",
"securityKeyRegisterSuccess": "Beveiligingssleutel succesvol geregistreerd",
"securityKeyRegisterError": "Fout bij registreren van beveiligingssleutel",
"securityKeyRemoveSuccess": "Beveiligingssleutel succesvol verwijderd",
"securityKeyRemoveError": "Fout bij verwijderen van beveiligingssleutel",
"securityKeyLoadError": "Fout bij laden van beveiligingssleutels",
"securityKeyLogin": "Inloggen met beveiligingssleutel",
"securityKeyAuthError": "Fout bij authenticatie met beveiligingssleutel",
"securityKeyRecommendation": "Overweeg om een andere beveiligingssleutel te registreren op een ander apparaat om ervoor te zorgen dat u niet buitengesloten raakt van uw account."
}

View file

@ -1132,5 +1132,23 @@
"initialSetupTitle": "Wstępna konfiguracja serwera",
"initialSetupDescription": "Utwórz początkowe konto administratora serwera. Może istnieć tylko jeden administrator serwera. Zawsze można zmienić te dane uwierzytelniające.",
"createAdminAccount": "Utwórz konto administratora",
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera."
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.",
"securityKeyManage": "Zarządzaj kluczami bezpieczeństwa",
"securityKeyDescription": "Dodaj lub usuń klucze bezpieczeństwa do uwierzytelniania bez hasła",
"securityKeyRegister": "Zarejestruj nowy klucz bezpieczeństwa",
"securityKeyList": "Twoje klucze bezpieczeństwa",
"securityKeyNone": "Brak zarejestrowanych kluczy bezpieczeństwa",
"securityKeyNameRequired": "Nazwa jest wymagana",
"securityKeyRemove": "Usuń",
"securityKeyLastUsed": "Ostatnio używany: {date}",
"securityKeyNameLabel": "Nazwa",
"securityKeyNamePlaceholder": "Wprowadź nazwę dla tego klucza bezpieczeństwa",
"securityKeyRegisterSuccess": "Klucz bezpieczeństwa został pomyślnie zarejestrowany",
"securityKeyRegisterError": "Błąd podczas rejestracji klucza bezpieczeństwa",
"securityKeyRemoveSuccess": "Klucz bezpieczeństwa został pomyślnie usunięty",
"securityKeyRemoveError": "Błąd podczas usuwania klucza bezpieczeństwa",
"securityKeyLoadError": "Błąd podczas ładowania kluczy bezpieczeństwa",
"securityKeyLogin": "Zaloguj się kluczem bezpieczeństwa",
"securityKeyAuthError": "Błąd podczas uwierzytelniania kluczem bezpieczeństwa",
"securityKeyRecommendation": "Rozważ zarejestrowanie innego klucza bezpieczeństwa na innym urządzeniu, aby upewnić się, że nie zostaniesz zablokowany z dostępu do swojego konta."
}

View file

@ -1132,5 +1132,23 @@
"initialSetupTitle": "Configuração Inicial do Servidor",
"initialSetupDescription": "Crie a conta de administrador inicial do servidor. Apenas um administrador do servidor pode existir. Você sempre pode alterar essas credenciais posteriormente.",
"createAdminAccount": "Criar Conta de Administrador",
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor."
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.",
"securityKeyManage": "Gerenciar chaves de segurança",
"securityKeyDescription": "Adicionar ou remover chaves de segurança para autenticação sem senha",
"securityKeyRegister": "Registrar nova chave de segurança",
"securityKeyList": "Suas chaves de segurança",
"securityKeyNone": "Nenhuma chave de segurança registrada",
"securityKeyNameRequired": "Nome é obrigatório",
"securityKeyRemove": "Remover",
"securityKeyLastUsed": "Último uso: {date}",
"securityKeyNameLabel": "Nome",
"securityKeyNamePlaceholder": "Digite um nome para esta chave de segurança",
"securityKeyRegisterSuccess": "Chave de segurança registrada com sucesso",
"securityKeyRegisterError": "Erro ao registrar chave de segurança",
"securityKeyRemoveSuccess": "Chave de segurança removida com sucesso",
"securityKeyRemoveError": "Erro ao remover chave de segurança",
"securityKeyLoadError": "Erro ao carregar chaves de segurança",
"securityKeyLogin": "Entrar com chave de segurança",
"securityKeyAuthError": "Erro ao autenticar com chave de segurança",
"securityKeyRecommendation": "Considere registrar outra chave de segurança em um dispositivo diferente para garantir que você não fique bloqueado da sua conta."
}

View file

@ -1132,5 +1132,23 @@
"initialSetupTitle": "İlk Sunucu Kurulumu",
"initialSetupDescription": "İlk sunucu yönetici hesabını oluşturun. Yalnızca bir sunucu yöneticisi olabilir. Bu kimlik bilgilerini daha sonra her zaman değiştirebilirsiniz.",
"createAdminAccount": "Yönetici Hesabı Oluştur",
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu."
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.",
"securityKeyManage": "Güvenlik Anahtarlarını Yönet",
"securityKeyDescription": "Şifresiz kimlik doğrulama için güvenlik anahtarları ekleyin veya kaldırın",
"securityKeyRegister": "Yeni Güvenlik Anahtarı Kaydet",
"securityKeyList": "Güvenlik Anahtarlarınız",
"securityKeyNone": "Henüz kayıtlı güvenlik anahtarı yok",
"securityKeyNameRequired": "İsim gerekli",
"securityKeyRemove": "Kaldır",
"securityKeyLastUsed": "Son kullanım: {date}",
"securityKeyNameLabel": "İsim",
"securityKeyNamePlaceholder": "Bu güvenlik anahtarı için bir isim girin",
"securityKeyRegisterSuccess": "Güvenlik anahtarı başarıyla kaydedildi",
"securityKeyRegisterError": "Güvenlik anahtarı kaydedilirken hata oluştu",
"securityKeyRemoveSuccess": "Güvenlik anahtarı başarıyla kaldırıldı",
"securityKeyRemoveError": "Güvenlik anahtarı kaldırılırken hata oluştu",
"securityKeyLoadError": "Güvenlik anahtarları yüklenirken hata oluştu",
"securityKeyLogin": "Güvenlik anahtarı ile giriş yap",
"securityKeyAuthError": "Güvenlik anahtarı ile kimlik doğrulama başarısız oldu",
"securityKeyRecommendation": "Hesabınızdan kilitlenmediğinizden emin olmak için farklı bir cihazda başka bir güvenlik anahtarı kaydetmeyi düşünün."
}

View file

@ -212,7 +212,7 @@
"orgDeleteConfirm": "确认删除组织",
"orgMessageRemove": "此操作不可逆,这将删除所有相关数据。",
"orgMessageConfirm": "要确认,请在下面输入组织名称。",
"orgQuestionRemove": "你确定要删除 “{selectedOrg}” 组织吗?",
"orgQuestionRemove": "你确定要删除 \"{selectedOrg}\" 组织吗?",
"orgUpdated": "组织已更新",
"orgUpdatedDescription": "组织已更新。",
"orgErrorUpdate": "更新组织失败",
@ -279,7 +279,7 @@
"apiKeysAdd": "生成 API 密钥",
"apiKeysErrorDelete": "删除 API 密钥出错",
"apiKeysErrorDeleteMessage": "删除 API 密钥出错",
"apiKeysQuestionRemove": "您确定要从组织中删除 “{selectedApiKey}” API密钥吗",
"apiKeysQuestionRemove": "您确定要从组织中删除 \"{selectedApiKey}\" API密钥吗",
"apiKeysMessageRemove": "一旦删除此API密钥将无法被使用。",
"apiKeysMessageConfirm": "要确认请在下方输入API密钥名称。",
"apiKeysDeleteConfirm": "确认删除 API 密钥",
@ -715,7 +715,7 @@
"idpManageDescription": "查看和管理系统中的身份提供商",
"idpDeletedDescription": "身份提供商删除成功",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "你确定要永久删除 “{name}” 这个身份提供商吗?",
"idpQuestionRemove": "你确定要永久删除 \"{name}\" 这个身份提供商吗?",
"idpMessageRemove": "这将删除身份提供者和所有相关的配置。通过此提供者进行身份验证的用户将无法登录。",
"idpMessageConfirm": "要确认,请在下面输入身份提供者的名称。",
"idpConfirmDelete": "确认删除身份提供商",
@ -1132,5 +1132,23 @@
"initialSetupTitle": "初始服务器设置",
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
"createAdminAccount": "创建管理员帐户",
"setupErrorCreateAdmin": "创建服务器管理员帐户时出错。"
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
"securityKeyManage": "管理安全密钥",
"securityKeyDescription": "添加或删除用于无密码认证的安全密钥",
"securityKeyRegister": "注册新的安全密钥",
"securityKeyList": "您的安全密钥",
"securityKeyNone": "尚未注册安全密钥",
"securityKeyNameRequired": "名称为必填项",
"securityKeyRemove": "删除",
"securityKeyLastUsed": "上次使用:{date}",
"securityKeyNameLabel": "名称",
"securityKeyNamePlaceholder": "为此安全密钥输入名称",
"securityKeyRegisterSuccess": "安全密钥注册成功",
"securityKeyRegisterError": "注册安全密钥失败",
"securityKeyRemoveSuccess": "安全密钥删除成功",
"securityKeyRemoveError": "删除安全密钥失败",
"securityKeyLoadError": "加载安全密钥失败",
"securityKeyLogin": "使用安全密钥登录",
"securityKeyAuthError": "使用安全密钥认证失败",
"securityKeyRecommendation": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。"
}

26
package-lock.json generated
View file

@ -10,7 +10,7 @@
"license": "SEE LICENSE IN LICENSE AND README.md",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.4",
"@hookform/resolvers": "3.9.1",
"@hookform/resolvers": "^3.10.0",
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
@ -42,7 +42,7 @@
"axios": "1.9.0",
"better-sqlite3": "11.7.0",
"canvas-confetti": "1.9.3",
"class-variance-authority": "0.7.1",
"class-variance-authority": "^0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"cookie": "^1.0.2",
@ -78,7 +78,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-easy-sort": "^1.6.0",
"react-hook-form": "7.56.4",
"react-hook-form": "^7.60.0",
"react-icons": "^5.5.0",
"rebuild": "0.1.2",
"semver": "^7.7.2",
@ -91,7 +91,7 @@
"winston-daily-rotate-file": "5.0.0",
"ws": "8.18.2",
"yargs": "18.0.0",
"zod": "3.25.56",
"zod": "^3.25.74",
"zod-validation-error": "3.4.1"
},
"devDependencies": {
@ -1515,9 +1515,9 @@
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz",
"integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==",
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0"
@ -14418,9 +14418,9 @@
}
},
"node_modules/react-hook-form": {
"version": "7.56.4",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz",
"integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==",
"version": "7.60.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz",
"integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@ -16770,9 +16770,9 @@
}
},
"node_modules/zod": {
"version": "3.25.56",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz",
"integrity": "sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ==",
"version": "3.25.74",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.74.tgz",
"integrity": "sha512-J8poo92VuhKjNknViHRAIuuN6li/EwFbAC8OedzI8uxpEPGiXHGQu9wemIAioIpqgfB4SySaJhdk0mH5Y4ICBg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View file

@ -28,7 +28,7 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.4",
"@hookform/resolvers": "3.9.1",
"@hookform/resolvers": "^3.10.0",
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
@ -60,7 +60,7 @@
"axios": "1.9.0",
"better-sqlite3": "11.7.0",
"canvas-confetti": "1.9.3",
"class-variance-authority": "0.7.1",
"class-variance-authority": "^0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"cookie": "^1.0.2",
@ -96,7 +96,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-easy-sort": "^1.6.0",
"react-hook-form": "7.56.4",
"react-hook-form": "^7.60.0",
"react-icons": "^5.5.0",
"rebuild": "0.1.2",
"semver": "^7.7.2",
@ -109,7 +109,7 @@
"winston-daily-rotate-file": "5.0.0",
"ws": "8.18.2",
"yargs": "18.0.0",
"zod": "3.25.56",
"zod": "^3.25.74",
"zod-validation-error": "3.4.1"
},
"devDependencies": {

View file

@ -4,7 +4,7 @@ import {
serializeSessionCookie
} from "@server/auth/sessions/app";
import { db } from "@server/db";
import { users } from "@server/db";
import { users, passkeys } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, and } from "drizzle-orm";
@ -91,6 +91,22 @@ export async function login(
const existingUser = existingUserRes[0];
// Check if user has passkeys registered
const userPasskeys = await db
.select()
.from(passkeys)
.where(eq(passkeys.userId, existingUser.userId));
if (userPasskeys.length > 0) {
return response<{ usePasskey: boolean }>(res, {
data: { usePasskey: true },
success: true,
error: false,
message: "Please use your security key to sign in",
status: HttpCode.UNAUTHORIZED
});
}
const validPassword = await verifyPassword(
password,
existingUser.passwordHash!

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>
</>
);
}