diff --git a/messages/de-DE.json b/messages/de-DE.json index 09276f72..377ec94b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -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." } diff --git a/messages/en-US.json b/messages/en-US.json index 9e53388d..fda1a590 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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." } diff --git a/messages/es-ES.json b/messages/es-ES.json index 60856fe8..226c02b6 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -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." } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index a7a237bf..4681f0cc 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -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 d’entré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." } diff --git a/messages/it-IT.json b/messages/it-IT.json index cfe983d2..0af5e8e4 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -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." } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 7e625b00..39aaa9b6 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -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." } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 25cf2e6a..e21902ea 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -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." } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 69e650d5..1d1b9ba1 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -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." } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index ad6a0fe3..085505b4 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -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." } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 676d5f56..8076ebda 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -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": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。" } diff --git a/package-lock.json b/package-lock.json index ece74553..031c57b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 69a20917..c807d047 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index f5f7ff77..72040a03 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -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! diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index d2650f43..f806f77d 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -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>("/auth/login", { + try { + const res = await api.post>("/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 (
+ {showSecurityKeyPrompt && ( + + + + {t('securityKeyPrompt', { + defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready." + })} + + + )} + {!mfaRequested && ( <>
@@ -362,7 +392,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { form="form" className="w-full" loading={loading} - disabled={loading} + disabled={loading || showSecurityKeyPrompt} > {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} > - {t('passkeyLogin')} + {t('securityKeyLogin', { + defaultValue: "Sign in with security key" + })} {hasIdp && ( diff --git a/src/components/PasskeyForm.tsx b/src/components/PasskeyForm.tsx deleted file mode 100644 index 14c3f393..00000000 --- a/src/components/PasskeyForm.tsx +++ /dev/null @@ -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([]); - const [step, setStep] = useState<"list" | "register" | "delete">("list"); - const [selectedPasskey, setSelectedPasskey] = useState(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>({ - resolver: zodResolver(registerSchema), - defaultValues: { - name: "", - password: "" - } - }); - - const deleteForm = useForm>({ - 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) => { - 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) => { - 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 ( - { - setOpen(val); - if (!val) reset(); - }} - > - - - {t('passkeyManage')} - - {t('passkeyDescription')} - - - -
- {step === "list" && ( - <> -
-

{t('passkeyList')}

- {passkeys.length === 0 ? ( -
-

- {t('passkeyNone')} -

-
- ) : ( -
- {passkeys.map((passkey) => ( -
-
-

{passkey.name}

-

- {t('passkeyLastUsed', { - date: new Date(passkey.lastUsed).toLocaleDateString() - })} -

-
- -
- ))} - {passkeys.length === 1 && ( -
- {t('passkeyRecommendation')} -
- )} -
- )} -
- -
- -
- - )} - - {step === "register" && ( -
-

{t('passkeyRegister')}

- - - ( - - {t('passkeyNameLabel')} - - - - - - )} - /> - - ( - - {t('password')} - - - - - - )} - /> - -
- - -
- - -
- )} - - {step === "delete" && selectedPasskey && ( -
-
-

Remove Passkey

-

- Enter your password to remove the passkey "{selectedPasskey.name}" -

-
- -
- - ( - - {t('password')} - - - - - - )} - /> - -
- - -
- - -
- )} -
-
- - - - - -
-
- ); -} \ No newline at end of file diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index cbed2c96..34a3799a 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -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() { <> - +
@@ -133,9 +133,9 @@ export default function ProfileIcon() { )} setOpenPasskey(true)} + onClick={() => setOpenSecurityKey(true)} > - {t('passkeyManage')} + {t('securityKeyManage')} diff --git a/src/components/SecurityKeyForm.tsx b/src/components/SecurityKeyForm.tsx new file mode 100644 index 00000000..73b82c3a --- /dev/null +++ b/src/components/SecurityKeyForm.tsx @@ -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) => void; + onBlur: () => void; + name: string; + ref: React.Ref; + }; +}; + +export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [securityKeys, setSecurityKeys] = useState([]); + const [isRegistering, setIsRegistering] = useState(false); + const [showRegisterDialog, setShowRegisterDialog] = useState(false); + const [selectedSecurityKey, setSelectedSecurityKey] = useState(null); + + 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({ + resolver: zodResolver(registerSchema), + defaultValues: { + name: "", + password: "", + }, + }); + + const deleteForm = useForm({ + 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 ( + <> + + + + + + {t('securityKeyManage')} + + + {t('securityKeyDescription')} + + + +
+
+

{t('securityKeyList')}

+
+ {securityKeys.length > 0 && ( + + {securityKeys.length} {securityKeys.length === 1 ? 'key' : 'keys'} + + )} + +
+
+ + {securityKeys.length > 0 ? ( +
+ {securityKeys.map((securityKey) => ( + + +
+
+ +
+
+

{securityKey.name}

+

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

+
+
+ +
+
+ ))} +
+ ) : ( +
+ +

No security keys registered

+

Add a security key to enhance your account security

+
+ )} + + {securityKeys.length === 1 && ( + + {t('securityKeyRecommendation')} + + )} +
+
+
+ + + + + Register New Security Key + + Connect your security key and enter a name to identify it + + + +
+ + ( + + {t('securityKeyNameLabel')} + + + + + + )} + /> + ( + + {t('password')} + + + + + + )} + /> + + + + + + + +
+
+ + !open && setSelectedSecurityKey(null)}> + + + + + Remove Security Key + + + Enter your password to remove the security key "{selectedSecurityKey?.name}" + + + +
+ + ( + + {t('password')} + + + + + + )} + /> + + + + + + + +
+
+ + ); +} \ No newline at end of file