mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-01 00:24:38 +02:00
improved security key management interface, also updated locales
This commit is contained in:
parent
d5e67835aa
commit
5130071a60
17 changed files with 712 additions and 505 deletions
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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
26
package-lock.json
generated
|
@ -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"
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 />
|
||||
</>
|
||||
|
|
409
src/components/SecurityKeyForm.tsx
Normal file
409
src/components/SecurityKeyForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue