Kaynağa Gözat

enhance: by default, passkey is used for 2fa if passkey is used to login

Jacky 7 ay önce
ebeveyn
işleme
f42a6c2d08

+ 131 - 131
api/user/2fa.go

@@ -1,156 +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"
+	"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"`
+	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
+	// 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))
+	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),
-    })
+	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,
-    })
+	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,
-    })
+	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,
-    })
+	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,
+	})
 }

+ 2 - 1
api/user/router.go

@@ -13,6 +13,8 @@ func InitAuthRouter(r *gin.RouterGroup) {
 
 	r.GET("/casdoor_uri", GetCasdoorUri)
 	r.POST("/casdoor_callback", CasdoorCallback)
+
+	r.GET("/passkeys/config", GetPasskeyConfigStatus)
 }
 
 func InitManageUserRouter(r *gin.RouterGroup) {
@@ -38,7 +40,6 @@ func InitUserRouter(r *gin.RouterGroup) {
 	r.GET("/begin_passkey_register", BeginPasskeyRegistration)
 	r.POST("/finish_passkey_register", FinishPasskeyRegistration)
 
-	r.GET("/passkeys/config", GetPasskeyConfigStatus)
 	r.GET("/passkeys", GetPasskeyList)
 	r.POST("/passkeys/:id", UpdatePasskey)
 	r.DELETE("/passkeys/:id", DeletePasskey)

+ 1 - 0
app/components.d.ts

@@ -8,6 +8,7 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     2FA2FAAuthorization: typeof import('./src/components/2FA/2FAAuthorization.vue')['default']
+    2FAAuthorization: typeof import('./src/components/2FA/Authorization.vue')['default']
     2FAOTPAuthorization: typeof import('./src/components/2FA/OTPAuthorization.vue')['default']
     AAlert: typeof import('ant-design-vue/es')['Alert']
     AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']

+ 3 - 2
app/src/api/passkey.ts

@@ -5,6 +5,7 @@ import type { ModelBase } from '@/api/curd'
 export interface Passkey extends ModelBase {
   name: string
   user_id: string
+  raw_id: string
 }
 
 const passkey = {
@@ -27,8 +28,8 @@ const passkey = {
   remove(passkeyId: number) {
     return http.delete(`/passkeys/${passkeyId}`)
   },
-  get_passkey_config_status(): Promise<{ status: boolean }> {
-    return http.get('/passkey/config')
+  get_config_status(): Promise<{ status: boolean }> {
+    return http.get('/passkeys/config')
   },
 }
 

+ 52 - 36
app/src/components/2FA/2FAAuthorization.vue → app/src/components/2FA/Authorization.vue

@@ -3,11 +3,17 @@ 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 { $gettext } from '@/gettext'
+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('')
@@ -55,48 +61,58 @@ async function passkeyAuthenticate() {
   }
   passkeyLoading.value = false
 }
+
+onMounted(() => {
+  if (user.passkeyLoginAvailable)
+    passkeyAuthenticate()
+})
 </script>
 
 <template>
   <div>
-    <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
+    <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
-        @click="clickUseOTP"
-      >{{ $gettext('Use OTP') }}</a>
+        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 class="flex flex-col justify-center">
-      <ADivider>
+    <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>

+ 6 - 5
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/2FA/2FAAuthorization.vue'
+import Authorization from '@/components/2FA/Authorization.vue'
 import twoFA from '@/api/2fa'
 import { useUserStore } from '@/pinia'
 
 const use2FAModal = () => {
-  const refOTPAuthorization = ref<typeof OTPAuthorization>()
+  const refOTPAuthorization = ref<typeof Authorization>()
   const randomId = Math.random().toString(36).substring(2, 8)
   const { secureSessionId } = storeToRefs(useUserStore())
 
@@ -22,11 +22,11 @@ const use2FAModal = () => {
   }
 
   const open = async (): Promise<string> => {
-    const { enabled } = await twoFA.status()
+    const twoFAStatus = await twoFA.status()
     const { status: secureSessionStatus } = await twoFA.secure_session_status()
 
     return new Promise((resolve, reject) => {
-      if (!enabled) {
+      if (!twoFAStatus.enabled) {
         resolve('')
 
         return
@@ -80,9 +80,10 @@ const use2FAModal = () => {
         },
       }, {
         default: () => h(
-          OTPAuthorization,
+          Authorization,
           {
             ref: refOTPAuthorization,
+            twoFAStatus,
             class: 'mt-3',
             onSubmitOTP: verifyOTP,
             onSubmitSecureSessionID: setSessionId,

+ 10 - 2
app/src/components/SetLanguage/SetLanguage.vue

@@ -29,14 +29,22 @@ 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() {

+ 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 } })

+ 24 - 9
app/src/views/other/Login.vue

@@ -8,8 +8,9 @@ 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/2FA/2FAAuthorization.vue'
+import Authorization from '@/components/2FA/Authorization.vue'
 import gettext, { $gettext } from '@/gettext'
+import passkey from '@/api/passkey'
 
 const thisYear = new Date().getFullYear()
 
@@ -26,6 +27,7 @@ const enabled2FA = ref(false)
 const refOTP = ref()
 const passcode = ref('')
 const recoveryCode = ref('')
+const passkeyConfigStatus = ref(false)
 
 const modelRef = reactive({
   username: '',
@@ -49,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 = () => {
@@ -97,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)
@@ -147,8 +149,13 @@ function handleOTPSubmit(code: string, recovery: string) {
     onSubmit()
   })
 }
+
+passkey.get_config_status().then(r => {
+  passkeyConfigStatus.value = r.status
+})
+
 const passkeyLoginLoading = ref(false)
-async function passkeyLogin() {
+async function handlePasskeyLogin() {
   passkeyLoginLoading.value = true
   try {
     const begin = await auth.begin_passkey_login()
@@ -162,7 +169,7 @@ async function passkeyLogin() {
     if (r.token) {
       const next = (route.query?.next || '').toString() || '/'
 
-      login(r.token)
+      passkeyLogin(asseResp.rawId, r.token)
 
       await router.push(next)
     }
@@ -217,9 +224,14 @@ async function passkeyLogin() {
               </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>
 
@@ -235,7 +247,10 @@ async function passkeyLogin() {
                 {{ $gettext('Login') }}
               </AButton>
 
-              <div class="flex flex-col justify-center">
+              <div
+                v-if="passkeyConfigStatus"
+                class="flex flex-col justify-center"
+              >
                 <ADivider>
                   <div class="text-sm font-normal opacity-75">
                     {{ $gettext('Or') }}
@@ -244,7 +259,7 @@ async function passkeyLogin() {
 
                 <AButton
                   :loading="passkeyLoginLoading"
-                  @click="passkeyLogin"
+                  @click="handlePasskeyLogin"
                 >
                   <KeyOutlined />
                   {{ $gettext('Sign in with a passkey') }}

+ 1 - 2
app/src/views/preference/AuthSettings.vue

@@ -2,13 +2,12 @@
 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'
 import type { Settings } from '@/views/preference/typedef'
 import TOTP from '@/views/preference/components/TOTP.vue'
-import PasskeyRegistration from '@/components/Passkey/PasskeyRegistration.vue'
-import { $gettext } from '@/gettext'
 
 const data: Settings = inject('data') as Settings
 

+ 28 - 9
app/src/components/Passkey/PasskeyRegistration.vue → app/src/views/preference/components/Passkey.vue

@@ -8,10 +8,11 @@ import { formatDateTime } from '@/lib/helper'
 import type { Passkey } from '@/api/passkey'
 import passkey from '@/api/passkey'
 import ReactiveFromNow from '@/components/ReactiveFromNow/ReactiveFromNow.vue'
-import { $gettext } from '@/gettext'
+import { useUserStore } from '@/pinia'
 
 dayjs.extend(relativeTime)
 
+const user = useUserStore()
 const passkeyName = ref('')
 const addPasskeyModelOpen = ref(false)
 
@@ -29,6 +30,8 @@ async function registerPasskey() {
 
     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) {
@@ -66,10 +69,14 @@ function update(id: number, record: Passkey) {
   })
 }
 
-function remove(id: number) {
-  passkey.remove(id).then(() => {
+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'))
   })
@@ -83,19 +90,31 @@ function addPasskey() {
 
 <template>
   <div>
-    <div class="flex justify-between items-center">
-      <h3 class="mb-0">
+    <div>
+      <h3>
         {{ $gettext('Passkey') }}
       </h3>
-      <AButton @click="addPasskey">
-        {{ $gettext('Add a passkey') }}
-      </AButton>
+      <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>
+          <AButton @click="addPasskey">
+            {{ $gettext('Add a passkey') }}
+          </AButton>
+        </div>
+      </template>
       <template #renderItem="{ item, index }">
         <AListItem>
           <AListItemMeta>
@@ -127,7 +146,7 @@ function addPasskey() {
 
               <APopconfirm
                 :title="$gettext('Are you sure to delete this passkey immediately?')"
-                @confirm="() => remove(item.id)"
+                @confirm="() => remove(item)"
               >
                 <AButton
                   type="link"

+ 1 - 1
app/src/views/preference/components/TOTP.vue

@@ -4,7 +4,7 @@ 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)