mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-11 05:06:39 +00:00
improved security key management interface, also updated locales
This commit is contained in:
@@ -1132,5 +1132,23 @@
|
|||||||
"initialSetupTitle": "Initial Einrichtung des Servers",
|
"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.",
|
"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",
|
"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.",
|
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
|
||||||
"createAdminAccount": "Create Admin Account",
|
"createAdminAccount": "Create Admin Account",
|
||||||
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
|
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
|
||||||
"passkeyManage": "Manage Passkeys",
|
"securityKeyManage": "Manage Security Keys",
|
||||||
"passkeyDescription": "Add or remove passkeys for passwordless authentication",
|
"securityKeyDescription": "Add or remove security keys for passwordless authentication",
|
||||||
"passkeyRegister": "Register New Passkey",
|
"securityKeyRegister": "Register New Security Key",
|
||||||
"passkeyList": "Your Passkeys",
|
"securityKeyList": "Your Security Keys",
|
||||||
"passkeyNone": "No passkeys registered yet",
|
"securityKeyNone": "No security keys registered yet",
|
||||||
"passkeyNameRequired": "Name is required",
|
"securityKeyNameRequired": "Name is required",
|
||||||
"passkeyRemove": "Remove",
|
"securityKeyRemove": "Remove",
|
||||||
"passkeyLastUsed": "Last used: {date}",
|
"securityKeyLastUsed": "Last used: {date}",
|
||||||
"passkeyNameLabel": "Name",
|
"securityKeyNameLabel": "Name",
|
||||||
"passkeyNamePlaceholder": "Enter a name for this passkey",
|
"securityKeyNamePlaceholder": "Enter a name for this security key",
|
||||||
"passkeyRegisterSuccess": "Passkey registered successfully",
|
"securityKeyRegisterSuccess": "Security key registered successfully",
|
||||||
"passkeyRegisterError": "Failed to register passkey",
|
"securityKeyRegisterError": "Failed to register security key",
|
||||||
"passkeyRemoveSuccess": "Passkey removed successfully",
|
"securityKeyRemoveSuccess": "Security key removed successfully",
|
||||||
"passkeyRemoveError": "Failed to remove passkey",
|
"securityKeyRemoveError": "Failed to remove security key",
|
||||||
"passkeyLoadError": "Failed to load passkeys",
|
"securityKeyLoadError": "Failed to load security keys",
|
||||||
"passkeyLogin": "Login with Passkey",
|
"securityKeyLogin": "Sign in with security key",
|
||||||
"passkeyAuthError": "Failed to authenticate with passkey",
|
"securityKeyAuthError": "Failed to authenticate with security key",
|
||||||
"passkeyRecommendation": "Consider registering another passkey on a different device to ensure you don't get locked out of your account."
|
"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",
|
"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.",
|
"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",
|
"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",
|
"cancel": "Abandonner",
|
||||||
"resourceConfig": "Snippets de configuration",
|
"resourceConfig": "Snippets de configuration",
|
||||||
"resourceConfigDescription": "Copiez et collez ces modules de configuration pour configurer votre ressource TCP/UDP",
|
"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",
|
"resourceExposePorts": "Gerbil: Exposer des ports dans Docker Compose",
|
||||||
"resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP",
|
"resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP",
|
||||||
"resourceBack": "Retour aux ressources",
|
"resourceBack": "Retour aux ressources",
|
||||||
@@ -1132,5 +1132,23 @@
|
|||||||
"initialSetupTitle": "Configuration initiale du serveur",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"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",
|
"initialSetupTitle": "Initiële serverconfiguratie",
|
||||||
"initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.",
|
"initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.",
|
||||||
"createAdminAccount": "Maak een beheeraccount aan",
|
"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",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"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": "确认删除组织",
|
"orgDeleteConfirm": "确认删除组织",
|
||||||
"orgMessageRemove": "此操作不可逆,这将删除所有相关数据。",
|
"orgMessageRemove": "此操作不可逆,这将删除所有相关数据。",
|
||||||
"orgMessageConfirm": "要确认,请在下面输入组织名称。",
|
"orgMessageConfirm": "要确认,请在下面输入组织名称。",
|
||||||
"orgQuestionRemove": "你确定要删除 “{selectedOrg}” 组织吗?",
|
"orgQuestionRemove": "你确定要删除 \"{selectedOrg}\" 组织吗?",
|
||||||
"orgUpdated": "组织已更新",
|
"orgUpdated": "组织已更新",
|
||||||
"orgUpdatedDescription": "组织已更新。",
|
"orgUpdatedDescription": "组织已更新。",
|
||||||
"orgErrorUpdate": "更新组织失败",
|
"orgErrorUpdate": "更新组织失败",
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
"apiKeysAdd": "生成 API 密钥",
|
"apiKeysAdd": "生成 API 密钥",
|
||||||
"apiKeysErrorDelete": "删除 API 密钥出错",
|
"apiKeysErrorDelete": "删除 API 密钥出错",
|
||||||
"apiKeysErrorDeleteMessage": "删除 API 密钥出错",
|
"apiKeysErrorDeleteMessage": "删除 API 密钥出错",
|
||||||
"apiKeysQuestionRemove": "您确定要从组织中删除 “{selectedApiKey}” API密钥吗?",
|
"apiKeysQuestionRemove": "您确定要从组织中删除 \"{selectedApiKey}\" API密钥吗?",
|
||||||
"apiKeysMessageRemove": "一旦删除,此API密钥将无法被使用。",
|
"apiKeysMessageRemove": "一旦删除,此API密钥将无法被使用。",
|
||||||
"apiKeysMessageConfirm": "要确认,请在下方输入API密钥名称。",
|
"apiKeysMessageConfirm": "要确认,请在下方输入API密钥名称。",
|
||||||
"apiKeysDeleteConfirm": "确认删除 API 密钥",
|
"apiKeysDeleteConfirm": "确认删除 API 密钥",
|
||||||
@@ -715,7 +715,7 @@
|
|||||||
"idpManageDescription": "查看和管理系统中的身份提供商",
|
"idpManageDescription": "查看和管理系统中的身份提供商",
|
||||||
"idpDeletedDescription": "身份提供商删除成功",
|
"idpDeletedDescription": "身份提供商删除成功",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "你确定要永久删除 “{name}” 这个身份提供商吗?",
|
"idpQuestionRemove": "你确定要永久删除 \"{name}\" 这个身份提供商吗?",
|
||||||
"idpMessageRemove": "这将删除身份提供者和所有相关的配置。通过此提供者进行身份验证的用户将无法登录。",
|
"idpMessageRemove": "这将删除身份提供者和所有相关的配置。通过此提供者进行身份验证的用户将无法登录。",
|
||||||
"idpMessageConfirm": "要确认,请在下面输入身份提供者的名称。",
|
"idpMessageConfirm": "要确认,请在下面输入身份提供者的名称。",
|
||||||
"idpConfirmDelete": "确认删除身份提供商",
|
"idpConfirmDelete": "确认删除身份提供商",
|
||||||
@@ -1132,5 +1132,23 @@
|
|||||||
"initialSetupTitle": "初始服务器设置",
|
"initialSetupTitle": "初始服务器设置",
|
||||||
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
|
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
|
||||||
"createAdminAccount": "创建管理员帐户",
|
"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",
|
"license": "SEE LICENSE IN LICENSE AND README.md",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
||||||
"@hookform/resolvers": "3.9.1",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"axios": "1.9.0",
|
"axios": "1.9.0",
|
||||||
"better-sqlite3": "11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"canvas-confetti": "1.9.3",
|
"canvas-confetti": "1.9.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-easy-sort": "^1.6.0",
|
"react-easy-sort": "^1.6.0",
|
||||||
"react-hook-form": "7.56.4",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.2",
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.18.2",
|
"ws": "8.18.2",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "3.25.56",
|
"zod": "^3.25.74",
|
||||||
"zod-validation-error": "3.4.1"
|
"zod-validation-error": "3.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1515,9 +1515,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.9.1",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
|
||||||
"integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==",
|
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react-hook-form": "^7.0.0"
|
"react-hook-form": "^7.0.0"
|
||||||
@@ -14418,9 +14418,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.56.4",
|
"version": "7.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz",
|
||||||
"integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==",
|
"integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -16770,9 +16770,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.56",
|
"version": "3.25.74",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.74.tgz",
|
||||||
"integrity": "sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ==",
|
"integrity": "sha512-J8poo92VuhKjNknViHRAIuuN6li/EwFbAC8OedzI8uxpEPGiXHGQu9wemIAioIpqgfB4SySaJhdk0mH5Y4ICBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
||||||
"@hookform/resolvers": "3.9.1",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"axios": "1.9.0",
|
"axios": "1.9.0",
|
||||||
"better-sqlite3": "11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"canvas-confetti": "1.9.3",
|
"canvas-confetti": "1.9.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-easy-sort": "^1.6.0",
|
"react-easy-sort": "^1.6.0",
|
||||||
"react-hook-form": "7.56.4",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.2",
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.18.2",
|
"ws": "8.18.2",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "3.25.56",
|
"zod": "^3.25.74",
|
||||||
"zod-validation-error": "3.4.1"
|
"zod-validation-error": "3.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
serializeSessionCookie
|
serializeSessionCookie
|
||||||
} from "@server/auth/sessions/app";
|
} from "@server/auth/sessions/app";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { users } from "@server/db";
|
import { users, passkeys } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
@@ -91,6 +91,22 @@ export async function login(
|
|||||||
|
|
||||||
const existingUser = existingUserRes[0];
|
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(
|
const validPassword = await verifyPassword(
|
||||||
password,
|
password,
|
||||||
existingUser.passwordHash!
|
existingUser.passwordHash!
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
const hasIdp = idps && idps.length > 0;
|
const hasIdp = idps && idps.length > 0;
|
||||||
|
|
||||||
const [mfaRequested, setMfaRequested] = useState(false);
|
const [mfaRequested, setMfaRequested] = useState(false);
|
||||||
|
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
|
||||||
|
|
||||||
const t = useTranslations();
|
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) {
|
async function onSubmit(values: any) {
|
||||||
const { email, password } = form.getValues();
|
const { email, password } = form.getValues();
|
||||||
const { code } = mfaForm.getValues();
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await api
|
try {
|
||||||
.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
code
|
code
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
setError(
|
|
||||||
formatAxiosError(e, t('loginError'))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const data = res.data.data;
|
||||||
|
|
||||||
const data = res.data.data;
|
if (data?.usePasskey) {
|
||||||
|
await initiateSecurityKeyAuth();
|
||||||
if (data?.codeRequested) {
|
return;
|
||||||
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?.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;
|
return;
|
||||||
}
|
}
|
||||||
|
setError(errorMessage);
|
||||||
if (onLogin) {
|
|
||||||
await onLogin();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -166,26 +181,28 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginWithPasskey() {
|
async function loginWithSecurityKey() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const email = form.getValues().email;
|
const email = form.getValues().email;
|
||||||
|
|
||||||
// Start passkey authentication
|
// Start WebAuthn authentication
|
||||||
const startRes = await api.post("/auth/passkey/authenticate/start", {
|
const startRes = await api.post("/auth/passkey/authenticate/start", {
|
||||||
email: email || undefined
|
email: email || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!startRes) {
|
if (!startRes) {
|
||||||
setError(t('passkeyAuthError'));
|
setError(t('securityKeyAuthError', {
|
||||||
|
defaultValue: "Failed to start security key authentication"
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tempSessionId, ...options } = startRes.data.data;
|
const { tempSessionId, ...options } = startRes.data.data;
|
||||||
|
|
||||||
// Perform passkey authentication
|
// Perform WebAuthn authentication
|
||||||
const credential = await startAuthentication(options);
|
const credential = await startAuthentication(options);
|
||||||
|
|
||||||
// Verify authentication
|
// Verify authentication
|
||||||
@@ -206,7 +223,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(formatAxiosError(e, t('passkeyAuthError')));
|
setError(formatAxiosError(e, t('securityKeyAuthError', {
|
||||||
|
defaultValue: "Security key authentication failed"
|
||||||
|
})));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -214,6 +233,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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 && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -362,7 +392,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
form="form"
|
form="form"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading || showSecurityKeyPrompt}
|
||||||
>
|
>
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
<LockIcon className="w-4 h-4 mr-2" />
|
||||||
{t('login')}
|
{t('login')}
|
||||||
@@ -372,12 +402,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={loginWithPasskey}
|
onClick={initiateSecurityKeyAuth}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading || showSecurityKeyPrompt}
|
||||||
>
|
>
|
||||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||||
{t('passkeyLogin')}
|
{t('securityKeyLogin', {
|
||||||
|
defaultValue: "Sign in with security key"
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{hasIdp && (
|
{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 { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import Disable2FaForm from "./Disable2FaForm";
|
import Disable2FaForm from "./Disable2FaForm";
|
||||||
import Enable2FaForm from "./Enable2FaForm";
|
import Enable2FaForm from "./Enable2FaForm";
|
||||||
import PasskeyForm from "./PasskeyForm";
|
import SecurityKeyForm from "./SecurityKeyForm";
|
||||||
import SupporterStatus from "./SupporterStatus";
|
import SupporterStatus from "./SupporterStatus";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import LocaleSwitcher from '@app/components/LocaleSwitcher';
|
import LocaleSwitcher from '@app/components/LocaleSwitcher';
|
||||||
@@ -40,7 +40,7 @@ export default function ProfileIcon() {
|
|||||||
|
|
||||||
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
||||||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||||
const [openPasskey, setOpenPasskey] = useState(false);
|
const [openSecurityKey, setOpenSecurityKey] = useState(false);
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export default function ProfileIcon() {
|
|||||||
<>
|
<>
|
||||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
<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">
|
<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">
|
<span className="truncate max-w-full font-medium min-w-0">
|
||||||
@@ -133,9 +133,9 @@ export default function ProfileIcon() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setOpenPasskey(true)}
|
onClick={() => setOpenSecurityKey(true)}
|
||||||
>
|
>
|
||||||
<span>{t('passkeyManage')}</span>
|
<span>{t('securityKeyManage')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user