Merge branch 'dev' into user-management-and-resources

This commit is contained in:
Adrian Astles 2025-07-15 05:51:47 +08:00 committed by GitHub
commit 5278c4d6f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2109 additions and 76 deletions

View file

@ -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."
} }

View file

@ -1176,8 +1176,33 @@
"sendEmailNotification": "Send Email Notification", "sendEmailNotification": "Send Email Notification",
"linkCopied": "Link Copied", "linkCopied": "Link Copied",
"linkCopiedDescription": "The reset link has been copied to your clipboard" "linkCopiedDescription": "The reset link has been copied to your clipboard"
"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": "Tip: Register a backup security key on another device to ensure you always have access to your account.",
"registering": "Registering...",
"securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.",
"securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.",
"securityKeyPermissionDenied": "Please allow access to your security key to continue signing in.",
"securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.",
"securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.",
"securityKeyUnknownError": "There was a problem using your security key. Please try again.",
"twoFactorRequired": "Two-factor authentication is required to register a security key.",
"twoFactor": "Two-Factor Authentication", "twoFactor": "Two-Factor Authentication",
"otpSetupDescription": "Secure your account with an extra layer of protection",
"adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.",
"continueToApplication": "Continue to Application" "continueToApplication": "Continue to Application"
} }

View file

@ -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."
} }

View file

@ -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 dentré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."
} }

View file

@ -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."
} }

View file

@ -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."
} }

View file

@ -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."
} }

View file

@ -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."
} }

View file

@ -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."
} }

View file

@ -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": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。"
} }

238
package-lock.json generated
View file

@ -33,6 +33,8 @@
"@radix-ui/react-toast": "1.2.14", "@radix-ui/react-toast": "1.2.14",
"@react-email/components": "0.3.1", "@react-email/components": "0.3.1",
"@react-email/render": "^1.1.2", "@react-email/render": "^1.1.2",
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/server": "^9.0.3",
"@react-email/tailwind": "1.2.1", "@react-email/tailwind": "1.2.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
@ -40,7 +42,7 @@
"axios": "1.10.0", "axios": "1.10.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",
@ -79,7 +81,7 @@
"react-hook-form": "7.60.0", "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",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"tailwind-merge": "3.3.1", "tailwind-merge": "3.3.1",
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
@ -101,6 +103,7 @@
"@types/cors": "2.8.19", "@types/cors": "2.8.19",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/express": "5.0.0", "@types/express": "5.0.0",
"@types/express-session": "^1.18.2",
"@types/jmespath": "^0.15.2", "@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
@ -108,7 +111,7 @@
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@types/semver": "7.7.0", "@types/semver": "^7.7.0",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
@ -179,16 +182,16 @@
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.27.5", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.27.5", "@babel/parser": "^7.28.0",
"@babel/types": "^7.27.3", "@babel/types": "^7.28.0",
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2" "jsesc": "^3.0.2"
}, },
"engines": { "engines": {
@ -216,13 +219,13 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.27.7", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.7.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==", "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.27.7" "@babel/types": "^7.28.0"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@ -276,9 +279,9 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.27.7", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.7.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
"integrity": "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==", "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1509,6 +1512,12 @@
"tslib": "2" "tslib": "2"
} }
}, },
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "3.9.1", "version": "3.9.1",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz",
@ -2027,9 +2036,9 @@
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.11", "version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.11.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-C512c1ytBTio4MrpWKlJpyFHT6+qfFL8SZ58zBzJ1OOzUEjHeF1BtjY2fH7n4x/g2OV/KiiMLAivOp1DXmiMMw==", "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2065,6 +2074,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz",
@ -2887,6 +2902,64 @@
"integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@peculiar/asn1-android": {
"version": "2.3.16",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.16.tgz",
"integrity": "sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.15",
"asn1js": "^3.0.5",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.15.tgz",
"integrity": "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.15",
"@peculiar/asn1-x509": "^2.3.15",
"asn1js": "^3.0.5",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.15.tgz",
"integrity": "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.15",
"@peculiar/asn1-x509": "^2.3.15",
"asn1js": "^3.0.5",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.15.tgz",
"integrity": "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==",
"license": "MIT",
"dependencies": {
"asn1js": "^3.0.5",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.15.tgz",
"integrity": "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.15",
"asn1js": "^3.0.5",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@ -4238,6 +4311,38 @@
"url": "https://ko-fi.com/killymxi" "url": "https://ko-fi.com/killymxi"
} }
}, },
"node_modules/@simplewebauthn/browser": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz",
"integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz",
"integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.3.10",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-rsa": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"@simplewebauthn/types": "^9.0.1",
"cross-fetch": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz",
"integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==",
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": { "node_modules/@socket.io/component-emitter": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@ -4681,6 +4786,16 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@ -5711,6 +5826,20 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/asn1js": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
"integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ast-types-flow": { "node_modules/ast-types-flow": {
"version": "0.0.8", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@ -6471,6 +6600,35 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/cross-fetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-fetch/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -14104,6 +14262,24 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/qrcode.react": { "node_modules/qrcode.react": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
@ -15776,6 +15952,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/triple-beam": { "node_modules/triple-beam": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
@ -16298,6 +16480,22 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",

View file

@ -51,6 +51,8 @@
"@radix-ui/react-toast": "1.2.14", "@radix-ui/react-toast": "1.2.14",
"@react-email/components": "0.3.1", "@react-email/components": "0.3.1",
"@react-email/render": "^1.1.2", "@react-email/render": "^1.1.2",
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/server": "^9.0.3",
"@react-email/tailwind": "1.2.1", "@react-email/tailwind": "1.2.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
@ -58,7 +60,7 @@
"axios": "1.10.0", "axios": "1.10.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",
@ -97,7 +99,7 @@
"react-hook-form": "7.60.0", "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",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"tailwind-merge": "3.3.1", "tailwind-merge": "3.3.1",
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
@ -119,6 +121,7 @@
"@types/cors": "2.8.19", "@types/cors": "2.8.19",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/express": "5.0.0", "@types/express": "5.0.0",
"@types/express-session": "^1.18.2",
"@types/jmespath": "^0.15.2", "@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
@ -126,7 +129,7 @@
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@types/semver": "7.7.0", "@types/semver": "^7.7.0",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",

View file

@ -493,6 +493,30 @@ export const idpOrg = pgTable("idpOrg", {
orgMapping: varchar("orgMapping") orgMapping: varchar("orgMapping")
}); });
export const securityKeys = pgTable("webauthnCredentials", {
credentialId: varchar("credentialId").primaryKey(),
userId: varchar("userId").notNull().references(() => users.userId, {
onDelete: "cascade"
}),
publicKey: varchar("publicKey").notNull(),
signCount: integer("signCount").notNull(),
transports: varchar("transports"),
name: varchar("name"),
lastUsed: varchar("lastUsed").notNull(),
dateCreated: varchar("dateCreated").notNull(),
securityKeyName: varchar("securityKeyName")
});
export const webauthnChallenge = pgTable("webauthnChallenge", {
sessionId: varchar("sessionId").primaryKey(),
challenge: varchar("challenge").notNull(),
securityKeyName: varchar("securityKeyName"),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
});
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;

