Explorar o código

Merge pull request #555 from 0xJacky/feat/passkey

feat/passkey
Jacky hai 7 meses
pai
achega
b445a1bb69
Modificáronse 74 ficheiros con 3802 adicións e 1591 borrados
  1. 2 2
      api/api.go
  2. 2 2
      api/system/install.go
  3. 156 0
      api/user/2fa.go
  4. 3 1
      api/user/auth.go
  5. 134 215
      api/user/otp.go
  6. 195 0
      api/user/passkey.go
  7. 17 3
      api/user/router.go
  8. 8 8
      api/user/user.go
  9. 6 0
      app.example.ini
  10. 2 0
      app/components.d.ts
  11. 3 1
      app/package.json
  12. 189 164
      app/pnpm-lock.yaml
  13. 37 0
      app/src/api/2fa.ts
  14. 11 0
      app/src/api/auth.ts
  15. 0 12
      app/src/api/otp.ts
  16. 36 0
      app/src/api/passkey.ts
  17. 136 0
      app/src/components/2FA/Authorization.vue
  18. 22 16
      app/src/components/2FA/use2FAModal.ts
  19. 1 1
      app/src/components/CodeEditor/CodeEditor.vue
  20. 0 78
      app/src/components/OTP/OTPAuthorization.vue
  21. 63 0
      app/src/components/ReactiveFromNow/ReactiveFromNow.vue
  22. 59 4
      app/src/components/SetLanguage/SetLanguage.vue
  23. 1 0
      app/src/components/StdDesign/StdDataDisplay/StdCurd.vue
  24. 1 1
      app/src/components/StdDesign/StdDataDisplay/StdPagination.vue
  25. 172 59
      app/src/language/en/app.po
  26. 174 58
      app/src/language/es/app.po
  27. 172 59
      app/src/language/fr_FR/app.po
  28. 172 59
      app/src/language/ko_KR/app.po
  29. 151 59
      app/src/language/messages.pot
  30. 174 58
      app/src/language/ru_RU/app.po
  31. 172 59
      app/src/language/vi_VN/app.po
  32. BIN=BIN
      app/src/language/zh_CN/app.mo
  33. 175 61
      app/src/language/zh_CN/app.po
  34. 174 58
      app/src/language/zh_TW/app.po
  35. 2 2
      app/src/layouts/SideBar.vue
  36. 2 2
      app/src/lib/http/index.ts
  37. 12 1
      app/src/pinia/moudule/user.ts
  38. 1 2
      app/src/routes/index.ts
  39. 1 1
      app/src/views/config/ConfigEditor.vue
  40. 2 2
      app/src/views/config/components/Mkdir.vue
  41. 2 2
      app/src/views/config/components/Rename.vue
  42. 1 1
      app/src/views/domain/components/RightSettings.vue
  43. 1 1
      app/src/views/domain/ngx_conf/NgxConfigEditor.vue
  44. 1 1
      app/src/views/domain/ngx_conf/NgxServer.vue
  45. 1 1
      app/src/views/domain/ngx_conf/NgxUpstream.vue
  46. 65 6
      app/src/views/other/Login.vue
  47. 6 0
      app/src/views/preference/AuthSettings.vue
  48. 3 3
      app/src/views/preference/Preference.vue
  49. 118 0
      app/src/views/preference/components/AddPasskey.vue
  50. 159 0
      app/src/views/preference/components/Passkey.vue
  51. 8 7
      app/src/views/preference/components/TOTP.vue
  52. 4 4
      app/src/views/pty/Terminal.vue
  53. 1 1
      app/src/views/stream/components/RightSettings.vue
  54. 5 1
      go.mod
  55. 12 252
      go.sum
  56. 4 0
      internal/cache/cache.go
  57. 2 0
      internal/kernal/boot.go
  58. 2 2
      internal/kernal/skip_install.go
  59. 1 1
      internal/middleware/secure_session.go
  60. 46 0
      internal/passkey/webauthn.go
  61. 2 2
      internal/user/login.go
  62. 2 2
      internal/user/otp.go
  63. 7 7
      internal/user/user.go
  64. 43 11
      model/auth.go
  65. 2 1
      model/model.go
  66. 13 0
      model/passkey.go
  67. 157 157
      query/auths.gen.go
  68. 16 8
      query/gen.go
  69. 382 0
      query/passkeys.gen.go
  70. 71 71
      router/routers.go
  71. 1 1
      settings/auth.go
  72. 7 0
      settings/settings.go
  73. 8 0
      settings/settings_test.go
  74. 9 0
      settings/webauthn.go

+ 2 - 2
api/api.go

@@ -12,8 +12,8 @@ import (
 	"strings"
 )
 
-func CurrentUser(c *gin.Context) *model.Auth {
-	return c.MustGet("user").(*model.Auth)
+func CurrentUser(c *gin.Context) *model.User {
+	return c.MustGet("user").(*model.User)
 }
 
 func ErrHandler(c *gin.Context, err error) {

+ 2 - 2
api/system/install.go

@@ -61,8 +61,8 @@ func InstallNginxUI(c *gin.Context) {
 
 	pwd, _ := bcrypt.GenerateFromPassword([]byte(json.Password), bcrypt.DefaultCost)
 
-	u := query.Auth
-	err = u.Create(&model.Auth{
+	u := query.User
+	err = u.Create(&model.User{
 		Name:     json.Username,
 		Password: string(pwd),
 	})

+ 156 - 0
api/user/2fa.go

@@ -0,0 +1,156 @@
+package user
+
+import (
+	"encoding/base64"
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/cache"
+	"github.com/0xJacky/Nginx-UI/internal/passkey"
+	"github.com/0xJacky/Nginx-UI/internal/user"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/gin-gonic/gin"
+	"github.com/go-webauthn/webauthn/webauthn"
+	"github.com/google/uuid"
+	"net/http"
+	"strings"
+	"time"
+)
+
+type Status2FA struct {
+	Enabled       bool `json:"enabled"`
+	OTPStatus     bool `json:"otp_status"`
+	PasskeyStatus bool `json:"passkey_status"`
+}
+
+func get2FAStatus(c *gin.Context) (status Status2FA) {
+	// when accessing the node from the main cluster, there is no user in the context
+	u, ok := c.Get("user")
+	if ok {
+		userPtr := u.(*model.User)
+		status.OTPStatus = userPtr.EnabledOTP()
+		status.PasskeyStatus = userPtr.EnabledPasskey() && passkey.Enabled()
+		status.Enabled = status.OTPStatus || status.PasskeyStatus
+	}
+	return
+}
+
+func Get2FAStatus(c *gin.Context) {
+	c.JSON(http.StatusOK, get2FAStatus(c))
+}
+
+func SecureSessionStatus(c *gin.Context) {
+	status2FA := get2FAStatus(c)
+	if !status2FA.Enabled {
+		c.JSON(http.StatusOK, gin.H{
+			"status": false,
+		})
+		return
+	}
+
+	ssid := c.GetHeader("X-Secure-Session-ID")
+	if ssid == "" {
+		ssid = c.Query("X-Secure-Session-ID")
+	}
+	if ssid == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"status": false,
+		})
+		return
+	}
+
+	u := api.CurrentUser(c)
+
+	c.JSON(http.StatusOK, gin.H{
+		"status": user.VerifySecureSessionID(ssid, u.ID),
+	})
+}
+
+func Start2FASecureSessionByOTP(c *gin.Context) {
+	var json struct {
+		OTP          string `json:"otp"`
+		RecoveryCode string `json:"recovery_code"`
+	}
+	if !api.BindAndValid(c, &json) {
+		return
+	}
+	u := api.CurrentUser(c)
+	if !u.EnabledOTP() {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"message": "User has not configured OTP as 2FA",
+		})
+		return
+	}
+
+	if json.OTP == "" && json.RecoveryCode == "" {
+		c.JSON(http.StatusBadRequest, LoginResponse{
+			Message: "The user has enabled OTP as 2FA",
+		})
+		return
+	}
+
+	if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
+		c.JSON(http.StatusBadRequest, LoginResponse{
+			Message: "Invalid OTP or recovery code",
+		})
+		return
+	}
+
+	sessionId := user.SetSecureSessionID(u.ID)
+
+	c.JSON(http.StatusOK, gin.H{
+		"session_id": sessionId,
+	})
+}
+
+func BeginStart2FASecureSessionByPasskey(c *gin.Context) {
+	if !passkey.Enabled() {
+		api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
+		return
+	}
+	webauthnInstance := passkey.GetInstance()
+	u := api.CurrentUser(c)
+	options, sessionData, err := webauthnInstance.BeginLogin(u)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	passkeySessionID := uuid.NewString()
+	cache.Set(passkeySessionID, sessionData, passkeyTimeout)
+	c.JSON(http.StatusOK, gin.H{
+		"session_id": passkeySessionID,
+		"options":    options,
+	})
+}
+
+func FinishStart2FASecureSessionByPasskey(c *gin.Context) {
+	if !passkey.Enabled() {
+		api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
+		return
+	}
+	passkeySessionID := c.GetHeader("X-Passkey-Session-ID")
+	sessionDataBytes, ok := cache.Get(passkeySessionID)
+	if !ok {
+		api.ErrHandler(c, fmt.Errorf("session not found"))
+		return
+	}
+	sessionData := sessionDataBytes.(*webauthn.SessionData)
+	webauthnInstance := passkey.GetInstance()
+	u := api.CurrentUser(c)
+	credential, err := webauthnInstance.FinishLogin(u, *sessionData, c.Request)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	rawID := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=")
+	p := query.Passkey
+	_, _ = p.Where(p.RawID.Eq(rawID)).Updates(&model.Passkey{
+		LastUsedAt: time.Now().Unix(),
+	})
+
+	sessionId := user.SetSecureSessionID(u.ID)
+
+	c.JSON(http.StatusOK, gin.H{
+		"session_id": sessionId,
+	})
+}

+ 3 - 1
api/user/auth.go

@@ -8,6 +8,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
+	"math/rand/v2"
 	"net/http"
 	"sync"
 	"time"
@@ -67,7 +68,8 @@ func Login(c *gin.Context) {
 
 	u, err := user.Login(json.Name, json.Password)
 	if err != nil {
-		// time.Sleep(5 * time.Second)
+		random := time.Duration(rand.Int() % 10)
+		time.Sleep(random * time.Second)
 		switch {
 		case errors.Is(err, user.ErrPasswordIncorrect):
 			c.JSON(http.StatusForbidden, LoginResponse{

+ 134 - 215
api/user/otp.go

@@ -1,228 +1,147 @@
 package user
 
 import (
-    "bytes"
-    "crypto/sha1"
-    "encoding/base64"
-    "encoding/hex"
-    "fmt"
-    "github.com/0xJacky/Nginx-UI/api"
-    "github.com/0xJacky/Nginx-UI/internal/crypto"
-    "github.com/0xJacky/Nginx-UI/internal/user"
-    "github.com/0xJacky/Nginx-UI/model"
-    "github.com/0xJacky/Nginx-UI/query"
-    "github.com/0xJacky/Nginx-UI/settings"
-    "github.com/gin-gonic/gin"
-    "github.com/pquerna/otp"
-    "github.com/pquerna/otp/totp"
-    "image/jpeg"
-    "net/http"
-    "strings"
+	"bytes"
+	"crypto/sha1"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/crypto"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/0xJacky/Nginx-UI/settings"
+	"github.com/gin-gonic/gin"
+	"github.com/pquerna/otp"
+	"github.com/pquerna/otp/totp"
+	"image/jpeg"
+	"net/http"
+	"strings"
 )
 
 func GenerateTOTP(c *gin.Context) {
-    u := api.CurrentUser(c)
-
-    issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
-    issuer = strings.TrimSpace(issuer)
-
-    otpOpts := totp.GenerateOpts{
-        Issuer:      issuer,
-        AccountName: u.Name,
-        Period:      30, // seconds
-        Digits:      otp.DigitsSix,
-        Algorithm:   otp.AlgorithmSHA1,
-    }
-    otpKey, err := totp.Generate(otpOpts)
-    if err != nil {
-        api.ErrHandler(c, err)
-        return
-    }
-
-    qrCode, err := otpKey.Image(512, 512)
-    if err != nil {
-        api.ErrHandler(c, err)
-        return
-    }
-
-    // Encode the image to a buffer
-    var buf []byte
-    buffer := bytes.NewBuffer(buf)
-    err = jpeg.Encode(buffer, qrCode, nil)
-    if err != nil {
-        fmt.Println("Error encoding image:", err)
-        return
-    }
-
-    // Convert the buffer to a base64 string
-    base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())
-
-    c.JSON(http.StatusOK, gin.H{
-        "secret":  otpKey.Secret(),
-        "qr_code": base64Str,
-    })
+	u := api.CurrentUser(c)
+
+	issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
+	issuer = strings.TrimSpace(issuer)
+
+	otpOpts := totp.GenerateOpts{
+		Issuer:      issuer,
+		AccountName: u.Name,
+		Period:      30, // seconds
+		Digits:      otp.DigitsSix,
+		Algorithm:   otp.AlgorithmSHA1,
+	}
+	otpKey, err := totp.Generate(otpOpts)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	qrCode, err := otpKey.Image(512, 512)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	// Encode the image to a buffer
+	var buf []byte
+	buffer := bytes.NewBuffer(buf)
+	err = jpeg.Encode(buffer, qrCode, nil)
+	if err != nil {
+		fmt.Println("Error encoding image:", err)
+		return
+	}
+
+	// Convert the buffer to a base64 string
+	base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())
+
+	c.JSON(http.StatusOK, gin.H{
+		"secret":  otpKey.Secret(),
+		"qr_code": base64Str,
+	})
 }
 
 func EnrollTOTP(c *gin.Context) {
-    cUser := api.CurrentUser(c)
-    if cUser.EnabledOTP() {
-        c.JSON(http.StatusBadRequest, gin.H{
-            "message": "User already enrolled",
-        })
-        return
-    }
-
-    if settings.ServerSettings.Demo {
-        c.JSON(http.StatusBadRequest, gin.H{
-            "message": "This feature is disabled in demo mode",
-        })
-        return
-    }
-
-    var json struct {
-        Secret   string `json:"secret" binding:"required"`
-        Passcode string `json:"passcode" binding:"required"`
-    }
-    if !api.BindAndValid(c, &json) {
-        return
-    }
-
-    if ok := totp.Validate(json.Passcode, json.Secret); !ok {
-        c.JSON(http.StatusNotAcceptable, gin.H{
-            "message": "Invalid passcode",
-        })
-        return
-    }
-
-    ciphertext, err := crypto.AesEncrypt([]byte(json.Secret))
-    if err != nil {
-        api.ErrHandler(c, err)
-        return
-    }
-
-    u := query.Auth
-    _, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)
-    if err != nil {
-        api.ErrHandler(c, err)
-        return
-    }
-
-    recoveryCode := sha1.Sum(ciphertext)
-
-    c.JSON(http.StatusOK, gin.H{
-        "message":       "ok",
-        "recovery_code": hex.EncodeToString(recoveryCode[:]),
-    })
+	cUser := api.CurrentUser(c)
+	if cUser.EnabledOTP() {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"message": "User already enrolled",
+		})
+		return
+	}
+
+	if settings.ServerSettings.Demo {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"message": "This feature is disabled in demo mode",
+		})
+		return
+	}
+
+	var json struct {
+		Secret   string `json:"secret" binding:"required"`
+		Passcode string `json:"passcode" binding:"required"`
+	}
+	if !api.BindAndValid(c, &json) {
+		return
+	}
+
+	if ok := totp.Validate(json.Passcode, json.Secret); !ok {
+		c.JSON(http.StatusNotAcceptable, gin.H{
+			"message": "Invalid passcode",
+		})
+		return
+	}
+
+	ciphertext, err := crypto.AesEncrypt([]byte(json.Secret))
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	u := query.User
+	_, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	recoveryCode := sha1.Sum(ciphertext)
+
+	c.JSON(http.StatusOK, gin.H{
+		"message":       "ok",
+		"recovery_code": hex.EncodeToString(recoveryCode[:]),
+	})
 }
 
 func ResetOTP(c *gin.Context) {
-    var json struct {
-        RecoveryCode string `json:"recovery_code"`
-    }
-    if !api.BindAndValid(c, &json) {
-        return
-    }
-    recoverCode, err := hex.DecodeString(json.RecoveryCode)
-    if err != nil {
-        api.ErrHandler(c, err)
-        return
-    }
-    cUser := api.CurrentUser(c)
-    k := sha1.Sum(cUser.OTPSecret)
-    if !bytes.Equal(k[:], recoverCode) {
-        c.JSON(http.StatusBadRequest, gin.H{
-            "message": "Invalid recovery code",
-        })
-        return
-    }
-
-    u := query.Auth
-    _, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null())
-    if err != nil {
-        api.ErrHandler(c, err)
-        return
-    }
-
-    c.JSON(http.StatusOK, gin.H{
-        "message": "ok",
-    })
-}
-
-func OTPStatus(c *gin.Context) {
-    status := false
-    u, ok := c.Get("user")
-    if ok {
-        status = u.(*model.Auth).EnabledOTP()
-    }
-    c.JSON(http.StatusOK, gin.H{
-        "status": status,
-    })
-}
-
-func SecureSessionStatus(c *gin.Context) {
-    u, ok := c.Get("user")
-    if !ok || !u.(*model.Auth).EnabledOTP() {
-        c.JSON(http.StatusOK, gin.H{
-            "status": false,
-        })
-        return
-    }
-    ssid := c.GetHeader("X-Secure-Session-ID")
-    if ssid == "" {
-        ssid = c.Query("X-Secure-Session-ID")
-    }
-    if ssid == "" {
-        c.JSON(http.StatusOK, gin.H{
-            "status": false,
-        })
-        return
-    }
-
-    if user.VerifySecureSessionID(ssid, u.(*model.Auth).ID) {
-        c.JSON(http.StatusOK, gin.H{
-            "status": true,
-        })
-        return
-    }
-
-    c.JSON(http.StatusOK, gin.H{
-        "status": false,
-    })
-}
-
-func StartSecure2FASession(c *gin.Context) {
-    var json struct {
-        OTP          string `json:"otp"`
-        RecoveryCode string `json:"recovery_code"`
-    }
-    if !api.BindAndValid(c, &json) {
-        return
-    }
-    u := api.CurrentUser(c)
-    if !u.EnabledOTP() {
-        c.JSON(http.StatusBadRequest, gin.H{
-            "message": "User not configured with 2FA",
-        })
-        return
-    }
-
-    if json.OTP == "" && json.RecoveryCode == "" {
-        c.JSON(http.StatusBadRequest, LoginResponse{
-            Message: "The user has enabled 2FA",
-        })
-        return
-    }
-
-    if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
-        c.JSON(http.StatusBadRequest, LoginResponse{
-            Message: "Invalid 2FA or recovery code",
-        })
-        return
-    }
-
-    sessionId := user.SetSecureSessionID(u.ID)
-
-    c.JSON(http.StatusOK, gin.H{
-        "session_id": sessionId,
-    })
+	var json struct {
+		RecoveryCode string `json:"recovery_code"`
+	}
+	if !api.BindAndValid(c, &json) {
+		return
+	}
+	recoverCode, err := hex.DecodeString(json.RecoveryCode)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	cUser := api.CurrentUser(c)
+	k := sha1.Sum(cUser.OTPSecret)
+	if !bytes.Equal(k[:], recoverCode) {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"message": "Invalid recovery code",
+		})
+		return
+	}
+
+	u := query.User
+	_, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null())
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
 }

+ 195 - 0
api/user/passkey.go

@@ -0,0 +1,195 @@
+package user
+
+import (
+	"encoding/base64"
+	"fmt"
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/cache"
+	"github.com/0xJacky/Nginx-UI/internal/cosy"
+	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/0xJacky/Nginx-UI/internal/passkey"
+	"github.com/0xJacky/Nginx-UI/internal/user"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/gin-gonic/gin"
+	"github.com/go-webauthn/webauthn/webauthn"
+	"github.com/google/uuid"
+	"github.com/spf13/cast"
+	"gorm.io/gorm"
+	"net/http"
+	"strings"
+	"time"
+)
+
+const passkeyTimeout = 30 * time.Second
+
+func buildCachePasskeyRegKey(id int) string {
+	return fmt.Sprintf("passkey-reg-%d", id)
+}
+
+func GetPasskeyConfigStatus(c *gin.Context) {
+	c.JSON(http.StatusOK, gin.H{
+		"status": passkey.Enabled(),
+	})
+}
+
+func BeginPasskeyRegistration(c *gin.Context) {
+	u := api.CurrentUser(c)
+
+	webauthnInstance := passkey.GetInstance()
+
+	options, sessionData, err := webauthnInstance.BeginRegistration(u)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	cache.Set(buildCachePasskeyRegKey(u.ID), sessionData, passkeyTimeout)
+
+	c.JSON(http.StatusOK, options)
+}
+
+func FinishPasskeyRegistration(c *gin.Context) {
+	cUser := api.CurrentUser(c)
+	webauthnInstance := passkey.GetInstance()
+	sessionDataBytes, ok := cache.Get(buildCachePasskeyRegKey(cUser.ID))
+	if !ok {
+		api.ErrHandler(c, fmt.Errorf("session not found"))
+		return
+	}
+
+	sessionData := sessionDataBytes.(*webauthn.SessionData)
+	credential, err := webauthnInstance.FinishRegistration(cUser, *sessionData, c.Request)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	cache.Del(buildCachePasskeyRegKey(cUser.ID))
+
+	rawId := strings.TrimRight(base64.StdEncoding.EncodeToString(credential.ID), "=")
+	passkeyName := c.Query("name")
+	p := query.Passkey
+	err = p.Create(&model.Passkey{
+		UserID:     cUser.ID,
+		Name:       passkeyName,
+		RawID:      rawId,
+		Credential: credential,
+		LastUsedAt: time.Now().Unix(),
+	})
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}
+
+func BeginPasskeyLogin(c *gin.Context) {
+	if !passkey.Enabled() {
+		api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
+		return
+	}
+	webauthnInstance := passkey.GetInstance()
+	options, sessionData, err := webauthnInstance.BeginDiscoverableLogin()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	sessionID := uuid.NewString()
+	cache.Set(sessionID, sessionData, passkeyTimeout)
+
+	c.JSON(http.StatusOK, gin.H{
+		"session_id": sessionID,
+		"options":    options,
+	})
+}
+
+func FinishPasskeyLogin(c *gin.Context) {
+	if !passkey.Enabled() {
+		api.ErrHandler(c, fmt.Errorf("WebAuthn settings are not configured"))
+		return
+	}
+	sessionId := c.GetHeader("X-Passkey-Session-ID")
+	sessionDataBytes, ok := cache.Get(sessionId)
+	if !ok {
+		api.ErrHandler(c, fmt.Errorf("session not found"))
+		return
+	}
+	webauthnInstance := passkey.GetInstance()
+	sessionData := sessionDataBytes.(*webauthn.SessionData)
+	var outUser *model.User
+	_, err := webauthnInstance.FinishDiscoverableLogin(
+		func(rawID, userHandle []byte) (user webauthn.User, err error) {
+			encodeRawID := strings.TrimRight(base64.StdEncoding.EncodeToString(rawID), "=")
+			u := query.User
+			logger.Debug("[WebAuthn] Discoverable Login", cast.ToInt(string(userHandle)))
+
+			p := query.Passkey
+			_, _ = p.Where(p.RawID.Eq(encodeRawID)).Updates(&model.Passkey{
+				LastUsedAt: time.Now().Unix(),
+			})
+
+			outUser, err = u.FirstByID(cast.ToInt(string(userHandle)))
+			return outUser, err
+		}, *sessionData, c.Request)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	b := query.BanIP
+	clientIP := c.ClientIP()
+	// login success, clear banned record
+	_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
+
+	logger.Info("[User Login]", outUser.Name)
+	token, err := user.GenerateJWT(outUser)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, LoginResponse{
+			Message: err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, LoginResponse{
+		Code:    LoginSuccess,
+		Message: "ok",
+		Token:   token,
+		// SecureSessionID: secureSessionID,
+	})
+}
+
+func GetPasskeyList(c *gin.Context) {
+	u := api.CurrentUser(c)
+	p := query.Passkey
+	passkeys, err := p.Where(p.UserID.Eq(u.ID)).Find()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	if len(passkeys) == 0 {
+		passkeys = make([]*model.Passkey, 0)
+	}
+
+	c.JSON(http.StatusOK, passkeys)
+}
+
+func UpdatePasskey(c *gin.Context) {
+	u := api.CurrentUser(c)
+	cosy.Core[model.Passkey](c).
+		SetValidRules(gin.H{
+			"name": "required",
+		}).GormScope(func(tx *gorm.DB) *gorm.DB {
+		return tx.Where("user_id", u.ID)
+	}).Modify()
+}
+
+func DeletePasskey(c *gin.Context) {
+	u := api.CurrentUser(c)
+	cosy.Core[model.Passkey](c).
+		GormScope(func(tx *gorm.DB) *gorm.DB {
+			return tx.Where("user_id", u.ID)
+		}).PermanentlyDelete()
+}

+ 17 - 3
api/user/router.go

@@ -8,8 +8,13 @@ func InitAuthRouter(r *gin.RouterGroup) {
 	r.POST("/login", Login)
 	r.DELETE("/logout", Logout)
 
+	r.GET("/begin_passkey_login", BeginPasskeyLogin)
+	r.POST("/finish_passkey_login", FinishPasskeyLogin)
+
 	r.GET("/casdoor_uri", GetCasdoorUri)
 	r.POST("/casdoor_callback", CasdoorCallback)
+
+	r.GET("/passkeys/config", GetPasskeyConfigStatus)
 }
 
 func InitManageUserRouter(r *gin.RouterGroup) {
@@ -22,11 +27,20 @@ func InitManageUserRouter(r *gin.RouterGroup) {
 }
 
 func InitUserRouter(r *gin.RouterGroup) {
-	r.GET("/otp_status", OTPStatus)
+	r.GET("/2fa_status", Get2FAStatus)
+	r.GET("/2fa_secure_session/status", SecureSessionStatus)
+	r.POST("/2fa_secure_session/otp", Start2FASecureSessionByOTP)
+	r.GET("/2fa_secure_session/passkey", BeginStart2FASecureSessionByPasskey)
+	r.POST("/2fa_secure_session/passkey", FinishStart2FASecureSessionByPasskey)
+
 	r.GET("/otp_secret", GenerateTOTP)
 	r.POST("/otp_enroll", EnrollTOTP)
 	r.POST("/otp_reset", ResetOTP)
 
-	r.GET("/otp_secure_session_status", SecureSessionStatus)
-	r.POST("/otp_secure_session", StartSecure2FASession)
+	r.GET("/begin_passkey_register", BeginPasskeyRegistration)
+	r.POST("/finish_passkey_register", FinishPasskeyRegistration)
+
+	r.GET("/passkeys", GetPasskeyList)
+	r.POST("/passkeys/:id", UpdatePasskey)
+	r.DELETE("/passkeys/:id", DeletePasskey)
 }

+ 8 - 8
api/user/user.go

@@ -13,13 +13,13 @@ import (
 )
 
 func GetUsers(c *gin.Context) {
-	cosy.Core[model.Auth](c).SetFussy("name").PagingList()
+	cosy.Core[model.User](c).SetFussy("name").PagingList()
 }
 
 func GetUser(c *gin.Context) {
 	id := cast.ToInt(c.Param("id"))
 
-	u := query.Auth
+	u := query.User
 
 	user, err := u.FirstByID(id)
 
@@ -43,7 +43,7 @@ func AddUser(c *gin.Context) {
 		return
 	}
 
-	u := query.Auth
+	u := query.User
 
 	pwd, err := bcrypt.GenerateFromPassword([]byte(json.Password), bcrypt.DefaultCost)
 	if err != nil {
@@ -52,7 +52,7 @@ func AddUser(c *gin.Context) {
 	}
 	json.Password = string(pwd)
 
-	user := model.Auth{
+	user := model.User{
 		Name:     json.Name,
 		Password: json.Password,
 	}
@@ -84,14 +84,14 @@ func EditUser(c *gin.Context) {
 		return
 	}
 
-	u := query.Auth
+	u := query.User
 	user, err := u.FirstByID(userId)
 
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
 	}
-	edit := &model.Auth{
+	edit := &model.User{
 		Name: json.Name,
 	}
 
@@ -124,9 +124,9 @@ func DeleteUser(c *gin.Context) {
 		})
 		return
 	}
-	cosy.Core[model.Auth](c).Destroy()
+	cosy.Core[model.User](c).Destroy()
 }
 
 func RecoverUser(c *gin.Context) {
-	cosy.Core[model.Auth](c).Recover()
+	cosy.Core[model.User](c).Recover()
 }

+ 6 - 0
app.example.ini

@@ -17,6 +17,7 @@ CertRenewalInterval  = 7
 RecursiveNameservers = 
 SkipInstallation     = false
 Name                 = 
+InsecureSkipVerify   = false
 
 [nginx]
 AccessLogPath = /var/log/nginx/access.log
@@ -59,3 +60,8 @@ MaxAttempts         = 10
 
 [crypto]
 Secret = secret2
+
+[webauthn]
+RPDisplayName = 
+RPID          = 
+RPOrigins     = 

+ 2 - 0
app/components.d.ts

@@ -81,6 +81,8 @@ declare module 'vue' {
     OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
     OTPOTPAuthorization: typeof import('./src/components/OTP/OTPAuthorization.vue')['default']
     PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
+    PasskeyPasskeyRegistration: typeof import('./src/components/Passkey/PasskeyRegistration.vue')['default']
+    ReactiveFromNowReactiveFromNow: typeof import('./src/components/ReactiveFromNow/ReactiveFromNow.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     SensitiveStringSensitiveString: typeof import('./src/components/SensitiveString/SensitiveString.vue')['default']

+ 3 - 1
app/package.json

@@ -14,6 +14,7 @@
     "@0xjacky/vue-github-button": "^3.1.1",
     "@ant-design/icons-vue": "^7.0.1",
     "@formkit/auto-animate": "^0.8.2",
+    "@simplewebauthn/browser": "^10.0.0",
     "@vue/reactivity": "^3.5.5",
     "@vue/shared": "^3.5.5",
     "@vueuse/components": "^11.0.3",
@@ -46,6 +47,7 @@
   },
   "devDependencies": {
     "@antfu/eslint-config-vue": "^0.43.1",
+    "@simplewebauthn/types": "^10.0.0",
     "@types/lodash": "^4.17.7",
     "@types/nprogress": "^0.2.3",
     "@types/sortablejs": "^1.15.8",
@@ -67,7 +69,7 @@
     "less": "^4.2.0",
     "postcss": "^8.4.47",
     "tailwindcss": "^3.4.11",
-    "typescript": "5.5.4",
+    "typescript": "5.3.3",
     "unplugin-auto-import": "^0.18.3",
     "unplugin-vue-components": "^0.27.4",
     "unplugin-vue-define-options": "^1.5.1",

+ 189 - 164
app/pnpm-lock.yaml

@@ -13,10 +13,13 @@ importers:
         version: 3.1.1
       '@ant-design/icons-vue':
         specifier: ^7.0.1
-        version: 7.0.1(vue@3.5.5(typescript@5.5.4))
+        version: 7.0.1(vue@3.5.5(typescript@5.3.3))
       '@formkit/auto-animate':
         specifier: ^0.8.2
         version: 0.8.2
+      '@simplewebauthn/browser':
+        specifier: ^10.0.0
+        version: 10.0.0
       '@vue/reactivity':
         specifier: ^3.5.5
         version: 3.5.5
@@ -25,13 +28,13 @@ importers:
         version: 3.5.5
       '@vueuse/components':
         specifier: ^11.0.3
-        version: 11.0.3(vue@3.5.5(typescript@5.5.4))
+        version: 11.0.3(vue@3.5.5(typescript@5.3.3))
       '@vueuse/core':
         specifier: ^11.0.3
-        version: 11.0.3(vue@3.5.5(typescript@5.5.4))
+        version: 11.0.3(vue@3.5.5(typescript@5.3.3))
       '@vueuse/integrations':
         specifier: ^11.0.3
-        version: 11.0.3(async-validator@4.2.5)(axios@1.7.7)(nprogress@0.2.0)(sortablejs@1.15.3)(universal-cookie@7.2.0)(vue@3.5.5(typescript@5.5.4))
+        version: 11.0.3(async-validator@4.2.5)(axios@1.7.7)(nprogress@0.2.0)(sortablejs@1.15.3)(universal-cookie@7.2.0)(vue@3.5.5(typescript@5.3.3))
       '@xterm/addon-attach':
         specifier: ^0.11.0
         version: 0.11.0(@xterm/xterm@5.5.0)
@@ -43,7 +46,7 @@ importers:
         version: 5.5.0
       ant-design-vue:
         specifier: ^4.2.4
-        version: 4.2.4(vue@3.5.5(typescript@5.5.4))
+        version: 4.2.4(vue@3.5.5(typescript@5.3.3))
       apexcharts:
         specifier: ^3.53.0
         version: 3.53.0
@@ -67,10 +70,10 @@ importers:
         version: 0.2.0
       pinia:
         specifier: ^2.2.2
-        version: 2.2.2(typescript@5.5.4)(vue@3.5.5(typescript@5.5.4))
+        version: 2.2.2(typescript@5.3.3)(vue@3.5.5(typescript@5.3.3))
       pinia-plugin-persistedstate:
         specifier: ^3.2.3
-        version: 3.2.3(pinia@2.2.2(typescript@5.5.4)(vue@3.5.5(typescript@5.5.4)))
+        version: 3.2.3(pinia@2.2.2(typescript@5.3.3)(vue@3.5.5(typescript@5.3.3)))
       reconnecting-websocket:
         specifier: ^4.4.0
         version: 4.4.0
@@ -85,29 +88,32 @@ importers:
         version: 0.3.6
       vue:
         specifier: ^3.5.5
-        version: 3.5.5(typescript@5.5.4)
+        version: 3.5.5(typescript@5.3.3)
       vue-router:
         specifier: ^4.4.5
-        version: 4.4.5(vue@3.5.5(typescript@5.5.4))
+        version: 4.4.5(vue@3.5.5(typescript@5.3.3))
       vue3-ace-editor:
         specifier: 2.2.4
-        version: 2.2.4(ace-builds@1.36.2)(vue@3.5.5(typescript@5.5.4))
+        version: 2.2.4(ace-builds@1.36.2)(vue@3.5.5(typescript@5.3.3))
       vue3-apexcharts:
         specifier: 1.5.3
-        version: 1.5.3(apexcharts@3.53.0)(vue@3.5.5(typescript@5.5.4))
+        version: 1.5.3(apexcharts@3.53.0)(vue@3.5.5(typescript@5.3.3))
       vue3-gettext:
         specifier: 3.0.0-beta.6
-        version: 3.0.0-beta.6(@vue/compiler-sfc@3.5.5)(typescript@5.5.4)(vue@3.5.5(typescript@5.5.4))
+        version: 3.0.0-beta.6(@vue/compiler-sfc@3.5.5)(typescript@5.3.3)(vue@3.5.5(typescript@5.3.3))
       vue3-otp-input:
         specifier: ^0.5.21
-        version: 0.5.21(vue@3.5.5(typescript@5.5.4))
+        version: 0.5.21(vue@3.5.5(typescript@5.3.3))
       vuedraggable:
         specifier: ^4.1.0
-        version: 4.1.0(vue@3.5.5(typescript@5.5.4))
+        version: 4.1.0(vue@3.5.5(typescript@5.3.3))
     devDependencies:
       '@antfu/eslint-config-vue':
         specifier: ^0.43.1
-        version: 0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.5.4)
+        version: 0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.3.3)
+      '@simplewebauthn/types':
+        specifier: ^10.0.0
+        version: 10.0.0
       '@types/lodash':
         specifier: ^4.17.7
         version: 4.17.7
@@ -119,16 +125,16 @@ importers:
         version: 1.15.8
       '@typescript-eslint/eslint-plugin':
         specifier: ^6.21.0
-        version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)
+        version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)
       '@typescript-eslint/parser':
         specifier: ^6.21.0
-        version: 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+        version: 6.21.0(eslint@8.57.0)(typescript@5.3.3)
       '@vitejs/plugin-vue':
         specifier: ^5.1.3
-        version: 5.1.3(vite@5.4.5(@types/node@22.5.5)(less@4.2.0))(vue@3.5.5(typescript@5.5.4))
+        version: 5.1.3(vite@5.4.5(@types/node@22.5.5)(less@4.2.0))(vue@3.5.5(typescript@5.3.3))
       '@vitejs/plugin-vue-jsx':
         specifier: ^4.0.1
-        version: 4.0.1(vite@5.4.5(@types/node@22.5.5)(less@4.2.0))(vue@3.5.5(typescript@5.5.4))
+        version: 4.0.1(vite@5.4.5(@types/node@22.5.5)(less@4.2.0))(vue@3.5.5(typescript@5.3.3))
       '@vue/compiler-sfc':
         specifier: ^3.5.5
         version: 3.5.5
@@ -146,13 +152,13 @@ importers:
         version: 8.57.0
       eslint-import-resolver-alias:
         specifier: ^1.1.2
-        version: 1.1.2(eslint-plugin-import@2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0))
+        version: 1.1.2(eslint-plugin-import@2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0))
       eslint-import-resolver-typescript:
         specifier: ^3.6.3
-        version: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0)
+        version: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0)
       eslint-plugin-import:
         specifier: ^2.30.0
-        version: 2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0)
+        version: 2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0)
       eslint-plugin-regex:
         specifier: ^1.10.0
         version: 1.10.0(eslint@8.57.0)
@@ -172,26 +178,26 @@ importers:
         specifier: ^3.4.11
         version: 3.4.11
       typescript:
-        specifier: 5.5.4
-        version: 5.5.4
+        specifier: 5.3.3
+        version: 5.3.3
       unplugin-auto-import:
         specifier: ^0.18.3
-        version: 0.18.3(@vueuse/core@11.0.3(vue@3.5.5(typescript@5.5.4)))(rollup@4.21.3)(webpack-sources@3.2.3)
+        version: 0.18.3(@vueuse/core@11.0.3(vue@3.5.5(typescript@5.3.3)))(rollup@4.21.3)(webpack-sources@3.2.3)
       unplugin-vue-components:
         specifier: ^0.27.4
-        version: 0.27.4(@babel/parser@7.25.6)(rollup@4.21.3)(vue@3.5.5(typescript@5.5.4))(webpack-sources@3.2.3)
+        version: 0.27.4(@babel/parser@7.25.6)(rollup@4.21.3)(vue@3.5.5(typescript@5.3.3))(webpack-sources@3.2.3)
       unplugin-vue-define-options:
         specifier: ^1.5.1
-        version: 1.5.1(rollup@4.21.3)(vue@3.5.5(typescript@5.5.4))(webpack-sources@3.2.3)
+        version: 1.5.1(rollup@4.21.3)(vue@3.5.5(typescript@5.3.3))(webpack-sources@3.2.3)
       vite:
         specifier: ^5.4.5
         version: 5.4.5(@types/node@22.5.5)(less@4.2.0)
       vite-svg-loader:
         specifier: ^5.1.0
-        version: 5.1.0(vue@3.5.5(typescript@5.5.4))
+        version: 5.1.0(vue@3.5.5(typescript@5.3.3))
       vue-tsc:
         specifier: ^2.1.6
-        version: 2.1.6(typescript@5.5.4)
+        version: 2.1.6(typescript@5.3.3)
 
 packages:
 
@@ -684,6 +690,12 @@ packages:
   '@simonwep/pickr@1.8.2':
     resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==}
 
+  '@simplewebauthn/browser@10.0.0':
+    resolution: {integrity: sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==}
+
+  '@simplewebauthn/types@10.0.0':
+    resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==}
+
   '@stylistic/eslint-plugin-js@0.0.4':
     resolution: {integrity: sha512-W1rq2xxlFNhgZZJO+L59wtvlDI0xARYxx0WD8EeWNBO7NDybUSYSozCIcY9XvxQbTAsEXBjwqokeYm0crt7RxQ==}
 
@@ -2908,6 +2920,11 @@ packages:
     resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==}
     engines: {node: '>= 0.4'}
 
+  typescript@5.3.3:
+    resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
   typescript@5.5.4:
     resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==}
     engines: {node: '>=14.17'}
@@ -3178,20 +3195,20 @@ snapshots:
 
   '@ant-design/icons-svg@4.4.2': {}
 
-  '@ant-design/icons-vue@7.0.1(vue@3.5.5(typescript@5.5.4))':
+  '@ant-design/icons-vue@7.0.1(vue@3.5.5(typescript@5.3.3))':
     dependencies:
       '@ant-design/colors': 6.0.0
       '@ant-design/icons-svg': 4.4.2
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
-  '@antfu/eslint-config-basic@0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.5.4)':
+  '@antfu/eslint-config-basic@0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
       '@stylistic/eslint-plugin-js': 0.0.4
       eslint: 8.57.0
-      eslint-plugin-antfu: 0.43.1(eslint@8.57.0)(typescript@5.5.4)
+      eslint-plugin-antfu: 0.43.1(eslint@8.57.0)(typescript@5.3.3)
       eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0)
       eslint-plugin-html: 7.1.0
-      eslint-plugin-import: eslint-plugin-i@2.28.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)
+      eslint-plugin-import: eslint-plugin-i@2.28.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)
       eslint-plugin-jsdoc: 46.10.1(eslint@8.57.0)
       eslint-plugin-jsonc: 2.16.0(eslint@8.57.0)
       eslint-plugin-markdown: 3.0.1(eslint@8.57.0)
@@ -3199,7 +3216,7 @@ snapshots:
       eslint-plugin-no-only-tests: 3.3.0
       eslint-plugin-promise: 6.6.0(eslint@8.57.0)
       eslint-plugin-unicorn: 48.0.1(eslint@8.57.0)
-      eslint-plugin-unused-imports: 3.2.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)
+      eslint-plugin-unused-imports: 3.2.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)
       eslint-plugin-yml: 1.14.0(eslint@8.57.0)
       jsonc-eslint-parser: 2.4.0
       yaml-eslint-parser: 1.2.3
@@ -3211,25 +3228,25 @@ snapshots:
       - supports-color
       - typescript
 
-  '@antfu/eslint-config-ts@0.43.1(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.5.4)':
+  '@antfu/eslint-config-ts@0.43.1(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
-      '@antfu/eslint-config-basic': 0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.5.4)
-      '@stylistic/eslint-plugin-ts': 0.0.4(eslint@8.57.0)(typescript@5.5.4)
-      '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)
-      '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+      '@antfu/eslint-config-basic': 0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.3.3)
+      '@stylistic/eslint-plugin-ts': 0.0.4(eslint@8.57.0)(typescript@5.3.3)
+      '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)
+      '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
       eslint: 8.57.0
-      eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)
-      typescript: 5.5.4
+      eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)
+      typescript: 5.3.3
     transitivePeerDependencies:
       - eslint-import-resolver-typescript
       - eslint-import-resolver-webpack
       - jest
       - supports-color
 
-  '@antfu/eslint-config-vue@0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.5.4)':
+  '@antfu/eslint-config-vue@0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
-      '@antfu/eslint-config-basic': 0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.5.4)
-      '@antfu/eslint-config-ts': 0.43.1(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.5.4)
+      '@antfu/eslint-config-basic': 0.43.1(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.3.3)
+      '@antfu/eslint-config-ts': 0.43.1(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)(typescript@5.3.3)
       eslint: 8.57.0
       eslint-plugin-vue: 9.28.0(eslint@8.57.0)
       local-pkg: 0.4.3
@@ -3651,6 +3668,12 @@ snapshots:
       core-js: 3.38.1
       nanopop: 2.4.2
 
+  '@simplewebauthn/browser@10.0.0':
+    dependencies:
+      '@simplewebauthn/types': 10.0.0
+
+  '@simplewebauthn/types@10.0.0': {}
+
   '@stylistic/eslint-plugin-js@0.0.4':
     dependencies:
       acorn: 8.12.1
@@ -3660,15 +3683,15 @@ snapshots:
       esutils: 2.0.3
       graphemer: 1.4.0
 
-  '@stylistic/eslint-plugin-ts@0.0.4(eslint@8.57.0)(typescript@5.5.4)':
+  '@stylistic/eslint-plugin-ts@0.0.4(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
       '@stylistic/eslint-plugin-js': 0.0.4
       '@typescript-eslint/scope-manager': 6.21.0
-      '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
-      '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
+      '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
       eslint: 8.57.0
       graphemer: 1.4.0
-      typescript: 5.5.4
+      typescript: 5.3.3
     transitivePeerDependencies:
       - supports-color
 
@@ -3717,13 +3740,13 @@ snapshots:
 
   '@types/web-bluetooth@0.0.20': {}
 
-  '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)':
+  '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
       '@eslint-community/regexpp': 4.11.1
-      '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
       '@typescript-eslint/scope-manager': 6.21.0
-      '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
-      '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
+      '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
       '@typescript-eslint/visitor-keys': 6.21.0
       debug: 4.3.7
       eslint: 8.57.0
@@ -3731,22 +3754,22 @@ snapshots:
       ignore: 5.3.2
       natural-compare: 1.4.0
       semver: 7.6.3
-      ts-api-utils: 1.3.0(typescript@5.5.4)
+      ts-api-utils: 1.3.0(typescript@5.3.3)
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4)':
+  '@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
       '@typescript-eslint/scope-manager': 6.21.0
       '@typescript-eslint/types': 6.21.0
-      '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.5.4)
+      '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
       '@typescript-eslint/visitor-keys': 6.21.0
       debug: 4.3.7
       eslint: 8.57.0
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
     transitivePeerDependencies:
       - supports-color
 
@@ -3760,15 +3783,15 @@ snapshots:
       '@typescript-eslint/types': 6.21.0
       '@typescript-eslint/visitor-keys': 6.21.0
 
-  '@typescript-eslint/type-utils@6.21.0(eslint@8.57.0)(typescript@5.5.4)':
+  '@typescript-eslint/type-utils@6.21.0(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
-      '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.5.4)
-      '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
+      '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
       debug: 4.3.7
       eslint: 8.57.0
-      ts-api-utils: 1.3.0(typescript@5.5.4)
+      ts-api-utils: 1.3.0(typescript@5.3.3)
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
     transitivePeerDependencies:
       - supports-color
 
@@ -3776,7 +3799,7 @@ snapshots:
 
   '@typescript-eslint/types@6.21.0': {}
 
-  '@typescript-eslint/typescript-estree@5.62.0(typescript@5.5.4)':
+  '@typescript-eslint/typescript-estree@5.62.0(typescript@5.3.3)':
     dependencies:
       '@typescript-eslint/types': 5.62.0
       '@typescript-eslint/visitor-keys': 5.62.0
@@ -3784,13 +3807,13 @@ snapshots:
       globby: 11.1.0
       is-glob: 4.0.3
       semver: 7.6.3
-      tsutils: 3.21.0(typescript@5.5.4)
+      tsutils: 3.21.0(typescript@5.3.3)
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/typescript-estree@6.21.0(typescript@5.5.4)':
+  '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)':
     dependencies:
       '@typescript-eslint/types': 6.21.0
       '@typescript-eslint/visitor-keys': 6.21.0
@@ -3799,20 +3822,20 @@ snapshots:
       is-glob: 4.0.3
       minimatch: 9.0.3
       semver: 7.6.3
-      ts-api-utils: 1.3.0(typescript@5.5.4)
+      ts-api-utils: 1.3.0(typescript@5.3.3)
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/utils@5.62.0(eslint@8.57.0)(typescript@5.5.4)':
+  '@typescript-eslint/utils@5.62.0(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
       '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0)
       '@types/json-schema': 7.0.15
       '@types/semver': 7.5.8
       '@typescript-eslint/scope-manager': 5.62.0
       '@typescript-eslint/types': 5.62.0
-      '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4)
+      '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.3.3)
       eslint: 8.57.0
       eslint-scope: 5.1.1
       semver: 7.6.3
@@ -3820,14 +3843,14 @@ snapshots:
       - supports-color
       - typescript
 
-  '@typescript-eslint/utils@6.21.0(eslint@8.57.0)(typescript@5.5.4)':
+  '@typescript-eslint/utils@6.21.0(eslint@8.57.0)(typescript@5.3.3)':
     dependencies:
       '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0)
       '@types/json-schema': 7.0.15
       '@types/semver': 7.5.8
       '@typescript-eslint/scope-manager': 6.21.0
       '@typescript-eslint/types': 6.21.0
-      '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.5.4)
+      '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
       eslint: 8.57.0
       semver: 7.6.3
     transitivePeerDependencies:
@@ -3846,20 +3869,20 @@ snapshots:
 
   '@ungap/structured-clone@1.2.0': {}
 
-  '@vitejs/plugin-vue-jsx@4.0.1(vite@5.4.5(@types/node@22.5.5)(less@4.2.0))(vue@3.5.5(typescript@5.5.4))':
+  '@vitejs/plugin-vue-jsx@4.0.1(vite@5.4.5(@types/node@22.5.5)(less@4.2.0))(vue@3.5.5(typescript@5.3.3))':
     dependencies:
       '@babel/core': 7.25.2
       '@babel/plugin-transform-typescript': 7.25.2(@babel/core@7.25.2)
       '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.25.2)
       vite: 5.4.5(@types/node@22.5.5)(less@4.2.0)
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
     transitivePeerDependencies:
       - supports-color
 
-  '@vitejs/plugin-vue@5.1.3(vite@5.4.5(@types/node@22.5.5)(less@4.2.0))(vue@3.5.5(typescript@5.5.4))':
+  '@vitejs/plugin-vue@5.1.3(vite@5.4.5(@types/node@22.5.5)(less@4.2.0))(vue@3.5.5(typescript@5.3.3))':
     dependencies:
       vite: 5.4.5(@types/node@22.5.5)(less@4.2.0)
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
   '@volar/language-core@2.4.5':
     dependencies:
@@ -3873,7 +3896,7 @@ snapshots:
       path-browserify: 1.0.1
       vscode-uri: 3.0.8
 
-  '@vue-macros/common@1.14.0(rollup@4.21.3)(vue@3.5.5(typescript@5.5.4))':
+  '@vue-macros/common@1.14.0(rollup@4.21.3)(vue@3.5.5(typescript@5.3.3))':
     dependencies:
       '@babel/types': 7.25.6
       '@rollup/pluginutils': 5.1.0(rollup@4.21.3)
@@ -3882,7 +3905,7 @@ snapshots:
       local-pkg: 0.5.0
       magic-string-ast: 0.6.2
     optionalDependencies:
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
     transitivePeerDependencies:
       - rollup
 
@@ -3953,7 +3976,7 @@ snapshots:
 
   '@vue/devtools-api@6.6.4': {}
 
-  '@vue/language-core@2.1.6(typescript@5.5.4)':
+  '@vue/language-core@2.1.6(typescript@5.3.3)':
     dependencies:
       '@volar/language-core': 2.4.5
       '@vue/compiler-dom': 3.5.5
@@ -3964,7 +3987,7 @@ snapshots:
       muggle-string: 0.4.1
       path-browserify: 1.0.1
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
 
   '@vue/reactivity@3.5.5':
     dependencies:
@@ -3982,40 +4005,40 @@ snapshots:
       '@vue/shared': 3.5.5
       csstype: 3.1.3
 
-  '@vue/server-renderer@3.5.5(vue@3.5.5(typescript@5.5.4))':
+  '@vue/server-renderer@3.5.5(vue@3.5.5(typescript@5.3.3))':
     dependencies:
       '@vue/compiler-ssr': 3.5.5
       '@vue/shared': 3.5.5
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
   '@vue/shared@3.5.5': {}
 
   '@vue/tsconfig@0.5.1': {}
 
-  '@vueuse/components@11.0.3(vue@3.5.5(typescript@5.5.4))':
+  '@vueuse/components@11.0.3(vue@3.5.5(typescript@5.3.3))':
     dependencies:
-      '@vueuse/core': 11.0.3(vue@3.5.5(typescript@5.5.4))
-      '@vueuse/shared': 11.0.3(vue@3.5.5(typescript@5.5.4))
-      vue-demi: 0.14.10(vue@3.5.5(typescript@5.5.4))
+      '@vueuse/core': 11.0.3(vue@3.5.5(typescript@5.3.3))
+      '@vueuse/shared': 11.0.3(vue@3.5.5(typescript@5.3.3))
+      vue-demi: 0.14.10(vue@3.5.5(typescript@5.3.3))
     transitivePeerDependencies:
       - '@vue/composition-api'
       - vue
 
-  '@vueuse/core@11.0.3(vue@3.5.5(typescript@5.5.4))':
+  '@vueuse/core@11.0.3(vue@3.5.5(typescript@5.3.3))':
     dependencies:
       '@types/web-bluetooth': 0.0.20
       '@vueuse/metadata': 11.0.3
-      '@vueuse/shared': 11.0.3(vue@3.5.5(typescript@5.5.4))
-      vue-demi: 0.14.10(vue@3.5.5(typescript@5.5.4))
+      '@vueuse/shared': 11.0.3(vue@3.5.5(typescript@5.3.3))
+      vue-demi: 0.14.10(vue@3.5.5(typescript@5.3.3))
     transitivePeerDependencies:
       - '@vue/composition-api'
       - vue
 
-  '@vueuse/integrations@11.0.3(async-validator@4.2.5)(axios@1.7.7)(nprogress@0.2.0)(sortablejs@1.15.3)(universal-cookie@7.2.0)(vue@3.5.5(typescript@5.5.4))':
+  '@vueuse/integrations@11.0.3(async-validator@4.2.5)(axios@1.7.7)(nprogress@0.2.0)(sortablejs@1.15.3)(universal-cookie@7.2.0)(vue@3.5.5(typescript@5.3.3))':
     dependencies:
-      '@vueuse/core': 11.0.3(vue@3.5.5(typescript@5.5.4))
-      '@vueuse/shared': 11.0.3(vue@3.5.5(typescript@5.5.4))
-      vue-demi: 0.14.10(vue@3.5.5(typescript@5.5.4))
+      '@vueuse/core': 11.0.3(vue@3.5.5(typescript@5.3.3))
+      '@vueuse/shared': 11.0.3(vue@3.5.5(typescript@5.3.3))
+      vue-demi: 0.14.10(vue@3.5.5(typescript@5.3.3))
     optionalDependencies:
       async-validator: 4.2.5
       axios: 1.7.7
@@ -4028,9 +4051,9 @@ snapshots:
 
   '@vueuse/metadata@11.0.3': {}
 
-  '@vueuse/shared@11.0.3(vue@3.5.5(typescript@5.5.4))':
+  '@vueuse/shared@11.0.3(vue@3.5.5(typescript@5.3.3))':
     dependencies:
-      vue-demi: 0.14.10(vue@3.5.5(typescript@5.5.4))
+      vue-demi: 0.14.10(vue@3.5.5(typescript@5.3.3))
     transitivePeerDependencies:
       - '@vue/composition-api'
       - vue
@@ -4076,10 +4099,10 @@ snapshots:
 
   ansi-styles@6.2.1: {}
 
-  ant-design-vue@4.2.4(vue@3.5.5(typescript@5.5.4)):
+  ant-design-vue@4.2.4(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       '@ant-design/colors': 6.0.0
-      '@ant-design/icons-vue': 7.0.1(vue@3.5.5(typescript@5.5.4))
+      '@ant-design/icons-vue': 7.0.1(vue@3.5.5(typescript@5.3.3))
       '@babel/runtime': 7.25.6
       '@ctrl/tinycolor': 3.6.1
       '@emotion/hash': 0.9.2
@@ -4098,8 +4121,8 @@ snapshots:
       shallow-equal: 1.2.1
       stylis: 4.3.4
       throttle-debounce: 5.0.2
-      vue: 3.5.5(typescript@5.5.4)
-      vue-types: 3.0.2(vue@3.5.5(typescript@5.5.4))
+      vue: 3.5.5(typescript@5.3.3)
+      vue-types: 3.0.2(vue@3.5.5(typescript@5.3.3))
       warning: 4.0.3
 
   any-promise@1.3.0: {}
@@ -4347,14 +4370,14 @@ snapshots:
 
   core-js@3.38.1: {}
 
-  cosmiconfig@9.0.0(typescript@5.5.4):
+  cosmiconfig@9.0.0(typescript@5.3.3):
     dependencies:
       env-paths: 2.2.1
       import-fresh: 3.3.0
       js-yaml: 4.1.0
       parse-json: 5.2.0
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
 
   crc-32@1.2.2: {}
 
@@ -4624,9 +4647,9 @@ snapshots:
       eslint: 8.57.0
       semver: 7.6.3
 
-  eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0)):
+  eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0)):
     dependencies:
-      eslint-plugin-import: 2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0)
+      eslint-plugin-import: 2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0)
 
   eslint-import-resolver-node@0.3.9:
     dependencies:
@@ -4636,39 +4659,39 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0):
+  eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0):
     dependencies:
       '@nolyfill/is-core-module': 1.0.39
       debug: 4.3.7
       enhanced-resolve: 5.17.1
       eslint: 8.57.0
-      eslint-module-utils: 2.11.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)
+      eslint-module-utils: 2.11.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)
       fast-glob: 3.3.2
       get-tsconfig: 4.8.1
       is-bun-module: 1.2.1
       is-glob: 4.0.3
     optionalDependencies:
-      eslint-plugin-import: 2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0)
+      eslint-plugin-import: 2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0)
     transitivePeerDependencies:
       - '@typescript-eslint/parser'
       - eslint-import-resolver-node
       - eslint-import-resolver-webpack
       - supports-color
 
-  eslint-module-utils@2.11.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0):
+  eslint-module-utils@2.11.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0):
     dependencies:
       debug: 3.2.7
     optionalDependencies:
-      '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
       eslint: 8.57.0
       eslint-import-resolver-node: 0.3.9
-      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0)
+      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0)
     transitivePeerDependencies:
       - supports-color
 
-  eslint-plugin-antfu@0.43.1(eslint@8.57.0)(typescript@5.5.4):
+  eslint-plugin-antfu@0.43.1(eslint@8.57.0)(typescript@5.3.3):
     dependencies:
-      '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
     transitivePeerDependencies:
       - eslint
       - supports-color
@@ -4691,13 +4714,13 @@ snapshots:
     dependencies:
       htmlparser2: 8.0.2
 
-  eslint-plugin-i@2.28.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0):
+  eslint-plugin-i@2.28.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0):
     dependencies:
       debug: 3.2.7
       doctrine: 2.1.0
       eslint: 8.57.0
       eslint-import-resolver-node: 0.3.9
-      eslint-module-utils: 2.11.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)
+      eslint-module-utils: 2.11.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)
       get-tsconfig: 4.8.1
       is-glob: 4.0.3
       minimatch: 3.1.2
@@ -4709,7 +4732,7 @@ snapshots:
       - eslint-import-resolver-webpack
       - supports-color
 
-  eslint-plugin-import@2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0):
+  eslint-plugin-import@2.30.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0):
     dependencies:
       '@rtsao/scc': 1.1.0
       array-includes: 3.1.8
@@ -4720,7 +4743,7 @@ snapshots:
       doctrine: 2.1.0
       eslint: 8.57.0
       eslint-import-resolver-node: 0.3.9
-      eslint-module-utils: 2.11.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)
+      eslint-module-utils: 2.11.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0)
       hasown: 2.0.2
       is-core-module: 2.15.1
       is-glob: 4.0.3
@@ -4731,18 +4754,18 @@ snapshots:
       semver: 6.3.1
       tsconfig-paths: 3.15.0
     optionalDependencies:
-      '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.3.3)
     transitivePeerDependencies:
       - eslint-import-resolver-typescript
       - eslint-import-resolver-webpack
       - supports-color
 
-  eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4):
+  eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3):
     dependencies:
-      '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.3.3)
       eslint: 8.57.0
     optionalDependencies:
-      '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)
     transitivePeerDependencies:
       - supports-color
       - typescript
@@ -4828,12 +4851,12 @@ snapshots:
       semver: 7.6.3
       strip-indent: 3.0.0
 
-  eslint-plugin-unused-imports@3.2.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0):
+  eslint-plugin-unused-imports@3.2.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0):
     dependencies:
       eslint: 8.57.0
       eslint-rule-composer: 0.3.0
     optionalDependencies:
-      '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)
+      '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)
 
   eslint-plugin-vue@9.28.0(eslint@8.57.0):
     dependencies:
@@ -5054,7 +5077,7 @@ snapshots:
       glob: 7.2.3
       parse5: 6.0.1
       pofile: 1.0.11
-      typescript: 5.5.4
+      typescript: 5.3.3
 
   github-buttons@2.29.0: {}
 
@@ -5659,17 +5682,17 @@ snapshots:
 
   pify@4.0.1: {}
 
-  pinia-plugin-persistedstate@3.2.3(pinia@2.2.2(typescript@5.5.4)(vue@3.5.5(typescript@5.5.4))):
+  pinia-plugin-persistedstate@3.2.3(pinia@2.2.2(typescript@5.3.3)(vue@3.5.5(typescript@5.3.3))):
     dependencies:
-      pinia: 2.2.2(typescript@5.5.4)(vue@3.5.5(typescript@5.5.4))
+      pinia: 2.2.2(typescript@5.3.3)(vue@3.5.5(typescript@5.3.3))
 
-  pinia@2.2.2(typescript@5.5.4)(vue@3.5.5(typescript@5.5.4)):
+  pinia@2.2.2(typescript@5.3.3)(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       '@vue/devtools-api': 6.6.4
-      vue: 3.5.5(typescript@5.5.4)
-      vue-demi: 0.14.10(vue@3.5.5(typescript@5.5.4))
+      vue: 3.5.5(typescript@5.3.3)
+      vue-demi: 0.14.10(vue@3.5.5(typescript@5.3.3))
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
 
   pirates@4.0.6: {}
 
@@ -6103,9 +6126,9 @@ snapshots:
     dependencies:
       is-number: 7.0.0
 
-  ts-api-utils@1.3.0(typescript@5.5.4):
+  ts-api-utils@1.3.0(typescript@5.3.3):
     dependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
 
   ts-interface-checker@0.1.13: {}
 
@@ -6120,10 +6143,10 @@ snapshots:
 
   tslib@2.7.0: {}
 
-  tsutils@3.21.0(typescript@5.5.4):
+  tsutils@3.21.0(typescript@5.3.3):
     dependencies:
       tslib: 1.14.1
-      typescript: 5.5.4
+      typescript: 5.3.3
 
   type-check@0.4.0:
     dependencies:
@@ -6167,6 +6190,8 @@ snapshots:
       is-typed-array: 1.1.13
       possible-typed-array-names: 1.0.0
 
+  typescript@5.3.3: {}
+
   typescript@5.5.4: {}
 
   typical@4.0.0: {}
@@ -6210,7 +6235,7 @@ snapshots:
       '@types/cookie': 0.6.0
       cookie: 0.6.0
 
-  unplugin-auto-import@0.18.3(@vueuse/core@11.0.3(vue@3.5.5(typescript@5.5.4)))(rollup@4.21.3)(webpack-sources@3.2.3):
+  unplugin-auto-import@0.18.3(@vueuse/core@11.0.3(vue@3.5.5(typescript@5.3.3)))(rollup@4.21.3)(webpack-sources@3.2.3):
     dependencies:
       '@antfu/utils': 0.7.10
       '@rollup/pluginutils': 5.1.0(rollup@4.21.3)
@@ -6221,12 +6246,12 @@ snapshots:
       unimport: 3.12.0(rollup@4.21.3)(webpack-sources@3.2.3)
       unplugin: 1.14.1(webpack-sources@3.2.3)
     optionalDependencies:
-      '@vueuse/core': 11.0.3(vue@3.5.5(typescript@5.5.4))
+      '@vueuse/core': 11.0.3(vue@3.5.5(typescript@5.3.3))
     transitivePeerDependencies:
       - rollup
       - webpack-sources
 
-  unplugin-vue-components@0.27.4(@babel/parser@7.25.6)(rollup@4.21.3)(vue@3.5.5(typescript@5.5.4))(webpack-sources@3.2.3):
+  unplugin-vue-components@0.27.4(@babel/parser@7.25.6)(rollup@4.21.3)(vue@3.5.5(typescript@5.3.3))(webpack-sources@3.2.3):
     dependencies:
       '@antfu/utils': 0.7.10
       '@rollup/pluginutils': 5.1.0(rollup@4.21.3)
@@ -6238,7 +6263,7 @@ snapshots:
       minimatch: 9.0.5
       mlly: 1.7.1
       unplugin: 1.14.1(webpack-sources@3.2.3)
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
     optionalDependencies:
       '@babel/parser': 7.25.6
     transitivePeerDependencies:
@@ -6246,9 +6271,9 @@ snapshots:
       - supports-color
       - webpack-sources
 
-  unplugin-vue-define-options@1.5.1(rollup@4.21.3)(vue@3.5.5(typescript@5.5.4))(webpack-sources@3.2.3):
+  unplugin-vue-define-options@1.5.1(rollup@4.21.3)(vue@3.5.5(typescript@5.3.3))(webpack-sources@3.2.3):
     dependencies:
-      '@vue-macros/common': 1.14.0(rollup@4.21.3)(vue@3.5.5(typescript@5.5.4))
+      '@vue-macros/common': 1.14.0(rollup@4.21.3)(vue@3.5.5(typescript@5.3.3))
       ast-walker-scope: 0.6.2
       unplugin: 1.14.1(webpack-sources@3.2.3)
     transitivePeerDependencies:
@@ -6287,10 +6312,10 @@ snapshots:
       node-object-hash: 3.0.0
       typescript: 5.5.4
 
-  vite-svg-loader@5.1.0(vue@3.5.5(typescript@5.5.4)):
+  vite-svg-loader@5.1.0(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       svgo: 3.3.2
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
   vite@5.4.5(@types/node@22.5.5)(less@4.2.0):
     dependencies:
@@ -6304,9 +6329,9 @@ snapshots:
 
   vscode-uri@3.0.8: {}
 
-  vue-demi@0.14.10(vue@3.5.5(typescript@5.5.4)):
+  vue-demi@0.14.10(vue@3.5.5(typescript@5.3.3)):
     dependencies:
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
   vue-eslint-parser@9.4.3(eslint@8.57.0):
     dependencies:
@@ -6321,67 +6346,67 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  vue-router@4.4.5(vue@3.5.5(typescript@5.5.4)):
+  vue-router@4.4.5(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       '@vue/devtools-api': 6.6.4
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
-  vue-tsc@2.1.6(typescript@5.5.4):
+  vue-tsc@2.1.6(typescript@5.3.3):
     dependencies:
       '@volar/typescript': 2.4.5
-      '@vue/language-core': 2.1.6(typescript@5.5.4)
+      '@vue/language-core': 2.1.6(typescript@5.3.3)
       semver: 7.6.3
-      typescript: 5.5.4
+      typescript: 5.3.3
 
-  vue-types@3.0.2(vue@3.5.5(typescript@5.5.4)):
+  vue-types@3.0.2(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       is-plain-object: 3.0.1
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
-  vue3-ace-editor@2.2.4(ace-builds@1.36.2)(vue@3.5.5(typescript@5.5.4)):
+  vue3-ace-editor@2.2.4(ace-builds@1.36.2)(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       ace-builds: 1.36.2
       resize-observer-polyfill: 1.5.1
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
-  vue3-apexcharts@1.5.3(apexcharts@3.53.0)(vue@3.5.5(typescript@5.5.4)):
+  vue3-apexcharts@1.5.3(apexcharts@3.53.0)(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       apexcharts: 3.53.0
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
-  vue3-gettext@3.0.0-beta.6(@vue/compiler-sfc@3.5.5)(typescript@5.5.4)(vue@3.5.5(typescript@5.5.4)):
+  vue3-gettext@3.0.0-beta.6(@vue/compiler-sfc@3.5.5)(typescript@5.3.3)(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       '@vue/compiler-sfc': 3.5.5
       chalk: 4.1.2
       command-line-args: 5.2.1
-      cosmiconfig: 9.0.0(typescript@5.5.4)
+      cosmiconfig: 9.0.0(typescript@5.3.3)
       gettext-extractor: 3.8.0
       glob: 10.4.5
       parse5: 6.0.1
       parse5-htmlparser2-tree-adapter: 6.0.1
       pofile: 1.1.4
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
     transitivePeerDependencies:
       - typescript
 
-  vue3-otp-input@0.5.21(vue@3.5.5(typescript@5.5.4)):
+  vue3-otp-input@0.5.21(vue@3.5.5(typescript@5.3.3)):
     dependencies:
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
-  vue@3.5.5(typescript@5.5.4):
+  vue@3.5.5(typescript@5.3.3):
     dependencies:
       '@vue/compiler-dom': 3.5.5
       '@vue/compiler-sfc': 3.5.5
       '@vue/runtime-dom': 3.5.5
-      '@vue/server-renderer': 3.5.5(vue@3.5.5(typescript@5.5.4))
+      '@vue/server-renderer': 3.5.5(vue@3.5.5(typescript@5.3.3))
       '@vue/shared': 3.5.5
     optionalDependencies:
-      typescript: 5.5.4
+      typescript: 5.3.3
 
-  vuedraggable@4.1.0(vue@3.5.5(typescript@5.5.4)):
+  vuedraggable@4.1.0(vue@3.5.5(typescript@5.3.3)):
     dependencies:
       sortablejs: 1.14.0
-      vue: 3.5.5(typescript@5.5.4)
+      vue: 3.5.5(typescript@5.3.3)
 
   warning@4.0.3:
     dependencies:

+ 37 - 0
app/src/api/2fa.ts

@@ -0,0 +1,37 @@
+import type { AuthenticationResponseJSON } from '@simplewebauthn/types'
+import http from '@/lib/http'
+
+export interface TwoFAStatusResponse {
+  enabled: boolean
+  otp_status: boolean
+  passkey_status: boolean
+}
+
+const twoFA = {
+  status(): Promise<TwoFAStatusResponse> {
+    return http.get('/2fa_status')
+  },
+  start_secure_session_by_otp(passcode: string, recovery_code: string): Promise<{ session_id: string }> {
+    return http.post('/2fa_secure_session/otp', {
+      otp: passcode,
+      recovery_code,
+    })
+  },
+  secure_session_status(): Promise<{ status: boolean }> {
+    return http.get('/2fa_secure_session/status')
+  },
+  begin_start_secure_session_by_passkey() {
+    return http.get('/2fa_secure_session/passkey')
+  },
+  finish_start_secure_session_by_passkey(data: { session_id: string; options: AuthenticationResponseJSON }): Promise<{
+    session_id: string
+  }> {
+    return http.post('/2fa_secure_session/passkey', data.options, {
+      headers: {
+        'X-Passkey-Session-Id': data.session_id,
+      },
+    })
+  },
+}
+
+export default twoFA

+ 11 - 0
app/src/api/auth.ts

@@ -1,3 +1,4 @@
+import type { AuthenticationResponseJSON } from '@simplewebauthn/types'
 import http from '@/lib/http'
 import { useUserStore } from '@/pinia'
 
@@ -37,6 +38,16 @@ const auth = {
   async get_casdoor_uri(): Promise<{ uri: string }> {
     return http.get('/casdoor_uri')
   },
+  begin_passkey_login() {
+    return http.get('/begin_passkey_login')
+  },
+  finish_passkey_login(data: { session_id: string; options: AuthenticationResponseJSON }) {
+    return http.post('/finish_passkey_login', data.options, {
+      headers: {
+        'X-Passkey-Session-Id': data.session_id,
+      },
+    })
+  },
 }
 
 export default auth

+ 0 - 12
app/src/api/otp.ts

@@ -6,9 +6,6 @@ export interface OTPGenerateSecretResponse {
 }
 
 const otp = {
-  status(): Promise<{ status: boolean }> {
-    return http.get('/otp_status')
-  },
   generate_secret(): Promise<OTPGenerateSecretResponse> {
     return http.get('/otp_secret')
   },
@@ -18,15 +15,6 @@ const otp = {
   reset(recovery_code: string) {
     return http.post('/otp_reset', { recovery_code })
   },
-  start_secure_session(passcode: string, recovery_code: string): Promise<{ session_id: string }> {
-    return http.post('/otp_secure_session', {
-      otp: passcode,
-      recovery_code,
-    })
-  },
-  secure_session_status() {
-    return http.get('/otp_secure_session_status')
-  },
 }
 
 export default otp

+ 36 - 0
app/src/api/passkey.ts

@@ -0,0 +1,36 @@
+import type { RegistrationResponseJSON } from '@simplewebauthn/types'
+import http from '@/lib/http'
+import type { ModelBase } from '@/api/curd'
+
+export interface Passkey extends ModelBase {
+  name: string
+  user_id: string
+  raw_id: string
+}
+
+const passkey = {
+  begin_registration() {
+    return http.get('/begin_passkey_register')
+  },
+  finish_registration(attestationResponse: RegistrationResponseJSON, passkeyName: string) {
+    return http.post('/finish_passkey_register', attestationResponse, {
+      params: {
+        name: passkeyName,
+      },
+    })
+  },
+  get_list() {
+    return http.get('/passkeys')
+  },
+  update(passkeyId: number, data: Passkey) {
+    return http.post(`/passkeys/${passkeyId}`, data)
+  },
+  remove(passkeyId: number) {
+    return http.delete(`/passkeys/${passkeyId}`)
+  },
+  get_config_status(): Promise<{ status: boolean }> {
+    return http.get('/passkeys/config')
+  },
+}
+
+export default passkey

+ 136 - 0
app/src/components/2FA/Authorization.vue

@@ -0,0 +1,136 @@
+<script setup lang="ts">
+import { KeyOutlined } from '@ant-design/icons-vue'
+import { startAuthentication } from '@simplewebauthn/browser'
+import { message } from 'ant-design-vue'
+import OTPInput from '@/components/OTPInput/OTPInput.vue'
+import type { TwoFAStatusResponse } from '@/api/2fa'
+import twoFA from '@/api/2fa'
+import { useUserStore } from '@/pinia'
+
+defineProps<{
+  twoFAStatus: TwoFAStatusResponse
+}>()
+
+const emit = defineEmits(['submitOTP', 'submitSecureSessionID'])
+
+const user = useUserStore()
+const refOTP = ref()
+const useRecoveryCode = ref(false)
+const passcode = ref('')
+const recoveryCode = ref('')
+const passkeyLoading = ref(false)
+
+function clickUseRecoveryCode() {
+  passcode.value = ''
+  useRecoveryCode.value = true
+}
+
+function clickUseOTP() {
+  passcode.value = ''
+  useRecoveryCode.value = false
+}
+
+function onSubmit() {
+  emit('submitOTP', passcode.value, recoveryCode.value)
+}
+
+function clearInput() {
+  refOTP.value?.clearInput()
+}
+
+defineExpose({
+  clearInput,
+})
+
+async function passkeyAuthenticate() {
+  passkeyLoading.value = true
+  try {
+    const begin = await twoFA.begin_start_secure_session_by_passkey()
+    const asseResp = await startAuthentication(begin.options.publicKey)
+
+    const r = await twoFA.finish_start_secure_session_by_passkey({
+      session_id: begin.session_id,
+      options: asseResp,
+    })
+
+    emit('submitSecureSessionID', r.session_id)
+  }
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  catch (e: any) {
+    message.error($gettext(e.message ?? 'Server error'))
+  }
+  passkeyLoading.value = false
+}
+
+onMounted(() => {
+  if (user.passkeyLoginAvailable)
+    passkeyAuthenticate()
+})
+</script>
+
+<template>
+  <div>
+    <div v-if="twoFAStatus.otp_status">
+      <div v-if="!useRecoveryCode">
+        <p>{{ $gettext('Please enter the OTP code:') }}</p>
+        <OTPInput
+          ref="refOTP"
+          v-model="passcode"
+          class="justify-center mb-6"
+          @on-complete="onSubmit"
+        />
+      </div>
+      <div
+        v-else
+        class="mt-2 mb-4"
+      >
+        <p>{{ $gettext('Input the recovery code:') }}</p>
+        <AInputGroup compact>
+          <AInput v-model:value="recoveryCode" />
+          <AButton
+            type="primary"
+            @click="onSubmit"
+          >
+            {{ $gettext('Recovery') }}
+          </AButton>
+        </AInputGroup>
+      </div>
+
+      <div class="flex justify-center">
+        <a
+          v-if="!useRecoveryCode"
+          @click="clickUseRecoveryCode"
+        >{{ $gettext('Use recovery code') }}</a>
+        <a
+          v-else
+          @click="clickUseOTP"
+        >{{ $gettext('Use OTP') }}</a>
+      </div>
+    </div>
+
+    <div
+      v-if="twoFAStatus.passkey_status"
+      class="flex flex-col justify-center"
+    >
+      <ADivider v-if="twoFAStatus.otp_status">
+        <div class="text-sm font-normal opacity-75">
+          {{ $gettext('Or') }}
+        </div>
+      </ADivider>
+
+      <AButton
+        :loading="passkeyLoading"
+        @click="passkeyAuthenticate"
+      >
+        <KeyOutlined />
+        {{ $gettext('Authenticate with a passkey') }}
+      </AButton>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less">
+:deep(.ant-input-group.ant-input-group-compact) {
+  display: flex;
+}
+</style>

+ 22 - 16
app/src/components/OTP/useOTPModal.ts → app/src/components/2FA/use2FAModal.ts

@@ -1,12 +1,12 @@
 import { createVNode, render } from 'vue'
 import { Modal, message } from 'ant-design-vue'
 import { useCookies } from '@vueuse/integrations/useCookies'
-import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
-import otp from '@/api/otp'
+import Authorization from '@/components/2FA/Authorization.vue'
+import twoFA from '@/api/2fa'
 import { useUserStore } from '@/pinia'
 
-const useOTPModal = () => {
-  const refOTPAuthorization = ref<typeof OTPAuthorization>()
+const use2FAModal = () => {
+  const refOTPAuthorization = ref<typeof Authorization>()
   const randomId = Math.random().toString(36).substring(2, 8)
   const { secureSessionId } = storeToRefs(useUserStore())
 
@@ -22,11 +22,11 @@ const useOTPModal = () => {
   }
 
   const open = async (): Promise<string> => {
-    const { status } = await otp.status()
-    const { status: secureSessionStatus } = await otp.secure_session_status()
+    const twoFAStatus = await twoFA.status()
+    const { status: secureSessionStatus } = await twoFA.secure_session_status()
 
     return new Promise((resolve, reject) => {
-      if (!status) {
+      if (!twoFAStatus.enabled) {
         resolve('')
 
         return
@@ -50,12 +50,16 @@ const useOTPModal = () => {
         container = null
       }
 
-      const verify = (passcode: string, recovery: string) => {
-        otp.start_secure_session(passcode, recovery).then(async r => {
-          cookies.set('secure_session_id', r.session_id, { maxAge: 60 * 3 })
-          close()
-          secureSessionId.value = r.session_id
-          resolve(r.session_id)
+      const setSessionId = (sessionId: string) => {
+        cookies.set('secure_session_id', sessionId, { maxAge: 60 * 3 })
+        close()
+        secureSessionId.value = sessionId
+        resolve(sessionId)
+      }
+
+      const verifyOTP = (passcode: string, recovery: string) => {
+        twoFA.start_secure_session_by_otp(passcode, recovery).then(async r => {
+          setSessionId(r.session_id)
         }).catch(async () => {
           refOTPAuthorization.value?.clearInput()
           await message.error($gettext('Invalid passcode or recovery code'))
@@ -76,11 +80,13 @@ const useOTPModal = () => {
         },
       }, {
         default: () => h(
-          OTPAuthorization,
+          Authorization,
           {
             ref: refOTPAuthorization,
+            twoFAStatus,
             class: 'mt-3',
-            onOnSubmit: verify,
+            onSubmitOTP: verifyOTP,
+            onSubmitSecureSessionID: setSessionId,
           },
         ),
       })
@@ -92,4 +98,4 @@ const useOTPModal = () => {
   return { open }
 }
 
-export default useOTPModal
+export default use2FAModal

+ 1 - 1
app/src/components/CodeEditor/CodeEditor.vue

@@ -40,7 +40,7 @@ ace.config.setModuleUrl('ace/ext/searchbox', extSearchboxUrl)
   />
 </template>
 
-<style scoped>
+<style lang="less" scoped>
 :deep(.ace_placeholder) {
   z-index: 1;
   position: relative;

+ 0 - 78
app/src/components/OTP/OTPAuthorization.vue

@@ -1,78 +0,0 @@
-<script setup lang="ts">
-import OTPInput from '@/components/OTPInput/OTPInput.vue'
-
-const emit = defineEmits(['onSubmit'])
-
-const refOTP = ref()
-const useRecoveryCode = ref(false)
-const passcode = ref('')
-const recoveryCode = ref('')
-
-function clickUseRecoveryCode() {
-  passcode.value = ''
-  useRecoveryCode.value = true
-}
-
-function clickUseOTP() {
-  passcode.value = ''
-  useRecoveryCode.value = false
-}
-
-function onSubmit() {
-  emit('onSubmit', passcode.value, recoveryCode.value)
-}
-
-function clearInput() {
-  refOTP.value?.clearInput()
-}
-
-defineExpose({
-  clearInput,
-})
-</script>
-
-<template>
-  <div>
-    <div v-if="!useRecoveryCode">
-      <p>{{ $gettext('Please enter the 2FA code:') }}</p>
-      <OTPInput
-        ref="refOTP"
-        v-model="passcode"
-        class="justify-center mb-6"
-        @on-complete="onSubmit"
-      />
-    </div>
-    <div
-      v-else
-      class="mt-2 mb-4"
-    >
-      <p>{{ $gettext('Input the recovery code:') }}</p>
-      <AInputGroup compact>
-        <AInput v-model:value="recoveryCode" />
-        <AButton
-          type="primary"
-          @click="onSubmit"
-        >
-          {{ $gettext('Recovery') }}
-        </AButton>
-      </AInputGroup>
-    </div>
-
-    <div class="flex justify-center">
-      <a
-        v-if="!useRecoveryCode"
-        @click="clickUseRecoveryCode"
-      >{{ $gettext('Use recovery code') }}</a>
-      <a
-        v-else
-        @click="clickUseOTP"
-      >{{ $gettext('Use OTP') }}</a>
-    </div>
-  </div>
-</template>
-
-<style scoped lang="less">
-:deep(.ant-input-group.ant-input-group-compact) {
-  display: flex;
-}
-</style>

+ 63 - 0
app/src/components/ReactiveFromNow/ReactiveFromNow.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+
+const props = defineProps<{
+  time?: string | number
+}>()
+
+dayjs.extend(relativeTime)
+
+const text = ref('')
+
+const time = computed(() => {
+  if (!props.time)
+    return ''
+
+  if (typeof props.time === 'number')
+    return props.time
+
+  return Number.parseInt(props.time)
+})
+
+let timer: NodeJS.Timeout
+let step: number = 1
+
+async function computedText() {
+  if (!time.value)
+    return
+
+  // if time is not today, return the datetime
+  const thatDay = dayjs.unix(time.value).format('YYYY-MM-DD')
+  if (dayjs().format('YYYY-MM-DD') !== dayjs.unix(time.value).format('YYYY-MM-DD')) {
+    clearInterval(timer)
+    text.value = thatDay
+
+    return
+  }
+
+  text.value = dayjs.unix(time.value).fromNow()
+
+  clearInterval(timer)
+
+  timer = setInterval(computedText, step * 60 * 1000)
+
+  step += 5
+
+  if (step >= 60)
+    step = 60
+}
+
+onMounted(computedText)
+watch(() => props.time, computedText)
+</script>
+
+<template>
+  <div class="reactive-time inline">
+    {{ text }}
+  </div>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 59 - 4
app/src/components/SetLanguage/SetLanguage.vue

@@ -1,10 +1,19 @@
 <script setup lang="ts">
-import { watch } from 'vue'
-
+import dayjs from 'dayjs'
 import { useSettingsStore } from '@/pinia'
 import gettext from '@/gettext'
 import loadTranslations from '@/api/translations'
 
+import 'dayjs/locale/fr'
+import 'dayjs/locale/ja'
+import 'dayjs/locale/ko'
+import 'dayjs/locale/de'
+import 'dayjs/locale/zh-cn'
+import 'dayjs/locale/zh-tw'
+import 'dayjs/locale/pt'
+import 'dayjs/locale/es'
+import 'dayjs/locale/it'
+
 const settings = useSettingsStore()
 
 const route = useRoute()
@@ -20,15 +29,61 @@ const current = computed({
 
 const languageAvailable = gettext.available
 
+const updateTitle = () => {
+  const name = route.meta.name as never as () => string
+
+  document.title = `${name()} | Nginx UI`
+}
+
 watch(current, v => {
   loadTranslations(route)
   settings.set_language(v)
   gettext.current = v
 
-  const name = route.meta.name as never as () => string
+  updateTitle()
+})
 
-  document.title = `${name()} | Nginx UI`
+onMounted(() => {
+  updateTitle()
 })
+
+function init() {
+  switch (current.value) {
+    case 'fr':
+      dayjs.locale('fr')
+      break
+    case 'ja':
+      dayjs.locale('ja')
+      break
+    case 'ko':
+      dayjs.locale('ko')
+      break
+    case 'de':
+      dayjs.locale('de')
+      break
+    case 'en':
+      dayjs.locale('en')
+      break
+    case 'zh_TW':
+      dayjs.locale('zh-tw')
+      break
+    case 'pt':
+      dayjs.locale('pt')
+      break
+    case 'es':
+      dayjs.locale('es')
+      break
+    case 'it':
+      dayjs.locale('it')
+      break
+    default:
+      dayjs.locale('zh-cn')
+  }
+}
+
+init()
+
+watch(current, init)
 </script>
 
 <template>

+ 1 - 0
app/src/components/StdDesign/StdDataDisplay/StdCurd.vue

@@ -161,6 +161,7 @@ function view(id: number | string) {
   get(id).then(() => {
     visible.value = true
     modifyMode.value = false
+    editMode.value = 'modify'
   }).catch(e => {
     message.error($gettext(e?.message ?? 'Server error'), 5)
   })

+ 1 - 1
app/src/components/StdDesign/StdDataDisplay/StdPagination.vue

@@ -30,7 +30,7 @@ const pageSize = computed({
     class="pagination-container"
   >
     <APagination
-      v-model:pageSize="pageSize"
+      v-model:page-size="pageSize"
       :disabled="loading"
       :current="pagination.current_page"
       show-size-changer

+ 172 - 59
app/src/language/en/app.po

@@ -13,7 +13,7 @@ msgstr ""
 msgid "2FA"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr ""
 
@@ -37,7 +37,7 @@ msgstr "Username"
 #: src/views/config/configColumns.ts:38 src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
 msgstr "Action"
@@ -52,6 +52,11 @@ msgstr "Action"
 msgid "Add"
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr ""
+
 #: src/routes/index.ts:112 src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
 #, fuzzy
@@ -90,6 +95,10 @@ msgstr "Add Location"
 msgid "Advance Mode"
 msgstr "Advance Mode"
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr ""
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr ""
@@ -110,11 +119,16 @@ msgstr ""
 msgid "Arch"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "Are you sure you want to remove this directive?"
 
+#: src/views/preference/components/Passkey.vue:119
+#, fuzzy
+msgid "Are you sure to delete this passkey immediately?"
+msgstr "Are you sure you want to remove this directive?"
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 #, fuzzy
@@ -168,7 +182,7 @@ msgstr ""
 msgid "Assistant"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -176,6 +190,14 @@ msgstr ""
 msgid "Auth"
 msgstr ""
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr ""
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -209,15 +231,15 @@ msgstr "Back"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -259,7 +281,7 @@ msgstr ""
 msgid "CADir"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr ""
 
@@ -273,6 +295,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -435,6 +458,7 @@ msgid "Create Folder"
 msgstr "Create Another"
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr "Created at"
@@ -456,12 +480,12 @@ msgstr ""
 msgid "Credentials"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
+#: src/views/preference/components/TOTP.vue:99
+msgid "Current account is enabled TOTP."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is not enabled TOTP."
 msgstr ""
 
 #: src/views/system/Upgrade.vue:167
@@ -682,6 +706,12 @@ msgstr ""
 msgid "Dry run mode enabled"
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid ""
+"Due to the security policies of some browsers, you cannot use passkeys on "
+"non-HTTPS websites, except when running on localhost."
+msgstr ""
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -757,12 +787,7 @@ msgstr ""
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:124
-#, fuzzy
-msgid "Enable 2FA"
-msgstr "Enabled"
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 #, fuzzy
 msgid "Enable 2FA successfully"
 msgstr "Enabled successfully"
@@ -785,6 +810,11 @@ msgstr "Enabled successfully"
 msgid "Enable TLS"
 msgstr "Enable TLS"
 
+#: src/views/preference/components/TOTP.vue:125
+#, fuzzy
+msgid "Enable TOTP"
+msgstr "Enable TLS"
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175 src/views/domain/DomainList.vue:29
 #: src/views/environment/envColumns.tsx:104
@@ -902,6 +932,12 @@ msgstr ""
 msgid "Finished"
 msgstr "Finished"
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid ""
+"Follow the instructions in the dialog to complete the passkey registration "
+"process."
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:43
 msgid "For Chinese user: https://mirror.ghproxy.com/"
 msgstr ""
@@ -974,18 +1010,22 @@ msgstr ""
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid ""
 "If you lose your mobile phone, you can use the recovery code to reset your "
 "2FA."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr ""
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid ""
 "If your domain has CNAME records and you cannot obtain certificates, you "
@@ -1001,7 +1041,7 @@ msgstr ""
 msgid "Import Certificate"
 msgstr "Certificate Status"
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 msgid "Incorrect username or password"
 msgstr ""
 
@@ -1017,12 +1057,12 @@ msgstr ""
 msgid "Initialing core upgrader"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr ""
 
@@ -1044,7 +1084,7 @@ msgstr ""
 msgid "Invalid"
 msgstr "Invalid E-mail!"
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr ""
 
@@ -1058,11 +1098,11 @@ msgstr "Invalid E-mail!"
 msgid "Invalid folder name"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1098,6 +1138,11 @@ msgstr ""
 msgid "Last checked at"
 msgstr ""
 
+#: src/views/preference/components/Passkey.vue:102
+#, fuzzy
+msgid "Last used at"
+msgstr "Created at"
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr "Leave blank for no change"
@@ -1163,11 +1208,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "Login"
 
-#: src/routes/index.ts:305 src/views/other/Login.vue:207
+#: src/routes/index.ts:305 src/views/other/Login.vue:247
 msgid "Login"
 msgstr "Login"
 
-#: src/views/other/Login.vue:130 src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133 src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr "Login successful"
 
@@ -1221,7 +1266,7 @@ msgstr "Manage Users"
 msgid "Managed Certificate"
 msgstr "Certificate is valid"
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr ""
 
@@ -1281,6 +1326,7 @@ msgstr "Single Directive"
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13 src/views/stream/StreamList.vue:188
@@ -1367,7 +1413,7 @@ msgstr "Saved successfully"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1469,6 +1515,10 @@ msgstr ""
 msgid "OpenAI"
 msgstr ""
 
+#: src/components/2FA/Authorization.vue:117 src/views/other/Login.vue:256
+msgid "Or"
+msgstr ""
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr ""
@@ -1498,7 +1548,18 @@ msgstr ""
 msgid "Params"
 msgstr "Params"
 
-#: src/views/other/Login.vue:174 src/views/user/userColumns.tsx:18
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:68
+msgid ""
+"Passkeys are webauthn credentials that validate your identity using touch, "
+"facial recognition, a device password, or a PIN. They can be used as a "
+"password replacement or as a 2FA method."
+msgstr ""
+
+#: src/views/other/Login.vue:208 src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr "Password"
 
@@ -1524,8 +1585,14 @@ msgstr ""
 msgid "Performing core upgrade"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid ""
+"Please enter a name for the passkey you wish to create and click the OK "
+"button below."
+msgstr ""
+
+#: src/components/2FA/Authorization.vue:75
+msgid "Please enter the OTP code:"
 msgstr ""
 
 #: src/views/certificate/DNSCredential.vue:53
@@ -1566,11 +1633,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "Please input your E-mail!"
 
-#: src/views/other/Install.vue:44 src/views/other/Login.vue:44
+#: src/views/other/Install.vue:44 src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr "Please input your password!"
 
-#: src/views/other/Install.vue:38 src/views/other/Login.vue:38
+#: src/views/other/Install.vue:38 src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr "Please input your username!"
 
@@ -1634,16 +1701,16 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "Saved successfully"
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr ""
 
@@ -1664,6 +1731,11 @@ msgstr ""
 msgid "Register failed"
 msgstr "Enable failed"
 
+#: src/views/preference/components/AddPasskey.vue:26
+#, fuzzy
+msgid "Register passkey successfully"
+msgstr "Enabled successfully"
+
 #: src/views/certificate/ACMEUser.vue:67
 #, fuzzy
 msgid "Register successfully"
@@ -1699,11 +1771,12 @@ msgstr ""
 msgid "Reloading nginx"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 #, fuzzy
 msgid "Remove successfully"
 msgstr "Saved successfully"
@@ -1783,7 +1856,7 @@ msgstr ""
 msgid "Reset"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 msgid "Reset 2FA"
 msgstr ""
 
@@ -1808,6 +1881,7 @@ msgstr ""
 #: src/views/certificate/CertificateEditor.vue:254
 #: src/views/config/ConfigEditor.vue:222 src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151 src/views/stream/StreamEdit.vue:252
 msgid "Save"
 msgstr "Save"
@@ -1836,7 +1910,7 @@ msgstr "Saved successfully"
 msgid "Saved successfully"
 msgstr "Saved successfully"
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr ""
 
@@ -1844,7 +1918,7 @@ msgstr ""
 msgid "SDK"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr ""
 
@@ -1869,9 +1943,12 @@ msgstr "Send"
 #: src/views/config/ConfigEditor.vue:108 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1916,6 +1993,10 @@ msgstr ""
 msgid "Show"
 msgstr ""
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr "Single Directive"
@@ -1949,7 +2030,7 @@ msgstr "Certificate Status"
 msgid "SSL Certificate Path"
 msgstr "Certificate Status"
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 #, fuzzy
 msgid "SSO Login"
 msgstr "Login"
@@ -2120,7 +2201,7 @@ msgstr "Certificate Status"
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid ""
 "The recovery code is only displayed once, please save it in a safe place."
 msgstr ""
@@ -2184,7 +2265,8 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr ""
@@ -2193,12 +2275,19 @@ msgstr ""
 msgid "Title"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid ""
 "To enable it, you need to install the Google or Microsoft Authenticator app "
 "on your mobile phone."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid ""
+"To ensure security, Webauthn configuration cannot be added through the UI. "
+"Please manually configure the following in the app.ini configuration file "
+"and restart Nginx UI."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -2211,11 +2300,15 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:92
 msgid ""
 "TOTP is a two-factor authentication method that uses a time-based one-time "
 "password algorithm."
@@ -2225,7 +2318,7 @@ msgstr ""
 msgid "Trash"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr ""
 
@@ -2235,6 +2328,11 @@ msgstr ""
 msgid "Type"
 msgstr ""
 
+#: src/views/preference/components/Passkey.vue:41
+#, fuzzy
+msgid "Update successfully"
+msgstr "Saved successfully"
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31 src/views/config/ConfigEditor.vue:275
@@ -2281,11 +2379,11 @@ msgstr "Uptime:"
 msgid "URL"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr ""
 
@@ -2294,11 +2392,11 @@ msgstr ""
 msgid "User"
 msgstr "Username"
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:164 src/views/user/userColumns.tsx:9
+#: src/views/other/Login.vue:198 src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr "Username"
 
@@ -2338,6 +2436,7 @@ msgstr "Basic Mode"
 #: src/constants/index.ts:17 src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr "Warning"
 
@@ -2368,7 +2467,7 @@ msgstr ""
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Yes"
@@ -2381,6 +2480,20 @@ msgstr ""
 msgid "You can check Nginx UI upgrade at this page."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:93
+msgid ""
+"You have not configured the settings of Webauthn, so you cannot add a "
+"passkey."
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr ""
+
+#, fuzzy
+#~ msgid "Enable 2FA"
+#~ msgstr "Enabled"
+
 #, fuzzy
 #~ msgid "Rename "
 #~ msgstr "Username"

+ 174 - 58
app/src/language/es/app.po

@@ -20,7 +20,7 @@ msgstr ""
 msgid "2FA"
 msgstr "2FA"
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr "Configuración de 2FA"
 
@@ -43,7 +43,7 @@ msgstr "Usuario ACME"
 #: src/views/config/configColumns.ts:38 src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
 msgstr "Acción"
@@ -58,6 +58,11 @@ msgstr "Acción"
 msgid "Add"
 msgstr "Agregar"
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr ""
+
 #: src/routes/index.ts:112 src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
 msgid "Add Configuration"
@@ -92,6 +97,10 @@ msgstr "Adicional"
 msgid "Advance Mode"
 msgstr "Modo avanzado"
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr ""
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr "URL Base de la API"
@@ -112,10 +121,15 @@ msgstr "Token de la API"
 msgid "Arch"
 msgstr "Arquitectura"
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "¿Está seguro de eliminar esta IP bloqueada inmediatamente?"
 
+#: src/views/preference/components/Passkey.vue:119
+#, fuzzy
+msgid "Are you sure to delete this passkey immediately?"
+msgstr "¿Está seguro de eliminar esta IP bloqueada inmediatamente?"
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 msgid "Are you sure you want to clear all notifications?"
@@ -161,7 +175,7 @@ msgstr "Preguntar por ayuda a ChatGPT"
 msgid "Assistant"
 msgstr "Asistente"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr "Intentos"
 
@@ -169,6 +183,14 @@ msgstr "Intentos"
 msgid "Auth"
 msgstr "Autenticación"
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr ""
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -201,15 +223,15 @@ msgstr "Volver al Inicio"
 msgid "Back to list"
 msgstr "Volver a la lista"
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr "Umbral de Prohibición en Minutos"
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr "IPs prohibidas"
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr "Bloqueado hasta"
 
@@ -249,7 +271,7 @@ msgstr "Dir CA"
 msgid "CADir"
 msgstr "Directorio CA"
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr ""
 
@@ -263,6 +285,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -412,6 +435,7 @@ msgid "Create Folder"
 msgstr "Crear carpeta"
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr "Creado el"
@@ -432,13 +456,15 @@ msgstr "Credencial"
 msgid "Credentials"
 msgstr "Credenciales"
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
+#: src/views/preference/components/TOTP.vue:99
+#, fuzzy
+msgid "Current account is enabled TOTP."
 msgstr ""
 "La cuenta actual tiene habilitada la autenticación de dos factores (2FA)."
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
+#: src/views/preference/components/TOTP.vue:96
+#, fuzzy
+msgid "Current account is not enabled TOTP."
 msgstr ""
 "La cuenta actual no tiene habilitada la autenticación de dos factores (2FA)."
 
@@ -652,6 +678,12 @@ msgstr "Descargando la última versión"
 msgid "Dry run mode enabled"
 msgstr "Modo de ejecución de prueba habilitado"
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid ""
+"Due to the security policies of some browsers, you cannot use passkeys on "
+"non-HTTPS websites, except when running on localhost."
+msgstr ""
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -720,11 +752,7 @@ msgstr "Falló el habilitado de %{conf_name} en %{node_name}"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "Habilitado exitoso de %{conf_name} en %{node_name}"
 
-#: src/views/preference/components/TOTP.vue:124
-msgid "Enable 2FA"
-msgstr "Habilitar 2FA"
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 msgid "Enable 2FA successfully"
 msgstr "Habilitar 2FA exitoso"
 
@@ -745,6 +773,11 @@ msgstr "Habilitado con Éxito"
 msgid "Enable TLS"
 msgstr "Habilitar TLS"
 
+#: src/views/preference/components/TOTP.vue:125
+#, fuzzy
+msgid "Enable TOTP"
+msgstr "Habilitar TLS"
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175 src/views/domain/DomainList.vue:29
 #: src/views/environment/envColumns.tsx:104
@@ -859,6 +892,12 @@ msgstr "Filtro"
 msgid "Finished"
 msgstr "Terminado"
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid ""
+"Follow the instructions in the dialog to complete the passkey registration "
+"process."
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:43
 msgid "For Chinese user: https://mirror.ghproxy.com/"
 msgstr "Para usuario chino: https://mirror.ghproxy.com/"
@@ -927,7 +966,7 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr "Si se deja en blanco, se utilizará el directorio CA predeterminado."
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
@@ -936,7 +975,7 @@ msgstr ""
 "el máximo de intentos en los minutos del umbral de prohibición, la IP será "
 "bloqueada por un período de tiempo."
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid ""
 "If you lose your mobile phone, you can use the recovery code to reset your "
 "2FA."
@@ -944,6 +983,10 @@ msgstr ""
 "Si pierde su teléfono móvil, puede usar el código de recuperación para "
 "restablecer su 2FA."
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr ""
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid ""
 "If your domain has CNAME records and you cannot obtain certificates, you "
@@ -960,7 +1003,7 @@ msgstr "Importar"
 msgid "Import Certificate"
 msgstr "Importar Certificado"
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 msgid "Incorrect username or password"
 msgstr "Nombre de usuario o contraseña incorrectos"
 
@@ -976,12 +1019,12 @@ msgstr "Error de actualización de kernel inicial"
 msgid "Initialing core upgrader"
 msgstr "Inicializando la actualización del kernel"
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr "Ingrese el código de la aplicación:"
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr "Ingrese el código de recuperación:"
 
@@ -1001,7 +1044,7 @@ msgstr "Intervalo"
 msgid "Invalid"
 msgstr "Inválido"
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr "Código 2FA o de recuperación inválido"
 
@@ -1014,11 +1057,11 @@ msgstr "Nombre de archivo inválido"
 msgid "Invalid folder name"
 msgstr "Nombre de carpeta inválido"
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr "Código de acceso o código de recuperación inválido"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr "IP"
 
@@ -1051,6 +1094,11 @@ msgstr "Tipo llave"
 msgid "Last checked at"
 msgstr "Comprobado por última vez el"
 
+#: src/views/preference/components/Passkey.vue:102
+#, fuzzy
+msgid "Last used at"
+msgstr "Comprobado por última vez el"
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr "Para no modificar dejar en blanco"
@@ -1110,11 +1158,11 @@ msgstr "Ubicaciones"
 msgid "Log"
 msgstr "Registro"
 
-#: src/routes/index.ts:305 src/views/other/Login.vue:207
+#: src/routes/index.ts:305 src/views/other/Login.vue:247
 msgid "Login"
 msgstr "Acceso"
 
-#: src/views/other/Login.vue:130 src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133 src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr "Acceso exitoso"
 
@@ -1172,7 +1220,7 @@ msgstr "Administrar usuarios"
 msgid "Managed Certificate"
 msgstr "Certificado Administrado"
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr "Intentos máximos"
 
@@ -1227,6 +1275,7 @@ msgstr "Directiva multilínea"
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13 src/views/stream/StreamList.vue:188
@@ -1308,7 +1357,7 @@ msgstr "Nginx reiniciado con éxito"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1409,6 +1458,10 @@ msgstr "En línea"
 msgid "OpenAI"
 msgstr "OpenAI"
 
+#: src/components/2FA/Authorization.vue:117 src/views/other/Login.vue:256
+msgid "Or"
+msgstr ""
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr "Nombre original"
@@ -1437,7 +1490,18 @@ msgstr "Sobrescribir archivo existente"
 msgid "Params"
 msgstr "Parámetros"
 
-#: src/views/other/Login.vue:174 src/views/user/userColumns.tsx:18
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:68
+msgid ""
+"Passkeys are webauthn credentials that validate your identity using touch, "
+"facial recognition, a device password, or a PIN. They can be used as a "
+"password replacement or as a 2FA method."
+msgstr ""
+
+#: src/views/other/Login.vue:208 src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr "Contraseña"
 
@@ -1463,8 +1527,15 @@ msgstr "Error al ejecutar la actualización del kernel"
 msgid "Performing core upgrade"
 msgstr "Realizando la actualizaciónd el kernel"
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid ""
+"Please enter a name for the passkey you wish to create and click the OK "
+"button below."
+msgstr ""
+
+#: src/components/2FA/Authorization.vue:75
+#, fuzzy
+msgid "Please enter the OTP code:"
 msgstr "Por favor, ingrese el código 2FA:"
 
 #: src/views/certificate/DNSCredential.vue:53
@@ -1510,11 +1581,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "¡Por favor ingrese su correo electrónico!"
 
-#: src/views/other/Install.vue:44 src/views/other/Login.vue:44
+#: src/views/other/Install.vue:44 src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr "¡Por favor ingrese su contraseña!"
 
-#: src/views/other/Install.vue:38 src/views/other/Login.vue:38
+#: src/views/other/Install.vue:38 src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr "¡Por favor ingrese su nombre de usuario!"
 
@@ -1578,16 +1649,16 @@ msgstr "Recuperar"
 msgid "Recovered Successfully"
 msgstr "Recuperado con éxito"
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr "Recuperación"
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr "Código de Recuperación"
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr "Código de Recuperación:"
 
@@ -1607,6 +1678,11 @@ msgstr "Registrar"
 msgid "Register failed"
 msgstr "Fallo en el registro"
 
+#: src/views/preference/components/AddPasskey.vue:26
+#, fuzzy
+msgid "Register passkey successfully"
+msgstr "Registrado con éxito"
+
 #: src/views/certificate/ACMEUser.vue:67
 msgid "Register successfully"
 msgstr "Registrado con éxito"
@@ -1640,11 +1716,12 @@ msgstr "Recargando"
 msgid "Reloading nginx"
 msgstr "Recargando Nginx"
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr "Eliminar"
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 msgid "Remove successfully"
 msgstr "Eliminado con éxito"
 
@@ -1714,7 +1791,7 @@ msgstr "Pedido con parámetros incorrectos"
 msgid "Reset"
 msgstr "Limpiar"
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 msgid "Reset 2FA"
 msgstr "Restablecer 2FA"
 
@@ -1738,6 +1815,7 @@ msgstr "Corriendo"
 #: src/views/certificate/CertificateEditor.vue:254
 #: src/views/config/ConfigEditor.vue:222 src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151 src/views/stream/StreamEdit.vue:252
 msgid "Save"
 msgstr "Guardar"
@@ -1765,7 +1843,7 @@ msgstr "Guardado con éxito"
 msgid "Saved successfully"
 msgstr "Guardado con éxito"
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr ""
 "Escanee el código QR con su teléfono móvil para agregar la cuenta a la "
@@ -1775,7 +1853,7 @@ msgstr ""
 msgid "SDK"
 msgstr "SDK"
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr ""
 
@@ -1800,9 +1878,12 @@ msgstr "Enviado"
 #: src/views/config/ConfigEditor.vue:108 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1848,6 +1929,10 @@ msgstr "Usando el proveedor de desafíos HTTP01"
 msgid "Show"
 msgstr "Mostrar"
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr "Directiva de una sola línea"
@@ -1876,7 +1961,7 @@ msgstr "Ruta de la llave del certificado SSL"
 msgid "SSL Certificate Path"
 msgstr "Ruta del certificado SSL"
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 msgid "SSO Login"
 msgstr "Acceso SSO"
 
@@ -2045,7 +2130,7 @@ msgstr "La ruta existe, pero el archivo no es un certificado"
 msgid "The path exists, but the file is not a private key"
 msgstr "La ruta existe, pero el archivo no es una clave privada"
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid ""
 "The recovery code is only displayed once, please save it in a safe place."
 msgstr ""
@@ -2117,7 +2202,8 @@ msgstr ""
 "Esto actualizará o reinstalará la interfaz de usuario de Nginx en "
 "%{nodeNames} a %{version}."
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr "Consejos"
@@ -2126,7 +2212,7 @@ msgstr "Consejos"
 msgid "Title"
 msgstr "Título"
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid ""
 "To enable it, you need to install the Google or Microsoft Authenticator app "
 "on your mobile phone."
@@ -2134,6 +2220,13 @@ msgstr ""
 "Para habilitarlo, necesitas instalar la aplicación Google Authenticator o "
 "Microsoft Authenticator en tu teléfono móvil."
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid ""
+"To ensure security, Webauthn configuration cannot be added through the UI. "
+"Please manually configure the following in the app.ini configuration file "
+"and restart Nginx UI."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -2150,13 +2243,17 @@ msgstr ""
 msgid "Token is not valid"
 msgstr "El token no es válido"
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 "Demasiados intentos fallidos de inicio de sesión, por favor intente "
 "nuevamente más tarde"
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:92
 msgid ""
 "TOTP is a two-factor authentication method that uses a time-based one-time "
 "password algorithm."
@@ -2168,7 +2265,7 @@ msgstr ""
 msgid "Trash"
 msgstr "Basura"
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr "Se requiere autenticación de dos factores"
 
@@ -2178,6 +2275,11 @@ msgstr "Se requiere autenticación de dos factores"
 msgid "Type"
 msgstr "Tipo"
 
+#: src/views/preference/components/Passkey.vue:41
+#, fuzzy
+msgid "Update successfully"
+msgstr "Actualización exitosa"
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31 src/views/config/ConfigEditor.vue:275
@@ -2221,11 +2323,11 @@ msgstr "Tiempo encendido:"
 msgid "URL"
 msgstr "URL"
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr "Usar OTP"
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr "Usar código de recuperación"
 
@@ -2233,11 +2335,11 @@ msgstr "Usar código de recuperación"
 msgid "User"
 msgstr "Usuario"
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr "El usuario está bloqueado"
 
-#: src/views/other/Login.vue:164 src/views/user/userColumns.tsx:9
+#: src/views/other/Login.vue:198 src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr "Nombre de usuario"
 
@@ -2275,6 +2377,7 @@ msgstr "Modo de vista"
 #: src/constants/index.ts:17 src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr "Advertencia"
 
@@ -2309,7 +2412,7 @@ msgstr "Escribir certificado a disco"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Si"
@@ -2322,6 +2425,19 @@ msgstr "Estás usando la última versión"
 msgid "You can check Nginx UI upgrade at this page."
 msgstr "Puede consultar la actualización de Nginx UI en esta página."
 
+#: src/views/preference/components/AddPasskey.vue:93
+msgid ""
+"You have not configured the settings of Webauthn, so you cannot add a "
+"passkey."
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr ""
+
+#~ msgid "Enable 2FA"
+#~ msgstr "Habilitar 2FA"
+
 #, fuzzy
 #~ msgid "Rename "
 #~ msgstr "Renombrar"

+ 172 - 59
app/src/language/fr_FR/app.po

@@ -15,7 +15,7 @@ msgstr ""
 msgid "2FA"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr ""
 
@@ -39,7 +39,7 @@ msgstr "Nom d'utilisateur"
 #: src/views/config/configColumns.ts:38 src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
 msgstr "Action"
@@ -54,6 +54,11 @@ msgstr "Action"
 msgid "Add"
 msgstr "Ajouter"
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr ""
+
 #: src/routes/index.ts:112 src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
 #, fuzzy
@@ -92,6 +97,10 @@ msgstr "Supplémentaire"
 msgid "Advance Mode"
 msgstr "Mode avancé"
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr ""
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr "URL de base de l'API"
@@ -114,11 +123,16 @@ msgstr "Jeton d'API"
 msgid "Arch"
 msgstr "Arch"
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "Etes-vous sûr que vous voulez supprimer ?"
 
+#: src/views/preference/components/Passkey.vue:119
+#, fuzzy
+msgid "Are you sure to delete this passkey immediately?"
+msgstr "Etes-vous sûr que vous voulez supprimer ?"
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 #, fuzzy
@@ -170,7 +184,7 @@ msgstr "Modèle ChatGPT"
 msgid "Assistant"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -179,6 +193,14 @@ msgstr ""
 msgid "Auth"
 msgstr "Autheur"
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr ""
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -211,15 +233,15 @@ msgstr "Retour au menu principal"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -261,7 +283,7 @@ msgstr ""
 msgid "CADir"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr ""
 
@@ -275,6 +297,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -435,6 +458,7 @@ msgid "Create Folder"
 msgstr "Créer un autre"
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr "Créé le"
@@ -456,12 +480,12 @@ msgstr "Identifiant"
 msgid "Credentials"
 msgstr "Identifiants"
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
+#: src/views/preference/components/TOTP.vue:99
+msgid "Current account is enabled TOTP."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is not enabled TOTP."
 msgstr ""
 
 #: src/views/system/Upgrade.vue:167
@@ -682,6 +706,12 @@ msgstr "Téléchargement de la dernière version"
 msgid "Dry run mode enabled"
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid ""
+"Due to the security policies of some browsers, you cannot use passkeys on "
+"non-HTTPS websites, except when running on localhost."
+msgstr ""
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -757,12 +787,7 @@ msgstr ""
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:124
-#, fuzzy
-msgid "Enable 2FA"
-msgstr "Activé"
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 #, fuzzy
 msgid "Enable 2FA successfully"
 msgstr "Activé avec succès"
@@ -785,6 +810,11 @@ msgstr "Activé avec succès"
 msgid "Enable TLS"
 msgstr "Activer TLS"
 
+#: src/views/preference/components/TOTP.vue:125
+#, fuzzy
+msgid "Enable TOTP"
+msgstr "Activer TLS"
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175 src/views/domain/DomainList.vue:29
 #: src/views/environment/envColumns.tsx:104
@@ -905,6 +935,12 @@ msgstr "Filtrer"
 msgid "Finished"
 msgstr "Finie"
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid ""
+"Follow the instructions in the dialog to complete the passkey registration "
+"process."
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:43
 #, fuzzy
 msgid "For Chinese user: https://mirror.ghproxy.com/"
@@ -975,18 +1011,22 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid ""
 "If you lose your mobile phone, you can use the recovery code to reset your "
 "2FA."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr ""
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid ""
 "If your domain has CNAME records and you cannot obtain certificates, you "
@@ -1003,7 +1043,7 @@ msgstr "Exporter"
 msgid "Import Certificate"
 msgstr "État du certificat"
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "Le pseudo ou mot de passe est incorect"
@@ -1020,12 +1060,12 @@ msgstr "Erreur du programme de mise à niveau initial du core"
 msgid "Initialing core upgrader"
 msgstr "Initialisation du programme de mise à niveau du core"
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr ""
 
@@ -1045,7 +1085,7 @@ msgstr ""
 msgid "Invalid"
 msgstr ""
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr ""
 
@@ -1058,11 +1098,11 @@ msgstr ""
 msgid "Invalid folder name"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1098,6 +1138,11 @@ msgstr "Type"
 msgid "Last checked at"
 msgstr "Dernière vérification le"
 
+#: src/views/preference/components/Passkey.vue:102
+#, fuzzy
+msgid "Last used at"
+msgstr "Dernière vérification le"
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr "Laisser vide pour aucun changement"
@@ -1165,11 +1210,11 @@ msgstr "Localisations"
 msgid "Log"
 msgstr "Connexion"
 
-#: src/routes/index.ts:305 src/views/other/Login.vue:207
+#: src/routes/index.ts:305 src/views/other/Login.vue:247
 msgid "Login"
 msgstr "Connexion"
 
-#: src/views/other/Login.vue:130 src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133 src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr "Connexion réussie"
 
@@ -1223,7 +1268,7 @@ msgstr "Gérer les utilisateurs"
 msgid "Managed Certificate"
 msgstr "Changer de certificat"
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr ""
 
@@ -1281,6 +1326,7 @@ msgstr "Directive multiligne"
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13 src/views/stream/StreamList.vue:188
@@ -1365,7 +1411,7 @@ msgstr "Nginx a redémarré avec succès"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1467,6 +1513,10 @@ msgstr ""
 msgid "OpenAI"
 msgstr "OpenAI"
 
+#: src/components/2FA/Authorization.vue:117 src/views/other/Login.vue:256
+msgid "Or"
+msgstr ""
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr ""
@@ -1495,7 +1545,18 @@ msgstr ""
 msgid "Params"
 msgstr "Paramètres"
 
-#: src/views/other/Login.vue:174 src/views/user/userColumns.tsx:18
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:68
+msgid ""
+"Passkeys are webauthn credentials that validate your identity using touch, "
+"facial recognition, a device password, or a PIN. They can be used as a "
+"password replacement or as a 2FA method."
+msgstr ""
+
+#: src/views/other/Login.vue:208 src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr "Mot de passe"
 
@@ -1521,8 +1582,14 @@ msgstr "Erreur lors de la mise a niveau du core"
 msgid "Performing core upgrade"
 msgstr "Exécution de la mise à niveau du core"
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid ""
+"Please enter a name for the passkey you wish to create and click the OK "
+"button below."
+msgstr ""
+
+#: src/components/2FA/Authorization.vue:75
+msgid "Please enter the OTP code:"
 msgstr ""
 
 #: src/views/certificate/DNSCredential.vue:53
@@ -1570,11 +1637,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "Veuillez saisir votre e-mail !"
 
-#: src/views/other/Install.vue:44 src/views/other/Login.vue:44
+#: src/views/other/Install.vue:44 src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr "Veuillez saisir votre mot de passe !"
 
-#: src/views/other/Install.vue:38 src/views/other/Login.vue:38
+#: src/views/other/Install.vue:38 src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr "Veuillez saisir votre nom d'utilisateur !"
 
@@ -1640,16 +1707,16 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "Enregistré avec succès"
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr ""
 
@@ -1671,6 +1738,11 @@ msgstr "Enregistrement de l'utilisateur"
 msgid "Register failed"
 msgstr "Enregistrement de l'utilisateur"
 
+#: src/views/preference/components/AddPasskey.vue:26
+#, fuzzy
+msgid "Register passkey successfully"
+msgstr "Activé avec succès"
+
 #: src/views/certificate/ACMEUser.vue:67
 #, fuzzy
 msgid "Register successfully"
@@ -1706,11 +1778,12 @@ msgstr "Rechargement"
 msgid "Reloading nginx"
 msgstr "Rechargement de nginx"
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 #, fuzzy
 msgid "Remove successfully"
 msgstr "Enregistré avec succès"
@@ -1790,7 +1863,7 @@ msgstr ""
 msgid "Reset"
 msgstr "Réinitialiser"
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 #, fuzzy
 msgid "Reset 2FA"
 msgstr "Réinitialiser"
@@ -1815,6 +1888,7 @@ msgstr "En cours d'éxécution"
 #: src/views/certificate/CertificateEditor.vue:254
 #: src/views/config/ConfigEditor.vue:222 src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151 src/views/stream/StreamEdit.vue:252
 msgid "Save"
 msgstr "Enregistrer"
@@ -1842,7 +1916,7 @@ msgstr "Sauvegarde réussie"
 msgid "Saved successfully"
 msgstr "Enregistré avec succès"
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr ""
 
@@ -1850,7 +1924,7 @@ msgstr ""
 msgid "SDK"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr ""
 
@@ -1875,9 +1949,12 @@ msgstr "Envoyer"
 #: src/views/config/ConfigEditor.vue:108 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1924,6 +2001,10 @@ msgstr "Utilisation du fournisseur de challenge HTTP01"
 msgid "Show"
 msgstr ""
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr "Directive unique"
@@ -1954,7 +2035,7 @@ msgstr "Chemin de la clé du certificat SSL"
 msgid "SSL Certificate Path"
 msgstr "Chemin du certificat SSL"
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 #, fuzzy
 msgid "SSO Login"
 msgstr "Connexion"
@@ -2126,7 +2207,7 @@ msgstr "Chemin de la clé du certificat SSL"
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid ""
 "The recovery code is only displayed once, please save it in a safe place."
 msgstr ""
@@ -2194,7 +2275,8 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr ""
@@ -2203,12 +2285,19 @@ msgstr ""
 msgid "Title"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid ""
 "To enable it, you need to install the Google or Microsoft Authenticator app "
 "on your mobile phone."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid ""
+"To ensure security, Webauthn configuration cannot be added through the UI. "
+"Please manually configure the following in the app.ini configuration file "
+"and restart Nginx UI."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -2225,11 +2314,15 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:92
 msgid ""
 "TOTP is a two-factor authentication method that uses a time-based one-time "
 "password algorithm."
@@ -2239,7 +2332,7 @@ msgstr ""
 msgid "Trash"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr ""
 
@@ -2249,6 +2342,11 @@ msgstr ""
 msgid "Type"
 msgstr "Type"
 
+#: src/views/preference/components/Passkey.vue:41
+#, fuzzy
+msgid "Update successfully"
+msgstr "Mis à jour avec succés"
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31 src/views/config/ConfigEditor.vue:275
@@ -2293,11 +2391,11 @@ msgstr "Disponibilité :"
 msgid "URL"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr ""
 
@@ -2306,11 +2404,11 @@ msgstr ""
 msgid "User"
 msgstr "Nom d'utilisateur"
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:164 src/views/user/userColumns.tsx:9
+#: src/views/other/Login.vue:198 src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr "Nom d'utilisateur"
 
@@ -2351,6 +2449,7 @@ msgstr "Mode simple"
 #: src/constants/index.ts:17 src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr "Avertissement"
 
@@ -2383,7 +2482,7 @@ msgstr "Écriture du certificat sur le disque"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Oui"
@@ -2396,6 +2495,20 @@ msgstr "Vous utilisez la dernière version"
 msgid "You can check Nginx UI upgrade at this page."
 msgstr "Vous pouvez vérifier la mise à niveau de Nginx UI sur cette page."
 
+#: src/views/preference/components/AddPasskey.vue:93
+msgid ""
+"You have not configured the settings of Webauthn, so you cannot add a "
+"passkey."
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr ""
+
+#, fuzzy
+#~ msgid "Enable 2FA"
+#~ msgstr "Activé"
+
 #, fuzzy
 #~ msgid "Rename "
 #~ msgstr "Nom d'utilisateur"

+ 172 - 59
app/src/language/ko_KR/app.po

@@ -18,7 +18,7 @@ msgstr ""
 msgid "2FA"
 msgstr "2FA"
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr "2FA 설정"
 
@@ -41,7 +41,7 @@ msgstr "ACME 사용자"
 #: src/views/config/configColumns.ts:38 src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
 msgstr "작업"
@@ -56,6 +56,11 @@ msgstr "작업"
 msgid "Add"
 msgstr "추가"
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr ""
+
 #: src/routes/index.ts:112 src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
 msgid "Add Configuration"
@@ -90,6 +95,10 @@ msgstr "추가적인"
 msgid "Advance Mode"
 msgstr "고급 모드"
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr ""
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr "API 기본 URL"
@@ -110,10 +119,15 @@ msgstr "API 토큰"
 msgid "Arch"
 msgstr "아키텍처"
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "차단된 IP를 즉시 삭제하시겠습니까?"
 
+#: src/views/preference/components/Passkey.vue:119
+#, fuzzy
+msgid "Are you sure to delete this passkey immediately?"
+msgstr "차단된 IP를 즉시 삭제하시겠습니까?"
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 msgid "Are you sure you want to clear all notifications?"
@@ -159,7 +173,7 @@ msgstr "ChatGPT에게 도움 요청"
 msgid "Assistant"
 msgstr "조수"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr "시도 횟수"
 
@@ -167,6 +181,14 @@ msgstr "시도 횟수"
 msgid "Auth"
 msgstr "인증"
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr ""
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -199,15 +221,15 @@ msgstr "홈으로"
 msgid "Back to list"
 msgstr "목록으로 돌아가기"
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr "차단 시간(분)"
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr "차단된 IP"
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr "차단될 시간"
 
@@ -247,7 +269,7 @@ msgstr "CA 디렉토리"
 msgid "CADir"
 msgstr "CA 디렉토리"
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr ""
 
@@ -261,6 +283,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -414,6 +437,7 @@ msgid "Create Folder"
 msgstr "다른 것 생성하기"
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr "생성 시간"
@@ -435,12 +459,12 @@ msgstr "인증 정보"
 msgid "Credentials"
 msgstr "인증 정보들"
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
+#: src/views/preference/components/TOTP.vue:99
+msgid "Current account is enabled TOTP."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is not enabled TOTP."
 msgstr ""
 
 #: src/views/system/Upgrade.vue:167
@@ -650,6 +674,12 @@ msgstr "최신 릴리스 다운로드 중"
 msgid "Dry run mode enabled"
 msgstr "드라이런 모드 활성화됨"
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid ""
+"Due to the security policies of some browsers, you cannot use passkeys on "
+"non-HTTPS websites, except when running on localhost."
+msgstr ""
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -720,12 +750,7 @@ msgstr "%{node_name}에서 %{conf_name} 활성화 실패"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "%{node_name}에서 %{conf_name} 성공적으로 활성화됨"
 
-#: src/views/preference/components/TOTP.vue:124
-#, fuzzy
-msgid "Enable 2FA"
-msgstr "활성화"
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 #, fuzzy
 msgid "Enable 2FA successfully"
 msgstr "성공적으로 활성화"
@@ -747,6 +772,11 @@ msgstr "성공적으로 활성화"
 msgid "Enable TLS"
 msgstr "TLS 활성화"
 
+#: src/views/preference/components/TOTP.vue:125
+#, fuzzy
+msgid "Enable TOTP"
+msgstr "TLS 활성화"
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175 src/views/domain/DomainList.vue:29
 #: src/views/environment/envColumns.tsx:104
@@ -864,6 +894,12 @@ msgstr "필터"
 msgid "Finished"
 msgstr "완료됨"
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid ""
+"Follow the instructions in the dialog to complete the passkey registration "
+"process."
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:43
 msgid "For Chinese user: https://mirror.ghproxy.com/"
 msgstr "중국 사용자를 위해: https://mirror.ghproxy.com/"
@@ -936,18 +972,22 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid ""
 "If you lose your mobile phone, you can use the recovery code to reset your "
 "2FA."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr ""
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid ""
 "If your domain has CNAME records and you cannot obtain certificates, you "
@@ -963,7 +1003,7 @@ msgstr "가져오기"
 msgid "Import Certificate"
 msgstr "인증서 상태"
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "사용자 이름 또는 비밀번호가 올바르지 않습니다"
@@ -980,12 +1020,12 @@ msgstr "초기 코어 업그레이더 오류"
 msgid "Initialing core upgrader"
 msgstr "코어 업그레이더 초기화"
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr ""
 
@@ -1007,7 +1047,7 @@ msgstr "간격"
 msgid "Invalid"
 msgstr "유효함"
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr ""
 
@@ -1021,11 +1061,11 @@ msgstr "Invalid E-mail!"
 msgid "Invalid folder name"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1061,6 +1101,11 @@ msgstr "키 유형"
 msgid "Last checked at"
 msgstr "마지막 확인 시간"
 
+#: src/views/preference/components/Passkey.vue:102
+#, fuzzy
+msgid "Last used at"
+msgstr "마지막 확인 시간"
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr "변경사항이 없으면 비워두세요"
@@ -1126,11 +1171,11 @@ msgstr "위치들"
 msgid "Log"
 msgstr "로그인"
 
-#: src/routes/index.ts:305 src/views/other/Login.vue:207
+#: src/routes/index.ts:305 src/views/other/Login.vue:247
 msgid "Login"
 msgstr "로그인"
 
-#: src/views/other/Login.vue:130 src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133 src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr "로그인 성공"
 
@@ -1189,7 +1234,7 @@ msgstr "사용자 관리"
 msgid "Managed Certificate"
 msgstr "인증서 유효"
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr ""
 
@@ -1249,6 +1294,7 @@ msgstr "단일 지시문"
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13 src/views/stream/StreamList.vue:188
@@ -1335,7 +1381,7 @@ msgstr "Nginx가 성공적으로 재시작됨"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1437,6 +1483,10 @@ msgstr "온라인"
 msgid "OpenAI"
 msgstr "오픈AI"
 
+#: src/components/2FA/Authorization.vue:117 src/views/other/Login.vue:256
+msgid "Or"
+msgstr ""
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr ""
@@ -1466,7 +1516,18 @@ msgstr "기존 파일 덮어쓰기"
 msgid "Params"
 msgstr "파라미터"
 
-#: src/views/other/Login.vue:174 src/views/user/userColumns.tsx:18
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:68
+msgid ""
+"Passkeys are webauthn credentials that validate your identity using touch, "
+"facial recognition, a device password, or a PIN. They can be used as a "
+"password replacement or as a 2FA method."
+msgstr ""
+
+#: src/views/other/Login.vue:208 src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr "비밀번호"
 
@@ -1492,8 +1553,14 @@ msgstr "핵심 업그레이드 오류 수행"
 msgid "Performing core upgrade"
 msgstr "핵심 업그레이드 수행 중"
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid ""
+"Please enter a name for the passkey you wish to create and click the OK "
+"button below."
+msgstr ""
+
+#: src/components/2FA/Authorization.vue:75
+msgid "Please enter the OTP code:"
 msgstr ""
 
 #: src/views/certificate/DNSCredential.vue:53
@@ -1536,11 +1603,11 @@ msgstr "이름을 입력해주세요, 이것은 새 구성의 파일 이름으
 msgid "Please input your E-mail!"
 msgstr "이메일을 입력해주세요!"
 
-#: src/views/other/Install.vue:44 src/views/other/Login.vue:44
+#: src/views/other/Install.vue:44 src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr "비밀번호를 입력해주세요!"
 
-#: src/views/other/Install.vue:38 src/views/other/Login.vue:38
+#: src/views/other/Install.vue:38 src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr "사용자 이름을 입력해주세요!"
 
@@ -1604,16 +1671,16 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "성공적으로 제거됨"
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr ""
 
@@ -1635,6 +1702,11 @@ msgstr "사용자 등록 중"
 msgid "Register failed"
 msgstr "사용자 등록 중"
 
+#: src/views/preference/components/AddPasskey.vue:26
+#, fuzzy
+msgid "Register passkey successfully"
+msgstr "성공적으로 갱신됨"
+
 #: src/views/certificate/ACMEUser.vue:67
 #, fuzzy
 msgid "Register successfully"
@@ -1671,11 +1743,12 @@ msgstr "리로딩 중"
 msgid "Reloading nginx"
 msgstr "Nginx 리로딩 중"
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 #, fuzzy
 msgid "Remove successfully"
 msgstr "성공적으로 제거됨"
@@ -1755,7 +1828,7 @@ msgstr "잘못된 매개변수로 요청됨"
 msgid "Reset"
 msgstr "재설정"
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 #, fuzzy
 msgid "Reset 2FA"
 msgstr "재설정"
@@ -1781,6 +1854,7 @@ msgstr "실행 중"
 #: src/views/certificate/CertificateEditor.vue:254
 #: src/views/config/ConfigEditor.vue:222 src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151 src/views/stream/StreamEdit.vue:252
 msgid "Save"
 msgstr "저장"
@@ -1809,7 +1883,7 @@ msgstr "성공적으로 저장됨"
 msgid "Saved successfully"
 msgstr "성공적으로 저장됨"
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr ""
 
@@ -1817,7 +1891,7 @@ msgstr ""
 msgid "SDK"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr ""
 
@@ -1842,9 +1916,12 @@ msgstr "보내기"
 #: src/views/config/ConfigEditor.vue:108 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1889,6 +1966,10 @@ msgstr "HTTP01 공급자 설정"
 msgid "Show"
 msgstr ""
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr "단일 지시문"
@@ -1921,7 +2002,7 @@ msgstr "SSL 인증서 키 경로"
 msgid "SSL Certificate Path"
 msgstr "SSL 인증서 경로"
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 #, fuzzy
 msgid "SSO Login"
 msgstr "SSO 로그인"
@@ -2092,7 +2173,7 @@ msgstr "Certificate Status"
 msgid "The path exists, but the file is not a private key"
 msgstr "경로는 존재하지만 파일은 개인 키가 아닙니다"
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid ""
 "The recovery code is only displayed once, please save it in a safe place."
 msgstr ""
@@ -2158,7 +2239,8 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr "팁"
@@ -2167,12 +2249,19 @@ msgstr "팁"
 msgid "Title"
 msgstr "제목"
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid ""
 "To enable it, you need to install the Google or Microsoft Authenticator app "
 "on your mobile phone."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid ""
+"To ensure security, Webauthn configuration cannot be added through the UI. "
+"Please manually configure the following in the app.ini configuration file "
+"and restart Nginx UI."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -2188,11 +2277,15 @@ msgstr ""
 msgid "Token is not valid"
 msgstr "토큰이 유효하지 않습니다"
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:92
 msgid ""
 "TOTP is a two-factor authentication method that uses a time-based one-time "
 "password algorithm."
@@ -2202,7 +2295,7 @@ msgstr ""
 msgid "Trash"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr ""
 
@@ -2212,6 +2305,11 @@ msgstr ""
 msgid "Type"
 msgstr "유형"
 
+#: src/views/preference/components/Passkey.vue:41
+#, fuzzy
+msgid "Update successfully"
+msgstr "성공적으로 저장되었습니다"
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31 src/views/config/ConfigEditor.vue:275
@@ -2258,11 +2356,11 @@ msgstr "가동 시간:"
 msgid "URL"
 msgstr "URL"
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr ""
 
@@ -2271,11 +2369,11 @@ msgstr ""
 msgid "User"
 msgstr "사용자 이름"
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:164 src/views/user/userColumns.tsx:9
+#: src/views/other/Login.vue:198 src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr "사용자 이름"
 
@@ -2317,6 +2415,7 @@ msgstr "기본 모드"
 #: src/constants/index.ts:17 src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr "경고"
 
@@ -2351,7 +2450,7 @@ msgstr "인증서를 디스크에 쓰기"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "예"
@@ -2364,6 +2463,20 @@ msgstr "최신 버전을 사용하고 있습니다"
 msgid "You can check Nginx UI upgrade at this page."
 msgstr "이 페이지에서 Nginx UI 업그레이드를 확인할 수 있습니다."
 
+#: src/views/preference/components/AddPasskey.vue:93
+msgid ""
+"You have not configured the settings of Webauthn, so you cannot add a "
+"passkey."
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr ""
+
+#, fuzzy
+#~ msgid "Enable 2FA"
+#~ msgstr "활성화"
+
 #, fuzzy
 #~ msgid "Enter"
 #~ msgstr "간격"

+ 151 - 59
app/src/language/messages.pot

@@ -6,7 +6,7 @@ msgstr ""
 msgid "2FA"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr ""
 
@@ -32,7 +32,7 @@ msgstr ""
 #: src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26
+#: src/views/preference/AuthSettings.vue:27
 #: src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
@@ -48,6 +48,11 @@ msgstr ""
 msgid "Add"
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr ""
+
 #: src/routes/index.ts:112
 #: src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
@@ -85,6 +90,10 @@ msgstr ""
 msgid "Advance Mode"
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr ""
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr ""
@@ -105,10 +114,14 @@ msgstr ""
 msgid "Arch"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr ""
 
+#: src/views/preference/components/Passkey.vue:119
+msgid "Are you sure to delete this passkey immediately?"
+msgstr ""
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 msgid "Are you sure you want to clear all notifications?"
@@ -155,7 +168,7 @@ msgstr ""
 msgid "Assistant"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -163,6 +176,14 @@ msgstr ""
 msgid "Auth"
 msgstr ""
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr ""
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -198,15 +219,15 @@ msgstr ""
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -247,7 +268,7 @@ msgstr ""
 msgid "CADir"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr ""
 
@@ -261,6 +282,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -412,6 +434,7 @@ msgid "Create Folder"
 msgstr ""
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr ""
@@ -432,12 +455,12 @@ msgstr ""
 msgid "Credentials"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
+#: src/views/preference/components/TOTP.vue:99
+msgid "Current account is enabled TOTP."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is not enabled TOTP."
 msgstr ""
 
 #: src/views/system/Upgrade.vue:167
@@ -652,6 +675,10 @@ msgstr ""
 msgid "Dry run mode enabled"
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid "Due to the security policies of some browsers, you cannot use passkeys on non-HTTPS websites, except when running on localhost."
+msgstr ""
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -722,11 +749,7 @@ msgstr ""
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:124
-msgid "Enable 2FA"
-msgstr ""
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 msgid "Enable 2FA successfully"
 msgstr ""
 
@@ -747,6 +770,10 @@ msgstr ""
 msgid "Enable TLS"
 msgstr ""
 
+#: src/views/preference/components/TOTP.vue:125
+msgid "Enable TOTP"
+msgstr ""
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175
 #: src/views/domain/DomainList.vue:29
@@ -868,6 +895,10 @@ msgstr ""
 msgid "Finished"
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid "Follow the instructions in the dialog to complete the passkey registration process."
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:43
 msgid "For Chinese user: https://mirror.ghproxy.com/"
 msgstr ""
@@ -937,14 +968,18 @@ msgstr ""
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid "If the number of login failed attempts from a ip reach the max attempts in ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid "If you lose your mobile phone, you can use the recovery code to reset your 2FA."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr ""
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid "If your domain has CNAME records and you cannot obtain certificates, you need to enable this option."
 msgstr ""
@@ -958,7 +993,7 @@ msgstr ""
 msgid "Import Certificate"
 msgstr ""
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 msgid "Incorrect username or password"
 msgstr ""
 
@@ -975,12 +1010,12 @@ msgstr ""
 msgid "Initialing core upgrader"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr ""
 
@@ -1001,7 +1036,7 @@ msgstr ""
 msgid "Invalid"
 msgstr ""
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr ""
 
@@ -1014,11 +1049,11 @@ msgstr ""
 msgid "Invalid folder name"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1051,6 +1086,10 @@ msgstr ""
 msgid "Last checked at"
 msgstr ""
 
+#: src/views/preference/components/Passkey.vue:102
+msgid "Last used at"
+msgstr ""
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr ""
@@ -1111,12 +1150,12 @@ msgid "Log"
 msgstr ""
 
 #: src/routes/index.ts:305
-#: src/views/other/Login.vue:207
+#: src/views/other/Login.vue:247
 msgid "Login"
 msgstr ""
 
-#: src/views/other/Login.vue:130
-#: src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133
+#: src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr ""
 
@@ -1162,7 +1201,7 @@ msgstr ""
 msgid "Managed Certificate"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr ""
 
@@ -1219,6 +1258,7 @@ msgstr ""
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13
@@ -1303,7 +1343,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1401,6 +1441,11 @@ msgstr ""
 msgid "OpenAI"
 msgstr ""
 
+#: src/components/2FA/Authorization.vue:117
+#: src/views/other/Login.vue:256
+msgid "Or"
+msgstr ""
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr ""
@@ -1429,7 +1474,15 @@ msgstr ""
 msgid "Params"
 msgstr ""
 
-#: src/views/other/Login.vue:174
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:68
+msgid "Passkeys are webauthn credentials that validate your identity using touch, facial recognition, a device password, or a PIN. They can be used as a password replacement or as a 2FA method."
+msgstr ""
+
+#: src/views/other/Login.vue:208
 #: src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr ""
@@ -1456,8 +1509,12 @@ msgstr ""
 msgid "Performing core upgrade"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid "Please enter a name for the passkey you wish to create and click the OK button below."
+msgstr ""
+
+#: src/components/2FA/Authorization.vue:75
+msgid "Please enter the OTP code:"
 msgstr ""
 
 #: src/views/certificate/DNSCredential.vue:53
@@ -1491,12 +1548,12 @@ msgid "Please input your E-mail!"
 msgstr ""
 
 #: src/views/other/Install.vue:44
-#: src/views/other/Login.vue:44
+#: src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr ""
 
 #: src/views/other/Install.vue:38
-#: src/views/other/Login.vue:38
+#: src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr ""
 
@@ -1559,16 +1616,16 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr ""
 
@@ -1588,6 +1645,10 @@ msgstr ""
 msgid "Register failed"
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:26
+msgid "Register passkey successfully"
+msgstr ""
+
 #: src/views/certificate/ACMEUser.vue:67
 msgid "Register successfully"
 msgstr ""
@@ -1621,11 +1682,12 @@ msgstr ""
 msgid "Reloading nginx"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 msgid "Remove successfully"
 msgstr ""
 
@@ -1689,7 +1751,7 @@ msgstr ""
 msgid "Reset"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 msgid "Reset 2FA"
 msgstr ""
 
@@ -1714,6 +1776,7 @@ msgstr ""
 #: src/views/config/ConfigEditor.vue:222
 #: src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151
 #: src/views/stream/StreamEdit.vue:252
 msgid "Save"
@@ -1744,7 +1807,7 @@ msgstr ""
 msgid "Saved successfully"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr ""
 
@@ -1752,7 +1815,7 @@ msgstr ""
 msgid "SDK"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr ""
 
@@ -1779,9 +1842,12 @@ msgstr ""
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15
 #: src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83
 #: src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81
@@ -1825,6 +1891,10 @@ msgstr ""
 msgid "Show"
 msgstr ""
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr ""
@@ -1853,7 +1923,7 @@ msgstr ""
 msgid "SSL Certificate Path"
 msgstr ""
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 msgid "SSO Login"
 msgstr ""
 
@@ -2001,7 +2071,7 @@ msgstr ""
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid "The recovery code is only displayed once, please save it in a safe place."
 msgstr ""
 
@@ -2053,7 +2123,8 @@ msgstr ""
 msgid "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr ""
@@ -2062,10 +2133,14 @@ msgstr ""
 msgid "Title"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid "To enable it, you need to install the Google or Microsoft Authenticator app on your mobile phone."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid "To ensure security, Webauthn configuration cannot be added through the UI. Please manually configure the following in the app.ini configuration file and restart Nginx UI."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid "To make sure the certification auto-renewal can work normally, we need to add a location which can proxy the request from authority to backend, and we need to save this file and reload the Nginx. Are you sure you want to continue?"
 msgstr ""
@@ -2074,11 +2149,15 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:92
 msgid "TOTP is a two-factor authentication method that uses a time-based one-time password algorithm."
 msgstr ""
 
@@ -2086,7 +2165,7 @@ msgstr ""
 msgid "Trash"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr ""
 
@@ -2096,6 +2175,10 @@ msgstr ""
 msgid "Type"
 msgstr ""
 
+#: src/views/preference/components/Passkey.vue:41
+msgid "Update successfully"
+msgstr ""
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31
@@ -2145,11 +2228,11 @@ msgstr ""
 msgid "URL"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr ""
 
@@ -2157,11 +2240,11 @@ msgstr ""
 msgid "User"
 msgstr ""
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:164
+#: src/views/other/Login.vue:198
 #: src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr ""
@@ -2201,6 +2284,7 @@ msgstr ""
 #: src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr ""
 
@@ -2227,7 +2311,7 @@ msgstr ""
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr ""
@@ -2239,3 +2323,11 @@ msgstr ""
 #: src/views/system/Upgrade.vue:166
 msgid "You can check Nginx UI upgrade at this page."
 msgstr ""
+
+#: src/views/preference/components/AddPasskey.vue:93
+msgid "You have not configured the settings of Webauthn, so you cannot add a passkey."
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr ""

+ 174 - 58
app/src/language/ru_RU/app.po

@@ -18,7 +18,7 @@ msgstr ""
 msgid "2FA"
 msgstr "2ФА"
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr "Настройки 2ФА"
 
@@ -41,7 +41,7 @@ msgstr "Пользователь ACME"
 #: src/views/config/configColumns.ts:38 src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
 msgstr "Действие"
@@ -56,6 +56,11 @@ msgstr "Действие"
 msgid "Add"
 msgstr "Добавить"
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr ""
+
 #: src/routes/index.ts:112 src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
 msgid "Add Configuration"
@@ -90,6 +95,10 @@ msgstr "Дополнительно"
 msgid "Advance Mode"
 msgstr "Расширенный режим"
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr ""
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr "Базовый URL API"
@@ -110,10 +119,15 @@ msgstr "API токен"
 msgid "Arch"
 msgstr "Архитектура"
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "Вы уверены, что хотите немедленно удалить этот заблокированный IP?"
 
+#: src/views/preference/components/Passkey.vue:119
+#, fuzzy
+msgid "Are you sure to delete this passkey immediately?"
+msgstr "Вы уверены, что хотите немедленно удалить этот заблокированный IP?"
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 msgid "Are you sure you want to clear all notifications?"
@@ -161,7 +175,7 @@ msgstr "Обратитесь за помощью к ChatGPT"
 msgid "Assistant"
 msgstr "Ассистент"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr "Попытки"
 
@@ -169,6 +183,14 @@ msgstr "Попытки"
 msgid "Auth"
 msgstr "Авторизация"
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr ""
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -201,15 +223,15 @@ msgstr "Вернуться на главную"
 msgid "Back to list"
 msgstr "Возврат к списку"
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr "Порог блокировки в минутах"
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr "Заблокированные IP-адреса"
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr "Заблокирован до"
 
@@ -250,7 +272,7 @@ msgstr "Директория корневого сертификата"
 msgid "CADir"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr ""
 
@@ -264,6 +286,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -413,6 +436,7 @@ msgid "Create Folder"
 msgstr "Создать папку"
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr "Создан в"
@@ -433,12 +457,14 @@ msgstr "Учетные данные"
 msgid "Credentials"
 msgstr "Учетные данные"
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
+#: src/views/preference/components/TOTP.vue:99
+#, fuzzy
+msgid "Current account is enabled TOTP."
 msgstr "Текущая учетная запись имеет включенную 2ФА."
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
+#: src/views/preference/components/TOTP.vue:96
+#, fuzzy
+msgid "Current account is not enabled TOTP."
 msgstr ""
 "Текущая учетная запись не имеет включенной двухфакторной аутентификации."
 
@@ -650,6 +676,12 @@ msgstr "Загрузка последней версии"
 msgid "Dry run mode enabled"
 msgstr "Включен пробный режим"
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid ""
+"Due to the security policies of some browsers, you cannot use passkeys on "
+"non-HTTPS websites, except when running on localhost."
+msgstr ""
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -720,11 +752,7 @@ msgstr "Включение %{conf_name} in %{node_name} нипалучилася
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "Включение %{conf_name} in %{node_name} успешно"
 
-#: src/views/preference/components/TOTP.vue:124
-msgid "Enable 2FA"
-msgstr "Включить 2ФА"
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 msgid "Enable 2FA successfully"
 msgstr "Двухфакторная аутентификация успешно включена"
 
@@ -745,6 +773,11 @@ msgstr "Включено успешно"
 msgid "Enable TLS"
 msgstr "Включить TLS"
 
+#: src/views/preference/components/TOTP.vue:125
+#, fuzzy
+msgid "Enable TOTP"
+msgstr "Включить TLS"
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175 src/views/domain/DomainList.vue:29
 #: src/views/environment/envColumns.tsx:104
@@ -858,6 +891,12 @@ msgstr "Фильтр"
 msgid "Finished"
 msgstr "Готово"
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid ""
+"Follow the instructions in the dialog to complete the passkey registration "
+"process."
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:43
 msgid "For Chinese user: https://mirror.ghproxy.com/"
 msgstr "Для китайских пользователей: https://mirror.ghproxy.com/"
@@ -926,7 +965,7 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr "Если оставить пустым, будет использоваться каталог CA по умолчанию."
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
@@ -935,7 +974,7 @@ msgstr ""
 "количества попыток в течение пороговых минут блокировки, IP будет "
 "заблокирован на определенный период времени."
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid ""
 "If you lose your mobile phone, you can use the recovery code to reset your "
 "2FA."
@@ -943,6 +982,10 @@ msgstr ""
 "Если вы потеряете свой мобильный телефон, вы можете использовать код "
 "восстановления для сброса 2FA."
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr ""
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid ""
 "If your domain has CNAME records and you cannot obtain certificates, you "
@@ -959,7 +1002,7 @@ msgstr "Импорт"
 msgid "Import Certificate"
 msgstr "Импортировать сертификат"
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 msgid "Incorrect username or password"
 msgstr "Неверное имя пользователя или пароль"
 
@@ -975,12 +1018,12 @@ msgstr "Ошибка первоначального обновления ядр
 msgid "Initialing core upgrader"
 msgstr "Инициализация программы обновления ядра"
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr "Введите код из приложения:"
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr "Введите код восстановления:"
 
@@ -1000,7 +1043,7 @@ msgstr "Интервал"
 msgid "Invalid"
 msgstr "Недействительно"
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr "Неверный 2FA или код восстановления"
 
@@ -1013,11 +1056,11 @@ msgstr "Неверное имя файла"
 msgid "Invalid folder name"
 msgstr "Недопустимое имя папки"
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr "Неверный пароль или код восстановления"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr "IP"
 
@@ -1050,6 +1093,11 @@ msgstr "Тип ключа"
 msgid "Last checked at"
 msgstr "Последняя проверка в"
 
+#: src/views/preference/components/Passkey.vue:102
+#, fuzzy
+msgid "Last used at"
+msgstr "Последняя проверка в"
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr "Оставьте пустым без изменений"
@@ -1109,11 +1157,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "Журнал"
 
-#: src/routes/index.ts:305 src/views/other/Login.vue:207
+#: src/routes/index.ts:305 src/views/other/Login.vue:247
 msgid "Login"
 msgstr "Логин"
 
-#: src/views/other/Login.vue:130 src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133 src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr "Авторизация успешна"
 
@@ -1170,7 +1218,7 @@ msgstr "Пользователи"
 msgid "Managed Certificate"
 msgstr "Управление сертификатом"
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr "Максимальное количество попыток"
 
@@ -1225,6 +1273,7 @@ msgstr "Многострочная директива"
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13 src/views/stream/StreamList.vue:188
@@ -1306,7 +1355,7 @@ msgstr "Nginx успешно перезапущен"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1407,6 +1456,10 @@ msgstr "Онлайн"
 msgid "OpenAI"
 msgstr "OpenAI"
 
+#: src/components/2FA/Authorization.vue:117 src/views/other/Login.vue:256
+msgid "Or"
+msgstr ""
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr "Оригинальное имя"
@@ -1435,7 +1488,18 @@ msgstr "Перезаписать существующий файл"
 msgid "Params"
 msgstr "Параметры"
 
-#: src/views/other/Login.vue:174 src/views/user/userColumns.tsx:18
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:68
+msgid ""
+"Passkeys are webauthn credentials that validate your identity using touch, "
+"facial recognition, a device password, or a PIN. They can be used as a "
+"password replacement or as a 2FA method."
+msgstr ""
+
+#: src/views/other/Login.vue:208 src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr "Пароль"
 
@@ -1461,8 +1525,15 @@ msgstr "Ошибка обновления ядра"
 msgid "Performing core upgrade"
 msgstr "Выполнение обновления ядра"
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid ""
+"Please enter a name for the passkey you wish to create and click the OK "
+"button below."
+msgstr ""
+
+#: src/components/2FA/Authorization.vue:75
+#, fuzzy
+msgid "Please enter the OTP code:"
 msgstr "Пожалуйста, введите код 2FA:"
 
 #: src/views/certificate/DNSCredential.vue:53
@@ -1508,11 +1579,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "Введите ваш E-mail!"
 
-#: src/views/other/Install.vue:44 src/views/other/Login.vue:44
+#: src/views/other/Install.vue:44 src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr "Введите ваш пароль!"
 
-#: src/views/other/Install.vue:38 src/views/other/Login.vue:38
+#: src/views/other/Install.vue:38 src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr "Введите ваше имя пользователя!"
 
@@ -1576,16 +1647,16 @@ msgstr "Восстановить"
 msgid "Recovered Successfully"
 msgstr "Восстановлено успешно"
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr "Восстановление"
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr "Код восстановления"
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr "Код восстановления:"
 
@@ -1605,6 +1676,11 @@ msgstr "Регистрация"
 msgid "Register failed"
 msgstr "Регистрация не удалась"
 
+#: src/views/preference/components/AddPasskey.vue:26
+#, fuzzy
+msgid "Register passkey successfully"
+msgstr "Зарегистрировано успешно"
+
 #: src/views/certificate/ACMEUser.vue:67
 msgid "Register successfully"
 msgstr "Зарегистрировано успешно"
@@ -1638,11 +1714,12 @@ msgstr "Перезагружается"
 msgid "Reloading nginx"
 msgstr "Перезагружается nginx"
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr "Удалить"
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 msgid "Remove successfully"
 msgstr "Удалено успешно"
 
@@ -1713,7 +1790,7 @@ msgstr "Запрос с неправильными параметрами"
 msgid "Reset"
 msgstr "Сброс"
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 msgid "Reset 2FA"
 msgstr "Сброс 2FA"
 
@@ -1737,6 +1814,7 @@ msgstr "Выполняется"
 #: src/views/certificate/CertificateEditor.vue:254
 #: src/views/config/ConfigEditor.vue:222 src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151 src/views/stream/StreamEdit.vue:252
 msgid "Save"
 msgstr "Сохранить"
@@ -1764,7 +1842,7 @@ msgstr "Сохранено успешно"
 msgid "Saved successfully"
 msgstr "Успешно сохранено"
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr ""
 "Отсканируйте QR-код с помощью мобильного телефона, чтобы добавить учетную "
@@ -1774,7 +1852,7 @@ msgstr ""
 msgid "SDK"
 msgstr "SDK"
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr ""
 
@@ -1799,9 +1877,12 @@ msgstr "Отправлено"
 #: src/views/config/ConfigEditor.vue:108 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1847,6 +1928,10 @@ msgstr ""
 msgid "Show"
 msgstr "Показать"
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr "Одиночная Директива"
@@ -1875,7 +1960,7 @@ msgstr "Путь к ключу SSL-сертификата"
 msgid "SSL Certificate Path"
 msgstr "Путь к SSL сертификату"
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 msgid "SSO Login"
 msgstr "SSO Вход"
 
@@ -2042,7 +2127,7 @@ msgstr "Путь существует, но файл не является се
 msgid "The path exists, but the file is not a private key"
 msgstr "Путь существует, но файл не является приватным ключом"
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid ""
 "The recovery code is only displayed once, please save it in a safe place."
 msgstr ""
@@ -2114,7 +2199,8 @@ msgstr ""
 "Это обновит или переустановит интерфейс Nginx на %{nodeNames} до версии "
 "%{version}."
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr "Советы"
@@ -2123,7 +2209,7 @@ msgstr "Советы"
 msgid "Title"
 msgstr "Заголовок"
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid ""
 "To enable it, you need to install the Google or Microsoft Authenticator app "
 "on your mobile phone."
@@ -2131,6 +2217,13 @@ msgstr ""
 "Чтобы включить это, вам нужно установить приложение Google или Microsoft "
 "Authenticator на свой мобильный телефон."
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid ""
+"To ensure security, Webauthn configuration cannot be added through the UI. "
+"Please manually configure the following in the app.ini configuration file "
+"and restart Nginx UI."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -2147,11 +2240,15 @@ msgstr ""
 msgid "Token is not valid"
 msgstr "Токен недействителен"
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr "Слишком много неудачных попыток входа, попробуйте позже"
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:92
 msgid ""
 "TOTP is a two-factor authentication method that uses a time-based one-time "
 "password algorithm."
@@ -2163,7 +2260,7 @@ msgstr ""
 msgid "Trash"
 msgstr "Корзина"
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr "Требуется двухфакторная аутентификация"
 
@@ -2173,6 +2270,11 @@ msgstr "Требуется двухфакторная аутентификаци
 msgid "Type"
 msgstr "Тип"
 
+#: src/views/preference/components/Passkey.vue:41
+#, fuzzy
+msgid "Update successfully"
+msgstr "Успешно обновлено"
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31 src/views/config/ConfigEditor.vue:275
@@ -2216,11 +2318,11 @@ msgstr "Аптайм:"
 msgid "URL"
 msgstr "URL"
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr "Использовать OTP"
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr "Использовать код восстановления"
 
@@ -2228,11 +2330,11 @@ msgstr "Использовать код восстановления"
 msgid "User"
 msgstr "Пользователь"
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr "Пользователь заблокирован"
 
-#: src/views/other/Login.vue:164 src/views/user/userColumns.tsx:9
+#: src/views/other/Login.vue:198 src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr "Имя пользователя"
 
@@ -2271,6 +2373,7 @@ msgstr "Простой режим"
 #: src/constants/index.ts:17 src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr "Внимание"
 
@@ -2305,7 +2408,7 @@ msgstr "Запись сертификата на диск"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Да"
@@ -2318,6 +2421,19 @@ msgstr "Вы используете последнюю версию"
 msgid "You can check Nginx UI upgrade at this page."
 msgstr "Вы можете проверить обновление Nginx UI на этой странице."
 
+#: src/views/preference/components/AddPasskey.vue:93
+msgid ""
+"You have not configured the settings of Webauthn, so you cannot add a "
+"passkey."
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr ""
+
+#~ msgid "Enable 2FA"
+#~ msgstr "Включить 2ФА"
+
 #, fuzzy
 #~ msgid "Rename "
 #~ msgstr "Имя пользователя"

+ 172 - 59
app/src/language/vi_VN/app.po

@@ -13,7 +13,7 @@ msgstr ""
 msgid "2FA"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr ""
 
@@ -37,7 +37,7 @@ msgstr "Người dùng"
 #: src/views/config/configColumns.ts:38 src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
 msgstr "Hành động"
@@ -52,6 +52,11 @@ msgstr "Hành động"
 msgid "Add"
 msgstr "Thêm"
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr ""
+
 #: src/routes/index.ts:112 src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
 #, fuzzy
@@ -90,6 +95,10 @@ msgstr "Tùy chọn bổ sung"
 msgid "Advance Mode"
 msgstr "Nâng cao"
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr ""
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr ""
@@ -110,11 +119,16 @@ msgstr ""
 msgid "Arch"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 #, fuzzy
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "Bạn chắc chắn muốn xóa nó "
 
+#: src/views/preference/components/Passkey.vue:119
+#, fuzzy
+msgid "Are you sure to delete this passkey immediately?"
+msgstr "Bạn chắc chắn muốn xóa nó "
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 #, fuzzy
@@ -168,7 +182,7 @@ msgstr "Hỏi ChatGPT"
 msgid "Assistant"
 msgstr "Trợ lý"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr ""
 
@@ -177,6 +191,14 @@ msgstr ""
 msgid "Auth"
 msgstr "Tác giả"
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr ""
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -210,15 +232,15 @@ msgstr "Quay lại"
 msgid "Back to list"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr ""
 
@@ -261,7 +283,7 @@ msgstr ""
 msgid "CADir"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr ""
 
@@ -275,6 +297,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -437,6 +460,7 @@ msgid "Create Folder"
 msgstr "Tạo thêm"
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr "Ngày tạo"
@@ -458,12 +482,12 @@ msgstr "Chứng chỉ"
 msgid "Credentials"
 msgstr "Chứng chỉ"
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
+#: src/views/preference/components/TOTP.vue:99
+msgid "Current account is enabled TOTP."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is not enabled TOTP."
 msgstr ""
 
 #: src/views/system/Upgrade.vue:167
@@ -687,6 +711,12 @@ msgstr "Đang tải phiên bản mới nhất"
 msgid "Dry run mode enabled"
 msgstr "Đã bật chế độ Dry run"
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid ""
+"Due to the security policies of some browsers, you cannot use passkeys on "
+"non-HTTPS websites, except when running on localhost."
+msgstr ""
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -762,12 +792,7 @@ msgstr "Không thể bật %{conf_name} trên %{node_name}"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "Đã bật %{conf_name} trên %{node_name}"
 
-#: src/views/preference/components/TOTP.vue:124
-#, fuzzy
-msgid "Enable 2FA"
-msgstr "Đã bật"
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 #, fuzzy
 msgid "Enable 2FA successfully"
 msgstr "Đã bật"
@@ -790,6 +815,11 @@ msgstr "Đã bật"
 msgid "Enable TLS"
 msgstr "Bật TLS"
 
+#: src/views/preference/components/TOTP.vue:125
+#, fuzzy
+msgid "Enable TOTP"
+msgstr "Bật TLS"
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175 src/views/domain/DomainList.vue:29
 #: src/views/environment/envColumns.tsx:104
@@ -908,6 +938,12 @@ msgstr "Lọc"
 msgid "Finished"
 msgstr "Đã hoàn thành"
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid ""
+"Follow the instructions in the dialog to complete the passkey registration "
+"process."
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:43
 #, fuzzy
 msgid "For Chinese user: https://mirror.ghproxy.com/"
@@ -981,18 +1017,22 @@ msgstr ""
 msgid "If left blank, the default CA Dir will be used."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid ""
 "If you lose your mobile phone, you can use the recovery code to reset your "
 "2FA."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr ""
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid ""
 "If your domain has CNAME records and you cannot obtain certificates, you "
@@ -1009,7 +1049,7 @@ msgstr "Xuất"
 msgid "Import Certificate"
 msgstr "Chứng chỉ"
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 #, fuzzy
 msgid "Incorrect username or password"
 msgstr "Tên người dùng hoặc mật khẩu không chính xác"
@@ -1026,12 +1066,12 @@ msgstr "Không thể khởi tạo trình nâng cấp"
 msgid "Initialing core upgrader"
 msgstr "Đang khởi tạo trình nâng cấp"
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr ""
 
@@ -1053,7 +1093,7 @@ msgstr ""
 msgid "Invalid"
 msgstr "Hợp lệ"
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr ""
 
@@ -1067,11 +1107,11 @@ msgstr "E-mail không chính xác!"
 msgid "Invalid folder name"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr ""
 
@@ -1108,6 +1148,11 @@ msgstr "Loại"
 msgid "Last checked at"
 msgstr "Kiểm tra lần cuối lúc"
 
+#: src/views/preference/components/Passkey.vue:102
+#, fuzzy
+msgid "Last used at"
+msgstr "Kiểm tra lần cuối lúc"
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr "Bỏ trống nếu không thay đổi"
@@ -1173,11 +1218,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "Log"
 
-#: src/routes/index.ts:305 src/views/other/Login.vue:207
+#: src/routes/index.ts:305 src/views/other/Login.vue:247
 msgid "Login"
 msgstr "Đăng nhập"
 
-#: src/views/other/Login.vue:130 src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133 src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr "Đăng nhập thành công"
 
@@ -1230,7 +1275,7 @@ msgstr "Người dùng"
 msgid "Managed Certificate"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr ""
 
@@ -1290,6 +1335,7 @@ msgstr "Single Directive"
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13 src/views/stream/StreamList.vue:188
@@ -1376,7 +1422,7 @@ msgstr "Restart Nginx thành công"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1478,6 +1524,10 @@ msgstr "Trực tuyến"
 msgid "OpenAI"
 msgstr ""
 
+#: src/components/2FA/Authorization.vue:117 src/views/other/Login.vue:256
+msgid "Or"
+msgstr ""
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr ""
@@ -1507,7 +1557,18 @@ msgstr "Ghi đè tập tin đã tồn tại"
 msgid "Params"
 msgstr "Tham số"
 
-#: src/views/other/Login.vue:174 src/views/user/userColumns.tsx:18
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:68
+msgid ""
+"Passkeys are webauthn credentials that validate your identity using touch, "
+"facial recognition, a device password, or a PIN. They can be used as a "
+"password replacement or as a 2FA method."
+msgstr ""
+
+#: src/views/other/Login.vue:208 src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr "Mật khẩu"
 
@@ -1533,8 +1594,14 @@ msgstr "Nâng cấp core không thành công"
 msgid "Performing core upgrade"
 msgstr "Nâng cấp core"
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid ""
+"Please enter a name for the passkey you wish to create and click the OK "
+"button below."
+msgstr ""
+
+#: src/components/2FA/Authorization.vue:75
+msgid "Please enter the OTP code:"
 msgstr ""
 
 #: src/views/certificate/DNSCredential.vue:53
@@ -1579,11 +1646,11 @@ msgstr ""
 msgid "Please input your E-mail!"
 msgstr "Vui lòng nhập E-mail của bạn!"
 
-#: src/views/other/Install.vue:44 src/views/other/Login.vue:44
+#: src/views/other/Install.vue:44 src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr "Vui lòng nhập mật khẩu!"
 
-#: src/views/other/Install.vue:38 src/views/other/Login.vue:38
+#: src/views/other/Install.vue:38 src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr "Vui lòng nhập username!"
 
@@ -1647,16 +1714,16 @@ msgstr ""
 msgid "Recovered Successfully"
 msgstr "Xoá thành công"
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr ""
 
@@ -1678,6 +1745,11 @@ msgstr "Đăng ký người dùng"
 msgid "Register failed"
 msgstr "Đăng ký người dùng"
 
+#: src/views/preference/components/AddPasskey.vue:26
+#, fuzzy
+msgid "Register passkey successfully"
+msgstr "Gia hạn chứng chỉ SSL"
+
 #: src/views/certificate/ACMEUser.vue:67
 #, fuzzy
 msgid "Register successfully"
@@ -1714,11 +1786,12 @@ msgstr "Đang tải lại"
 msgid "Reloading nginx"
 msgstr "Tải lại nginx"
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 #, fuzzy
 msgid "Remove successfully"
 msgstr "Xoá thành công"
@@ -1798,7 +1871,7 @@ msgstr "Yêu cầu có chứa tham số sai"
 msgid "Reset"
 msgstr "Đặt lại"
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 #, fuzzy
 msgid "Reset 2FA"
 msgstr "Đặt lại"
@@ -1824,6 +1897,7 @@ msgstr "Running"
 #: src/views/certificate/CertificateEditor.vue:254
 #: src/views/config/ConfigEditor.vue:222 src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151 src/views/stream/StreamEdit.vue:252
 msgid "Save"
 msgstr "Lưu"
@@ -1852,7 +1926,7 @@ msgstr "Lưu thành công"
 msgid "Saved successfully"
 msgstr "Lưu thành công"
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr ""
 
@@ -1860,7 +1934,7 @@ msgstr ""
 msgid "SDK"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr ""
 
@@ -1885,9 +1959,12 @@ msgstr "Gửi"
 #: src/views/config/ConfigEditor.vue:108 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1933,6 +2010,10 @@ msgstr "Sử dụng HTTP01 để xác thực SSL"
 msgid "Show"
 msgstr ""
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr "Single Directive"
@@ -1962,7 +2043,7 @@ msgstr ""
 msgid "SSL Certificate Path"
 msgstr ""
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 msgid "SSO Login"
 msgstr ""
 
@@ -2130,7 +2211,7 @@ msgstr ""
 msgid "The path exists, but the file is not a private key"
 msgstr ""
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid ""
 "The recovery code is only displayed once, please save it in a safe place."
 msgstr ""
@@ -2193,7 +2274,8 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr ""
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr ""
@@ -2202,12 +2284,19 @@ msgstr ""
 msgid "Title"
 msgstr "Tiêu đề"
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid ""
 "To enable it, you need to install the Google or Microsoft Authenticator app "
 "on your mobile phone."
 msgstr ""
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid ""
+"To ensure security, Webauthn configuration cannot be added through the UI. "
+"Please manually configure the following in the app.ini configuration file "
+"and restart Nginx UI."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -2224,11 +2313,15 @@ msgstr ""
 msgid "Token is not valid"
 msgstr ""
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr ""
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:92
 msgid ""
 "TOTP is a two-factor authentication method that uses a time-based one-time "
 "password algorithm."
@@ -2238,7 +2331,7 @@ msgstr ""
 msgid "Trash"
 msgstr ""
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr ""
 
@@ -2248,6 +2341,11 @@ msgstr ""
 msgid "Type"
 msgstr "Loại"
 
+#: src/views/preference/components/Passkey.vue:41
+#, fuzzy
+msgid "Update successfully"
+msgstr "Cập nhật thành công"
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31 src/views/config/ConfigEditor.vue:275
@@ -2294,11 +2392,11 @@ msgstr "Thời gian hoạt động:"
 msgid "URL"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr ""
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr ""
 
@@ -2307,11 +2405,11 @@ msgstr ""
 msgid "User"
 msgstr "Người dùng"
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr ""
 
-#: src/views/other/Login.vue:164 src/views/user/userColumns.tsx:9
+#: src/views/other/Login.vue:198 src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr "Username"
 
@@ -2353,6 +2451,7 @@ msgstr "Cơ bản"
 #: src/constants/index.ts:17 src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr "Lưu ý"
 
@@ -2387,7 +2486,7 @@ msgstr "Ghi chứng chỉ vào disk"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "Có"
@@ -2400,6 +2499,20 @@ msgstr "Bạn đang sử dụng phiên bản mới nhất"
 msgid "You can check Nginx UI upgrade at this page."
 msgstr "Bạn có thể kiểm tra nâng cấp Nginx UI tại trang này"
 
+#: src/views/preference/components/AddPasskey.vue:93
+msgid ""
+"You have not configured the settings of Webauthn, so you cannot add a "
+"passkey."
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr ""
+
+#, fuzzy
+#~ msgid "Enable 2FA"
+#~ msgstr "Đã bật"
+
 #, fuzzy
 #~ msgid "Rename "
 #~ msgstr "Username"

BIN=BIN
app/src/language/zh_CN/app.mo


+ 175 - 61
app/src/language/zh_CN/app.po

@@ -17,7 +17,7 @@ msgstr ""
 msgid "2FA"
 msgstr "2FA"
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr "2FA 设置"
 
@@ -40,7 +40,7 @@ msgstr "ACME 用户"
 #: src/views/config/configColumns.ts:38 src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
 msgstr "操作"
@@ -55,6 +55,11 @@ msgstr "操作"
 msgid "Add"
 msgstr "添加"
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr "添加 Passkey"
+
 #: src/routes/index.ts:112 src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
 msgid "Add Configuration"
@@ -89,6 +94,10 @@ msgstr "额外选项"
 msgid "Advance Mode"
 msgstr "高级模式"
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr "然后,刷新此页面并再次点击添加 Passkey。"
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr "API 地址"
@@ -109,10 +118,14 @@ msgstr "API Token"
 msgid "Arch"
 msgstr "架构"
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "您确定要立即删除这个被禁用的 IP 吗?"
 
+#: src/views/preference/components/Passkey.vue:119
+msgid "Are you sure to delete this passkey immediately?"
+msgstr "您确定要立即删除这个 Passkey 吗?"
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 msgid "Are you sure you want to clear all notifications?"
@@ -158,7 +171,7 @@ msgstr "与ChatGPT聊天"
 msgid "Assistant"
 msgstr "助手"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr "尝试次数"
 
@@ -166,6 +179,14 @@ msgstr "尝试次数"
 msgid "Auth"
 msgstr "认证"
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr "通过 Passkey 认证"
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr "认证设置"
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -198,15 +219,15 @@ msgstr "返回首页"
 msgid "Back to list"
 msgstr "返回列表"
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr "禁止阈值(分钟)"
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr "禁止 IP 列表"
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr "禁用至"
 
@@ -246,7 +267,7 @@ msgstr "CA Dir"
 msgid "CADir"
 msgstr "CADir"
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr "无法扫描?使用文本密钥绑定"
 
@@ -260,6 +281,7 @@ msgstr "无法扫描?使用文本密钥绑定"
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -407,6 +429,7 @@ msgid "Create Folder"
 msgstr "创建文件夹"
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr "创建时间"
@@ -427,13 +450,13 @@ msgstr "DNS 凭证"
 msgid "Credentials"
 msgstr "凭证"
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
-msgstr "当前账户已启用二步验证。"
+#: src/views/preference/components/TOTP.vue:99
+msgid "Current account is enabled TOTP."
+msgstr "当前账户已启用 TOTP 验证。"
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
-msgstr "当前用户未启用二步验证。"
+#: src/views/preference/components/TOTP.vue:96
+msgid "Current account is not enabled TOTP."
+msgstr "当前用户未启用 TOTP 验证。"
 
 #: src/views/system/Upgrade.vue:167
 msgid "Current Version"
@@ -640,6 +663,14 @@ msgstr "下载最新版本"
 msgid "Dry run mode enabled"
 msgstr "试运行模式已启动"
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid ""
+"Due to the security policies of some browsers, you cannot use passkeys on "
+"non-HTTPS websites, except when running on localhost."
+msgstr ""
+"由于某些浏览器的安全策略,除非在 localhost 上使用,否则不能在非 HTTPS 网站上"
+"使用 Passkey。"
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -708,11 +739,7 @@ msgstr "在%{node_name}中启用%{conf_name}失败"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "成功启用%{node_name}中的%{conf_name}"
 
-#: src/views/preference/components/TOTP.vue:124
-msgid "Enable 2FA"
-msgstr "启用二步验证"
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 msgid "Enable 2FA successfully"
 msgstr "二步验证启用成功"
 
@@ -733,6 +760,10 @@ msgstr "启用成功"
 msgid "Enable TLS"
 msgstr "启用 TLS"
 
+#: src/views/preference/components/TOTP.vue:125
+msgid "Enable TOTP"
+msgstr "启用 TOTP"
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175 src/views/domain/DomainList.vue:29
 #: src/views/environment/envColumns.tsx:104
@@ -846,6 +877,12 @@ msgstr "过滤"
 msgid "Finished"
 msgstr "完成"
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid ""
+"Follow the instructions in the dialog to complete the passkey registration "
+"process."
+msgstr "按照对话框中的指示完成 Passkey 的注册过程。"
+
 #: src/views/preference/BasicSettings.vue:43
 msgid "For Chinese user: https://mirror.ghproxy.com/"
 msgstr "中国用户:https://mirror.ghproxy.com/"
@@ -914,7 +951,7 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr "如果留空,则使用默认 CA Dir。"
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
@@ -922,12 +959,16 @@ msgstr ""
 "如果某个 IP 的登录失败次数达到禁用阈值分钟内的最大尝试次数,该 IP 将被禁止登"
 "录一段时间。"
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid ""
 "If you lose your mobile phone, you can use the recovery code to reset your "
 "2FA."
 msgstr "如果丢失了手机,可以使用恢复代码重置二步验证。"
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr "如果您的浏览器支持 WebAuthn Passkey,则会出现一个对话框。"
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid ""
 "If your domain has CNAME records and you cannot obtain certificates, you "
@@ -942,7 +983,7 @@ msgstr "导入"
 msgid "Import Certificate"
 msgstr "导入证书"
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 msgid "Incorrect username or password"
 msgstr "用户名或密码错误"
 
@@ -958,12 +999,12 @@ msgstr "初始化核心升级程序错误"
 msgid "Initialing core upgrader"
 msgstr "初始化核心升级器"
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr "输入应用程序中的代码:"
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr "输入恢复代码:"
 
@@ -983,7 +1024,7 @@ msgstr "间隔"
 msgid "Invalid"
 msgstr "无效的"
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr "无效的二步验证码或恢复密码"
 
@@ -996,11 +1037,11 @@ msgstr "文件名无效"
 msgid "Invalid folder name"
 msgstr "无效文件夹名"
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr "二次验证码或恢复代码无效"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr "IP"
 
@@ -1033,6 +1074,10 @@ msgstr "密钥类型"
 msgid "Last checked at"
 msgstr "最后检查时间"
 
+#: src/views/preference/components/Passkey.vue:102
+msgid "Last used at"
+msgstr "上次使用"
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr "留空表示不修改"
@@ -1092,11 +1137,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "日志"
 
-#: src/routes/index.ts:305 src/views/other/Login.vue:207
+#: src/routes/index.ts:305 src/views/other/Login.vue:247
 msgid "Login"
 msgstr "登录"
 
-#: src/views/other/Login.vue:130 src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133 src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr "登录成功"
 
@@ -1151,7 +1196,7 @@ msgstr "用户管理"
 msgid "Managed Certificate"
 msgstr "托管证书"
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr "最大尝试次数"
 
@@ -1206,6 +1251,7 @@ msgstr "多行指令"
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13 src/views/stream/StreamList.vue:188
@@ -1287,7 +1333,7 @@ msgstr "Nginx 重启成功"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1386,6 +1432,10 @@ msgstr "在线"
 msgid "OpenAI"
 msgstr "OpenAI"
 
+#: src/components/2FA/Authorization.vue:117 src/views/other/Login.vue:256
+msgid "Or"
+msgstr "或"
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr "原名"
@@ -1414,7 +1464,20 @@ msgstr "覆盖现有文件"
 msgid "Params"
 msgstr "参数"
 
-#: src/views/other/Login.vue:174 src/views/user/userColumns.tsx:18
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr "Passkey"
+
+#: src/views/preference/components/Passkey.vue:68
+msgid ""
+"Passkeys are webauthn credentials that validate your identity using touch, "
+"facial recognition, a device password, or a PIN. They can be used as a "
+"password replacement or as a 2FA method."
+msgstr ""
+"Passkey 是一种网络认证凭据,可通过指纹、面部识别、设备密码或 PIN 码验证身份。"
+"它们可用作密码替代品或二步验证方法。"
+
+#: src/views/other/Login.vue:208 src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr "密码"
 
@@ -1440,9 +1503,15 @@ msgstr "执行核心升级错误"
 msgid "Performing core upgrade"
 msgstr "正在进行核心升级"
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
-msgstr "请输入二步验证码:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid ""
+"Please enter a name for the passkey you wish to create and click the OK "
+"button below."
+msgstr "请为您要创建的 Passkey 输入一个名称,然后单击下面的 \"确定 \"按钮。"
+
+#: src/components/2FA/Authorization.vue:75
+msgid "Please enter the OTP code:"
+msgstr "请输入 OTP:"
 
 #: src/views/certificate/DNSCredential.vue:53
 msgid ""
@@ -1482,11 +1551,11 @@ msgstr "请输入名称,这将被用作新配置的文件名!"
 msgid "Please input your E-mail!"
 msgstr "请输入您的邮箱!"
 
-#: src/views/other/Install.vue:44 src/views/other/Login.vue:44
+#: src/views/other/Install.vue:44 src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr "请输入您的密码!"
 
-#: src/views/other/Install.vue:38 src/views/other/Login.vue:38
+#: src/views/other/Install.vue:38 src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr "请输入您的用户名!"
 
@@ -1548,16 +1617,16 @@ msgstr "恢复"
 msgid "Recovered Successfully"
 msgstr "恢复成功"
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr "恢复"
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr "恢复代码"
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr "恢复代码:"
 
@@ -1577,6 +1646,10 @@ msgstr "注册"
 msgid "Register failed"
 msgstr "注册失败"
 
+#: src/views/preference/components/AddPasskey.vue:26
+msgid "Register passkey successfully"
+msgstr "Passkey 注册成功"
+
 #: src/views/certificate/ACMEUser.vue:67
 msgid "Register successfully"
 msgstr "注册成功"
@@ -1610,11 +1683,12 @@ msgstr "重载中"
 msgid "Reloading nginx"
 msgstr "正在重载 Nginx"
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr "删除"
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 msgid "Remove successfully"
 msgstr "移除成功"
 
@@ -1684,7 +1758,7 @@ msgstr "请求参数错误"
 msgid "Reset"
 msgstr "重置"
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 msgid "Reset 2FA"
 msgstr "重置二步验证"
 
@@ -1708,6 +1782,7 @@ msgstr "运行中"
 #: src/views/certificate/CertificateEditor.vue:254
 #: src/views/config/ConfigEditor.vue:222 src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151 src/views/stream/StreamEdit.vue:252
 msgid "Save"
 msgstr "保存"
@@ -1735,7 +1810,7 @@ msgstr "保存成功"
 msgid "Saved successfully"
 msgstr "保存成功"
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr "用手机扫描二维码,将账户添加到应用程序中。"
 
@@ -1743,7 +1818,7 @@ msgstr "用手机扫描二维码,将账户添加到应用程序中。"
 msgid "SDK"
 msgstr "SDK"
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr "密钥已复制"
 
@@ -1768,9 +1843,12 @@ msgstr "上传"
 #: src/views/config/ConfigEditor.vue:108 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1814,6 +1892,10 @@ msgstr "使用 HTTP01 challenge provider"
 msgid "Show"
 msgstr "显示"
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr "使用 Passkey 登录"
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr "单行指令"
@@ -1842,7 +1924,7 @@ msgstr "SSL证书密钥路径"
 msgid "SSL Certificate Path"
 msgstr "SSL证书路径"
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 msgid "SSO Login"
 msgstr "SSO 登录"
 
@@ -2000,7 +2082,7 @@ msgstr "路径存在,但文件不是证书"
 msgid "The path exists, but the file is not a private key"
 msgstr "路径存在,但文件不是私钥"
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid ""
 "The recovery code is only displayed once, please save it in a safe place."
 msgstr "恢复密码只会显示一次,请妥善保存。"
@@ -2063,7 +2145,8 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr "将 %{nodeNames} 上的 Nginx UI 升级或重新安装到 %{version} 版本。"
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr "提示"
@@ -2072,13 +2155,22 @@ msgstr "提示"
 msgid "Title"
 msgstr "标题"
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid ""
 "To enable it, you need to install the Google or Microsoft Authenticator app "
 "on your mobile phone."
 msgstr ""
 "要启用该功能,您需要在手机上安装 Google 或 Microsoft Authenticator 应用程序。"
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid ""
+"To ensure security, Webauthn configuration cannot be added through the UI. "
+"Please manually configure the following in the app.ini configuration file "
+"and restart Nginx UI."
+msgstr ""
+"为确保安全,Webauthn 配置不能通过用户界面添加。请在 app.ini 配置文件中手动配"
+"置以下内容,并重启 Nginx UI 服务。"
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -2093,11 +2185,15 @@ msgstr ""
 msgid "Token is not valid"
 msgstr "Token 无效"
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr "登录失败次数过多,请稍后再试"
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr "TOTP"
+
+#: src/views/preference/components/TOTP.vue:92
 msgid ""
 "TOTP is a two-factor authentication method that uses a time-based one-time "
 "password algorithm."
@@ -2107,7 +2203,7 @@ msgstr "TOTP 是一种使用基于时间的一次性密码算法的双因素身
 msgid "Trash"
 msgstr "回收站"
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr "需要两步验证"
 
@@ -2117,6 +2213,10 @@ msgstr "需要两步验证"
 msgid "Type"
 msgstr "类型"
 
+#: src/views/preference/components/Passkey.vue:41
+msgid "Update successfully"
+msgstr "更新成功"
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31 src/views/config/ConfigEditor.vue:275
@@ -2160,11 +2260,11 @@ msgstr "运行时间:"
 msgid "URL"
 msgstr "URL"
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr "使用二步验证码"
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr "使用恢复代码"
 
@@ -2172,11 +2272,11 @@ msgstr "使用恢复代码"
 msgid "User"
 msgstr "用户"
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr "用户被禁止"
 
-#: src/views/other/Login.vue:164 src/views/user/userColumns.tsx:9
+#: src/views/other/Login.vue:198 src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr "用户名"
 
@@ -2214,6 +2314,7 @@ msgstr "预览模式"
 #: src/constants/index.ts:17 src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr "警告"
 
@@ -2245,7 +2346,7 @@ msgstr "正在将证书写入磁盘"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "是的"
@@ -2258,6 +2359,19 @@ msgstr "您使用的是最新版本"
 msgid "You can check Nginx UI upgrade at this page."
 msgstr "你可以在这个页面检查Nginx UI的升级。"
 
+#: src/views/preference/components/AddPasskey.vue:93
+msgid ""
+"You have not configured the settings of Webauthn, so you cannot add a "
+"passkey."
+msgstr "您尚未配置 Webauthn 的设置,因此无法添加 Passkey。"
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr "你的 Passkeys"
+
+#~ msgid "Enable 2FA"
+#~ msgstr "启用二步验证"
+
 #~ msgid "Enter"
 #~ msgstr "进入"
 

+ 174 - 58
app/src/language/zh_TW/app.po

@@ -21,7 +21,7 @@ msgstr ""
 msgid "2FA"
 msgstr "多重要素驗證"
 
-#: src/views/preference/components/TOTP.vue:90
+#: src/views/preference/AuthSettings.vue:58
 msgid "2FA Settings"
 msgstr "多重要素驗證設定"
 
@@ -44,7 +44,7 @@ msgstr "ACME 用戶"
 #: src/views/config/configColumns.ts:38 src/views/domain/DomainList.vue:47
 #: src/views/environment/envColumns.tsx:131
 #: src/views/notification/notificationColumns.tsx:54
-#: src/views/preference/AuthSettings.vue:26 src/views/stream/StreamList.vue:47
+#: src/views/preference/AuthSettings.vue:27 src/views/stream/StreamList.vue:47
 #: src/views/user/userColumns.tsx:60
 msgid "Action"
 msgstr "操作"
@@ -59,6 +59,11 @@ msgstr "操作"
 msgid "Add"
 msgstr "新增"
 
+#: src/views/preference/components/AddPasskey.vue:51
+#: src/views/preference/components/AddPasskey.vue:55
+msgid "Add a passkey"
+msgstr ""
+
 #: src/routes/index.ts:112 src/views/config/ConfigEditor.vue:143
 #: src/views/config/ConfigEditor.vue:204
 msgid "Add Configuration"
@@ -93,6 +98,10 @@ msgstr "其他設定"
 msgid "Advance Mode"
 msgstr "進階模式"
 
+#: src/views/preference/components/AddPasskey.vue:105
+msgid "Afterwards, refresh this page and click add passkey again."
+msgstr ""
+
 #: src/views/preference/OpenAISettings.vue:44
 msgid "API Base Url"
 msgstr "API 基礎網址"
@@ -113,10 +122,15 @@ msgstr "API Token"
 msgid "Arch"
 msgstr "架構"
 
-#: src/views/preference/AuthSettings.vue:96
+#: src/views/preference/AuthSettings.vue:102
 msgid "Are you sure to delete this banned IP immediately?"
 msgstr "您確定要刪除這個被禁用的 IP 嗎?"
 
+#: src/views/preference/components/Passkey.vue:119
+#, fuzzy
+msgid "Are you sure to delete this passkey immediately?"
+msgstr "您確定要刪除這個被禁用的 IP 嗎?"
+
 #: src/components/Notification/Notification.vue:86
 #: src/views/notification/Notification.vue:40
 msgid "Are you sure you want to clear all notifications?"
@@ -162,7 +176,7 @@ msgstr "向 ChatGPT 尋求幫助"
 msgid "Assistant"
 msgstr "助理"
 
-#: src/views/preference/AuthSettings.vue:17
+#: src/views/preference/AuthSettings.vue:18
 msgid "Attempts"
 msgstr "嘗試次數"
 
@@ -170,6 +184,14 @@ msgstr "嘗試次數"
 msgid "Auth"
 msgstr "身份驗證"
 
+#: src/components/2FA/Authorization.vue:126
+msgid "Authenticate with a passkey"
+msgstr ""
+
+#: src/views/preference/AuthSettings.vue:63
+msgid "Authentication Settings"
+msgstr ""
+
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:106
 #: src/views/domain/ngx_conf/config_template/ConfigTemplate.vue:120
 msgid "Author"
@@ -202,15 +224,15 @@ msgstr "返回首頁"
 msgid "Back to list"
 msgstr "返回列表"
 
-#: src/views/preference/AuthSettings.vue:70
+#: src/views/preference/AuthSettings.vue:76
 msgid "Ban Threshold Minutes"
 msgstr "封禁閾值分鐘數"
 
-#: src/views/preference/AuthSettings.vue:84
+#: src/views/preference/AuthSettings.vue:90
 msgid "Banned IPs"
 msgstr "被禁止的 IP"
 
-#: src/views/preference/AuthSettings.vue:20
+#: src/views/preference/AuthSettings.vue:21
 msgid "Banned Until"
 msgstr "禁止至"
 
@@ -250,7 +272,7 @@ msgstr "CA Dir"
 msgid "CADir"
 msgstr "CADir"
 
-#: src/views/preference/components/TOTP.vue:150
+#: src/views/preference/components/TOTP.vue:151
 msgid "Can't scan? Use text key binding"
 msgstr ""
 
@@ -264,6 +286,7 @@ msgstr ""
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:50
 #: src/views/domain/ngx_conf/NgxServer.vue:80
 #: src/views/domain/ngx_conf/NgxUpstream.vue:33
+#: src/views/preference/components/Passkey.vue:147
 #: src/views/stream/components/Deploy.vue:21
 #: src/views/stream/components/RightSettings.vue:51
 msgid "Cancel"
@@ -411,6 +434,7 @@ msgid "Create Folder"
 msgstr "創建資料夾"
 
 #: src/views/notification/notificationColumns.tsx:48
+#: src/views/preference/components/Passkey.vue:101
 #: src/views/user/userColumns.tsx:48
 msgid "Created at"
 msgstr "建立時間"
@@ -431,12 +455,14 @@ msgstr "認證"
 msgid "Credentials"
 msgstr "認證資訊"
 
-#: src/views/preference/components/TOTP.vue:98
-msgid "Current account is enabled 2FA."
+#: src/views/preference/components/TOTP.vue:99
+#, fuzzy
+msgid "Current account is enabled TOTP."
 msgstr "當前帳戶已啟用多因素身份驗證。"
 
-#: src/views/preference/components/TOTP.vue:95
-msgid "Current account is not enabled 2FA."
+#: src/views/preference/components/TOTP.vue:96
+#, fuzzy
+msgid "Current account is not enabled TOTP."
 msgstr "當前帳戶未啟用多因素身份驗證。"
 
 #: src/views/system/Upgrade.vue:167
@@ -644,6 +670,12 @@ msgstr "正在下載最新版本"
 msgid "Dry run mode enabled"
 msgstr "試運轉模式已啟用"
 
+#: src/views/preference/components/AddPasskey.vue:107
+msgid ""
+"Due to the security policies of some browsers, you cannot use passkeys on "
+"non-HTTPS websites, except when running on localhost."
+msgstr ""
+
 #: src/views/domain/components/SiteDuplicate.vue:122
 #: src/views/domain/DomainList.vue:140
 #: src/views/stream/components/StreamDuplicate.vue:122
@@ -712,11 +744,7 @@ msgstr "在 %{node_name} 啟用 %{conf_name} 失敗"
 msgid "Enable %{conf_name} in %{node_name} successfully"
 msgstr "成功在 %{node_name} 啟用 %{conf_name}"
 
-#: src/views/preference/components/TOTP.vue:124
-msgid "Enable 2FA"
-msgstr "啟用多因素身份驗證"
-
-#: src/views/preference/components/TOTP.vue:54
+#: src/views/preference/components/TOTP.vue:55
 msgid "Enable 2FA successfully"
 msgstr "啟用多因素身份驗證成功"
 
@@ -737,6 +765,11 @@ msgstr "啟用成功"
 msgid "Enable TLS"
 msgstr "啟用 TLS"
 
+#: src/views/preference/components/TOTP.vue:125
+#, fuzzy
+msgid "Enable TOTP"
+msgstr "啟用 TLS"
+
 #: src/views/domain/components/RightSettings.vue:77
 #: src/views/domain/DomainEdit.vue:175 src/views/domain/DomainList.vue:29
 #: src/views/environment/envColumns.tsx:104
@@ -850,6 +883,12 @@ msgstr "篩選"
 msgid "Finished"
 msgstr "完成"
 
+#: src/views/preference/components/AddPasskey.vue:77
+msgid ""
+"Follow the instructions in the dialog to complete the passkey registration "
+"process."
+msgstr ""
+
 #: src/views/preference/BasicSettings.vue:43
 msgid "For Chinese user: https://mirror.ghproxy.com/"
 msgstr "中國使用者:https://mirror.ghproxy.com/"
@@ -918,7 +957,7 @@ msgstr "HTTP01"
 msgid "If left blank, the default CA Dir will be used."
 msgstr "如果留空,將使用默認的 CA Dir。"
 
-#: src/views/preference/AuthSettings.vue:62
+#: src/views/preference/AuthSettings.vue:68
 msgid ""
 "If the number of login failed attempts from a ip reach the max attempts in "
 "ban threshold minutes, the ip will be banned for a period of time."
@@ -926,12 +965,16 @@ msgstr ""
 "如果來自某個 IP 的登錄失敗次數在禁止閾值分鐘內達到最大嘗試次數,該 IP 將被禁"
 "止一段時間。"
 
-#: src/views/preference/components/TOTP.vue:110
+#: src/views/preference/components/TOTP.vue:111
 msgid ""
 "If you lose your mobile phone, you can use the recovery code to reset your "
 "2FA."
 msgstr "如果您丟失了手機,可以使用恢復碼重置您的多重因素驗證驗證。"
 
+#: src/views/preference/components/AddPasskey.vue:76
+msgid "If your browser supports WebAuthn Passkey, a dialog box will appear."
+msgstr ""
+
 #: src/views/domain/cert/components/AutoCertStepOne.vue:109
 msgid ""
 "If your domain has CNAME records and you cannot obtain certificates, you "
@@ -946,7 +989,7 @@ msgstr "導入"
 msgid "Import Certificate"
 msgstr "導入憑證"
 
-#: src/views/other/Login.vue:76
+#: src/views/other/Login.vue:79
 msgid "Incorrect username or password"
 msgstr "使用者名稱或密碼不正確"
 
@@ -962,12 +1005,12 @@ msgstr "初始化核心升級程式錯誤"
 msgid "Initialing core upgrader"
 msgstr "正在初始化核心升級程式"
 
-#: src/views/preference/components/TOTP.vue:157
+#: src/views/preference/components/TOTP.vue:158
 msgid "Input the code from the app:"
 msgstr "請輸入應用程式中的代碼:"
 
-#: src/components/OTP/OTPAuthorization.vue:49
-#: src/views/preference/components/TOTP.vue:170
+#: src/components/2FA/Authorization.vue:87
+#: src/views/preference/components/TOTP.vue:171
 msgid "Input the recovery code:"
 msgstr "輸入恢復碼:"
 
@@ -987,7 +1030,7 @@ msgstr "間隔"
 msgid "Invalid"
 msgstr "無效"
 
-#: src/views/other/Login.vue:86
+#: src/views/other/Login.vue:89
 msgid "Invalid 2FA or recovery code"
 msgstr "無效的多重因素驗證或恢復碼"
 
@@ -1000,11 +1043,11 @@ msgstr "無效的檔案名"
 msgid "Invalid folder name"
 msgstr "無效的資料夾名稱"
 
-#: src/components/OTP/useOTPModal.ts:61
+#: src/components/2FA/use2FAModal.ts:65
 msgid "Invalid passcode or recovery code"
 msgstr "無效的密碼或恢復碼"
 
-#: src/views/preference/AuthSettings.vue:14
+#: src/views/preference/AuthSettings.vue:15
 msgid "IP"
 msgstr "IP"
 
@@ -1037,6 +1080,11 @@ msgstr "密鑰類型"
 msgid "Last checked at"
 msgstr "上次檢查時間"
 
+#: src/views/preference/components/Passkey.vue:102
+#, fuzzy
+msgid "Last used at"
+msgstr "上次檢查時間"
+
 #: src/views/user/userColumns.tsx:25
 msgid "Leave blank for no change"
 msgstr "留空表示不修改"
@@ -1096,11 +1144,11 @@ msgstr "Locations"
 msgid "Log"
 msgstr "日誌"
 
-#: src/routes/index.ts:305 src/views/other/Login.vue:207
+#: src/routes/index.ts:305 src/views/other/Login.vue:247
 msgid "Login"
 msgstr "登入"
 
-#: src/views/other/Login.vue:130 src/views/other/Login.vue:63
+#: src/views/other/Login.vue:133 src/views/other/Login.vue:66
 msgid "Login successful"
 msgstr "登入成功"
 
@@ -1154,7 +1202,7 @@ msgstr "管理使用者"
 msgid "Managed Certificate"
 msgstr "受管理的憑證"
 
-#: src/views/preference/AuthSettings.vue:76
+#: src/views/preference/AuthSettings.vue:82
 msgid "Max Attempts"
 msgstr "最大嘗試次數"
 
@@ -1209,6 +1257,7 @@ msgstr "多行指令"
 #: src/views/domain/DomainList.vue:13
 #: src/views/domain/ngx_conf/NgxUpstream.vue:175
 #: src/views/environment/envColumns.tsx:9
+#: src/views/preference/components/AddPasskey.vue:81
 #: src/views/stream/components/RightSettings.vue:82
 #: src/views/stream/components/StreamDuplicate.vue:129
 #: src/views/stream/StreamList.vue:13 src/views/stream/StreamList.vue:188
@@ -1290,7 +1339,7 @@ msgstr "Nginx 重啟成功"
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:90
 #: src/views/domain/ngx_conf/LocationEditor.vue:70
 #: src/views/notification/Notification.vue:38
-#: src/views/preference/AuthSettings.vue:98
+#: src/views/preference/AuthSettings.vue:104
 #: src/views/preference/BasicSettings.vue:101
 #: src/views/stream/StreamList.vue:165
 msgid "No"
@@ -1389,6 +1438,10 @@ msgstr "線上"
 msgid "OpenAI"
 msgstr "OpenAI"
 
+#: src/components/2FA/Authorization.vue:117 src/views/other/Login.vue:256
+msgid "Or"
+msgstr ""
+
 #: src/views/config/components/Rename.vue:69
 msgid "Original name"
 msgstr "原始名稱"
@@ -1417,7 +1470,18 @@ msgstr "覆蓋現有檔案"
 msgid "Params"
 msgstr "參數"
 
-#: src/views/other/Login.vue:174 src/views/user/userColumns.tsx:18
+#: src/views/preference/components/Passkey.vue:65
+msgid "Passkey"
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:68
+msgid ""
+"Passkeys are webauthn credentials that validate your identity using touch, "
+"facial recognition, a device password, or a PIN. They can be used as a "
+"password replacement or as a 2FA method."
+msgstr ""
+
+#: src/views/other/Login.vue:208 src/views/user/userColumns.tsx:18
 msgid "Password"
 msgstr "密碼"
 
@@ -1443,8 +1507,15 @@ msgstr "執行核心升級錯誤"
 msgid "Performing core upgrade"
 msgstr "正在執行核心升級"
 
-#: src/components/OTP/OTPAuthorization.vue:37
-msgid "Please enter the 2FA code:"
+#: src/views/preference/components/AddPasskey.vue:75
+msgid ""
+"Please enter a name for the passkey you wish to create and click the OK "
+"button below."
+msgstr ""
+
+#: src/components/2FA/Authorization.vue:75
+#, fuzzy
+msgid "Please enter the OTP code:"
 msgstr "請輸入多重因素驗證碼:"
 
 #: src/views/certificate/DNSCredential.vue:53
@@ -1485,11 +1556,11 @@ msgstr "請輸入名稱,這將作為新設定的檔名!"
 msgid "Please input your E-mail!"
 msgstr "請輸入您的電子郵件!"
 
-#: src/views/other/Install.vue:44 src/views/other/Login.vue:44
+#: src/views/other/Install.vue:44 src/views/other/Login.vue:47
 msgid "Please input your password!"
 msgstr "請輸入您的密碼!"
 
-#: src/views/other/Install.vue:38 src/views/other/Login.vue:38
+#: src/views/other/Install.vue:38 src/views/other/Login.vue:41
 msgid "Please input your username!"
 msgstr "請輸入您的使用者名稱!"
 
@@ -1551,16 +1622,16 @@ msgstr "恢復"
 msgid "Recovered Successfully"
 msgstr "恢復成功"
 
-#: src/components/OTP/OTPAuthorization.vue:56
-#: src/views/preference/components/TOTP.vue:177
+#: src/components/2FA/Authorization.vue:94
+#: src/views/preference/components/TOTP.vue:178
 msgid "Recovery"
 msgstr "恢復"
 
-#: src/views/preference/components/TOTP.vue:103
+#: src/views/preference/components/TOTP.vue:104
 msgid "Recovery Code"
 msgstr "恢復碼"
 
-#: src/views/preference/components/TOTP.vue:112
+#: src/views/preference/components/TOTP.vue:113
 msgid "Recovery Code:"
 msgstr "恢復碼:"
 
@@ -1580,6 +1651,11 @@ msgstr "註冊"
 msgid "Register failed"
 msgstr "註冊失敗"
 
+#: src/views/preference/components/AddPasskey.vue:26
+#, fuzzy
+msgid "Register passkey successfully"
+msgstr "註冊成功"
+
 #: src/views/certificate/ACMEUser.vue:67
 msgid "Register successfully"
 msgstr "註冊成功"
@@ -1613,11 +1689,12 @@ msgstr "重新載入中"
 msgid "Reloading nginx"
 msgstr "正在重新載入 Nginx"
 
-#: src/views/preference/AuthSettings.vue:103
+#: src/views/preference/AuthSettings.vue:109
 msgid "Remove"
 msgstr "移除"
 
-#: src/views/preference/AuthSettings.vue:47
+#: src/views/preference/AuthSettings.vue:48
+#: src/views/preference/components/Passkey.vue:50
 msgid "Remove successfully"
 msgstr "移除成功"
 
@@ -1687,7 +1764,7 @@ msgstr "請求參數錯誤"
 msgid "Reset"
 msgstr "重設"
 
-#: src/views/preference/components/TOTP.vue:132
+#: src/views/preference/components/TOTP.vue:133
 msgid "Reset 2FA"
 msgstr "重置多重因素驗證"
 
@@ -1711,6 +1788,7 @@ msgstr "執行中"
 #: src/views/certificate/CertificateEditor.vue:254
 #: src/views/config/ConfigEditor.vue:222 src/views/domain/DomainEdit.vue:260
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:120
+#: src/views/preference/components/Passkey.vue:136
 #: src/views/preference/Preference.vue:151 src/views/stream/StreamEdit.vue:252
 msgid "Save"
 msgstr "儲存"
@@ -1738,7 +1816,7 @@ msgstr "儲存成功"
 msgid "Saved successfully"
 msgstr "儲存成功"
 
-#: src/views/preference/components/TOTP.vue:93
+#: src/views/preference/components/TOTP.vue:94
 msgid "Scan the QR code with your mobile phone to add the account to the app."
 msgstr "用手機掃描二維碼將賬戶添加到應用程序中。"
 
@@ -1746,7 +1824,7 @@ msgstr "用手機掃描二維碼將賬戶添加到應用程序中。"
 msgid "SDK"
 msgstr "SDK"
 
-#: src/views/preference/components/TOTP.vue:149
+#: src/views/preference/components/TOTP.vue:150
 msgid "Secret has been copied"
 msgstr ""
 
@@ -1771,9 +1849,12 @@ msgstr "傳送"
 #: src/views/config/ConfigEditor.vue:108 src/views/domain/DomainList.vue:81
 #: src/views/environment/BatchUpgrader.vue:57
 #: src/views/environment/Environment.vue:15 src/views/other/Install.vue:68
-#: src/views/preference/AuthSettings.vue:49
-#: src/views/preference/components/TOTP.vue:44
-#: src/views/preference/components/TOTP.vue:57
+#: src/views/preference/AuthSettings.vue:50
+#: src/views/preference/components/Passkey.vue:26
+#: src/views/preference/components/Passkey.vue:43
+#: src/views/preference/components/Passkey.vue:56
+#: src/views/preference/components/TOTP.vue:45
+#: src/views/preference/components/TOTP.vue:58
 #: src/views/preference/Preference.vue:83 src/views/stream/StreamList.vue:113
 #: src/views/stream/StreamList.vue:81 src/views/system/Upgrade.vue:42
 msgid "Server error"
@@ -1817,6 +1898,10 @@ msgstr "使用 HTTP01 挑戰提供者"
 msgid "Show"
 msgstr "顯示"
 
+#: src/views/other/Login.vue:265
+msgid "Sign in with a passkey"
+msgstr ""
+
 #: src/views/domain/ngx_conf/directive/DirectiveAdd.vue:51
 msgid "Single Directive"
 msgstr "單一指令"
@@ -1845,7 +1930,7 @@ msgstr "SSL 憑證金鑰路徑"
 msgid "SSL Certificate Path"
 msgstr "SSL 憑證路徑"
 
-#: src/views/other/Login.vue:189
+#: src/views/other/Login.vue:223
 msgid "SSO Login"
 msgstr "SSO 登錄"
 
@@ -2003,7 +2088,7 @@ msgstr "路徑存在,但檔案不是憑證"
 msgid "The path exists, but the file is not a private key"
 msgstr "路徑存在,但檔案不是金鑰"
 
-#: src/views/preference/components/TOTP.vue:111
+#: src/views/preference/components/TOTP.vue:112
 msgid ""
 "The recovery code is only displayed once, please save it in a safe place."
 msgstr "恢復碼僅顯示一次,請將其保存在安全的地方。"
@@ -2066,7 +2151,8 @@ msgid ""
 "This will upgrade or reinstall the Nginx UI on %{nodeNames} to %{version}."
 msgstr "這將在 %{nodeNames} 上升級或重新安裝 Nginx UI 到 %{version}。"
 
-#: src/views/preference/AuthSettings.vue:61
+#: src/views/preference/AuthSettings.vue:67
+#: src/views/preference/components/AddPasskey.vue:71
 #: src/views/preference/LogrotateSettings.vue:11
 msgid "Tips"
 msgstr "提示"
@@ -2075,13 +2161,20 @@ msgstr "提示"
 msgid "Title"
 msgstr "標題"
 
-#: src/views/preference/components/TOTP.vue:92
+#: src/views/preference/components/TOTP.vue:93
 msgid ""
 "To enable it, you need to install the Google or Microsoft Authenticator app "
 "on your mobile phone."
 msgstr ""
 "要啟用它,您需要在手機上安裝 Google 或 Microsoft Authenticator 應用程序。"
 
+#: src/views/preference/components/AddPasskey.vue:95
+msgid ""
+"To ensure security, Webauthn configuration cannot be added through the UI. "
+"Please manually configure the following in the app.ini configuration file "
+"and restart Nginx UI."
+msgstr ""
+
 #: src/views/domain/ngx_conf/NgxConfigEditor.vue:44
 msgid ""
 "To make sure the certification auto-renewal can work normally, we need to "
@@ -2096,11 +2189,15 @@ msgstr ""
 msgid "Token is not valid"
 msgstr "令牌無效"
 
-#: src/views/other/Login.vue:79
+#: src/views/other/Login.vue:82
 msgid "Too many login failed attempts, please try again later"
 msgstr "登錄失敗次數過多,請稍後再試"
 
 #: src/views/preference/components/TOTP.vue:91
+msgid "TOTP"
+msgstr ""
+
+#: src/views/preference/components/TOTP.vue:92
 msgid ""
 "TOTP is a two-factor authentication method that uses a time-based one-time "
 "password algorithm."
@@ -2110,7 +2207,7 @@ msgstr "TOTP 是一種使用基於時間的一次性密碼算法的多重因素
 msgid "Trash"
 msgstr "垃圾桶"
 
-#: src/components/OTP/useOTPModal.ts:67
+#: src/components/2FA/use2FAModal.ts:71
 msgid "Two-factor authentication required"
 msgstr "需要多重因素驗證"
 
@@ -2120,6 +2217,11 @@ msgstr "需要多重因素驗證"
 msgid "Type"
 msgstr "類型"
 
+#: src/views/preference/components/Passkey.vue:41
+#, fuzzy
+msgid "Update successfully"
+msgstr "更新成功"
+
 #: src/views/certificate/ACMEUser.vue:53
 #: src/views/certificate/DNSCredential.vue:27
 #: src/views/config/configColumns.ts:31 src/views/config/ConfigEditor.vue:275
@@ -2163,11 +2265,11 @@ msgstr "運作時間:"
 msgid "URL"
 msgstr "URL"
 
-#: src/components/OTP/OTPAuthorization.vue:69
+#: src/components/2FA/Authorization.vue:107
 msgid "Use OTP"
 msgstr "使用一次性密碼"
 
-#: src/components/OTP/OTPAuthorization.vue:65
+#: src/components/2FA/Authorization.vue:103
 msgid "Use recovery code"
 msgstr "使用恢復碼"
 
@@ -2175,11 +2277,11 @@ msgstr "使用恢復碼"
 msgid "User"
 msgstr "使用者名稱"
 
-#: src/views/other/Login.vue:82
+#: src/views/other/Login.vue:85
 msgid "User is banned"
 msgstr "用戶被禁止"
 
-#: src/views/other/Login.vue:164 src/views/user/userColumns.tsx:9
+#: src/views/other/Login.vue:198 src/views/user/userColumns.tsx:9
 msgid "Username"
 msgstr "使用者名稱"
 
@@ -2217,6 +2319,7 @@ msgstr "查看模式"
 #: src/constants/index.ts:17 src/views/config/InspectConfig.vue:33
 #: src/views/domain/DomainAdd.vue:112
 #: src/views/notification/notificationColumns.tsx:19
+#: src/views/preference/components/AddPasskey.vue:88
 msgid "Warning"
 msgstr "警告"
 
@@ -2249,7 +2352,7 @@ msgstr "將憑證寫入磁碟"
 
 #: src/views/domain/ngx_conf/directive/DirectiveEditorItem.vue:89
 #: src/views/domain/ngx_conf/LocationEditor.vue:69
-#: src/views/preference/AuthSettings.vue:97
+#: src/views/preference/AuthSettings.vue:103
 #: src/views/preference/BasicSettings.vue:100
 msgid "Yes"
 msgstr "是的"
@@ -2262,6 +2365,19 @@ msgstr "您正在使用最新版本"
 msgid "You can check Nginx UI upgrade at this page."
 msgstr "您可以在此頁面檢查 Nginx UI 的升級。"
 
+#: src/views/preference/components/AddPasskey.vue:93
+msgid ""
+"You have not configured the settings of Webauthn, so you cannot add a "
+"passkey."
+msgstr ""
+
+#: src/views/preference/components/Passkey.vue:81
+msgid "Your passkeys"
+msgstr ""
+
+#~ msgid "Enable 2FA"
+#~ msgstr "啟用多因素身份驗證"
+
 #, fuzzy
 #~ msgid "Rename "
 #~ msgstr "使用者名稱"

+ 2 - 2
app/src/layouts/SideBar.vue

@@ -81,8 +81,8 @@ const visible: ComputedRef<sidebar[]> = computed(() => {
     <Logo />
 
     <AMenu
-      v-model:openKeys="openKeys"
-      v-model:selectedKeys="selectedKey"
+      v-model:open-keys="openKeys"
+      v-model:selected-keys="selectedKey"
       mode="inline"
     >
       <EnvIndicator />

+ 2 - 2
app/src/lib/http/index.ts

@@ -7,7 +7,7 @@ import { useSettingsStore, useUserStore } from '@/pinia'
 import 'nprogress/nprogress.css'
 
 import router from '@/routes'
-import useOTPModal from '@/components/OTP/useOTPModal'
+import use2FAModal from '@/components/2FA/use2FAModal'
 
 const user = useUserStore()
 const settings = useSettingsStore()
@@ -61,7 +61,7 @@ instance.interceptors.response.use(
   async error => {
     NProgress.done()
 
-    const otpModal = useOTPModal()
+    const otpModal = use2FAModal()
     const cookies = useCookies(['nginx-ui-2fa'])
     switch (error.response.status) {
       case 401:

+ 12 - 1
app/src/pinia/moudule/user.ts

@@ -5,18 +5,29 @@ export const useUserStore = defineStore('user', {
     token: '',
     unreadCount: 0,
     secureSessionId: '',
+    passkeyRawId: '',
   }),
   getters: {
-    is_login(state): boolean {
+    isLogin(state): boolean {
       return !!state.token
     },
+    passkeyLoginAvailable(state): boolean {
+      return !!state.passkeyRawId
+    },
   },
   actions: {
+    passkeyLogin(rawId: string, token: string) {
+      this.passkeyRawId = rawId
+      this.login(token)
+    },
     login(token: string) {
       this.token = token
     },
     logout() {
       this.token = ''
+      this.passkeyRawId = ''
+      this.secureSessionId = ''
+      this.unreadCount = 0
     },
   },
   persist: true,

+ 1 - 2
app/src/routes/index.ts

@@ -325,9 +325,8 @@ router.beforeEach((to, _, next) => {
   NProgress.start()
 
   const user = useUserStore()
-  const { is_login } = user
 
-  if (to.meta.noAuth || is_login)
+  if (to.meta.noAuth || user.isLogin)
     next()
   else
     next({ path: '/login', query: { next: to.fullPath } })

+ 1 - 1
app/src/views/config/ConfigEditor.vue

@@ -233,7 +233,7 @@ function goBack() {
     >
       <ACard class="col-right">
         <ACollapse
-          v-model:activeKey="activeKey"
+          v-model:active-key="activeKey"
           ghost
         >
           <ACollapsePanel

+ 2 - 2
app/src/views/config/components/Mkdir.vue

@@ -2,7 +2,7 @@
 
 import { message } from 'ant-design-vue'
 import config from '@/api/config'
-import useOTPModal from '@/components/OTP/useOTPModal'
+import use2FAModal from '@/components/2FA/use2FAModal'
 
 const emit = defineEmits(['created'])
 const visible = ref(false)
@@ -25,7 +25,7 @@ defineExpose({
 
 function ok() {
   refForm.value.validate().then(() => {
-    const otpModal = useOTPModal()
+    const otpModal = use2FAModal()
 
     otpModal.open().then(() => {
       config.mkdir(data.value.basePath, data.value.name).then(() => {

+ 2 - 2
app/src/views/config/components/Rename.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { message } from 'ant-design-vue'
 import config from '@/api/config'
-import useOTPModal from '@/components/OTP/useOTPModal'
+import use2FAModal from '@/components/2FA/use2FAModal'
 import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
 
 const emit = defineEmits(['renamed'])
@@ -33,7 +33,7 @@ function ok() {
   refForm.value.validate().then(() => {
     const { basePath, orig_name, new_name, sync_node_ids } = data.value
 
-    const otpModal = useOTPModal()
+    const otpModal = use2FAModal()
 
     otpModal.open().then(() => {
       config.rename(basePath, orig_name, new_name, sync_node_ids).then(() => {

+ 1 - 1
app/src/views/domain/components/RightSettings.vue

@@ -67,7 +67,7 @@ function on_change_enabled(checked: CheckedType) {
   >
     <ContextHolder />
     <ACollapse
-      v-model:activeKey="active_key"
+      v-model:active-key="active_key"
       ghost
     >
       <ACollapsePanel

+ 1 - 1
app/src/views/domain/ngx_conf/NgxConfigEditor.vue

@@ -183,7 +183,7 @@ const activeKey = ref(['3'])
     </AFormItem>
 
     <ACollapse
-      v-model:activeKey="activeKey"
+      v-model:active-key="activeKey"
       ghost
     >
       <ACollapsePanel

+ 1 - 1
app/src/views/domain/ngx_conf/NgxServer.vue

@@ -95,7 +95,7 @@ provide('ngx_directives', ngx_directives)
 <template>
   <div>
     <ContextHolder />
-    <ATabs v-model:activeKey="current_server_index">
+    <ATabs v-model:active-key="current_server_index">
       <ATabPane
         v-for="(v, k) in ngx_config.servers"
         :key="k"

+ 1 - 1
app/src/views/domain/ngx_conf/NgxUpstream.vue

@@ -107,7 +107,7 @@ watch(ngx_directives, () => {
     <ContextHolder />
     <ATabs
       v-if="ngx_config.upstreams && ngx_config.upstreams.length > 0"
-      v-model:activeKey="current_upstream_index"
+      v-model:active-key="current_upstream_index"
     >
       <ATabPane
         v-for="(v, k) in ngx_config.upstreams"

+ 65 - 6
app/src/views/other/Login.vue

@@ -1,14 +1,16 @@
 <script setup lang="ts">
-import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
+import { KeyOutlined, LockOutlined, UserOutlined } from '@ant-design/icons-vue'
 import { Form, message } from 'ant-design-vue'
 import { useCookies } from '@vueuse/integrations/useCookies'
+import { startAuthentication } from '@simplewebauthn/browser'
 import { useUserStore } from '@/pinia'
 import auth from '@/api/auth'
 import install from '@/api/install'
 import SetLanguage from '@/components/SetLanguage/SetLanguage.vue'
 import SwitchAppearance from '@/components/SwitchAppearance/SwitchAppearance.vue'
-import OTPAuthorization from '@/components/OTP/OTPAuthorization.vue'
+import Authorization from '@/components/2FA/Authorization.vue'
 import gettext from '@/gettext'
+import passkey from '@/api/passkey'
 
 const thisYear = new Date().getFullYear()
 
@@ -25,6 +27,7 @@ const enabled2FA = ref(false)
 const refOTP = ref()
 const passcode = ref('')
 const recoveryCode = ref('')
+const passkeyConfigStatus = ref(false)
 
 const modelRef = reactive({
   username: '',
@@ -48,7 +51,7 @@ const rulesRef = reactive({
 
 const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
 const userStore = useUserStore()
-const { login } = userStore
+const { login, passkeyLogin } = userStore
 const { secureSessionId } = storeToRefs(userStore)
 
 const onSubmit = () => {
@@ -96,7 +99,7 @@ const onSubmit = () => {
 
 const user = useUserStore()
 
-if (user.is_login) {
+if (user.isLogin) {
   const next = (route.query?.next || '').toString() || '/dashboard'
 
   router.push(next)
@@ -146,6 +149,37 @@ function handleOTPSubmit(code: string, recovery: string) {
     onSubmit()
   })
 }
+
+passkey.get_config_status().then(r => {
+  passkeyConfigStatus.value = r.status
+})
+
+const passkeyLoginLoading = ref(false)
+async function handlePasskeyLogin() {
+  passkeyLoginLoading.value = true
+  try {
+    const begin = await auth.begin_passkey_login()
+    const asseResp = await startAuthentication(begin.options.publicKey)
+
+    const r = await auth.finish_passkey_login({
+      session_id: begin.session_id,
+      options: asseResp,
+    })
+
+    if (r.token) {
+      const next = (route.query?.next || '').toString() || '/'
+
+      passkeyLogin(asseResp.rawId, r.token)
+
+      await router.push(next)
+    }
+  }
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  catch (e: any) {
+    message.error($gettext(e.message ?? 'Server error'))
+  }
+  passkeyLoginLoading.value = false
+}
 </script>
 
 <template>
@@ -190,9 +224,14 @@ function handleOTPSubmit(code: string, recovery: string) {
               </AButton>
             </template>
             <div v-else>
-              <OTPAuthorization
+              <Authorization
                 ref="refOTP"
-                @on-submit="handleOTPSubmit"
+                :two-f-a-status="{
+                  enabled: true,
+                  otp_status: true,
+                  passkey_status: true,
+                }"
+                @submit-o-t-p="handleOTPSubmit"
               />
             </div>
 
@@ -202,10 +241,30 @@ function handleOTPSubmit(code: string, recovery: string) {
                 block
                 html-type="submit"
                 :loading="loading"
+                class="mb-2"
                 @click="onSubmit"
               >
                 {{ $gettext('Login') }}
               </AButton>
+
+              <div
+                v-if="passkeyConfigStatus"
+                class="flex flex-col justify-center"
+              >
+                <ADivider>
+                  <div class="text-sm font-normal opacity-75">
+                    {{ $gettext('Or') }}
+                  </div>
+                </ADivider>
+
+                <AButton
+                  :loading="passkeyLoginLoading"
+                  @click="handlePasskeyLogin"
+                >
+                  <KeyOutlined />
+                  {{ $gettext('Sign in with a passkey') }}
+                </AButton>
+              </div>
             </AFormItem>
           </AForm>
           <div class="footer">

+ 6 - 0
app/src/views/preference/AuthSettings.vue

@@ -2,6 +2,7 @@
 import { message } from 'ant-design-vue'
 import type { Ref } from 'vue'
 import dayjs from 'dayjs'
+import PasskeyRegistration from './components/Passkey.vue'
 import type { BannedIP } from '@/api/settings'
 import setting from '@/api/settings'
 import type { customRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
@@ -54,8 +55,13 @@ function removeBannedIP(ip: string) {
 <template>
   <div class="flex justify-center">
     <div>
+      <h2>{{ $gettext('2FA Settings') }}</h2>
+      <PasskeyRegistration class="mb-4" />
       <TOTP class="mb-4" />
 
+      <h2>
+        {{ $gettext('Authentication Settings') }}
+      </h2>
       <AAlert
         class="mb-4"
         :message="$gettext('Tips')"

+ 3 - 3
app/src/views/preference/Preference.vue

@@ -11,7 +11,7 @@ import type { Settings } from '@/views/preference/typedef'
 import LogrotateSettings from '@/views/preference/LogrotateSettings.vue'
 import { useSettingsStore } from '@/pinia'
 import AuthSettings from '@/views/preference/AuthSettings.vue'
-import useOTPModal from '@/components/OTP/useOTPModal'
+import use2FAModal from '@/components/2FA/use2FAModal'
 
 const data = ref<Settings>({
   server: {
@@ -68,7 +68,7 @@ async function save() {
   // fix type
   data.value.server.http_challenge_port = data.value.server.http_challenge_port.toString()
 
-  const otpModal = useOTPModal()
+  const otpModal = use2FAModal()
 
   otpModal.open().then(() => {
     settings.save(data.value).then(r => {
@@ -110,7 +110,7 @@ onMounted(() => {
 <template>
   <ACard :title="$gettext('Preference')">
     <div class="preference-container">
-      <ATabs v-model:activeKey="activeKey">
+      <ATabs v-model:active-key="activeKey">
         <ATabPane
           key="basic"
           :tab="$gettext('Basic')"

+ 118 - 0
app/src/views/preference/components/AddPasskey.vue

@@ -0,0 +1,118 @@
+<script setup lang="ts">
+import { startRegistration } from '@simplewebauthn/browser'
+import { message } from 'ant-design-vue'
+import passkey from '@/api/passkey'
+import { useUserStore } from '@/pinia'
+
+const emit = defineEmits(['created'])
+
+const user = useUserStore()
+const passkeyName = ref('')
+const addPasskeyModelOpen = ref(false)
+const passkeyEnabled = ref(false)
+
+const regLoading = ref(false)
+async function registerPasskey() {
+  regLoading.value = true
+  try {
+    const options = await passkey.begin_registration()
+
+    const attestationResponse = await startRegistration(options.publicKey)
+
+    await passkey.finish_registration(attestationResponse, passkeyName.value)
+
+    emit('created')
+
+    message.success($gettext('Register passkey successfully'))
+    addPasskeyModelOpen.value = false
+
+    user.passkeyRawId = attestationResponse.rawId
+  }
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  catch (e: any) {
+    message.error($gettext(e.message ?? 'Server error'))
+  }
+  regLoading.value = false
+}
+
+function addPasskey() {
+  addPasskeyModelOpen.value = true
+  passkeyName.value = ''
+}
+
+passkey.get_config_status().then(r => {
+  passkeyEnabled.value = r.status
+})
+</script>
+
+<template>
+  <div>
+    <AButton @click="addPasskey">
+      {{ $gettext('Add a passkey') }}
+    </AButton>
+    <AModal
+      v-model:open="addPasskeyModelOpen"
+      :title="$gettext('Add a passkey')"
+      centered
+      :mask="false"
+      :mask-closable="!passkeyEnabled"
+      :closable="!passkeyEnabled"
+      :footer="passkeyEnabled ? undefined : false"
+      :confirm-loading="regLoading"
+      @ok="registerPasskey"
+    >
+      <AForm
+        v-if="passkeyEnabled"
+        layout="vertical"
+      >
+        <div>
+          <AAlert
+            class="mb-4"
+            :message="$gettext('Tips')"
+            type="info"
+          >
+            <template #description>
+              <p>{{ $gettext('Please enter a name for the passkey you wish to create and click the OK button below.') }}</p>
+              <p>{{ $gettext('If your browser supports WebAuthn Passkey, a dialog box will appear.') }}</p>
+              <p>{{ $gettext('Follow the instructions in the dialog to complete the passkey registration process.') }}</p>
+            </template>
+          </AAlert>
+        </div>
+        <AFormItem :label="$gettext('Name')">
+          <AInput v-model:value="passkeyName" />
+        </AFormItem>
+      </AForm>
+      <div v-else>
+        <AAlert
+          class="mb-4"
+          :message="$gettext('Warning')"
+          type="warning"
+          show-icon
+        >
+          <template #description>
+            <p>{{ $gettext('You have not configured the settings of Webauthn, so you cannot add a passkey.') }}</p>
+            <p>
+              {{ $gettext('To ensure security, Webauthn configuration cannot be added through the UI. '
+                + 'Please manually configure the following in the app.ini configuration file and restart Nginx UI.') }}
+            </p>
+            <pre>[webauthn]
+# This is the display name
+RPDisplayName = Nginx UI
+# The domain name of Nginx UI
+RPID          = localhost
+# The list of origin addresses
+RPOrigins     = http://localhost:3002</pre>
+            <p>{{ $gettext('Afterwards, refresh this page and click add passkey again.') }}</p>
+            <p>
+              {{ $gettext(`Due to the security policies of some browsers, you cannot use passkeys on non-HTTPS websites, except when running on localhost.`) }}
+            </p>
+          </template>
+        </AAlert>
+      </div>
+    </AModal>
+  </div>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 159 - 0
app/src/views/preference/components/Passkey.vue

@@ -0,0 +1,159 @@
+<script setup lang="ts">
+import { message } from 'ant-design-vue'
+import { DeleteOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons-vue'
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+import { formatDateTime } from '@/lib/helper'
+import type { Passkey } from '@/api/passkey'
+import passkey from '@/api/passkey'
+import ReactiveFromNow from '@/components/ReactiveFromNow/ReactiveFromNow.vue'
+import { useUserStore } from '@/pinia'
+import AddPasskey from '@/views/preference/components/AddPasskey.vue'
+
+dayjs.extend(relativeTime)
+
+const user = useUserStore()
+
+const getListLoading = ref(true)
+const data = ref([]) as Ref<Passkey[]>
+const passkeyName = ref('')
+
+function getList() {
+  getListLoading.value = true
+  passkey.get_list().then(r => {
+    data.value = r
+  }).catch((e: { message?: string }) => {
+    message.error(e?.message ?? $gettext('Server error'))
+  }).finally(() => {
+    getListLoading.value = false
+  })
+}
+
+onMounted(() => {
+  getList()
+})
+
+const modifyIdx = ref(-1)
+function update(id: number, record: Passkey) {
+  passkey.update(id, record).then(() => {
+    getList()
+    modifyIdx.value = -1
+    message.success($gettext('Update successfully'))
+  }).catch((e: { message?: string }) => {
+    message.error(e?.message ?? $gettext('Server error'))
+  })
+}
+
+function remove(item: Passkey) {
+  passkey.remove(item.id).then(() => {
+    getList()
+    message.success($gettext('Remove successfully'))
+
+    // if current passkey is removed, clear it from user store
+    if (user.passkeyLoginAvailable && user.passkeyRawId === item.raw_id)
+      user.passkeyRawId = ''
+  }).catch((e: { message?: string }) => {
+    message.error(e?.message ?? $gettext('Server error'))
+  })
+}
+</script>
+
+<template>
+  <div>
+    <div>
+      <h3>
+        {{ $gettext('Passkey') }}
+      </h3>
+      <p>
+        {{ $gettext('Passkeys are webauthn credentials that validate your identity using touch, '
+          + 'facial recognition, a device password, or a PIN. '
+          + 'They can be used as a password replacement or as a 2FA method.') }}
+      </p>
+    </div>
+    <AList
+      class="mt-4"
+      bordered
+      :data-source="data"
+    >
+      <template #header>
+        <div class="flex items-center justify-between">
+          <div class="font-bold">
+            {{ $gettext('Your passkeys') }}
+          </div>
+          <AddPasskey @created="() => getList()" />
+        </div>
+      </template>
+      <template #renderItem="{ item, index }">
+        <AListItem>
+          <AListItemMeta>
+            <template #title>
+              <div class="flex gap-2">
+                <KeyOutlined />
+                <div v-if="index !== modifyIdx">
+                  {{ item.name }}
+                </div>
+                <div v-else>
+                  <AInput v-model:value="passkeyName" />
+                </div>
+              </div>
+            </template>
+            <template #description>
+              {{ $gettext('Created at') }}: {{ formatDateTime(item.created_at) }} · {{
+                $gettext('Last used at') }}: <ReactiveFromNow :time="item.last_used_at" />
+            </template>
+          </AListItemMeta>
+          <template #extra>
+            <div v-if="modifyIdx !== index">
+              <AButton
+                type="link"
+                size="small"
+                @click="() => {
+                  modifyIdx = index
+                  passkeyName = item.name
+                }"
+              >
+                <EditOutlined />
+              </AButton>
+
+              <APopconfirm
+                :title="$gettext('Are you sure to delete this passkey immediately?')"
+                @confirm="() => remove(item)"
+              >
+                <AButton
+                  type="link"
+                  danger
+                  size="small"
+                >
+                  <DeleteOutlined />
+                </AButton>
+              </APopconfirm>
+            </div>
+            <div v-else>
+              <AButton
+                size="small"
+                @click="() => update(item.id, { ...item, name: passkeyName })"
+              >
+                {{ $gettext('Save') }}
+              </AButton>
+
+              <AButton
+                type="link"
+                size="small"
+                @click="() => {
+                  modifyIdx = -1
+                  passkeyName = item.name
+                }"
+              >
+                {{ $gettext('Cancel') }}
+              </AButton>
+            </div>
+          </template>
+        </AListItem>
+      </template>
+    </AList>
+  </div>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 8 - 7
app/src/views/preference/components/TOTP.vue

@@ -4,7 +4,8 @@ import { CheckCircleOutlined } from '@ant-design/icons-vue'
 import { UseClipboard } from '@vueuse/components'
 import otp from '@/api/otp'
 import OTPInput from '@/components/OTPInput/OTPInput.vue'
-import { $gettext } from '@/gettext'
+
+import twoFA from '@/api/2fa'
 
 const status = ref(false)
 const enrolling = ref(false)
@@ -59,8 +60,8 @@ function enroll(code: string) {
 }
 
 function get2FAStatus() {
-  otp.status().then(r => {
-    status.value = r.status
+  twoFA.status().then(r => {
+    status.value = r.otp_status
   })
 }
 
@@ -87,15 +88,15 @@ function reset2FA() {
 
 <template>
   <div>
-    <h3>{{ $gettext('2FA Settings') }}</h3>
+    <h3>{{ $gettext('TOTP') }}</h3>
     <p>{{ $gettext('TOTP is a two-factor authentication method that uses a time-based one-time password algorithm.') }}</p>
     <p>{{ $gettext('To enable it, you need to install the Google or Microsoft Authenticator app on your mobile phone.') }}</p>
     <p>{{ $gettext('Scan the QR code with your mobile phone to add the account to the app.') }}</p>
     <p v-if="!status">
-      {{ $gettext('Current account is not enabled 2FA.') }}
+      {{ $gettext('Current account is not enabled TOTP.') }}
     </p>
     <div v-else>
-      <p><CheckCircleOutlined class="mr-2 text-green-600" />{{ $gettext('Current account is enabled 2FA.') }}</p>
+      <p><CheckCircleOutlined class="mr-2 text-green-600" />{{ $gettext('Current account is enabled TOTP.') }}</p>
     </div>
 
     <AAlert
@@ -121,7 +122,7 @@ function reset2FA() {
       ghost
       @click="clickEnable2FA"
     >
-      {{ $gettext('Enable 2FA') }}
+      {{ $gettext('Enable TOTP') }}
     </AButton>
     <AButton
       v-if="status && !resetting"

+ 4 - 4
app/src/views/pty/Terminal.vue

@@ -4,8 +4,8 @@ import { Terminal } from '@xterm/xterm'
 import { FitAddon } from '@xterm/addon-fit'
 import _ from 'lodash'
 import ws from '@/lib/websocket'
-import useOTPModal from '@/components/OTP/useOTPModal'
-import otp from '@/api/otp'
+import use2FAModal from '@/components/2FA/use2FAModal'
+import twoFA from '@/api/2fa'
 
 let term: Terminal | null
 let ping: NodeJS.Timeout
@@ -15,9 +15,9 @@ const websocket = shallowRef()
 const lostConnection = ref(false)
 
 onMounted(() => {
-  otp.secure_session_status()
+  twoFA.secure_session_status()
 
-  const otpModal = useOTPModal()
+  const otpModal = use2FAModal()
 
   otpModal.open().then(secureSessionId => {
     websocket.value = ws(`/api/pty?X-Secure-Session-ID=${secureSessionId}`, false)

+ 1 - 1
app/src/views/stream/components/RightSettings.vue

@@ -66,7 +66,7 @@ function on_change_enabled(checked: CheckedType) {
   >
     <ContextHolder />
     <ACollapse
-      v-model:activeKey="active_key"
+      v-model:active-key="active_key"
       ghost
     >
       <ACollapsePanel

+ 5 - 1
go.mod

@@ -7,6 +7,7 @@ require (
 	github.com/BurntSushi/toml v1.4.0
 	github.com/caarlos0/env/v11 v11.2.2
 	github.com/casdoor/casdoor-go-sdk v0.50.0
+	github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476
 	github.com/creack/pty v1.1.23
 	github.com/dgraph-io/ristretto v0.1.1
 	github.com/dustin/go-humanize v1.0.1
@@ -16,6 +17,7 @@ require (
 	github.com/go-acme/lego/v4 v4.18.0
 	github.com/go-co-op/gocron v1.37.0
 	github.com/go-playground/validator/v10 v10.22.1
+	github.com/go-webauthn/webauthn v0.11.2
 	github.com/golang-jwt/jwt/v4 v4.5.0
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
@@ -100,6 +102,7 @@ require (
 	github.com/bytedance/sonic/loader v0.2.0 // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/chromedp/sysutil v1.0.0 // indirect
 	github.com/civo/civogo v0.3.77 // indirect
 	github.com/cloudflare/cloudflare-go v0.104.0 // indirect
 	github.com/cloudwego/base64x v0.1.4 // indirect
@@ -128,6 +131,7 @@ require (
 	github.com/go-resty/resty/v2 v2.15.0 // indirect
 	github.com/go-sql-driver/mysql v1.8.1 // indirect
 	github.com/go-viper/mapstructure/v2 v2.1.0 // indirect
+	github.com/go-webauthn/x v0.1.14 // indirect
 	github.com/goccy/go-json v0.10.3 // indirect
 	github.com/gofrs/flock v0.12.1 // indirect
 	github.com/gofrs/uuid v4.4.0+incompatible // indirect
@@ -138,6 +142,7 @@ require (
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
+	github.com/google/go-tpm v0.9.1 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
 	github.com/google/s2a-go v0.1.8 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
@@ -243,7 +248,6 @@ require (
 	github.com/vinyldns/go-vinyldns v0.9.16 // indirect
 	github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
-	github.com/vultr/govultr/v2 v2.17.2 // indirect
 	github.com/vultr/govultr/v3 v3.9.1 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/yandex-cloud/go-genproto v0.0.0-20240911120709-1fa0cb6f47c2 // indirect

+ 12 - 252
go.sum

@@ -39,8 +39,6 @@ cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRY
 cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM=
 cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
 cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
-cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
-cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
 cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4=
 cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw=
 cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E=
@@ -102,12 +100,6 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo
 cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo=
 cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
 cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E=
-cloud.google.com/go/auth v0.8.1 h1:QZW9FjC5lZzN864p13YxvAtGUlQ+KgRL+8Sg45Z6vxo=
-cloud.google.com/go/auth v0.8.1/go.mod h1:qGVp/Y3kDRSDZ5gFD/XPUfYQ9xW1iI7q8RIRoCyBbJc=
-cloud.google.com/go/auth v0.9.1 h1:+pMtLEV2k0AXKvs/tGZojuj6QaioxfUjOpMsG5Gtx+w=
-cloud.google.com/go/auth v0.9.1/go.mod h1:Sw8ocT5mhhXxFklyhT12Eiy0ed6tTrPMCJjSI8KhYLk=
-cloud.google.com/go/auth v0.9.2 h1:I+Rq388FYU8QdbVB1IiPd+6KNdrqtAPE/asiKHShBLM=
-cloud.google.com/go/auth v0.9.2/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
 cloud.google.com/go/auth v0.9.4 h1:DxF7imbEbiFu9+zdKC6cKBko1e8XeJnipNqIbWZ+kDI=
 cloud.google.com/go/auth v0.9.4/go.mod h1:SHia8n6//Ya940F1rLimhJCjjx7KE17t0ctFEci3HkA=
 cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
@@ -186,14 +178,10 @@ cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvj
 cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA=
 cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
 cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU=
-cloud.google.com/go/compute v1.28.0 h1:OPtBxMcheSS+DWfci803qvPly3d4w7Eu5ztKBcFfzwk=
-cloud.google.com/go/compute v1.28.0/go.mod h1:DEqZBtYrDnD5PvjsKwb3onnhX+qjdCVM7eshj1XdjV4=
 cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU=
 cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
 cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
-cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
-cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
 cloud.google.com/go/compute/metadata v0.5.1 h1:NM6oZeZNlYjiwYje+sYFjEpP0Q0zCan1bmQW/KmIrGs=
 cloud.google.com/go/compute/metadata v0.5.1/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
 cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY=
@@ -705,12 +693,6 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.0 h1:GIwkDPfeF/IBh5lZ5Mig50r1LXomNXR7t/oKGSMJWns=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.0/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.7 h1:MOFLOVlBI1MvP4I0cwb9cXf83GNcMss1btQqjbp11CM=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.7/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.11 h1:U+8nVd9AEZrxpn3iuZNQq1NKhO65oZAsbcVgdvILxkI=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.11/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
 github.com/aliyun/alibaba-cloud-sdk-go v1.63.16 h1:LNaqt0rxIcqHYarmdIZ3ZM7lqUWaWZ1Sqi1XPV1zMko=
 github.com/aliyun/alibaba-cloud-sdk-go v1.63.16/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
@@ -727,84 +709,32 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
 github.com/armon/go-metrics v0.4.0/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
 github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
-github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
-github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8=
-github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0=
 github.com/aws/aws-sdk-go-v2 v1.30.5 h1:mWSRTwQAb0aLE17dSzztCVJWI9+cRMgqebndjwDyK0g=
 github.com/aws/aws-sdk-go-v2 v1.30.5/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0=
-github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
-github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
-github.com/aws/aws-sdk-go-v2/config v1.27.31 h1:kxBoRsjhT3pq0cKthgj6RU6bXTm/2SgdoUMyrVw0rAI=
-github.com/aws/aws-sdk-go-v2/config v1.27.31/go.mod h1:z04nZdSWFPaDwK3DdJOG2r+scLQzMYuJeW0CujEm9FM=
 github.com/aws/aws-sdk-go-v2/config v1.27.33 h1:Nof9o/MsmH4oa0s2q9a0k7tMz5x/Yj5k06lDODWz3BU=
 github.com/aws/aws-sdk-go-v2/config v1.27.33/go.mod h1:kEqdYzRb8dd8Sy2pOdEbExTTF5v7ozEXX0McgPE7xks=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.30 h1:aau/oYFtibVovr2rDt8FHlU17BTicFEMAi29V1U+L5Q=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.30/go.mod h1:BPJ/yXV92ZVq6G8uYvbU0gSl8q94UB63nMT5ctNO38g=
 github.com/aws/aws-sdk-go-v2/credentials v1.17.32 h1:7Cxhp/BnT2RcGy4VisJ9miUPecY+lyE9I8JvcZofn9I=
 github.com/aws/aws-sdk-go-v2/credentials v1.17.32/go.mod h1:P5/QMF3/DCHbXGEGkdbilXHsyTBX5D3HSwcrSc9p20I=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 h1:yjwoSyDZF8Jth+mUk5lSPJCkMC0lMy6FaCD51jm6ayE=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12/go.mod h1:fuR57fAgMk7ot3WcNQfb6rSEn+SUffl7ri+aa8uKysI=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 h1:pfQ2sqNpMVK6xz2RbqLEL0GH87JOwSxPV2rzm8Zsb74=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13/go.mod h1:NG7RXPUlqfsCLLFfi0+IpKN4sCB9D9fw/qTaSB+xRoU=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 h1:pI7Bzt0BJtYA0N/JEC6B8fJ4RBrEMi1LBrkMdFYNSnQ=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17/go.mod h1:Dh5zzJYMtxfIjYW+/evjQ8uj2OyR/ve2KROHGHlSFqE=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs=
 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 h1:Mqr/V5gvrhA2gvgnF42Zh5iMiQNcOYthFYwCyrnuWlc=
 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17/go.mod h1:aLJpZlCmjE+V+KtN1q1uyZkfnUWpQGpbsn89XPKyzfU=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 h1:rfprUlsdzgl7ZL2KlXiUAoJnI/VxfHCvDFr2QDFj6u4=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19/go.mod h1:SCWkEdRq8/7EK60NcvvQ6NXKuTcchAD4ROAsC37VEZE=
-github.com/aws/aws-sdk-go-v2/service/lightsail v1.40.3 h1:dy4sbyGy7BS4c0KaPZwg1P5ZP+lW+auTVcPiwrmbn8M=
-github.com/aws/aws-sdk-go-v2/service/lightsail v1.40.3/go.mod h1:EMgqMhof+RuaYvQavxKC0ZWvP7yB4B4NJhP+dbm13u0=
-github.com/aws/aws-sdk-go-v2/service/lightsail v1.40.4 h1:nR4GnokNdp25C6Z6xvXz5VqmzIhp4+aWMcM4w5FhlJ4=
-github.com/aws/aws-sdk-go-v2/service/lightsail v1.40.4/go.mod h1:w/6Ddm5GNEn0uLR6Wc35MGTvUXKDz8uNEMRrrdDB2ps=
 github.com/aws/aws-sdk-go-v2/service/lightsail v1.40.6 h1:ea6TO3HgVeVTB2Ie1djyBFWBOc9CohpKbo/QZbGTCJQ=
 github.com/aws/aws-sdk-go-v2/service/lightsail v1.40.6/go.mod h1:D2TUTD3v6AWmE5LzdCXLWNFtoYbSf6IEjKh1ggbuVdw=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 h1:MmLCRqP4U4Cw9gJ4bNrCG0mWqEtBlmAVleyelcHARMU=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3/go.mod h1:AMPjK2YnRh0YgOID3PqhJA1BRNfXDfGOnSsKHtAe8yA=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.43.0 h1:xtp7jye7KhWu4ptBs5yh1Vep0vLAGSNGmArOUp997DU=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.43.0/go.mod h1:QN7tFo/W8QjLCR6aPZqMZKaVQJiAp95r/g78x1LWtkA=
 github.com/aws/aws-sdk-go-v2/service/route53 v1.43.2 h1:957e1/SwXIfPi/0OUJkH9YnPZRe9G6Kisd/xUhF7AUE=
 github.com/aws/aws-sdk-go-v2/service/route53 v1.43.2/go.mod h1:343vcjcyOTuHTBBgUrOxPM36/jE96qLZnGL447ldrB0=
-github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
-github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c=
-github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM=
 github.com/aws/aws-sdk-go-v2/service/sso v1.22.7 h1:pIaGg+08llrP7Q5aiz9ICWbY8cqhTkyy+0SHvfzQpTc=
 github.com/aws/aws-sdk-go-v2/service/sso v1.22.7/go.mod h1:eEygMHnTKH/3kNp9Jr1n3PdejuSNcgwLe1dWgQtO0VQ=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac=
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7 h1:/Cfdu0XV3mONYKaOt1Gr0k1KvQzkzPyiKUdlWJqy+J4=
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7/go.mod h1:bCbAxKDqNvkHxRaIMnyVPXPo+OaPRwvmgzMxbz1VKSA=
-github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
-github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
-github.com/aws/aws-sdk-go-v2/service/sts v1.30.5 h1:OMsEmCyz2i89XwRwPouAJvhj81wINh+4UK+k/0Yo/q8=
-github.com/aws/aws-sdk-go-v2/service/sts v1.30.5/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0=
 github.com/aws/aws-sdk-go-v2/service/sts v1.30.7 h1:NKTa1eqZYw8tiHSRGpP0VtTdub/8KNk8sDkNPFaOKDE=
 github.com/aws/aws-sdk-go-v2/service/sts v1.30.7/go.mod h1:NXi1dIAGteSaRLqYgarlhP/Ij0cFT+qmCwiJqWh/U5o=
 github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4=
@@ -825,8 +755,6 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
 github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
 github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
-github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
-github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
 github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg=
 github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
 github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -837,8 +765,6 @@ github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdf
 github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M=
 github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
 github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
-github.com/casdoor/casdoor-go-sdk v0.49.0 h1:TD2VhOinkCaLII0RJglN58eihLXgDRWGoofZ+S1eqyc=
-github.com/casdoor/casdoor-go-sdk v0.49.0/go.mod h1:cMnkCQJgMYpgAlgEx8reSt1AVaDIQLcJ1zk5pzBaz+4=
 github.com/casdoor/casdoor-go-sdk v0.50.0 h1:bUYbz/MzJuWfLKJbJM0+U0YpYewAur+THp5TKnufWZM=
 github.com/casdoor/casdoor-go-sdk v0.50.0/go.mod h1:cMnkCQJgMYpgAlgEx8reSt1AVaDIQLcJ1zk5pzBaz+4=
 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -846,28 +772,23 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
-github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476 h1:VnjHsRXCRti7Av7E+j4DCha3kf68echfDzQ+wD11SBU=
+github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
+github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
+github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
 github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
-github.com/civo/civogo v0.3.73 h1:thkNnkziU+xh+MEOChIUwRZI1forN20+SSAPe/VFDME=
-github.com/civo/civogo v0.3.73/go.mod h1:7UCYX+qeeJbrG55E1huv+0ySxcHTqq/26FcHLVelQJM=
-github.com/civo/civogo v0.3.75 h1:hrF+ALGDV5Be/jG9NmDo2wLhL4yuD8kIOxUbVRklGNU=
-github.com/civo/civogo v0.3.75/go.mod h1:7UCYX+qeeJbrG55E1huv+0ySxcHTqq/26FcHLVelQJM=
 github.com/civo/civogo v0.3.77 h1:1rl5cpQruPhh+w8BBMpGQsaovjDvA44udPoDTAa45rk=
 github.com/civo/civogo v0.3.77/go.mod h1:7UCYX+qeeJbrG55E1huv+0ySxcHTqq/26FcHLVelQJM=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cloudflare/cloudflare-go v0.102.0 h1:+0MGbkirM/yzVLOYpWMgW7CDdKzesSbdwA2Y+rABrWI=
-github.com/cloudflare/cloudflare-go v0.102.0/go.mod h1:BOB41tXf31ti/qtBO9paYhyapotQbGRDbQoLOAF7pSg=
-github.com/cloudflare/cloudflare-go v0.103.0 h1:XXKzgXeUbAo7UTtM4T5wuD2bJPBtNZv7TlZAEy5QI4k=
-github.com/cloudflare/cloudflare-go v0.103.0/go.mod h1:0DrjT4g8wgYFYIxhlqR8xi8dNWfyHFGilUkU3+XV8h0=
 github.com/cloudflare/cloudflare-go v0.104.0 h1:R/lB0dZupaZbOgibAH/BRrkFbZ6Acn/WsKg2iX2xXuY=
 github.com/cloudflare/cloudflare-go v0.104.0/go.mod h1:pfUQ4PIG4ISI0/Mmc21Bp86UnFU0ktmPf3iTgbSL+cM=
 github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@@ -971,8 +892,6 @@ github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0Nglqm
 github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
 github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
 github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
-github.com/go-acme/lego/v4 v4.17.4 h1:h0nePd3ObP6o7kAkndtpTzCw8shOZuWckNYeUQwo36Q=
-github.com/go-acme/lego/v4 v4.17.4/go.mod h1:dU94SvPNqimEeb7EVilGGSnS0nU1O5Exir0pQ4QFL4U=
 github.com/go-acme/lego/v4 v4.18.0 h1:2hH8KcdRBSb+p5o9VZIm61GAOXYALgILUCSs1Q+OYsk=
 github.com/go-acme/lego/v4 v4.18.0/go.mod h1:Blkg3izvXpl3zxk7WKngIuwR2I/hvYVP3vRnvgBp7m8=
 github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s=
@@ -1017,12 +936,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
-github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
 github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
 github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
-github.com/go-resty/resty/v2 v2.14.0 h1:/rhkzsAqGQkozwfKS5aFAbb6TyKd3zyFRWcdRXLPCAU=
-github.com/go-resty/resty/v2 v2.14.0/go.mod h1:IW6mekUOsElt9C7oWr0XRt9BNSD6D5rr9mhk6NjmNHg=
 github.com/go-resty/resty/v2 v2.15.0 h1:clPQLZ2x9h4yGY81IzpMPnty+xoGyFaDg0XMkCsHf90=
 github.com/go-resty/resty/v2 v2.15.0/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU=
 github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@@ -1035,6 +950,10 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
 github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w=
 github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
+github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
+github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
+github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
 github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA=
 github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -1053,7 +972,6 @@ github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zV
 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
 github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
-github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
 github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
@@ -1133,6 +1051,8 @@ github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
+github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -1174,10 +1094,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY
 github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
 github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
 github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
-github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
-github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
-github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0=
-github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
 github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
 github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
@@ -1381,8 +1297,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/linode/linodego v1.39.0 h1:gRsj2PXX+HTO3eYQaXEuQGsLeeLFDSBDontC5JL3Nn8=
-github.com/linode/linodego v1.39.0/go.mod h1:da8KzAQKSm5obwa06yXk5CZSDFMP9Wb08GA/O+aR9W0=
 github.com/linode/linodego v1.40.0 h1:7ESY0PwK94hoggoCtIroT1Xk6b1flrFBNZ6KwqbTqlI=
 github.com/linode/linodego v1.40.0/go.mod h1:NsUw4l8QrLdIofRg1NYFBbW5ZERnmbZykVBszPZLORM=
 github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs=
@@ -1392,10 +1306,6 @@ github.com/liquidweb/liquidweb-cli v0.7.0 h1:7j1r1U0MZa1TXiWo3IMU5V1YQwnBHMVxU+x
 github.com/liquidweb/liquidweb-cli v0.7.0/go.mod h1:+uU7L6BhaQtgo4cYKhhsP5UNCq/imNvjBjlf76Vqpb0=
 github.com/liquidweb/liquidweb-go v1.6.4 h1:6S0m3hHSpiLqGD7AFSb7lH/W/qr1wx+tKil9fgIbjMc=
 github.com/liquidweb/liquidweb-go v1.6.4/go.mod h1:B934JPIIcdA+uTq2Nz5PgOtG6CuCaEvQKe/Ge/5GgZ4=
-github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI=
-github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
-github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 h1:5RK988zAqB3/AN3opGfRpoQgAVqr6/A5+qRTi67VUZY=
-github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
 github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
 github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
 github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
@@ -1431,8 +1341,6 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
 github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
-github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
-github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
 github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0=
@@ -1529,10 +1437,6 @@ github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo=
 github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0=
 github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
 github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
-github.com/oracle/oci-go-sdk/v65 v65.71.1 h1:t1GpyLYaD/x2OrUoSyxNwBQaDaQP4F084FX8LQMXA/s=
-github.com/oracle/oci-go-sdk/v65 v65.71.1/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0=
-github.com/oracle/oci-go-sdk/v65 v65.72.0 h1:gPCb5fBUsZMyafIilPPB2B36yqjkKnnwwiJT4xexUMg=
-github.com/oracle/oci-go-sdk/v65 v65.72.0/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0=
 github.com/oracle/oci-go-sdk/v65 v65.73.0 h1:C7uel6CoKk4A1KPkdhFBAyvVyFRTHAmX8m0o64RmfPg=
 github.com/oracle/oci-go-sdk/v65 v65.73.0/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0=
 github.com/ovh/go-ovh v1.6.0 h1:ixLOwxQdzYDx296sXcgS35TOPEahJkpjMGtzPadCjQI=
@@ -1543,10 +1447,7 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
 github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
-github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
 github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
 github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
 github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
@@ -1633,16 +1534,8 @@ github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
 github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
 github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo=
 github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
-github.com/sashabaranov/go-openai v1.28.1 h1:aREx6faUTeOZNMDTNGAY8B9vNmmN7qoGvDV0Ke2J1Mc=
-github.com/sashabaranov/go-openai v1.28.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
-github.com/sashabaranov/go-openai v1.29.0 h1:eBH6LSjtX4md5ImDCX8hNhHQvaRf22zujiERoQpsvLo=
-github.com/sashabaranov/go-openai v1.29.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
-github.com/sashabaranov/go-openai v1.29.1 h1:AlB+vwpg1tibwr83OKXLsI4V1rnafVyTlw0BjR+6WUM=
-github.com/sashabaranov/go-openai v1.29.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
 github.com/sashabaranov/go-openai v1.29.2 h1:jYpp1wktFoOvxHnum24f/w4+DFzUdJnu83trr5+Slh0=
 github.com/sashabaranov/go-openai v1.29.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
-github.com/scaleway/scaleway-sdk-go v1.0.0-beta.29 h1:BkTk4gynLjguayxrYxZoMZjBnAOh7ntQvUkOFmkMqPU=
-github.com/scaleway/scaleway-sdk-go v1.0.0-beta.29/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg=
 github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 h1:yoKAVkEVwAqbGbR8n87rHQ1dulL25rKloGadb3vm770=
 github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30/go.mod h1:sH0u6fq6x4R5M7WxkoQFY/o7UaiItec0o1LinLCJNq8=
 github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk=
@@ -1738,20 +1631,8 @@ github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W
 github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
 github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
 github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.984 h1:QLSx+ibsV68NXKgzofPuo1gxFwYSWk2++rvxZxNjbVo=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.984/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.991 h1:0Xg2IUktDgGsjBv82WTmTQdHZFRwS2XDUnuOHexCxVw=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.991/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.993 h1:+iJMmF0q1MPyhLs0+J7CcJ47w/vq6ICsCxnV4gc0dKw=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.993/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1002 h1:RE84sHFFx6t24DJvSnF9fS1DzBNv9OpctzHK3t7AY+I=
 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1002/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.984 h1:ABZeSsOOkkBn+gToVp8KkMt4E69hQkBMEFegCD4w15Q=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.984/go.mod h1:r++X8dKvTZWltr4J83TIwqGlyvG5fKaVh7RGC2+BryI=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.991 h1:GTf6Cp2beg/zfxuhj5qwEHrR1AhBJrk+CYGzt6pRxJo=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.991/go.mod h1:9v9MJPZQHh7XMr7cESUHcIXpIJb/sFtp++OsanrwhaU=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.993 h1:x2nkr/Kok+DV1K1DHqnvNgZTXDjOZVgkBXwtqVptKWk=
-github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.993/go.mod h1:lEQPVB5HPTf8LU4EE9C7VpYtOwM0xpaFQerX0b+a9Z4=
 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1002 h1:QwE0dRkAAbdf+eACnkNULgDn9ZKUJpPWRyXdqJolP5E=
 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1002/go.mod h1:WdC0FYbqYhJwQ3kbqri6hVP5HAEp+rzX9FToItTAzUg=
 github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
@@ -1759,8 +1640,6 @@ github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYN
 github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
 github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/transip/gotransip/v6 v6.25.0 h1:/H+SjMq/9HNZ0/maE1OLhJpxLaCGHsxq0PWaMPJHxK4=
-github.com/transip/gotransip/v6 v6.25.0/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
 github.com/transip/gotransip/v6 v6.26.0 h1:Aejfvh8rSp8Mj2GX/RpdBjMCv+Iy/DmgfNgczPDP550=
 github.com/transip/gotransip/v6 v6.26.0/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
 github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
@@ -1772,8 +1651,6 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK
 github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
 github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
 github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
-github.com/ultradns/ultradns-go-sdk v1.6.2-20240501171831-432d643 h1:Y2gOdFNdP0QrXN7HkhrT42686bxBmDPqq5Xu8RgeU2s=
-github.com/ultradns/ultradns-go-sdk v1.6.2-20240501171831-432d643/go.mod h1:mqka31zT/P4yfNKj1qbOXUqamham/YO05GgUc/dOrl8=
 github.com/ultradns/ultradns-go-sdk v1.7.0-20240913052650-970ca9a h1:R6IR+Vj/RnGZLnX8PpPQsbbQthctO7Ah2q4tj5eoe2o=
 github.com/ultradns/ultradns-go-sdk v1.7.0-20240913052650-970ca9a/go.mod h1:BZr7Qs3ku1ckpqed8tCRSqTlp8NAeZfAVpfx4OzXMss=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -1786,8 +1663,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
 github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
-github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs=
-github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI=
 github.com/vultr/govultr/v3 v3.9.1 h1:uxSIb8Miel7tqTs3ee+z3t+JelZikwqBBsZzCOPBy/8=
 github.com/vultr/govultr/v3 v3.9.1/go.mod h1:Rd8ebpXm7jxH3MDmhnEs+zrlYW212ouhx+HeUMfHm2o=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -1801,20 +1676,8 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
 github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
 github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
-github.com/yandex-cloud/go-genproto v0.0.0-20240813143603-58770ef469b7 h1:PSXr/xm10ZZ0f2pDWCX6wtY7EXfyBtoAGAD5Rzxstb0=
-github.com/yandex-cloud/go-genproto v0.0.0-20240813143603-58770ef469b7/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
-github.com/yandex-cloud/go-genproto v0.0.0-20240819112322-98a264d392f6 h1:w57l27dDkJTVSi8hM3H/WVkiv+CsJwAIweqO6pFdljk=
-github.com/yandex-cloud/go-genproto v0.0.0-20240819112322-98a264d392f6/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
-github.com/yandex-cloud/go-genproto v0.0.0-20240829130658-0568052c5a6a h1:GCVnt5H4CB4np3ReSNH0GpBg5HDaLz1rZKnjhQjQGL4=
-github.com/yandex-cloud/go-genproto v0.0.0-20240829130658-0568052c5a6a/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
 github.com/yandex-cloud/go-genproto v0.0.0-20240911120709-1fa0cb6f47c2 h1:WgeEP+8WizCQyccJNHOMLONq23qVAzYHtyg5qTdUWmg=
 github.com/yandex-cloud/go-genproto v0.0.0-20240911120709-1fa0cb6f47c2/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
-github.com/yandex-cloud/go-sdk v0.0.0-20240813144531-905aa41b481f h1:oetXcQPVH/CfyBD5MXnxOQY7IFvhTZpLLQKKLmTVRPM=
-github.com/yandex-cloud/go-sdk v0.0.0-20240813144531-905aa41b481f/go.mod h1:9sGM6Epw7DGLLs57/XVQzzwY1ZRP3U5xyqg8j8wdn3M=
-github.com/yandex-cloud/go-sdk v0.0.0-20240819112606-8a626cdc403d h1:eYs6TKjvjzYgAar7n2Ic4a+jIP08IfswtvCZ8iJqdKk=
-github.com/yandex-cloud/go-sdk v0.0.0-20240819112606-8a626cdc403d/go.mod h1:WYdfvXcvRn3kbVcwpav4J3jd1STOYtYvkTqx0wm4leM=
-github.com/yandex-cloud/go-sdk v0.0.0-20240829131820-fa8ad79f88a4 h1:l9x2SuRwFBvCTZvIlr8JqnjrHlr0a2UF/m/zdGnl+cs=
-github.com/yandex-cloud/go-sdk v0.0.0-20240829131820-fa8ad79f88a4/go.mod h1:/kMfiARiUXWqYG9EX1g5cZuvW+vY5M/oFROiUg0na+0=
 github.com/yandex-cloud/go-sdk v0.0.0-20240911121212-e4e74d0d02f5 h1:Q4LvUMF4kzaGtopoIdXReL9/qGtmzOewBhF3dQvuHMU=
 github.com/yandex-cloud/go-sdk v0.0.0-20240911121212-e4e74d0d02f5/go.mod h1:9dt2V80cfJGRZA+5SKP3Ky+R/DxH02XfKObi2Uy2uPc=
 github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA=
@@ -1850,28 +1713,12 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
 go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 h1:ZIg3ZT/aQ7AfKqdwp7ECpOK6vHqquXXuyTjIO8ZdmPs=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0/go.mod h1:DQAwmETtZV00skUwgD6+0U89g80NKsJE3DCKeLLPQMI=
-go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
-go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
-go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
-go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
 go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts=
 go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
-go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
-go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
-go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
-go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
 go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w=
 go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
-go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
-go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
-go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
-go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
 go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
 go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
@@ -1897,8 +1744,6 @@ go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
 go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
-golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
 golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
 golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -1924,11 +1769,6 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58
 golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
 golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
-golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
-golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
 golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
 golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
 golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -1946,10 +1786,6 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0
 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
 golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
 golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
-golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
-golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
-golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
-golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
 golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
@@ -1994,11 +1830,6 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
-golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
 golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -2074,12 +1905,6 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
-golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
-golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
 golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
 golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -2111,8 +1936,6 @@ golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec
 golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
 golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
 golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
-golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
-golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
 golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -2131,9 +1954,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
-golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
 golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -2251,14 +2071,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
-golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
 golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -2272,11 +2086,8 @@ golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
-golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
-golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
+golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
+golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -2295,10 +2106,6 @@ golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
-golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
-golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
 golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -2378,10 +2185,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
 golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
-golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
-golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
 golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
 golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2458,12 +2261,6 @@ google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60c
 google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0=
 google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
 google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
-google.golang.org/api v0.192.0 h1:PljqpNAfZaaSpS+TnANfnNAXKdzHM/B9bKhwRlo7JP0=
-google.golang.org/api v0.192.0/go.mod h1:9VcphjvAxPKLmSxVSzPlSRXy/5ARMEw5bf58WoVXafQ=
-google.golang.org/api v0.194.0 h1:dztZKG9HgtIpbI35FhfuSNR/zmaMVdxNlntHj1sIS4s=
-google.golang.org/api v0.194.0/go.mod h1:AgvUFdojGANh3vI+P7EVnxj3AISHllxGCJSFmggmnd0=
-google.golang.org/api v0.195.0 h1:Ude4N8FvTKnnQJHU48RFI40jOBgIrL8Zqr3/QeST6yU=
-google.golang.org/api v0.195.0/go.mod h1:DOGRWuv3P8TU8Lnz7uQc4hyNqrBpMtD9ppW3wBJurgc=
 google.golang.org/api v0.197.0 h1:x6CwqQLsFiA5JKAiGyGBjc2bNtHtLddhJCE2IKuhhcQ=
 google.golang.org/api v0.197.0/go.mod h1:AuOuo20GoQ331nq7DquGHlU6d+2wN2fZ8O0ta60nRNw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@@ -2606,28 +2403,10 @@ google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOl
 google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
 google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
 google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
-google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142 h1:oLiyxGgE+rt22duwci1+TG7bg2/L1LQsXwfjPlmuJA0=
-google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142/go.mod h1:G11eXq53iI5Q+kyNOmCvnzBaxEA2Q/Ik5Tj7nqBE8j4=
-google.golang.org/genproto v0.0.0-20240826202546-f6391c0de4c7 h1:f9Ho9PuVgvteqb4gfM3WOeMUZG6n4Lq8xfZ1Ja2dohQ=
-google.golang.org/genproto v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:ICjniACoWvcDz8c8bOsHVKuuSGDJy1z5M4G0DM3HzTc=
-google.golang.org/genproto v0.0.0-20240827150818-7e3bb234dfed h1:4C4dbrVFtfIp3GXJdMX1Sj25mahfn5DywOo65/2ISQ8=
-google.golang.org/genproto v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:ICjniACoWvcDz8c8bOsHVKuuSGDJy1z5M4G0DM3HzTc=
 google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
 google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
-google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
-google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
-google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=
-google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
-google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed h1:3RgNmBoI9MZhsj3QxC+AP/qQhNwpCLOvYDYYsFrhFt0=
-google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
 google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
 google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed h1:J6izYgfBXAI3xTKLgxzTmUltdYaLsuBxFCgDHWJ/eXg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -2672,10 +2451,6 @@ google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v
 google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
 google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
 google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
-google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
-google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
-google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
-google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
 google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
 google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
@@ -2740,11 +2515,8 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C
 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gorm.io/datatypes v1.2.1 h1:r+g0bk4LPCW2v4+Ls7aeNgGme7JYdNDQ2VtvlNUfBh0=
-gorm.io/datatypes v1.2.1/go.mod h1:hYK6OTb/1x+m96PgoZZq10UXJ6RvEBb9kRDQ2yyhzGs=
 gorm.io/datatypes v1.2.2 h1:sdn7ZmG4l7JWtMDUb3L98f2Ym7CO5F8mZLlrQJMfF9g=
 gorm.io/datatypes v1.2.2/go.mod h1:f4BsLcFAX67szSv8svwLRjklArSHAvHLeE3pXAS5DZI=
-gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
 gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
 gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
 gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
@@ -2759,14 +2531,10 @@ gorm.io/gen v0.3.26/go.mod h1:a5lq5y3w4g5LMxBcw0wnO6tYUCdNutWODq5LrIt75LE=
 gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
 gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
 gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
-gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
-gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
 gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
 gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
 gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o=
 gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg=
-gorm.io/plugin/dbresolver v1.5.2 h1:Iut7lW4TXNoVs++I+ra3zxjSxTRj4ocIeFEVp4lLhII=
-gorm.io/plugin/dbresolver v1.5.2/go.mod h1:jPh59GOQbO7v7v28ZKZPd45tr+u3vyT+8tHdfdfOWcU=
 gorm.io/plugin/dbresolver v1.5.3 h1:wFwINGZZmttuu9h7XpvbDHd8Lf9bb8GNzp/NpAMV2wU=
 gorm.io/plugin/dbresolver v1.5.3/go.mod h1:TSrVhaUg2DZAWP3PrHlDlITEJmNOkL0tFTjvTEsQ4XE=
 gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
@@ -2779,20 +2547,12 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
-k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo=
-k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
 k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
 k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
-k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
-k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
 k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
 k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
 k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
 k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
-k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
-k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-k8s.io/utils v0.0.0-20240821151609-f90d01438635 h1:2wThSvJoW/Ncn9TmQEYXRnevZXi2duqHWf5OX9S3zjI=
-k8s.io/utils v0.0.0-20240821151609-f90d01438635/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 k8s.io/utils v0.0.0-20240902221715-702e33fdd3c3 h1:b2FmK8YH+QEwq/Sy2uAEhmqL5nPfGYbJOcaqjeYYZoA=
 k8s.io/utils v0.0.0-20240902221715-702e33fdd3c3/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=

+ 4 - 0
internal/cache/cache.go

@@ -29,3 +29,7 @@ func Set(key interface{}, value interface{}, ttl time.Duration) {
 func Get(key interface{}) (value interface{}, ok bool) {
 	return cache.Get(key)
 }
+
+func Del(key interface{}) {
+	cache.Del(key)
+}

+ 2 - 0
internal/kernal/boot.go

@@ -9,6 +9,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/internal/cluster"
 	"github.com/0xJacky/Nginx-UI/internal/cron"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/0xJacky/Nginx-UI/internal/passkey"
 	"github.com/0xJacky/Nginx-UI/internal/validation"
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/query"
@@ -50,6 +51,7 @@ func InitAfterDatabase() {
 		cron.InitCronJobs,
 		cluster.RegisterPredefinedNodes,
 		analytic.RetrieveNodesStatus,
+		passkey.Init,
 	}
 
 	for _, v := range syncs {

+ 2 - 2
internal/kernal/skip_install.go

@@ -51,7 +51,7 @@ func registerPredefinedUser() {
 		logger.Fatal(err)
 	}
 
-	u := query.Auth
+	u := query.User
 
 	_, err = u.First()
 
@@ -63,7 +63,7 @@ func registerPredefinedUser() {
 	// Create a new user with the predefined name and password
 	pwd, _ := bcrypt.GenerateFromPassword([]byte(pUser.Password), bcrypt.DefaultCost)
 
-	err = u.Create(&model.Auth{
+	err = u.Create(&model.User{
 		Name:     pUser.Name,
 		Password: string(pwd),
 	})

+ 1 - 1
internal/middleware/secure_session.go

@@ -14,7 +14,7 @@ func RequireSecureSession() gin.HandlerFunc {
 			c.Next()
 			return
 		}
-		cUser := u.(*model.Auth)
+		cUser := u.(*model.User)
 		if !cUser.EnabledOTP() {
 			c.Next()
 			return

+ 46 - 0
internal/passkey/webauthn.go

@@ -0,0 +1,46 @@
+package passkey
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/0xJacky/Nginx-UI/settings"
+	"github.com/go-webauthn/webauthn/protocol"
+	"github.com/go-webauthn/webauthn/webauthn"
+)
+
+var instance *webauthn.WebAuthn
+
+func Init() {
+	options := &settings.WebAuthnSettings
+
+	if !Enabled() {
+		logger.Debug("WebAuthn settings are not configured")
+		return
+	}
+	requireResidentKey := true
+	var err error
+	instance, err = webauthn.New(&webauthn.Config{
+		RPDisplayName: options.RPDisplayName,
+		RPID:          options.RPID,
+		RPOrigins:     options.RPOrigins,
+		AuthenticatorSelection: protocol.AuthenticatorSelection{
+			RequireResidentKey: &requireResidentKey,
+			UserVerification:   "required",
+		},
+	})
+
+	if err != nil {
+		logger.Fatal(err)
+	}
+}
+
+func Enabled() bool {
+	options := &settings.WebAuthnSettings
+	if options.RPDisplayName == "" || options.RPID == "" || len(options.RPOrigins) == 0 {
+		return false
+	}
+	return true
+}
+
+func GetInstance() *webauthn.WebAuthn {
+	return instance
+}

+ 2 - 2
internal/user/login.go

@@ -14,8 +14,8 @@ var (
 	ErrUserBanned        = errors.New("user banned")
 )
 
-func Login(name string, password string) (user *model.Auth, err error) {
-	u := query.Auth
+func Login(name string, password string) (user *model.User, err error) {
+	u := query.User
 
 	user, err = u.Where(u.Name.Eq(name)).First()
 	if err != nil {

+ 2 - 2
internal/user/otp.go

@@ -19,7 +19,7 @@ var (
 	ErrRecoveryCode = errors.New("invalid recovery code")
 )
 
-func VerifyOTP(user *model.Auth, otp, recoveryCode string) (err error) {
+func VerifyOTP(user *model.User, otp, recoveryCode string) (err error) {
 	if otp != "" {
 		decrypted, err := crypto.AesDecrypt(user.OTPSecret)
 		if err != nil {
@@ -43,7 +43,7 @@ func VerifyOTP(user *model.Auth, otp, recoveryCode string) (err error) {
 }
 
 func secureSessionIDCacheKey(sessionId string) string {
-	return fmt.Sprintf("otp_secure_session:_%s", sessionId)
+	return fmt.Sprintf("2fa_secure_session:_%s", sessionId)
 }
 
 func SetSecureSessionID(userId int) (sessionId string) {

+ 7 - 7
internal/user/user.go

@@ -26,9 +26,9 @@ func BuildCacheTokenKey(token string) string {
 	return sb.String()
 }
 
-func GetUser(name string) (user *model.Auth, err error) {
+func GetUser(name string) (user *model.User, err error) {
 	db := model.UseDB()
-	user = &model.Auth{}
+	user = &model.User{}
 	err = db.Where("name", name).First(user).Error
 	if err != nil {
 		return
@@ -41,7 +41,7 @@ func DeleteToken(token string) {
 	_, _ = q.Where(q.Token.Eq(token)).Delete()
 }
 
-func GetTokenUser(token string) (*model.Auth, bool) {
+func GetTokenUser(token string) (*model.User, bool) {
 	q := query.AuthToken
 	authToken, err := q.Where(q.Token.Eq(token)).First()
 	if err != nil {
@@ -53,12 +53,12 @@ func GetTokenUser(token string) (*model.Auth, bool) {
 		return nil, false
 	}
 
-	u := query.Auth
+	u := query.User
 	user, err := u.FirstByID(authToken.UserID)
 	return user, err == nil
 }
 
-func GenerateJWT(user *model.Auth) (string, error) {
+func GenerateJWT(user *model.User) (string, error) {
 	claims := JWTClaims{
 		Name:   user.Name,
 		UserID: user.ID,
@@ -114,7 +114,7 @@ func ValidateJWT(token string) (claims *JWTClaims, err error) {
 	return
 }
 
-func CurrentUser(token string) (u *model.Auth, err error) {
+func CurrentUser(token string) (u *model.User, err error) {
 	// validate token
 	var claims *JWTClaims
 	claims, err = ValidateJWT(token)
@@ -123,7 +123,7 @@ func CurrentUser(token string) (u *model.Auth, err error) {
 	}
 
 	// get user by id
-	user := query.Auth
+	user := query.User
 	u, err = user.FirstByID(claims.UserID)
 	if err != nil {
 		return

+ 43 - 11
model/auth.go

@@ -1,15 +1,17 @@
 package model
 
-import "gorm.io/gorm"
+import (
+	"github.com/go-webauthn/webauthn/webauthn"
+	"github.com/spf13/cast"
+)
 
-type Auth struct {
+type User struct {
 	Model
 
-	Name       string `json:"name"`
-	Password   string `json:"-"`
-	Status     bool   `json:"status" gorm:"default:1"`
-	OTPSecret  []byte `json:"-" gorm:"type:blob"`
-	Enabled2FA bool   `json:"enabled_2fa" gorm:"-"`
+	Name      string `json:"name"`
+	Password  string `json:"-"`
+	Status    bool   `json:"status" gorm:"default:1"`
+	OTPSecret []byte `json:"-" gorm:"type:blob"`
 }
 
 type AuthToken struct {
@@ -18,11 +20,41 @@ type AuthToken struct {
 	ExpiredAt int64  `json:"expired_at" gorm:"default:0"`
 }
 
-func (u *Auth) AfterFind(tx *gorm.DB) error {
-    u.Enabled2FA = u.EnabledOTP()
-    return nil
+func (u *User) TableName() string {
+	return "auths"
 }
 
-func (u *Auth) EnabledOTP() bool {
+func (u *User) EnabledOTP() bool {
 	return len(u.OTPSecret) != 0
 }
+
+func (u *User) EnabledPasskey() bool {
+	var passkeys Passkey
+	db.Where("user_id", u.ID).First(&passkeys)
+	return passkeys.ID != 0
+}
+
+func (u *User) Enabled2FA() bool {
+	return u.EnabledOTP() || u.EnabledPasskey()
+}
+
+func (u *User) WebAuthnID() []byte {
+	return []byte(cast.ToString(u.ID))
+}
+
+func (u *User) WebAuthnName() string {
+	return u.Name
+}
+
+func (u *User) WebAuthnDisplayName() string {
+	return u.Name
+}
+
+func (u *User) WebAuthnCredentials() (credentials []webauthn.Credential) {
+	var passkeys []Passkey
+	db.Where("user_id", u.ID).Find(&passkeys)
+	for _, passkey := range passkeys {
+		credentials = append(credentials, *passkey.Credential)
+	}
+	return
+}

+ 2 - 1
model/model.go

@@ -25,7 +25,7 @@ type Model struct {
 func GenerateAllModel() []any {
 	return []any{
 		ConfigBackup{},
-		Auth{},
+		User{},
 		AuthToken{},
 		Cert{},
 		ChatGPTLog{},
@@ -37,6 +37,7 @@ func GenerateAllModel() []any {
 		AcmeUser{},
 		BanIP{},
 		Config{},
+		Passkey{},
 	}
 }
 

+ 13 - 0
model/passkey.go

@@ -0,0 +1,13 @@
+package model
+
+import "github.com/go-webauthn/webauthn/webauthn"
+
+type Passkey struct {
+	Model
+
+	Name       string               `json:"name"`
+	UserID     int                  `json:"user_id"`
+	RawID      string               `json:"raw_id"`
+	Credential *webauthn.Credential `json:"-" gorm:"serializer:json"`
+	LastUsedAt int64                `json:"last_used_at" gorm:"default:0"`
+}

+ 157 - 157
query/auths.gen.go

@@ -20,30 +20,30 @@ import (
 	"github.com/0xJacky/Nginx-UI/model"
 )
 
-func newAuth(db *gorm.DB, opts ...gen.DOOption) auth {
-	_auth := auth{}
+func newUser(db *gorm.DB, opts ...gen.DOOption) user {
+	_user := user{}
 
-	_auth.authDo.UseDB(db, opts...)
-	_auth.authDo.UseModel(&model.Auth{})
+	_user.userDo.UseDB(db, opts...)
+	_user.userDo.UseModel(&model.User{})
 
-	tableName := _auth.authDo.TableName()
-	_auth.ALL = field.NewAsterisk(tableName)
-	_auth.ID = field.NewInt(tableName, "id")
-	_auth.CreatedAt = field.NewTime(tableName, "created_at")
-	_auth.UpdatedAt = field.NewTime(tableName, "updated_at")
-	_auth.DeletedAt = field.NewField(tableName, "deleted_at")
-	_auth.Name = field.NewString(tableName, "name")
-	_auth.Password = field.NewString(tableName, "password")
-	_auth.Status = field.NewBool(tableName, "status")
-	_auth.OTPSecret = field.NewBytes(tableName, "otp_secret")
+	tableName := _user.userDo.TableName()
+	_user.ALL = field.NewAsterisk(tableName)
+	_user.ID = field.NewInt(tableName, "id")
+	_user.CreatedAt = field.NewTime(tableName, "created_at")
+	_user.UpdatedAt = field.NewTime(tableName, "updated_at")
+	_user.DeletedAt = field.NewField(tableName, "deleted_at")
+	_user.Name = field.NewString(tableName, "name")
+	_user.Password = field.NewString(tableName, "password")
+	_user.Status = field.NewBool(tableName, "status")
+	_user.OTPSecret = field.NewBytes(tableName, "otp_secret")
 
-	_auth.fillFieldMap()
+	_user.fillFieldMap()
 
-	return _auth
+	return _user
 }
 
-type auth struct {
-	authDo
+type user struct {
+	userDo
 
 	ALL       field.Asterisk
 	ID        field.Int
@@ -58,34 +58,34 @@ type auth struct {
 	fieldMap map[string]field.Expr
 }
 
-func (a auth) Table(newTableName string) *auth {
-	a.authDo.UseTable(newTableName)
-	return a.updateTableName(newTableName)
+func (u user) Table(newTableName string) *user {
+	u.userDo.UseTable(newTableName)
+	return u.updateTableName(newTableName)
 }
 
-func (a auth) As(alias string) *auth {
-	a.authDo.DO = *(a.authDo.As(alias).(*gen.DO))
-	return a.updateTableName(alias)
+func (u user) As(alias string) *user {
+	u.userDo.DO = *(u.userDo.As(alias).(*gen.DO))
+	return u.updateTableName(alias)
 }
 
-func (a *auth) updateTableName(table string) *auth {
-	a.ALL = field.NewAsterisk(table)
-	a.ID = field.NewInt(table, "id")
-	a.CreatedAt = field.NewTime(table, "created_at")
-	a.UpdatedAt = field.NewTime(table, "updated_at")
-	a.DeletedAt = field.NewField(table, "deleted_at")
-	a.Name = field.NewString(table, "name")
-	a.Password = field.NewString(table, "password")
-	a.Status = field.NewBool(table, "status")
-	a.OTPSecret = field.NewBytes(table, "otp_secret")
+func (u *user) updateTableName(table string) *user {
+	u.ALL = field.NewAsterisk(table)
+	u.ID = field.NewInt(table, "id")
+	u.CreatedAt = field.NewTime(table, "created_at")
+	u.UpdatedAt = field.NewTime(table, "updated_at")
+	u.DeletedAt = field.NewField(table, "deleted_at")
+	u.Name = field.NewString(table, "name")
+	u.Password = field.NewString(table, "password")
+	u.Status = field.NewBool(table, "status")
+	u.OTPSecret = field.NewBytes(table, "otp_secret")
 
-	a.fillFieldMap()
+	u.fillFieldMap()
 
-	return a
+	return u
 }
 
-func (a *auth) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
-	_f, ok := a.fieldMap[fieldName]
+func (u *user) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
+	_f, ok := u.fieldMap[fieldName]
 	if !ok || _f == nil {
 		return nil, false
 	}
@@ -93,32 +93,32 @@ func (a *auth) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 	return _oe, ok
 }
 
-func (a *auth) fillFieldMap() {
-	a.fieldMap = make(map[string]field.Expr, 8)
-	a.fieldMap["id"] = a.ID
-	a.fieldMap["created_at"] = a.CreatedAt
-	a.fieldMap["updated_at"] = a.UpdatedAt
-	a.fieldMap["deleted_at"] = a.DeletedAt
-	a.fieldMap["name"] = a.Name
-	a.fieldMap["password"] = a.Password
-	a.fieldMap["status"] = a.Status
-	a.fieldMap["otp_secret"] = a.OTPSecret
+func (u *user) fillFieldMap() {
+	u.fieldMap = make(map[string]field.Expr, 8)
+	u.fieldMap["id"] = u.ID
+	u.fieldMap["created_at"] = u.CreatedAt
+	u.fieldMap["updated_at"] = u.UpdatedAt
+	u.fieldMap["deleted_at"] = u.DeletedAt
+	u.fieldMap["name"] = u.Name
+	u.fieldMap["password"] = u.Password
+	u.fieldMap["status"] = u.Status
+	u.fieldMap["otp_secret"] = u.OTPSecret
 }
 
-func (a auth) clone(db *gorm.DB) auth {
-	a.authDo.ReplaceConnPool(db.Statement.ConnPool)
-	return a
+func (u user) clone(db *gorm.DB) user {
+	u.userDo.ReplaceConnPool(db.Statement.ConnPool)
+	return u
 }
 
-func (a auth) replaceDB(db *gorm.DB) auth {
-	a.authDo.ReplaceDB(db)
-	return a
+func (u user) replaceDB(db *gorm.DB) user {
+	u.userDo.ReplaceDB(db)
+	return u
 }
 
-type authDo struct{ gen.DO }
+type userDo struct{ gen.DO }
 
 // FirstByID Where("id=@id")
-func (a authDo) FirstByID(id int) (result *model.Auth, err error) {
+func (u userDo) FirstByID(id int) (result *model.User, err error) {
 	var params []interface{}
 
 	var generateSQL strings.Builder
@@ -126,14 +126,14 @@ func (a authDo) FirstByID(id int) (result *model.Auth, err error) {
 	generateSQL.WriteString("id=? ")
 
 	var executeSQL *gorm.DB
-	executeSQL = a.UnderlyingDB().Where(generateSQL.String(), params...).Take(&result) // ignore_security_alert
+	executeSQL = u.UnderlyingDB().Where(generateSQL.String(), params...).Take(&result) // ignore_security_alert
 	err = executeSQL.Error
 
 	return
 }
 
 // DeleteByID update @@table set deleted_at=strftime('%Y-%m-%d %H:%M:%S','now') where id=@id
-func (a authDo) DeleteByID(id int) (err error) {
+func (u userDo) DeleteByID(id int) (err error) {
 	var params []interface{}
 
 	var generateSQL strings.Builder
@@ -141,206 +141,206 @@ func (a authDo) DeleteByID(id int) (err error) {
 	generateSQL.WriteString("update auths set deleted_at=strftime('%Y-%m-%d %H:%M:%S','now') where id=? ")
 
 	var executeSQL *gorm.DB
-	executeSQL = a.UnderlyingDB().Exec(generateSQL.String(), params...) // ignore_security_alert
+	executeSQL = u.UnderlyingDB().Exec(generateSQL.String(), params...) // ignore_security_alert
 	err = executeSQL.Error
 
 	return
 }
 
-func (a authDo) Debug() *authDo {
-	return a.withDO(a.DO.Debug())
+func (u userDo) Debug() *userDo {
+	return u.withDO(u.DO.Debug())
 }
 
-func (a authDo) WithContext(ctx context.Context) *authDo {
-	return a.withDO(a.DO.WithContext(ctx))
+func (u userDo) WithContext(ctx context.Context) *userDo {
+	return u.withDO(u.DO.WithContext(ctx))
 }
 
-func (a authDo) ReadDB() *authDo {
-	return a.Clauses(dbresolver.Read)
+func (u userDo) ReadDB() *userDo {
+	return u.Clauses(dbresolver.Read)
 }
 
-func (a authDo) WriteDB() *authDo {
-	return a.Clauses(dbresolver.Write)
+func (u userDo) WriteDB() *userDo {
+	return u.Clauses(dbresolver.Write)
 }
 
-func (a authDo) Session(config *gorm.Session) *authDo {
-	return a.withDO(a.DO.Session(config))
+func (u userDo) Session(config *gorm.Session) *userDo {
+	return u.withDO(u.DO.Session(config))
 }
 
-func (a authDo) Clauses(conds ...clause.Expression) *authDo {
-	return a.withDO(a.DO.Clauses(conds...))
+func (u userDo) Clauses(conds ...clause.Expression) *userDo {
+	return u.withDO(u.DO.Clauses(conds...))
 }
 
-func (a authDo) Returning(value interface{}, columns ...string) *authDo {
-	return a.withDO(a.DO.Returning(value, columns...))
+func (u userDo) Returning(value interface{}, columns ...string) *userDo {
+	return u.withDO(u.DO.Returning(value, columns...))
 }
 
-func (a authDo) Not(conds ...gen.Condition) *authDo {
-	return a.withDO(a.DO.Not(conds...))
+func (u userDo) Not(conds ...gen.Condition) *userDo {
+	return u.withDO(u.DO.Not(conds...))
 }
 
-func (a authDo) Or(conds ...gen.Condition) *authDo {
-	return a.withDO(a.DO.Or(conds...))
+func (u userDo) Or(conds ...gen.Condition) *userDo {
+	return u.withDO(u.DO.Or(conds...))
 }
 
-func (a authDo) Select(conds ...field.Expr) *authDo {
-	return a.withDO(a.DO.Select(conds...))
+func (u userDo) Select(conds ...field.Expr) *userDo {
+	return u.withDO(u.DO.Select(conds...))
 }
 
-func (a authDo) Where(conds ...gen.Condition) *authDo {
-	return a.withDO(a.DO.Where(conds...))
+func (u userDo) Where(conds ...gen.Condition) *userDo {
+	return u.withDO(u.DO.Where(conds...))
 }
 
-func (a authDo) Order(conds ...field.Expr) *authDo {
-	return a.withDO(a.DO.Order(conds...))
+func (u userDo) Order(conds ...field.Expr) *userDo {
+	return u.withDO(u.DO.Order(conds...))
 }
 
-func (a authDo) Distinct(cols ...field.Expr) *authDo {
-	return a.withDO(a.DO.Distinct(cols...))
+func (u userDo) Distinct(cols ...field.Expr) *userDo {
+	return u.withDO(u.DO.Distinct(cols...))
 }
 
-func (a authDo) Omit(cols ...field.Expr) *authDo {
-	return a.withDO(a.DO.Omit(cols...))
+func (u userDo) Omit(cols ...field.Expr) *userDo {
+	return u.withDO(u.DO.Omit(cols...))
 }
 
-func (a authDo) Join(table schema.Tabler, on ...field.Expr) *authDo {
-	return a.withDO(a.DO.Join(table, on...))
+func (u userDo) Join(table schema.Tabler, on ...field.Expr) *userDo {
+	return u.withDO(u.DO.Join(table, on...))
 }
 
-func (a authDo) LeftJoin(table schema.Tabler, on ...field.Expr) *authDo {
-	return a.withDO(a.DO.LeftJoin(table, on...))
+func (u userDo) LeftJoin(table schema.Tabler, on ...field.Expr) *userDo {
+	return u.withDO(u.DO.LeftJoin(table, on...))
 }
 
-func (a authDo) RightJoin(table schema.Tabler, on ...field.Expr) *authDo {
-	return a.withDO(a.DO.RightJoin(table, on...))
+func (u userDo) RightJoin(table schema.Tabler, on ...field.Expr) *userDo {
+	return u.withDO(u.DO.RightJoin(table, on...))
 }
 
-func (a authDo) Group(cols ...field.Expr) *authDo {
-	return a.withDO(a.DO.Group(cols...))
+func (u userDo) Group(cols ...field.Expr) *userDo {
+	return u.withDO(u.DO.Group(cols...))
 }
 
-func (a authDo) Having(conds ...gen.Condition) *authDo {
-	return a.withDO(a.DO.Having(conds...))
+func (u userDo) Having(conds ...gen.Condition) *userDo {
+	return u.withDO(u.DO.Having(conds...))
 }
 
-func (a authDo) Limit(limit int) *authDo {
-	return a.withDO(a.DO.Limit(limit))
+func (u userDo) Limit(limit int) *userDo {
+	return u.withDO(u.DO.Limit(limit))
 }
 
-func (a authDo) Offset(offset int) *authDo {
-	return a.withDO(a.DO.Offset(offset))
+func (u userDo) Offset(offset int) *userDo {
+	return u.withDO(u.DO.Offset(offset))
 }
 
-func (a authDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *authDo {
-	return a.withDO(a.DO.Scopes(funcs...))
+func (u userDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *userDo {
+	return u.withDO(u.DO.Scopes(funcs...))
 }
 
-func (a authDo) Unscoped() *authDo {
-	return a.withDO(a.DO.Unscoped())
+func (u userDo) Unscoped() *userDo {
+	return u.withDO(u.DO.Unscoped())
 }
 
-func (a authDo) Create(values ...*model.Auth) error {
+func (u userDo) Create(values ...*model.User) error {
 	if len(values) == 0 {
 		return nil
 	}
-	return a.DO.Create(values)
+	return u.DO.Create(values)
 }
 
-func (a authDo) CreateInBatches(values []*model.Auth, batchSize int) error {
-	return a.DO.CreateInBatches(values, batchSize)
+func (u userDo) CreateInBatches(values []*model.User, batchSize int) error {
+	return u.DO.CreateInBatches(values, batchSize)
 }
 
 // Save : !!! underlying implementation is different with GORM
 // The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
-func (a authDo) Save(values ...*model.Auth) error {
+func (u userDo) Save(values ...*model.User) error {
 	if len(values) == 0 {
 		return nil
 	}
-	return a.DO.Save(values)
+	return u.DO.Save(values)
 }
 
-func (a authDo) First() (*model.Auth, error) {
-	if result, err := a.DO.First(); err != nil {
+func (u userDo) First() (*model.User, error) {
+	if result, err := u.DO.First(); err != nil {
 		return nil, err
 	} else {
-		return result.(*model.Auth), nil
+		return result.(*model.User), nil
 	}
 }
 
-func (a authDo) Take() (*model.Auth, error) {
-	if result, err := a.DO.Take(); err != nil {
+func (u userDo) Take() (*model.User, error) {
+	if result, err := u.DO.Take(); err != nil {
 		return nil, err
 	} else {
-		return result.(*model.Auth), nil
+		return result.(*model.User), nil
 	}
 }
 
-func (a authDo) Last() (*model.Auth, error) {
-	if result, err := a.DO.Last(); err != nil {
+func (u userDo) Last() (*model.User, error) {
+	if result, err := u.DO.Last(); err != nil {
 		return nil, err
 	} else {
-		return result.(*model.Auth), nil
+		return result.(*model.User), nil
 	}
 }
 
-func (a authDo) Find() ([]*model.Auth, error) {
-	result, err := a.DO.Find()
-	return result.([]*model.Auth), err
+func (u userDo) Find() ([]*model.User, error) {
+	result, err := u.DO.Find()
+	return result.([]*model.User), err
 }
 
-func (a authDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.Auth, err error) {
-	buf := make([]*model.Auth, 0, batchSize)
-	err = a.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
+func (u userDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.User, err error) {
+	buf := make([]*model.User, 0, batchSize)
+	err = u.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
 		defer func() { results = append(results, buf...) }()
 		return fc(tx, batch)
 	})
 	return results, err
 }
 
-func (a authDo) FindInBatches(result *[]*model.Auth, batchSize int, fc func(tx gen.Dao, batch int) error) error {
-	return a.DO.FindInBatches(result, batchSize, fc)
+func (u userDo) FindInBatches(result *[]*model.User, batchSize int, fc func(tx gen.Dao, batch int) error) error {
+	return u.DO.FindInBatches(result, batchSize, fc)
 }
 
-func (a authDo) Attrs(attrs ...field.AssignExpr) *authDo {
-	return a.withDO(a.DO.Attrs(attrs...))
+func (u userDo) Attrs(attrs ...field.AssignExpr) *userDo {
+	return u.withDO(u.DO.Attrs(attrs...))
 }
 
-func (a authDo) Assign(attrs ...field.AssignExpr) *authDo {
-	return a.withDO(a.DO.Assign(attrs...))
+func (u userDo) Assign(attrs ...field.AssignExpr) *userDo {
+	return u.withDO(u.DO.Assign(attrs...))
 }
 
-func (a authDo) Joins(fields ...field.RelationField) *authDo {
+func (u userDo) Joins(fields ...field.RelationField) *userDo {
 	for _, _f := range fields {
-		a = *a.withDO(a.DO.Joins(_f))
+		u = *u.withDO(u.DO.Joins(_f))
 	}
-	return &a
+	return &u
 }
 
-func (a authDo) Preload(fields ...field.RelationField) *authDo {
+func (u userDo) Preload(fields ...field.RelationField) *userDo {
 	for _, _f := range fields {
-		a = *a.withDO(a.DO.Preload(_f))
+		u = *u.withDO(u.DO.Preload(_f))
 	}
-	return &a
+	return &u
 }
 
-func (a authDo) FirstOrInit() (*model.Auth, error) {
-	if result, err := a.DO.FirstOrInit(); err != nil {
+func (u userDo) FirstOrInit() (*model.User, error) {
+	if result, err := u.DO.FirstOrInit(); err != nil {
 		return nil, err
 	} else {
-		return result.(*model.Auth), nil
+		return result.(*model.User), nil
 	}
 }
 
-func (a authDo) FirstOrCreate() (*model.Auth, error) {
-	if result, err := a.DO.FirstOrCreate(); err != nil {
+func (u userDo) FirstOrCreate() (*model.User, error) {
+	if result, err := u.DO.FirstOrCreate(); err != nil {
 		return nil, err
 	} else {
-		return result.(*model.Auth), nil
+		return result.(*model.User), nil
 	}
 }
 
-func (a authDo) FindByPage(offset int, limit int) (result []*model.Auth, count int64, err error) {
-	result, err = a.Offset(offset).Limit(limit).Find()
+func (u userDo) FindByPage(offset int, limit int) (result []*model.User, count int64, err error) {
+	result, err = u.Offset(offset).Limit(limit).Find()
 	if err != nil {
 		return
 	}
@@ -350,29 +350,29 @@ func (a authDo) FindByPage(offset int, limit int) (result []*model.Auth, count i
 		return
 	}
 
-	count, err = a.Offset(-1).Limit(-1).Count()
+	count, err = u.Offset(-1).Limit(-1).Count()
 	return
 }
 
-func (a authDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
-	count, err = a.Count()
+func (u userDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
+	count, err = u.Count()
 	if err != nil {
 		return
 	}
 
-	err = a.Offset(offset).Limit(limit).Scan(result)
+	err = u.Offset(offset).Limit(limit).Scan(result)
 	return
 }
 
-func (a authDo) Scan(result interface{}) (err error) {
-	return a.DO.Scan(result)
+func (u userDo) Scan(result interface{}) (err error) {
+	return u.DO.Scan(result)
 }
 
-func (a authDo) Delete(models ...*model.Auth) (result gen.ResultInfo, err error) {
-	return a.DO.Delete(models)
+func (u userDo) Delete(models ...*model.User) (result gen.ResultInfo, err error) {
+	return u.DO.Delete(models)
 }
 
-func (a *authDo) withDO(do gen.Dao) *authDo {
-	a.DO = *do.(*gen.DO)
-	return a
+func (u *userDo) withDO(do gen.Dao) *userDo {
+	u.DO = *do.(*gen.DO)
+	return u
 }

+ 16 - 8
query/gen.go

@@ -18,7 +18,6 @@ import (
 var (
 	Q             = new(Query)
 	AcmeUser      *acmeUser
-	Auth          *auth
 	AuthToken     *authToken
 	BanIP         *banIP
 	Cert          *cert
@@ -28,14 +27,15 @@ var (
 	DnsCredential *dnsCredential
 	Environment   *environment
 	Notification  *notification
+	Passkey       *passkey
 	Site          *site
 	Stream        *stream
+	User          *user
 )
 
 func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
 	*Q = *Use(db, opts...)
 	AcmeUser = &Q.AcmeUser
-	Auth = &Q.Auth
 	AuthToken = &Q.AuthToken
 	BanIP = &Q.BanIP
 	Cert = &Q.Cert
@@ -45,15 +45,16 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
 	DnsCredential = &Q.DnsCredential
 	Environment = &Q.Environment
 	Notification = &Q.Notification
+	Passkey = &Q.Passkey
 	Site = &Q.Site
 	Stream = &Q.Stream
+	User = &Q.User
 }
 
 func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
 	return &Query{
 		db:            db,
 		AcmeUser:      newAcmeUser(db, opts...),
-		Auth:          newAuth(db, opts...),
 		AuthToken:     newAuthToken(db, opts...),
 		BanIP:         newBanIP(db, opts...),
 		Cert:          newCert(db, opts...),
@@ -63,8 +64,10 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
 		DnsCredential: newDnsCredential(db, opts...),
 		Environment:   newEnvironment(db, opts...),
 		Notification:  newNotification(db, opts...),
+		Passkey:       newPasskey(db, opts...),
 		Site:          newSite(db, opts...),
 		Stream:        newStream(db, opts...),
+		User:          newUser(db, opts...),
 	}
 }
 
@@ -72,7 +75,6 @@ type Query struct {
 	db *gorm.DB
 
 	AcmeUser      acmeUser
-	Auth          auth
 	AuthToken     authToken
 	BanIP         banIP
 	Cert          cert
@@ -82,8 +84,10 @@ type Query struct {
 	DnsCredential dnsCredential
 	Environment   environment
 	Notification  notification
+	Passkey       passkey
 	Site          site
 	Stream        stream
+	User          user
 }
 
 func (q *Query) Available() bool { return q.db != nil }
@@ -92,7 +96,6 @@ func (q *Query) clone(db *gorm.DB) *Query {
 	return &Query{
 		db:            db,
 		AcmeUser:      q.AcmeUser.clone(db),
-		Auth:          q.Auth.clone(db),
 		AuthToken:     q.AuthToken.clone(db),
 		BanIP:         q.BanIP.clone(db),
 		Cert:          q.Cert.clone(db),
@@ -102,8 +105,10 @@ func (q *Query) clone(db *gorm.DB) *Query {
 		DnsCredential: q.DnsCredential.clone(db),
 		Environment:   q.Environment.clone(db),
 		Notification:  q.Notification.clone(db),
+		Passkey:       q.Passkey.clone(db),
 		Site:          q.Site.clone(db),
 		Stream:        q.Stream.clone(db),
+		User:          q.User.clone(db),
 	}
 }
 
@@ -119,7 +124,6 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
 	return &Query{
 		db:            db,
 		AcmeUser:      q.AcmeUser.replaceDB(db),
-		Auth:          q.Auth.replaceDB(db),
 		AuthToken:     q.AuthToken.replaceDB(db),
 		BanIP:         q.BanIP.replaceDB(db),
 		Cert:          q.Cert.replaceDB(db),
@@ -129,14 +133,15 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
 		DnsCredential: q.DnsCredential.replaceDB(db),
 		Environment:   q.Environment.replaceDB(db),
 		Notification:  q.Notification.replaceDB(db),
+		Passkey:       q.Passkey.replaceDB(db),
 		Site:          q.Site.replaceDB(db),
 		Stream:        q.Stream.replaceDB(db),
+		User:          q.User.replaceDB(db),
 	}
 }
 
 type queryCtx struct {
 	AcmeUser      *acmeUserDo
-	Auth          *authDo
 	AuthToken     *authTokenDo
 	BanIP         *banIPDo
 	Cert          *certDo
@@ -146,14 +151,15 @@ type queryCtx struct {
 	DnsCredential *dnsCredentialDo
 	Environment   *environmentDo
 	Notification  *notificationDo
+	Passkey       *passkeyDo
 	Site          *siteDo
 	Stream        *streamDo
+	User          *userDo
 }
 
 func (q *Query) WithContext(ctx context.Context) *queryCtx {
 	return &queryCtx{
 		AcmeUser:      q.AcmeUser.WithContext(ctx),
-		Auth:          q.Auth.WithContext(ctx),
 		AuthToken:     q.AuthToken.WithContext(ctx),
 		BanIP:         q.BanIP.WithContext(ctx),
 		Cert:          q.Cert.WithContext(ctx),
@@ -163,8 +169,10 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
 		DnsCredential: q.DnsCredential.WithContext(ctx),
 		Environment:   q.Environment.WithContext(ctx),
 		Notification:  q.Notification.WithContext(ctx),
+		Passkey:       q.Passkey.WithContext(ctx),
 		Site:          q.Site.WithContext(ctx),
 		Stream:        q.Stream.WithContext(ctx),
+		User:          q.User.WithContext(ctx),
 	}
 }
 

+ 382 - 0
query/passkeys.gen.go

@@ -0,0 +1,382 @@
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+
+package query
+
+import (
+	"context"
+	"strings"
+
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+	"gorm.io/gorm/schema"
+
+	"gorm.io/gen"
+	"gorm.io/gen/field"
+
+	"gorm.io/plugin/dbresolver"
+
+	"github.com/0xJacky/Nginx-UI/model"
+)
+
+func newPasskey(db *gorm.DB, opts ...gen.DOOption) passkey {
+	_passkey := passkey{}
+
+	_passkey.passkeyDo.UseDB(db, opts...)
+	_passkey.passkeyDo.UseModel(&model.Passkey{})
+
+	tableName := _passkey.passkeyDo.TableName()
+	_passkey.ALL = field.NewAsterisk(tableName)
+	_passkey.ID = field.NewInt(tableName, "id")
+	_passkey.CreatedAt = field.NewTime(tableName, "created_at")
+	_passkey.UpdatedAt = field.NewTime(tableName, "updated_at")
+	_passkey.DeletedAt = field.NewField(tableName, "deleted_at")
+	_passkey.Name = field.NewString(tableName, "name")
+	_passkey.UserID = field.NewInt(tableName, "user_id")
+	_passkey.RawID = field.NewString(tableName, "raw_id")
+	_passkey.Credential = field.NewField(tableName, "credential")
+	_passkey.LastUsedAt = field.NewInt64(tableName, "last_used_at")
+
+	_passkey.fillFieldMap()
+
+	return _passkey
+}
+
+type passkey struct {
+	passkeyDo
+
+	ALL        field.Asterisk
+	ID         field.Int
+	CreatedAt  field.Time
+	UpdatedAt  field.Time
+	DeletedAt  field.Field
+	Name       field.String
+	UserID     field.Int
+	RawID      field.String
+	Credential field.Field
+	LastUsedAt field.Int64
+
+	fieldMap map[string]field.Expr
+}
+
+func (p passkey) Table(newTableName string) *passkey {
+	p.passkeyDo.UseTable(newTableName)
+	return p.updateTableName(newTableName)
+}
+
+func (p passkey) As(alias string) *passkey {
+	p.passkeyDo.DO = *(p.passkeyDo.As(alias).(*gen.DO))
+	return p.updateTableName(alias)
+}
+
+func (p *passkey) updateTableName(table string) *passkey {
+	p.ALL = field.NewAsterisk(table)
+	p.ID = field.NewInt(table, "id")
+	p.CreatedAt = field.NewTime(table, "created_at")
+	p.UpdatedAt = field.NewTime(table, "updated_at")
+	p.DeletedAt = field.NewField(table, "deleted_at")
+	p.Name = field.NewString(table, "name")
+	p.UserID = field.NewInt(table, "user_id")
+	p.RawID = field.NewString(table, "raw_id")
+	p.Credential = field.NewField(table, "credential")
+	p.LastUsedAt = field.NewInt64(table, "last_used_at")
+
+	p.fillFieldMap()
+
+	return p
+}
+
+func (p *passkey) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
+	_f, ok := p.fieldMap[fieldName]
+	if !ok || _f == nil {
+		return nil, false
+	}
+	_oe, ok := _f.(field.OrderExpr)
+	return _oe, ok
+}
+
+func (p *passkey) fillFieldMap() {
+	p.fieldMap = make(map[string]field.Expr, 9)
+	p.fieldMap["id"] = p.ID
+	p.fieldMap["created_at"] = p.CreatedAt
+	p.fieldMap["updated_at"] = p.UpdatedAt
+	p.fieldMap["deleted_at"] = p.DeletedAt
+	p.fieldMap["name"] = p.Name
+	p.fieldMap["user_id"] = p.UserID
+	p.fieldMap["raw_id"] = p.RawID
+	p.fieldMap["credential"] = p.Credential
+	p.fieldMap["last_used_at"] = p.LastUsedAt
+}
+
+func (p passkey) clone(db *gorm.DB) passkey {
+	p.passkeyDo.ReplaceConnPool(db.Statement.ConnPool)
+	return p
+}
+
+func (p passkey) replaceDB(db *gorm.DB) passkey {
+	p.passkeyDo.ReplaceDB(db)
+	return p
+}
+
+type passkeyDo struct{ gen.DO }
+
+// FirstByID Where("id=@id")
+func (p passkeyDo) FirstByID(id int) (result *model.Passkey, err error) {
+	var params []interface{}
+
+	var generateSQL strings.Builder
+	params = append(params, id)
+	generateSQL.WriteString("id=? ")
+
+	var executeSQL *gorm.DB
+	executeSQL = p.UnderlyingDB().Where(generateSQL.String(), params...).Take(&result) // ignore_security_alert
+	err = executeSQL.Error
+
+	return
+}
+
+// DeleteByID update @@table set deleted_at=strftime('%Y-%m-%d %H:%M:%S','now') where id=@id
+func (p passkeyDo) DeleteByID(id int) (err error) {
+	var params []interface{}
+
+	var generateSQL strings.Builder
+	params = append(params, id)
+	generateSQL.WriteString("update passkeys set deleted_at=strftime('%Y-%m-%d %H:%M:%S','now') where id=? ")
+
+	var executeSQL *gorm.DB
+	executeSQL = p.UnderlyingDB().Exec(generateSQL.String(), params...) // ignore_security_alert
+	err = executeSQL.Error
+
+	return
+}
+
+func (p passkeyDo) Debug() *passkeyDo {
+	return p.withDO(p.DO.Debug())
+}
+
+func (p passkeyDo) WithContext(ctx context.Context) *passkeyDo {
+	return p.withDO(p.DO.WithContext(ctx))
+}
+
+func (p passkeyDo) ReadDB() *passkeyDo {
+	return p.Clauses(dbresolver.Read)
+}
+
+func (p passkeyDo) WriteDB() *passkeyDo {
+	return p.Clauses(dbresolver.Write)
+}
+
+func (p passkeyDo) Session(config *gorm.Session) *passkeyDo {
+	return p.withDO(p.DO.Session(config))
+}
+
+func (p passkeyDo) Clauses(conds ...clause.Expression) *passkeyDo {
+	return p.withDO(p.DO.Clauses(conds...))
+}
+
+func (p passkeyDo) Returning(value interface{}, columns ...string) *passkeyDo {
+	return p.withDO(p.DO.Returning(value, columns...))
+}
+
+func (p passkeyDo) Not(conds ...gen.Condition) *passkeyDo {
+	return p.withDO(p.DO.Not(conds...))
+}
+
+func (p passkeyDo) Or(conds ...gen.Condition) *passkeyDo {
+	return p.withDO(p.DO.Or(conds...))
+}
+
+func (p passkeyDo) Select(conds ...field.Expr) *passkeyDo {
+	return p.withDO(p.DO.Select(conds...))
+}
+
+func (p passkeyDo) Where(conds ...gen.Condition) *passkeyDo {
+	return p.withDO(p.DO.Where(conds...))
+}
+
+func (p passkeyDo) Order(conds ...field.Expr) *passkeyDo {
+	return p.withDO(p.DO.Order(conds...))
+}
+
+func (p passkeyDo) Distinct(cols ...field.Expr) *passkeyDo {
+	return p.withDO(p.DO.Distinct(cols...))
+}
+
+func (p passkeyDo) Omit(cols ...field.Expr) *passkeyDo {
+	return p.withDO(p.DO.Omit(cols...))
+}
+
+func (p passkeyDo) Join(table schema.Tabler, on ...field.Expr) *passkeyDo {
+	return p.withDO(p.DO.Join(table, on...))
+}
+
+func (p passkeyDo) LeftJoin(table schema.Tabler, on ...field.Expr) *passkeyDo {
+	return p.withDO(p.DO.LeftJoin(table, on...))
+}
+
+func (p passkeyDo) RightJoin(table schema.Tabler, on ...field.Expr) *passkeyDo {
+	return p.withDO(p.DO.RightJoin(table, on...))
+}
+
+func (p passkeyDo) Group(cols ...field.Expr) *passkeyDo {
+	return p.withDO(p.DO.Group(cols...))
+}
+
+func (p passkeyDo) Having(conds ...gen.Condition) *passkeyDo {
+	return p.withDO(p.DO.Having(conds...))
+}
+
+func (p passkeyDo) Limit(limit int) *passkeyDo {
+	return p.withDO(p.DO.Limit(limit))
+}
+
+func (p passkeyDo) Offset(offset int) *passkeyDo {
+	return p.withDO(p.DO.Offset(offset))
+}
+
+func (p passkeyDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *passkeyDo {
+	return p.withDO(p.DO.Scopes(funcs...))
+}
+
+func (p passkeyDo) Unscoped() *passkeyDo {
+	return p.withDO(p.DO.Unscoped())
+}
+
+func (p passkeyDo) Create(values ...*model.Passkey) error {
+	if len(values) == 0 {
+		return nil
+	}
+	return p.DO.Create(values)
+}
+
+func (p passkeyDo) CreateInBatches(values []*model.Passkey, batchSize int) error {
+	return p.DO.CreateInBatches(values, batchSize)
+}
+
+// Save : !!! underlying implementation is different with GORM
+// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
+func (p passkeyDo) Save(values ...*model.Passkey) error {
+	if len(values) == 0 {
+		return nil
+	}
+	return p.DO.Save(values)
+}
+
+func (p passkeyDo) First() (*model.Passkey, error) {
+	if result, err := p.DO.First(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Passkey), nil
+	}
+}
+
+func (p passkeyDo) Take() (*model.Passkey, error) {
+	if result, err := p.DO.Take(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Passkey), nil
+	}
+}
+
+func (p passkeyDo) Last() (*model.Passkey, error) {
+	if result, err := p.DO.Last(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Passkey), nil
+	}
+}
+
+func (p passkeyDo) Find() ([]*model.Passkey, error) {
+	result, err := p.DO.Find()
+	return result.([]*model.Passkey), err
+}
+
+func (p passkeyDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.Passkey, err error) {
+	buf := make([]*model.Passkey, 0, batchSize)
+	err = p.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
+		defer func() { results = append(results, buf...) }()
+		return fc(tx, batch)
+	})
+	return results, err
+}
+
+func (p passkeyDo) FindInBatches(result *[]*model.Passkey, batchSize int, fc func(tx gen.Dao, batch int) error) error {
+	return p.DO.FindInBatches(result, batchSize, fc)
+}
+
+func (p passkeyDo) Attrs(attrs ...field.AssignExpr) *passkeyDo {
+	return p.withDO(p.DO.Attrs(attrs...))
+}
+
+func (p passkeyDo) Assign(attrs ...field.AssignExpr) *passkeyDo {
+	return p.withDO(p.DO.Assign(attrs...))
+}
+
+func (p passkeyDo) Joins(fields ...field.RelationField) *passkeyDo {
+	for _, _f := range fields {
+		p = *p.withDO(p.DO.Joins(_f))
+	}
+	return &p
+}
+
+func (p passkeyDo) Preload(fields ...field.RelationField) *passkeyDo {
+	for _, _f := range fields {
+		p = *p.withDO(p.DO.Preload(_f))
+	}
+	return &p
+}
+
+func (p passkeyDo) FirstOrInit() (*model.Passkey, error) {
+	if result, err := p.DO.FirstOrInit(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Passkey), nil
+	}
+}
+
+func (p passkeyDo) FirstOrCreate() (*model.Passkey, error) {
+	if result, err := p.DO.FirstOrCreate(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.Passkey), nil
+	}
+}
+
+func (p passkeyDo) FindByPage(offset int, limit int) (result []*model.Passkey, count int64, err error) {
+	result, err = p.Offset(offset).Limit(limit).Find()
+	if err != nil {
+		return
+	}
+
+	if size := len(result); 0 < limit && 0 < size && size < limit {
+		count = int64(size + offset)
+		return
+	}
+
+	count, err = p.Offset(-1).Limit(-1).Count()
+	return
+}
+
+func (p passkeyDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
+	count, err = p.Count()
+	if err != nil {
+		return
+	}
+
+	err = p.Offset(offset).Limit(limit).Scan(result)
+	return
+}
+
+func (p passkeyDo) Scan(result interface{}) (err error) {
+	return p.DO.Scan(result)
+}
+
+func (p passkeyDo) Delete(models ...*model.Passkey) (result gen.ResultInfo, err error) {
+	return p.DO.Delete(models)
+}
+
+func (p *passkeyDo) withDO(do gen.Dao) *passkeyDo {
+	p.DO = *do.(*gen.DO)
+	return p
+}

+ 71 - 71
router/routers.go

@@ -1,83 +1,83 @@
 package router
 
 import (
-    "github.com/0xJacky/Nginx-UI/api/analytic"
-    "github.com/0xJacky/Nginx-UI/api/certificate"
-    "github.com/0xJacky/Nginx-UI/api/cluster"
-    "github.com/0xJacky/Nginx-UI/api/config"
-    "github.com/0xJacky/Nginx-UI/api/nginx"
-    "github.com/0xJacky/Nginx-UI/api/notification"
-    "github.com/0xJacky/Nginx-UI/api/openai"
-    "github.com/0xJacky/Nginx-UI/api/settings"
-    "github.com/0xJacky/Nginx-UI/api/sites"
-    "github.com/0xJacky/Nginx-UI/api/streams"
-    "github.com/0xJacky/Nginx-UI/api/system"
-    "github.com/0xJacky/Nginx-UI/api/template"
-    "github.com/0xJacky/Nginx-UI/api/terminal"
-    "github.com/0xJacky/Nginx-UI/api/upstream"
-    "github.com/0xJacky/Nginx-UI/api/user"
-    "github.com/0xJacky/Nginx-UI/internal/middleware"
-    "github.com/gin-contrib/static"
-    "github.com/gin-gonic/gin"
-    "net/http"
+	"github.com/0xJacky/Nginx-UI/api/analytic"
+	"github.com/0xJacky/Nginx-UI/api/certificate"
+	"github.com/0xJacky/Nginx-UI/api/cluster"
+	"github.com/0xJacky/Nginx-UI/api/config"
+	"github.com/0xJacky/Nginx-UI/api/nginx"
+	"github.com/0xJacky/Nginx-UI/api/notification"
+	"github.com/0xJacky/Nginx-UI/api/openai"
+	"github.com/0xJacky/Nginx-UI/api/settings"
+	"github.com/0xJacky/Nginx-UI/api/sites"
+	"github.com/0xJacky/Nginx-UI/api/streams"
+	"github.com/0xJacky/Nginx-UI/api/system"
+	"github.com/0xJacky/Nginx-UI/api/template"
+	"github.com/0xJacky/Nginx-UI/api/terminal"
+	"github.com/0xJacky/Nginx-UI/api/upstream"
+	"github.com/0xJacky/Nginx-UI/api/user"
+	"github.com/0xJacky/Nginx-UI/internal/middleware"
+	"github.com/gin-contrib/static"
+	"github.com/gin-gonic/gin"
+	"net/http"
 )
 
 func InitRouter() *gin.Engine {
-    r := gin.New()
-    r.Use(
-        gin.Logger(),
-        middleware.Recovery(),
-        middleware.CacheJs(),
-        middleware.IPWhiteList(),
-        static.Serve("/", middleware.MustFs("")),
-    )
+	r := gin.New()
+	r.Use(
+		gin.Logger(),
+		middleware.Recovery(),
+		middleware.CacheJs(),
+		middleware.IPWhiteList(),
+		static.Serve("/", middleware.MustFs("")),
+	)
 
-    r.NoRoute(func(c *gin.Context) {
-        c.JSON(http.StatusNotFound, gin.H{
-            "message": "not found",
-        })
-    })
+	r.NoRoute(func(c *gin.Context) {
+		c.JSON(http.StatusNotFound, gin.H{
+			"message": "not found",
+		})
+	})
 
-    root := r.Group("/api")
-    {
-        system.InitPublicRouter(root)
-        user.InitAuthRouter(root)
+	root := r.Group("/api")
+	{
+		system.InitPublicRouter(root)
+		user.InitAuthRouter(root)
 
-        // Authorization required not websocket request
-        g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
-        {
-            user.InitUserRouter(g)
-            analytic.InitRouter(g)
-            user.InitManageUserRouter(g)
-            nginx.InitRouter(g)
-            sites.InitRouter(g)
-            streams.InitRouter(g)
-            config.InitRouter(g)
-            template.InitRouter(g)
-            certificate.InitCertificateRouter(g)
-            certificate.InitDNSCredentialRouter(g)
-            certificate.InitAcmeUserRouter(g)
-            system.InitPrivateRouter(g)
-            settings.InitRouter(g)
-            openai.InitRouter(g)
-            cluster.InitRouter(g)
-            notification.InitRouter(g)
-        }
+		// Authorization required and not websocket request
+		g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
+		{
+			user.InitUserRouter(g)
+			analytic.InitRouter(g)
+			user.InitManageUserRouter(g)
+			nginx.InitRouter(g)
+			sites.InitRouter(g)
+			streams.InitRouter(g)
+			config.InitRouter(g)
+			template.InitRouter(g)
+			certificate.InitCertificateRouter(g)
+			certificate.InitDNSCredentialRouter(g)
+			certificate.InitAcmeUserRouter(g)
+			system.InitPrivateRouter(g)
+			settings.InitRouter(g)
+			openai.InitRouter(g)
+			cluster.InitRouter(g)
+			notification.InitRouter(g)
+		}
 
-        // Authorization required and websocket request
-        w := root.Group("/", middleware.AuthRequired(), middleware.ProxyWs())
-        {
-            analytic.InitWebSocketRouter(w)
-            certificate.InitCertificateWebSocketRouter(w)
-            o := w.Group("", middleware.RequireSecureSession())
-            {
-                terminal.InitRouter(o)
-            }
-            nginx.InitNginxLogRouter(w)
-            upstream.InitRouter(w)
-            system.InitWebSocketRouter(w)
-        }
-    }
+		// Authorization required and websocket request
+		w := root.Group("/", middleware.AuthRequired(), middleware.ProxyWs())
+		{
+			analytic.InitWebSocketRouter(w)
+			certificate.InitCertificateWebSocketRouter(w)
+			o := w.Group("", middleware.RequireSecureSession())
+			{
+				terminal.InitRouter(o)
+			}
+			nginx.InitNginxLogRouter(w)
+			upstream.InitRouter(w)
+			system.InitWebSocketRouter(w)
+		}
+	}
 
-    return r
+	return r
 }

+ 1 - 1
settings/auth.go

@@ -1,7 +1,7 @@
 package settings
 
 type Auth struct {
-	IPWhiteList         []string `json:"ip_white_list" binding:"omitempty,dive,ip" ini:",,allowshadow"`
+	IPWhiteList         []string `json:"ip_white_list" binding:"omitempty,dive,ip" ini:",,allowshadow" protected:"true"`
 	BanThresholdMinutes int      `json:"ban_threshold_minutes" binding:"min=1"`
 	MaxAttempts         int      `json:"max_attempts" binding:"min=1"`
 }

+ 7 - 0
settings/settings.go

@@ -29,6 +29,7 @@ var sections = map[string]interface{}{
 	"cluster":   &ClusterSettings,
 	"auth":      &AuthSettings,
 	"crypto":    &CryptoSettings,
+	"webauthn":  &WebAuthnSettings,
 }
 
 func init() {
@@ -66,6 +67,7 @@ func Setup() {
 	parseEnv(&LogrotateSettings, "LOGROTATE_")
 	parseEnv(&AuthSettings, "AUTH_")
 	parseEnv(&CryptoSettings, "CRYPTO_")
+	parseEnv(&WebAuthnSettings, "WEBAUTHN_")
 
 	// if in official docker, set the restart cmd of nginx to "nginx -s stop",
 	// then the supervisor of s6-overlay will start the nginx again.
@@ -102,6 +104,11 @@ func Save() (err error) {
 		reflectFrom(k, v)
 	}
 
+	// fix unable to save empty slice
+	if len(ServerSettings.RecursiveNameservers) == 0 {
+		Conf.Section("server").Key("RecursiveNameservers").SetValue("")
+	}
+
 	err = Conf.SaveTo(ConfPath)
 	if err != nil {
 		return

+ 8 - 0
settings/settings_test.go

@@ -53,6 +53,10 @@ func TestSetup(t *testing.T) {
 	_ = os.Setenv("NGINX_UI_LOGROTATE_CMD", "logrotate /custom/logrotate.conf")
 	_ = os.Setenv("NGINX_UI_LOGROTATE_INTERVAL", "60")
 
+	_ = os.Setenv("NGINX_UI_WEBAUTHN_RP_DISPLAY_NAME", "WebAuthn")
+	_ = os.Setenv("NGINX_UI_WEBAUTHN_RPID", "localhost")
+	_ = os.Setenv("NGINX_UI_WEBAUTHN_RP_ORIGINS", "http://localhost:3002")
+
 	ConfPath = "app.testing.ini"
 	Setup()
 
@@ -98,6 +102,10 @@ func TestSetup(t *testing.T) {
 	assert.Equal(t, "logrotate /custom/logrotate.conf", LogrotateSettings.CMD)
 	assert.Equal(t, 60, LogrotateSettings.Interval)
 
+	assert.Equal(t, "WebAuthn", WebAuthnSettings.RPDisplayName)
+	assert.Equal(t, "localhost", WebAuthnSettings.RPID)
+	assert.Equal(t, []string{"http://localhost:3002"}, WebAuthnSettings.RPOrigins)
+
 	os.Clearenv()
 	_ = os.Remove("app.testing.ini")
 }

+ 9 - 0
settings/webauthn.go

@@ -0,0 +1,9 @@
+package settings
+
+type WebAuthn struct {
+	RPDisplayName string
+	RPID          string
+	RPOrigins     []string
+}
+
+var WebAuthnSettings = WebAuthn{}