View file

@ -13,6 +13,8 @@ bootstrapVolume();
function createDb() { function createDb() {
const sqlite = new Database(location); const sqlite = new Database(location);
sqlite.pragma('foreign_keys = ON');
sqlite.exec('VACUUM;'); // This will initialize the database file with a valid SQLite header
return DrizzleSqlite(sqlite, { schema }); return DrizzleSqlite(sqlite, { schema });
} }

View file

@ -1,12 +1,45 @@
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import db from "./driver"; import db from "./driver";
import path from "path"; import path from "path";
import { location } from "./driver";
import Database from "better-sqlite3";
import type { Database as BetterSqlite3Database } from "better-sqlite3";
const migrationsFolder = path.join("server/migrations"); const migrationsFolder = path.join("server/migrations");
const dropAllTables = (sqlite: BetterSqlite3Database) => {
console.log("Dropping all existing tables...");
// Disable foreign key checks
sqlite.pragma('foreign_keys = OFF');
// Get all tables
const tables = sqlite.prepare(`
SELECT name FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
`).all() as { name: string }[];
// Drop each table
for (const table of tables) {
console.log(`Dropping table: ${table.name}`);
sqlite.prepare(`DROP TABLE IF EXISTS "${table.name}"`).run();
}
// Re-enable foreign key checks
sqlite.pragma('foreign_keys = ON');
};
const runMigrations = async () => { const runMigrations = async () => {
console.log("Running migrations..."); console.log("Running migrations...");
try { try {
// Initialize the database file with a valid SQLite header
const sqlite = new Database(location) as BetterSqlite3Database;
// Drop all existing tables first
dropAllTables(sqlite);
// Run the migrations
migrate(db as any, { migrate(db as any, {
migrationsFolder: migrationsFolder, migrationsFolder: migrationsFolder,
}); });

View file

@ -139,6 +139,29 @@ export const users = sqliteTable("user", {
.default(false) .default(false)
}); });
export const securityKeys = sqliteTable("webauthnCredentials", {
credentialId: text("credentialId").primaryKey(),
userId: text("userId").notNull().references(() => users.userId, {
onDelete: "cascade"
}),
publicKey: text("publicKey").notNull(),
signCount: integer("signCount").notNull(),
transports: text("transports"),
name: text("name"),
lastUsed: text("lastUsed").notNull(),
dateCreated: text("dateCreated").notNull()
});
export const webauthnChallenge = sqliteTable("webauthnChallenge", {
sessionId: text("sessionId").primaryKey(),
challenge: text("challenge").notNull(),
securityKeyName: text("securityKeyName"),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
expiresAt: integer("expiresAt").notNull() // Unix timestamp
});
export const newts = sqliteTable("newt", { export const newts = sqliteTable("newt", {
newtId: text("id").primaryKey(), newtId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(), secretHash: text("secretHash").notNull(),

View file

@ -35,7 +35,7 @@ declare global {
interface Request { interface Request {
apiKey?: ApiKey; apiKey?: ApiKey;
user?: User; user?: User;
session?: Session; session: Session;
userOrg?: UserOrg; userOrg?: UserOrg;
apiKeyOrg?: ApiKeyOrg; apiKeyOrg?: ApiKeyOrg;
userOrgRoleId?: number; userOrgRoleId?: number;

View file

@ -6,9 +6,10 @@ export * from "./requestTotpSecret";
export * from "./disable2fa"; export * from "./disable2fa";
export * from "./verifyEmail"; export * from "./verifyEmail";
export * from "./requestEmailVerificationCode"; export * from "./requestEmailVerificationCode";
export * from "./changePassword";
export * from "./requestPasswordReset";
export * from "./resetPassword"; export * from "./resetPassword";
export * from "./checkResourceSession"; export * from "./requestPasswordReset";
export * from "./setServerAdmin"; export * from "./setServerAdmin";
export * from "./initialSetupComplete"; export * from "./initialSetupComplete";
export * from "./changePassword";
export * from "./checkResourceSession";
export * from "./securityKey";

View file

@ -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, securityKeys } 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";
@ -32,6 +32,7 @@ export type LoginBody = z.infer<typeof loginBodySchema>;
export type LoginResponse = { export type LoginResponse = {
codeRequested?: boolean; codeRequested?: boolean;
emailVerificationRequired?: boolean; emailVerificationRequired?: boolean;
useSecurityKey?: boolean;
twoFactorSetupRequired?: boolean; twoFactorSetupRequired?: boolean;
}; };
@ -105,6 +106,22 @@ export async function login(
); );
} }
// Check if user has security keys registered
const userSecurityKeys = await db
.select()
.from(securityKeys)
.where(eq(securityKeys.userId, existingUser.userId));
if (userSecurityKeys.length > 0) {
return response<LoginResponse>(res, {
data: { useSecurityKey: true },
success: true,
error: false,
message: "Security key authentication required",
status: HttpCode.OK
});
}
if ( if (
existingUser.twoFactorSetupRequested && existingUser.twoFactorSetupRequested &&
!existingUser.twoFactorEnabled !existingUser.twoFactorEnabled

View file

@ -0,0 +1,683 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { z } from "zod";
import { db } from "@server/db";
import { User, securityKeys, users, webauthnChallenge } from "@server/db";
import { eq, and, lt } from "drizzle-orm";
import { response } from "@server/lib";
import logger from "@server/logger";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from "@simplewebauthn/server";
import type {
GenerateRegistrationOptionsOpts,
VerifyRegistrationResponseOpts,
GenerateAuthenticationOptionsOpts,
VerifyAuthenticationResponseOpts,
VerifiedRegistrationResponse,
VerifiedAuthenticationResponse
} from "@simplewebauthn/server";
import type {
AuthenticatorTransport,
AuthenticatorTransportFuture,
PublicKeyCredentialDescriptorJSON,
PublicKeyCredentialDescriptorFuture
} from "@simplewebauthn/types";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { verifyTotpCode } from "@server/auth/totp";
// The RP ID is the domain name of your application
const rpID = (() => {
const url = new URL(config.getRawConfig().app.dashboard_url);
// For localhost, we must use 'localhost' without port
if (url.hostname === 'localhost') {
return 'localhost';
}
return url.hostname;
})();
const rpName = "Pangolin";
const origin = config.getRawConfig().app.dashboard_url;
// Database-based challenge storage (replaces in-memory storage)
// Challenges are stored in the webauthnChallenge table with automatic expiration
// This supports clustered deployments and persists across server restarts
// Clean up expired challenges every 5 minutes
setInterval(async () => {
try {
const now = Date.now();
await db
.delete(webauthnChallenge)
.where(lt(webauthnChallenge.expiresAt, now));
logger.debug("Cleaned up expired security key challenges");
} catch (error) {
logger.error("Failed to clean up expired security key challenges", error);
}
}, 5 * 60 * 1000);
// Helper functions for challenge management
async function storeChallenge(sessionId: string, challenge: string, securityKeyName?: string, userId?: string) {
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes
// Delete any existing challenge for this session
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
// Insert new challenge
await db.insert(webauthnChallenge).values({
sessionId,
challenge,
securityKeyName,
userId,
expiresAt
});
}
async function getChallenge(sessionId: string) {
const [challengeData] = await db
.select()
.from(webauthnChallenge)
.where(eq(webauthnChallenge.sessionId, sessionId))
.limit(1);
if (!challengeData) {
return null;
}
// Check if expired
if (challengeData.expiresAt < Date.now()) {
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
return null;
}
return challengeData;
}
async function clearChallenge(sessionId: string) {
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
}
export const registerSecurityKeyBody = z.object({
name: z.string().min(1),
password: z.string().min(1)
}).strict();
export const verifyRegistrationBody = z.object({
credential: z.any()
}).strict();
export const startAuthenticationBody = z.object({
email: z.string().email().optional()
}).strict();
export const verifyAuthenticationBody = z.object({
credential: z.any()
}).strict();
export const deleteSecurityKeyBody = z.object({
password: z.string().min(1),
code: z.string().optional()
}).strict();
export async function startRegistration(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = registerSecurityKeyBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { name, password } = parsedBody.data;
const user = req.user as User;
// Only allow internal users to use security keys
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Security keys are only available for internal users"
)
);
}
try {
// Verify password
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(unauthorized());
}
// Get existing security keys for user
const existingSecurityKeys = await db
.select()
.from(securityKeys)
.where(eq(securityKeys.userId, user.userId));
const excludeCredentials = existingSecurityKeys.map(key => ({
id: new Uint8Array(Buffer.from(key.credentialId, 'base64')),
type: 'public-key' as const,
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined
}));
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: user.userId,
userName: user.email || user.username,
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
}
};
const registrationOptions = await generateRegistrationOptions(options);
// Store challenge in database
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
return response<typeof registrationOptions>(res, {
data: registrationOptions,
success: true,
error: false,
message: "Registration options generated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to start registration"
)
);
}
}
export async function verifyRegistration(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = verifyRegistrationBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { credential } = parsedBody.data;
const user = req.user as User;
// Only allow internal users to use security keys
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Security keys are only available for internal users"
)
);
}
try {
// Get challenge from database
const challengeData = await getChallenge(req.session.sessionId);
if (!challengeData) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No challenge found in session or challenge expired"
)
);
}
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Verification failed"
)
);
}
// Store the security key in the database
await db.insert(securityKeys).values({
credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'),
userId: user.userId,
publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'),
signCount: registrationInfo.counter || 0,
transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null,
name: challengeData.securityKeyName,
lastUsed: new Date().toISOString(),
dateCreated: new Date().toISOString()
});
// Clear challenge data
await clearChallenge(req.session.sessionId);
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Security key registered successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to verify registration"
)
);
}
}
export async function listSecurityKeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const user = req.user as User;
// Only allow internal users to use security keys
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Security keys are only available for internal users"
)
);
}
try {
const userSecurityKeys = await db
.select()
.from(securityKeys)
.where(eq(securityKeys.userId, user.userId));
return response<typeof userSecurityKeys>(res, {
data: userSecurityKeys,
success: true,
error: false,
message: "Security keys retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve security keys"
)
);
}
}
export async function deleteSecurityKey(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const { credentialId: encodedCredentialId } = req.params;
const credentialId = decodeURIComponent(encodedCredentialId);
const user = req.user as User;
const parsedBody = deleteSecurityKeyBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { password, code } = parsedBody.data;
// Only allow internal users to use security keys
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Security keys are only available for internal users"
)
);
}
try {
// Verify password
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(unauthorized());
}
// If user has 2FA enabled, require and verify the code
if (user.twoFactorEnabled) {
if (!code) {
return response<{ codeRequested: boolean }>(res, {
data: { codeRequested: true },
success: true,
error: false,
message: "Two-factor authentication required",
status: HttpCode.ACCEPTED
});
}
const validOTP = await verifyTotpCode(
code,
user.twoFactorSecret!,
user.userId
);
if (!validOTP) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"The two-factor code you entered is incorrect"
)
);
}
}
await db
.delete(securityKeys)
.where(and(
eq(securityKeys.credentialId, credentialId),
eq(securityKeys.userId, user.userId)
));
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Security key deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete security key"
)
);
}
}
export async function startAuthentication(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = startAuthenticationBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { email } = parsedBody.data;
try {
let allowCredentials: PublicKeyCredentialDescriptorFuture[] = [];
let userId;
// If email is provided, get security keys for that specific user
if (email) {
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user || user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid credentials"
)
);
}
userId = user.userId;
const userSecurityKeys = await db
.select()
.from(securityKeys)
.where(eq(securityKeys.userId, user.userId));
if (userSecurityKeys.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No security keys registered for this user"
)
);
}
allowCredentials = userSecurityKeys.map(key => ({
id: new Uint8Array(Buffer.from(key.credentialId, 'base64')),
type: 'public-key' as const,
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined
}));
} else {
// If no email provided, allow any security key (for resident key authentication)
allowCredentials = [];
}
const options: GenerateAuthenticationOptionsOpts = {
rpID,
allowCredentials,
userVerification: 'preferred',
};
const authenticationOptions = await generateAuthenticationOptions(options);
// Generate a temporary session ID for unauthenticated users
const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`;
// Store challenge in database
await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId);
return response(res, {
data: { ...authenticationOptions, tempSessionId },
success: true,
error: false,
message: "Authentication options generated",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to generate authentication options"
)
);
}
}
export async function verifyAuthentication(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = verifyAuthenticationBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { credential } = parsedBody.data;
const tempSessionId = req.headers['x-temp-session-id'] as string;
if (!tempSessionId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Your session information is missing. This might happen if you've been inactive for too long or if your browser cleared temporary data. Please start the sign-in process again."
)
);
}
try {
// Get challenge from database
const challengeData = await getChallenge(tempSessionId);
if (!challengeData) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Your sign-in session has expired. For security reasons, you have 5 minutes to complete the authentication process. Please try signing in again."
)
);
}
// Find the security key in database
const credentialId = Buffer.from(credential.id, 'base64').toString('base64');
const [securityKey] = await db
.select()
.from(securityKeys)
.where(eq(securityKeys.credentialId, credentialId))
.limit(1);
if (!securityKey) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"We couldn't verify your security key. This might happen if your device isn't compatible or if the security key was removed too quickly. Please try again and keep your security key connected until the process completes."
)
);
}
// Get the user
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, securityKey.userId))
.limit(1);
if (!user || user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User not found or not authorized for security key authentication"
)
);
}
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: Buffer.from(securityKey.credentialId, 'base64'),
credentialPublicKey: Buffer.from(securityKey.publicKey, 'base64'),
counter: securityKey.signCount,
transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransportFuture[] : undefined
},
requireUserVerification: false
});
const { verified, authenticationInfo } = verification;
if (!verified) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Authentication failed. This could happen if your security key wasn't recognized or was removed too early. Please ensure your security key is properly connected and try again."
)
);
}
// Update sign count
await db
.update(securityKeys)
.set({
signCount: authenticationInfo.newCounter,
lastUsed: new Date().toISOString()
})
.where(eq(securityKeys.credentialId, credentialId));
// Create session for the user
const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app");
const token = generateSessionToken();
const session = await createSession(token, user.userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(session.expiresAt)
);
res.setHeader("Set-Cookie", cookie);
// Clear challenge data
await clearChallenge(tempSessionId);
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Authentication successful",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to verify authentication"
)
);
}
}

View file

@ -823,3 +823,36 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
authRouter.put("/set-server-admin", auth.setServerAdmin); authRouter.put("/set-server-admin", auth.setServerAdmin);
authRouter.get("/initial-setup-complete", auth.initialSetupComplete); authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
// Security Key routes
authRouter.post(
"/security-key/register/start",
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Allow 5 security key registrations per 15 minutes per IP
keyGenerator: (req) => `securityKeyRegister:${req.ip}:${req.user?.userId}`,
handler: (req, res, next) => {
const message = `You can only register ${5} security keys every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
}
}),
verifySessionUserMiddleware,
auth.startRegistration
);
authRouter.post("/security-key/register/verify", verifySessionUserMiddleware, auth.verifyRegistration);
authRouter.post(
"/security-key/authenticate/start",
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
keyGenerator: (req) => `securityKeyAuth:${req.ip}`,
handler: (req, res, next) => {
const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
}
}),
auth.startAuthentication
);
authRouter.post("/security-key/authenticate/verify", auth.verifyAuthentication);
authRouter.get("/security-key/list", verifySessionUserMiddleware, auth.listSecurityKeys);
authRouter.delete("/security-key/:credentialId", verifySessionUserMiddleware, auth.deleteSecurityKey);

View file

@ -23,6 +23,7 @@ import m19 from "./scriptsSqlite/1.3.0";
import m20 from "./scriptsSqlite/1.5.0"; import m20 from "./scriptsSqlite/1.5.0";
import m21 from "./scriptsSqlite/1.6.0"; import m21 from "./scriptsSqlite/1.6.0";
import m22 from "./scriptsSqlite/1.7.0"; import m22 from "./scriptsSqlite/1.7.0";
import m23 from "./scriptsSqlite/1.8.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -45,7 +46,8 @@ const migrations = [
{ version: "1.3.0", run: m19 }, { version: "1.3.0", run: m19 },
{ version: "1.5.0", run: m20 }, { version: "1.5.0", run: m20 },
{ version: "1.6.0", run: m21 }, { version: "1.6.0", run: m21 },
{ version: "1.7.0", run: m22 } { version: "1.7.0", run: m22 },
{ version: "1.8.0", run: m23 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;
@ -81,17 +83,21 @@ export async function runMigrations() {
try { try {
const appVersion = APP_VERSION; const appVersion = APP_VERSION;
if (exists) { // Check if the database file exists and has tables
const hasTables = await db.select().from(versionMigrations).limit(1).catch(() => false);
if (hasTables) {
await executeScripts(); await executeScripts();
} else { } else {
console.log("Running migrations..."); console.log("Running initial migrations...");
try { try {
migrate(db, { migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build migrationsFolder: path.join(APP_PATH, "server", "migrations")
}); });
console.log("Migrations completed successfully."); console.log("Initial migrations completed successfully.");
} catch (error) { } catch (error) {
console.error("Error running migrations:", error); console.error("Error running initial migrations:", error);
throw error;
} }
await db await db

View file

@ -0,0 +1,31 @@
import { db } from "../../db/sqlite";
import { sql } from "drizzle-orm";
const version = "1.4.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
db.transaction((trx) => {
trx.run(sql`CREATE TABLE 'securityKey' (
'credentialId' text PRIMARY KEY NOT NULL,
'userId' text NOT NULL,
'publicKey' text NOT NULL,
'signCount' integer NOT NULL,
'transports' text,
'name' text,
'lastUsed' text NOT NULL,
'dateCreated' text NOT NULL,
FOREIGN KEY ('userId') REFERENCES 'user'('id') ON DELETE CASCADE
);`);
});
console.log(`Migrated database schema`);
} catch (e) {
console.log("Unable to migrate database schema");
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -23,6 +23,25 @@ export default async function migration() {
} catch (e) { } catch (e) {
console.log("Error adding passwordResetTokenExpiryHours column to orgs table:"); console.log("Error adding passwordResetTokenExpiryHours column to orgs table:");
console.log(e); console.log(e);
db.exec(`
CREATE TABLE IF NOT EXISTS securityKey (
credentialId TEXT PRIMARY KEY,
userId TEXT NOT NULL,
publicKey TEXT NOT NULL,
signCount INTEGER NOT NULL,
transports TEXT,
name TEXT,
lastUsed TEXT NOT NULL,
dateCreated TEXT NOT NULL,
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
);
`);
})(); // executes the transaction immediately
db.pragma("foreign_keys = ON");
console.log(`Created securityKey table`);
} catch (e) {
console.error("Unable to create securityKey table");
console.error(e);
throw e; throw e;
} }

View file

@ -0,0 +1,38 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.8.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS securityKeyChallenge (
sessionId TEXT PRIMARY KEY,
challenge TEXT NOT NULL,
securityKeyName TEXT,
userId TEXT,
expiresAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_securityKeyChallenge_expiresAt ON securityKeyChallenge(expiresAt);
`);
})(); // executes the transaction immediately
db.pragma("foreign_keys = ON");
console.log(`Created securityKeyChallenge table`);
} catch (e) {
console.error("Unable to create securityKeyChallenge table");
console.error(e);
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -0,0 +1,27 @@
import { db } from "@server/db";
export default async function migrate() {
try {
console.log("Starting table rename migration...");
// Rename the table
await db.run(`
ALTER TABLE securityKeyChallenge RENAME TO webauthnChallenge;
`);
console.log("Successfully renamed table");
// Rename the index
await db.run(`
DROP INDEX IF EXISTS idx_securityKeyChallenge_expiresAt;
CREATE INDEX IF NOT EXISTS idx_webauthnChallenge_expiresAt ON webauthnChallenge(expiresAt);
`);
console.log("Successfully updated index");
console.log(`Renamed securityKeyChallenge table to webauthnChallenge`);
return true;
} catch (error: any) {
console.error("Unable to rename securityKeyChallenge table:", error);
console.error("Error details:", error.message);
return false;
}
}

View file

@ -4,8 +4,8 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@app/components/ui/input";
import { import {
Form, Form,
FormControl, FormControl,
@ -13,20 +13,20 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@/components/ui/form"; } from "@app/components/ui/form";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle
} from "@/components/ui/card"; } from "@app/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import { LoginResponse } from "@server/routers/auth"; import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { LockIcon } from "lucide-react"; import { LockIcon, FingerprintIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { import {
@ -41,6 +41,7 @@ import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { startAuthentication } from "@simplewebauthn/browser";
export type LoginFormIDP = { export type LoginFormIDP = {
idpId: number; idpId: number;
@ -65,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();
@ -94,30 +96,104 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
} }
}); });
async function initiateSecurityKeyAuth() {
setShowSecurityKeyPrompt(true);
setLoading(true);
setError(null);
try {
// Start WebAuthn authentication without email
const startRes = await api.post("/auth/security-key/authenticate/start", {});
if (!startRes) {
setError(t('securityKeyAuthError', {
defaultValue: "Failed to start security key authentication"
}));
return;
}
const { tempSessionId, ...options } = startRes.data.data;
// Perform WebAuthn authentication
try {
const credential = await startAuthentication(options);
// Verify authentication
const verifyRes = await api.post(
"/auth/security-key/authenticate/verify",
{ credential },
{
headers: {
'X-Temp-Session-Id': tempSessionId
}
}
);
if (verifyRes) {
if (onLogin) {
await onLogin();
}
}
} catch (error: any) {
if (error.name === 'NotAllowedError') {
if (error.message.includes('denied permission')) {
setError(t('securityKeyPermissionDenied', {
defaultValue: "Please allow access to your security key to continue signing in."
}));
} else {
setError(t('securityKeyRemovedTooQuickly', {
defaultValue: "Please keep your security key connected until the sign-in process completes."
}));
}
} else if (error.name === 'NotSupportedError') {
setError(t('securityKeyNotSupported', {
defaultValue: "Your security key may not be compatible. Please try a different security key."
}));
} else {
setError(t('securityKeyUnknownError', {
defaultValue: "There was a problem using your security key. Please try again."
}));
}
}
} catch (e: any) {
if (e.isAxiosError) {
setError(formatAxiosError(e, t('securityKeyAuthError', {
defaultValue: "Failed to authenticate with security key"
})));
} else {
console.error(e);
setError(e.message || t('securityKeyAuthError', {
defaultValue: "Failed to authenticate with security key"
}));
}
} finally {
setLoading(false);
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);
setError(null);
setShowSecurityKeyPrompt(false);
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) {
setError(null);
const data = res.data.data; const data = res.data.data;
if (data?.useSecurityKey) {
await initiateSecurityKeyAuth();
return;
}
if (data?.codeRequested) { if (data?.codeRequested) {
setMfaRequested(true); setMfaRequested(true);
setLoading(false); setLoading(false);
@ -143,10 +219,24 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
if (onLogin) { if (onLogin) {
await onLogin(); await onLogin();
} }
} catch (e: any) {
if (e.isAxiosError) {
const errorMessage = formatAxiosError(e, t('loginError', {
defaultValue: "Failed to log in"
}));
setError(errorMessage);
return;
} else {
console.error(e);
setError(e.message || t('loginError', {
defaultValue: "Failed to log in"
}));
return;
} }
} finally {
setLoading(false); setLoading(false);
} }
}
async function loginWithIdp(idpId: number) { async function loginWithIdp(idpId: number) {
try { try {
@ -173,6 +263,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}>
@ -222,6 +323,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex flex-col space-y-2">
<Button type="submit" disabled={loading}>
{loading ? t('idpConnectingToProcess', {
defaultValue: "Connecting..."
}) : t('login', {
defaultValue: "Log in"
})}
</Button>
</div>
</form> </form>
</Form> </Form>
</> </>
@ -256,9 +367,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
pattern={ pattern={
REGEXP_ONLY_DIGITS_AND_CHARS REGEXP_ONLY_DIGITS_AND_CHARS
} }
onChange={(e) => { onChange={(value: string) => {
field.onChange(e); field.onChange(value);
if (e.length === 6) { if (value.length === 6) {
mfaForm.handleSubmit(onSubmit)(); mfaForm.handleSubmit(onSubmit)();
} }
}} }}
@ -317,14 +428,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{!mfaRequested && ( {!mfaRequested && (
<> <>
<Button <Button
type="submit" type="button"
form="form" variant="outline"
className="w-full" className="w-full"
onClick={initiateSecurityKeyAuth}
loading={loading} loading={loading}
disabled={loading} disabled={loading || showSecurityKeyPrompt}
> >
<LockIcon className="w-4 h-4 mr-2" /> <FingerprintIcon className="w-4 h-4 mr-2" />
{t('login')} {t('securityKeyLogin', {
defaultValue: "Sign in with security key"
})}
</Button> </Button>
{hasIdp && ( {hasIdp && (

View file

@ -20,13 +20,13 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; 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 SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog"; import Enable2FaDialog from "./Enable2FaDialog";
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';
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export default function ProfileIcon() { export default function ProfileIcon() {
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme();
const { env } = useEnvContext(); const { env } = useEnvContext();
@ -40,6 +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 [openSecurityKey, setOpenSecurityKey] = useState(false);
const t = useTranslations(); const t = useTranslations();
@ -73,6 +74,7 @@ export default function ProfileIcon() {
<> <>
<Enable2FaDialog open={openEnable2fa} setOpen={setOpenEnable2fa} /> <Enable2FaDialog open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} /> <Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<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">
@ -130,6 +132,11 @@ export default function ProfileIcon() {
<span>{t('otpDisable')}</span> <span>{t('otpDisable')}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem
onClick={() => setOpenSecurityKey(true)}
>
<span>{t('securityKeyManage')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}

View file

@ -0,0 +1,587 @@
"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;
code?: 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);
const [show2FADialog, setShow2FADialog] = useState(false);
const [deleteInProgress, setDeleteInProgress] = useState(false);
const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState<string | null>(null);
const [pendingDeletePassword, setPendingDeletePassword] = useState<string | 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') }),
code: z.string().optional()
});
const registerForm = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: "",
password: "",
},
});
const deleteForm = useForm<DeleteFormValues>({
resolver: zodResolver(deleteSchema),
defaultValues: {
password: "",
code: ""
},
});
const loadSecurityKeys = async () => {
try {
const response = await api.get("/auth/security-key/list");
setSecurityKeys(response.data.data);
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(error, t('securityKeyLoadError')),
});
}
};
const handleRegisterSecurityKey = async (values: RegisterFormValues) => {
try {
// Check browser compatibility first
if (!window.PublicKeyCredential) {
toast({
variant: "destructive",
description: t('securityKeyBrowserNotSupported', {
defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari."
})
});
return;
}
setIsRegistering(true);
const startRes = await api.post("/auth/security-key/register/start", {
name: values.name,
password: values.password,
});
if (startRes.status === 202) {
toast({
variant: "destructive",
description: t('twoFactorRequired', {
defaultValue: "Two-factor authentication is required to register a security key."
})
});
return;
}
const options = startRes.data.data;
try {
const credential = await startRegistration(options);
await api.post("/auth/security-key/register/verify", {
credential,
});
toast({
description: t('securityKeyRegisterSuccess', {
defaultValue: "Security key registered successfully"
})
});
registerForm.reset();
setShowRegisterDialog(false);
await loadSecurityKeys();
} catch (error: any) {
if (error.name === 'NotAllowedError') {
if (error.message.includes('denied permission')) {
toast({
variant: "destructive",
description: t('securityKeyPermissionDenied', {
defaultValue: "Please allow access to your security key to continue registration."
})
});
} else {
toast({
variant: "destructive",
description: t('securityKeyRemovedTooQuickly', {
defaultValue: "Please keep your security key connected until the registration process completes."
})
});
}
} else if (error.name === 'NotSupportedError') {
toast({
variant: "destructive",
description: t('securityKeyNotSupported', {
defaultValue: "Your security key may not be compatible. Please try a different security key."
})
});
} else {
toast({
variant: "destructive",
description: t('securityKeyUnknownError', {
defaultValue: "There was a problem registering your security key. Please try again."
})
});
}
throw error; // Re-throw to be caught by outer catch
}
} catch (error) {
console.error('Security key registration error:', error);
toast({
variant: "destructive",
description: formatAxiosError(error, t('securityKeyRegisterError', {
defaultValue: "Failed to register security key"
}))
});
} finally {
setIsRegistering(false);
}
};
const handleDeleteSecurityKey = async (values: DeleteFormValues) => {
if (!selectedSecurityKey) return;
try {
setDeleteInProgress(true);
const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId);
const response = await api.delete(`/auth/security-key/${encodedCredentialId}`, {
data: {
password: values.password,
code: values.code
}
});
// If 2FA is required
if (response.status === 202 && response.data.data.codeRequested) {
setPendingDeleteCredentialId(encodedCredentialId);
setPendingDeletePassword(values.password);
setShow2FADialog(true);
return;
}
toast({
description: t('securityKeyRemoveSuccess')
});
deleteForm.reset();
setSelectedSecurityKey(null);
await loadSecurityKeys();
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(error, t('securityKeyRemoveError')),
});
} finally {
setDeleteInProgress(false);
}
};
const handle2FASubmit = async (values: DeleteFormValues) => {
if (!pendingDeleteCredentialId || !pendingDeletePassword) return;
try {
setDeleteInProgress(true);
await api.delete(`/auth/security-key/${pendingDeleteCredentialId}`, {
data: {
password: pendingDeletePassword,
code: values.code
}
});
toast({
description: t('securityKeyRemoveSuccess')
});
deleteForm.reset();
setSelectedSecurityKey(null);
setShow2FADialog(false);
setPendingDeleteCredentialId(null);
setPendingDeletePassword(null);
await loadSecurityKeys();
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(error, t('securityKeyRemoveError')),
});
} finally {
setDeleteInProgress(false);
}
};
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>
<Button
className="h-8 w-8 p-0"
onClick={() => setShowRegisterDialog(true)}
>
<Plus className="h-4 w-4" />
</Button>
</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 }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
{...field}
type="password"
disabled={deleteInProgress}
/>
</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);
}}
disabled={deleteInProgress}
>
{t('cancel')}
</Button>
<Button
type="submit"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteInProgress}
>
{deleteInProgress ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('securityKeyRemoving')}
</>
) : (
t('securityKeyRemove')
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<Dialog open={show2FADialog} onOpenChange={(open) => !open && setShow2FADialog(false)}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Two-Factor Authentication Required</DialogTitle>
<DialogDescription>
Please enter your two-factor authentication code to remove the security key
</DialogDescription>
</DialogHeader>
<Form {...deleteForm}>
<form onSubmit={deleteForm.handleSubmit(handle2FASubmit)} className="space-y-4">
<FormField
control={deleteForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Two-Factor Code</FormLabel>
<FormControl>
<Input
{...field}
type="text"
placeholder="Enter your 6-digit code"
disabled={deleteInProgress}
/>
</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();
setShow2FADialog(false);
setPendingDeleteCredentialId(null);
setPendingDeletePassword(null);
}}
disabled={deleteInProgress}
>
{t('cancel')}
</Button>
<Button
type="submit"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteInProgress}
>
{deleteInProgress ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('securityKeyRemoving')}
</>
) : (
t('securityKeyRemove')
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
}