From 2499e4f0ac966318b9092a4a255cbcb0fc7b6c0d Mon Sep 17 00:00:00 2001 From: cevin Date: Sun, 17 May 2026 15:01:27 +0800 Subject: [PATCH] feat(passkey): support multiple passkeys per user Previously, each user could only register one Passkey credential. The database had a unique index on user_id, and the UI only showed a single passkey status. This change removes that limitation. Backend changes: - Remove uniqueIndex on PasskeyCredential.UserID, add plain index - UpsertPasskeyCredential now inserts new credentials instead of replacing the old one (update by credential_id if exists) - Add DeletePasskeyByCredentialID for per-credential removal - GetPasskeyByUserID now returns []*PasskeyCredential - Controller endpoints updated to handle credential lists: * Register: exclusions include all existing credentials * Delete: requires credential_id param * Status: returns list of credentials with metadata * Verify: supports multiple allowCredentials * Login/Verify: find and update the matched credential - Add DB migration to drop old unique index on user_id Frontend (default theme): - PasskeyCard shows a list of registered credentials - Each credential displays device type, last used, created at - Add 'Add Passkey' button to register additional credentials - Delete action now targets a specific credential_id Frontend (classic theme): - AccountManagement shows credential list - PersonalSetting delete API updated to use credential_id path Breaking change: - DELETE /api/user/passkey -> DELETE /api/user/passkey/:credential_id --- controller/passkey.go | 147 +++++--- controller/secure_verification.go | 4 +- model/main.go | 26 ++ model/passkey.go | 46 ++- router/api-router.go | 2 +- service/passkey/user.go | 32 +- .../components/settings/PersonalSetting.jsx | 20 +- .../personal/cards/AccountManagement.jsx | 133 +++---- web/default/src/features/auth/passkey/api.ts | 8 +- .../passkey/hooks/use-passkey-management.ts | 59 ++-- .../src/features/auth/passkey/types.ts | 11 +- .../profile/components/passkey-card.tsx | 326 +++++++++++------- 12 files changed, 513 insertions(+), 301 deletions(-) diff --git a/controller/passkey.go b/controller/passkey.go index 79930fdff51..7ed8983696a 100644 --- a/controller/passkey.go +++ b/controller/passkey.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/base64" "errors" "fmt" "net/http" @@ -40,14 +41,11 @@ func PasskeyRegisterBegin(c *gin.Context) { return } - credential, err := model.GetPasskeyByUserID(user.Id) - if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + credentials, err := model.GetPasskeyByUserID(user.Id) + if err != nil { common.ApiError(c, err) return } - if errors.Is(err, model.ErrPasskeyNotFound) { - credential = nil - } wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { @@ -55,11 +53,14 @@ func PasskeyRegisterBegin(c *gin.Context) { return } - waUser := passkeysvc.NewWebAuthnUser(user, credential) + waUser := passkeysvc.NewWebAuthnUser(user, credentials...) var options []webauthnlib.RegistrationOption - if credential != nil { - descriptor := credential.ToWebAuthnCredential().Descriptor() - options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor})) + if len(credentials) > 0 { + descriptors := make([]protocol.CredentialDescriptor, 0, len(credentials)) + for _, cred := range credentials { + descriptors = append(descriptors, cred.ToWebAuthnCredential().Descriptor()) + } + options = append(options, webauthnlib.WithExclusions(descriptors)) } creation, sessionData, err := wa.BeginRegistration(waUser, options...) @@ -110,14 +111,11 @@ func PasskeyRegisterFinish(c *gin.Context) { return } - credentialRecord, err := model.GetPasskeyByUserID(user.Id) - if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + credentials, err := model.GetPasskeyByUserID(user.Id) + if err != nil { common.ApiError(c, err) return } - if errors.Is(err, model.ErrPasskeyNotFound) { - credentialRecord = nil - } sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey) if err != nil { @@ -125,7 +123,7 @@ func PasskeyRegisterFinish(c *gin.Context) { return } - waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord) + waUser := passkeysvc.NewWebAuthnUser(user, credentials...) credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request) if err != nil { common.ApiError(c, err) @@ -159,11 +157,17 @@ func PasskeyDelete(c *gin.Context) { return } + credentialID := c.Param("credential_id") + if credentialID == "" { + common.ApiErrorMsg(c, "无效的凭证 ID") + return + } + if !requirePasskeyDeleteVerification(c, user.Id) { return } - if err := model.DeletePasskeyByUserID(user.Id); err != nil { + if err := model.DeletePasskeyByCredentialID(credentialID, user.Id); err != nil { common.ApiError(c, err) return } @@ -184,31 +188,43 @@ func PasskeyStatus(c *gin.Context) { return } - credential, err := model.GetPasskeyByUserID(user.Id) - if errors.Is(err, model.ErrPasskeyNotFound) { + credentials, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + common.ApiError(c, err) + return + } + + if len(credentials) == 0 { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": gin.H{ "enabled": false, + "credentials": []gin.H{}, }, }) return } - if err != nil { - common.ApiError(c, err) - return - } - data := gin.H{ - "enabled": true, - "last_used_at": credential.LastUsedAt, + credList := make([]gin.H, 0, len(credentials)) + for _, cred := range credentials { + credList = append(credList, gin.H{ + "credential_id": cred.CredentialID, + "created_at": cred.CreatedAt, + "last_used_at": cred.LastUsedAt, + "backup_eligible": cred.BackupEligible, + "backup_state": cred.BackupState, + "attachment": cred.Attachment, + }) } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": data, + "data": gin.H{ + "enabled": true, + "credentials": credList, + }, }) } @@ -351,17 +367,18 @@ func AdminResetPasskey(c *gin.Context) { return } - if _, err := model.GetPasskeyByUserID(user.Id); err != nil { - if errors.Is(err, model.ErrPasskeyNotFound) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "该用户尚未绑定 Passkey", - }) - return - } + credentials, err := model.GetPasskeyByUserID(user.Id) + if err != nil { common.ApiError(c, err) return } + if len(credentials) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } if err := model.DeletePasskeyByUserID(user.Id); err != nil { common.ApiError(c, err) @@ -392,7 +409,7 @@ func PasskeyVerifyBegin(c *gin.Context) { return } - credential, err := model.GetPasskeyByUserID(user.Id) + credentials, err := model.GetPasskeyByUserID(user.Id) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -400,6 +417,13 @@ func PasskeyVerifyBegin(c *gin.Context) { }) return } + if len(credentials) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { @@ -407,7 +431,7 @@ func PasskeyVerifyBegin(c *gin.Context) { return } - waUser := passkeysvc.NewWebAuthnUser(user, credential) + waUser := passkeysvc.NewWebAuthnUser(user, credentials...) assertion, sessionData, err := wa.BeginLogin(waUser) if err != nil { common.ApiError(c, err) @@ -452,7 +476,7 @@ func PasskeyVerifyFinish(c *gin.Context) { return } - credential, err := model.GetPasskeyByUserID(user.Id) + credentials, err := model.GetPasskeyByUserID(user.Id) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -460,6 +484,13 @@ func PasskeyVerifyFinish(c *gin.Context) { }) return } + if len(credentials) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) if err != nil { @@ -467,19 +498,29 @@ func PasskeyVerifyFinish(c *gin.Context) { return } - waUser := passkeysvc.NewWebAuthnUser(user, credential) - _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + waUser := passkeysvc.NewWebAuthnUser(user, credentials...) + credential, err := wa.FinishLogin(waUser, *sessionData, c.Request) if err != nil { common.ApiError(c, err) return } - // 更新凭证的最后使用时间 - now := time.Now() - credential.LastUsedAt = &now - if err := model.UpsertPasskeyCredential(credential); err != nil { - common.ApiError(c, err) - return + // 更新匹配的凭证的最后使用时间 + credIDStr := base64.StdEncoding.EncodeToString(credential.ID) + var matched *model.PasskeyCredential + for _, cred := range credentials { + if cred.CredentialID == credIDStr { + matched = cred + break + } + } + if matched != nil { + now := time.Now() + matched.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(matched); err != nil { + common.ApiError(c, err) + return + } } session := sessions.Default(c) @@ -540,18 +581,18 @@ func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool { return requireSecureVerificationMethod(c, secureVerificationMethod2FA) } - _, err = model.GetPasskeyByUserID(userID) + credentials, err := model.GetPasskeyByUserID(userID) if err != nil { - if errors.Is(err, model.ErrPasskeyNotFound) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "该用户尚未绑定 Passkey", - }) - return false - } common.ApiError(c, err) return false } + if len(credentials) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return false + } return requireSecureVerificationMethod(c, secureVerificationMethodPasskey) } diff --git a/controller/secure_verification.go b/controller/secure_verification.go index 7640269eb59..78a3a2bd23b 100644 --- a/controller/secure_verification.go +++ b/controller/secure_verification.go @@ -69,8 +69,8 @@ func UniversalVerify(c *gin.Context) { twoFA, _ := model.GetTwoFAByUserId(userId) has2FA := twoFA != nil && twoFA.IsEnabled - passkey, passkeyErr := model.GetPasskeyByUserID(userId) - hasPasskey := passkeyErr == nil && passkey != nil + passkeys, passkeyErr := model.GetPasskeyByUserID(userId) + hasPasskey := passkeyErr == nil && len(passkeys) > 0 if !has2FA && !hasPasskey { common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey")) diff --git a/model/main.go b/model/main.go index 16cd373fb20..7d53ae56406 100644 --- a/model/main.go +++ b/model/main.go @@ -254,6 +254,8 @@ func migrateDB() error { if err := migrateTokenModelLimitsToText(); err != nil { return err } + // Migrate passkey_credentials: drop old unique index on user_id to allow multiple passkeys per user + migratePasskeyUserIDUniqueIndex() err := DB.AutoMigrate( &Channel{}, @@ -704,3 +706,27 @@ func PingDB() error { common.SysLog("Database pinged successfully") return nil } + + +func migratePasskeyUserIDUniqueIndex() { + m := DB.Migrator() + indexes, err := m.GetIndexes(&PasskeyCredential{}) + if err != nil { + common.SysLog(fmt.Sprintf("failed to get indexes for passkey_credentials: %v", err)) + return + } + for _, idx := range indexes { + unique, ok := idx.Unique() + if !ok || !unique { + continue + } + cols := idx.Columns() + if len(cols) == 1 && cols[0] == "user_id" { + if err := m.DropIndex(&PasskeyCredential{}, idx.Name()); err != nil { + common.SysLog(fmt.Sprintf("failed to drop index %s: %v", idx.Name(), err)) + } else { + common.SysLog(fmt.Sprintf("dropped old unique index %s on passkey_credentials.user_id", idx.Name())) + } + } + } +} diff --git a/model/passkey.go b/model/passkey.go index 5d2595cf8aa..6973e75c680 100644 --- a/model/passkey.go +++ b/model/passkey.go @@ -22,7 +22,7 @@ var ( type PasskeyCredential struct { ID int `json:"id" gorm:"primaryKey"` - UserID int `json:"user_id" gorm:"uniqueIndex;not null"` + UserID int `json:"user_id" gorm:"index;not null"` CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"` @@ -139,22 +139,17 @@ func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Creden p.SetTransports(credential.Transport) } -func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { +func GetPasskeyByUserID(userID int) ([]*PasskeyCredential, error) { if userID == 0 { common.SysLog("GetPasskeyByUserID: empty user ID") return nil, ErrFriendlyPasskeyNotFound } - var credential PasskeyCredential - if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志 - return nil, ErrPasskeyNotFound - } - // 只有真正的数据库错误才记录日志 + var credentials []*PasskeyCredential + if err := DB.Where("user_id = ?", userID).Order("created_at desc").Find(&credentials).Error; err != nil { common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err)) return nil, ErrFriendlyPasskeyNotFound } - return &credential, nil + return credentials, nil } func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) { @@ -183,11 +178,24 @@ func UpsertPasskeyCredential(credential *PasskeyCredential) error { return fmt.Errorf("Passkey 保存失败,请重试") } return DB.Transaction(func(tx *gorm.DB) error { - // 使用Unscoped()进行硬删除,避免唯一索引冲突 - if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil { - common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err)) + // 检查是否已存在相同 credential_id 的记录 + var existing PasskeyCredential + err := tx.Where("credential_id = ?", credential.CredentialID).First(&existing).Error + if err == nil { + // 存在则更新(保留 ID 和 CreatedAt) + credential.ID = existing.ID + credential.CreatedAt = existing.CreatedAt + if err := tx.Save(credential).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to update credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to query existing credential for user %d: %v", credential.UserID, err)) return fmt.Errorf("Passkey 保存失败,请重试") } + // 不存在则创建 if err := tx.Create(credential).Error; err != nil { common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err)) return fmt.Errorf("Passkey 保存失败,请重试") @@ -196,6 +204,18 @@ func UpsertPasskeyCredential(credential *PasskeyCredential) error { }) } +func DeletePasskeyByCredentialID(credentialID string, userID int) error { + if credentialID == "" || userID == 0 { + common.SysLog("DeletePasskeyByCredentialID: empty credential ID or user ID") + return fmt.Errorf("删除失败,请重试") + } + if err := DB.Unscoped().Where("credential_id = ? AND user_id = ?", credentialID, userID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("DeletePasskeyByCredentialID: failed to delete passkey for user %d: %v", userID, err)) + return fmt.Errorf("删除失败,请重试") + } + return nil +} + func DeletePasskeyByUserID(userID int) error { if userID == 0 { common.SysLog("DeletePasskeyByUserID: empty user ID") diff --git a/router/api-router.go b/router/api-router.go index 64ccbe15595..2620fe25a17 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -88,7 +88,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish) selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin) selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish) - selfRoute.DELETE("/passkey", controller.PasskeyDelete) + selfRoute.DELETE("/passkey/:credential_id", controller.PasskeyDelete) selfRoute.GET("/aff", controller.GetAffCode) selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/self", controller.GetUserTopUps) diff --git a/service/passkey/user.go b/service/passkey/user.go index 2ec248a9dbe..028cda8b4bc 100644 --- a/service/passkey/user.go +++ b/service/passkey/user.go @@ -11,12 +11,12 @@ import ( ) type WebAuthnUser struct { - user *model.User - credential *model.PasskeyCredential + user *model.User + credentials []*model.PasskeyCredential } -func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser { - return &WebAuthnUser{user: user, credential: credential} +func NewWebAuthnUser(user *model.User, credentials ...*model.PasskeyCredential) *WebAuthnUser { + return &WebAuthnUser{user: user, credentials: credentials} } func (u *WebAuthnUser) WebAuthnID() []byte { @@ -49,11 +49,17 @@ func (u *WebAuthnUser) WebAuthnDisplayName() string { } func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { - if u == nil || u.credential == nil { + if u == nil || len(u.credentials) == 0 { return nil } - cred := u.credential.ToWebAuthnCredential() - return []webauthn.Credential{cred} + result := make([]webauthn.Credential, 0, len(u.credentials)) + for _, cred := range u.credentials { + if cred == nil { + continue + } + result = append(result, cred.ToWebAuthnCredential()) + } + return result } func (u *WebAuthnUser) ModelUser() *model.User { @@ -63,9 +69,17 @@ func (u *WebAuthnUser) ModelUser() *model.User { return u.user } -func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential { +func (u *WebAuthnUser) PasskeyCredentials() []*model.PasskeyCredential { if u == nil { return nil } - return u.credential + return u.credentials +} + +// PasskeyCredential returns the first credential for backward compatibility. +func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential { + if u == nil || len(u.credentials) == 0 { + return nil + } + return u.credentials[0] } diff --git a/web/classic/src/components/settings/PersonalSetting.jsx b/web/classic/src/components/settings/PersonalSetting.jsx index e735c877c49..9511dd5dc8e 100644 --- a/web/classic/src/components/settings/PersonalSetting.jsx +++ b/web/classic/src/components/settings/PersonalSetting.jsx @@ -74,9 +74,9 @@ const PersonalSetting = () => { const [disableButton, setDisableButton] = useState(false); const [countdown, setCountdown] = useState(30); const [systemToken, setSystemToken] = useState(''); - const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false }); + const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false, credentials: [] }); const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false); - const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false); + const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(null); const [passkeySupported, setPasskeySupported] = useState(false); const [ passkeyRequiredVerificationMethod, @@ -225,9 +225,7 @@ const PersonalSetting = () => { if (success) { setPasskeyStatus({ enabled: data?.enabled || false, - last_used_at: data?.last_used_at || null, - backup_eligible: data?.backup_eligible || false, - backup_state: data?.backup_state || false, + credentials: data?.credentials || [], }); } else { showError(message); @@ -331,10 +329,10 @@ const PersonalSetting = () => { await startPasskeyRegistration(); }; - const removePasskey = async () => { - setPasskeyDeleteLoading(true); + const removePasskey = async (credentialId) => { + setPasskeyDeleteLoading(credentialId); try { - const res = await API.delete('/api/user/passkey'); + const res = await API.delete(`/api/user/passkey/${encodeURIComponent(credentialId)}`); const { success, message } = res.data; if (!success) { throw new Error(message || t('操作失败,请重试')); @@ -346,12 +344,12 @@ const PersonalSetting = () => { } catch (error) { throw new Error(error?.message || t('操作失败,请重试')); } finally { - setPasskeyDeleteLoading(false); + setPasskeyDeleteLoading(null); } }; - const handleRemovePasskey = async () => { - await startPasskeyManagementVerification(removePasskey); + const handleRemovePasskey = async (credentialId) => { + await startPasskeyManagementVerification(() => removePasskey(credentialId)); }; const handlePasskeyVerificationCancel = () => { diff --git a/web/classic/src/components/settings/personal/cards/AccountManagement.jsx b/web/classic/src/components/settings/personal/cards/AccountManagement.jsx index 29249caa162..924f7345b09 100644 --- a/web/classic/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/classic/src/components/settings/personal/cards/AccountManagement.jsx @@ -164,9 +164,7 @@ const AccountManagement = ({ }, []); const passkeyEnabled = passkeyStatus?.enabled; - const lastUsedLabel = passkeyStatus?.last_used_at - ? new Date(passkeyStatus.last_used_at).toLocaleString() - : t('尚未使用'); + const passkeyCredentials = passkeyStatus?.credentials || []; return ( @@ -660,72 +658,83 @@ const AccountManagement = ({ {/* Passkey 设置 */} -
-
-
- -
-
- - {t('Passkey 登录')} - - - {passkeyEnabled - ? t('已启用 Passkey,无需密码即可登录') - : t('使用 Passkey 实现免密且更安全的登录体验')} - -
-
- {t('最后使用时间')}:{lastUsedLabel} -
- {/*{passkeyEnabled && (*/} - {/*
*/} - {/* {t('备份支持')}:*/} - {/* {passkeyStatus?.backup_eligible*/} - {/* ? t('支持备份')*/} - {/* : t('不支持')}*/} - {/* ,{t('备份状态')}:*/} - {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/} - {/*
*/} - {/*)}*/} +
+
+
+
+ +
+
+ + {t('Passkey 登录')} + + + {passkeyEnabled + ? t('已启用 Passkey,无需密码即可登录') + : t('使用 Passkey 实现免密且更安全的登录体验')} + {!passkeySupported && ( -
+
{t('当前设备不支持 Passkey')}
)}
+
- + {passkeyEnabled && passkeyCredentials.length > 0 && ( +
+ {passkeyCredentials.map((cred) => ( +
+
+
+ {cred.attachment === 'platform' + ? t('内置设备') + : cred.attachment === 'cross-platform' + ? t('外部设备') + : t('未知设备')} +
+
+ {t('最后使用')}:{cred.last_used_at + ? new Date(cred.last_used_at).toLocaleString() + : t('尚未使用')} +
+
+ {t('创建时间')}:{new Date(cred.created_at).toLocaleString()} +
+
+ +
+ ))} +
+ )}
diff --git a/web/default/src/features/auth/passkey/api.ts b/web/default/src/features/auth/passkey/api.ts index 264c0d11a78..2fceea1b37c 100644 --- a/web/default/src/features/auth/passkey/api.ts +++ b/web/default/src/features/auth/passkey/api.ts @@ -43,8 +43,12 @@ export async function finishPasskeyRegistration( return res.data } -export async function deletePasskey(): Promise { - const res = await api.delete('/api/user/passkey') +export async function deletePasskey( + credentialId: string +): Promise { + const res = await api.delete( + `/api/user/passkey/${encodeURIComponent(credentialId)}` + ) return res.data } diff --git a/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts b/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts index 3b957be4c34..287c771e528 100644 --- a/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts +++ b/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts @@ -31,7 +31,7 @@ import { finishPasskeyRegistration, getPasskeyStatus, } from '../api' -import type { PasskeyStatus } from '../types' +import type { PasskeyCredential, PasskeyStatus } from '../types' interface UsePasskeyManagementOptions { onStatusChange?: (status: PasskeyStatus | null) => void @@ -45,7 +45,7 @@ export function usePasskeyManagement( const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [registering, setRegistering] = useState(false) - const [removing, setRemoving] = useState(false) + const [removing, setRemoving] = useState(null) const [supported, setSupported] = useState(false) const fetchStatus = useCallback(async () => { @@ -147,30 +147,39 @@ export function usePasskeyManagement( } }, [supported, fetchStatus]) - const remove = useCallback(async () => { - setRemoving(true) - try { - const res = await deletePasskey() - if (!res.success) { - toast.error(res.message || i18next.t('Failed to remove Passkey')) + const remove = useCallback( + async (credentialId: string) => { + setRemoving(credentialId) + try { + const res = await deletePasskey(credentialId) + if (!res.success) { + toast.error(res.message || i18next.t('Failed to remove Passkey')) + return false + } + + toast.success(i18next.t('Passkey removed successfully')) + await fetchStatus() + return true + } catch (error) { + // eslint-disable-next-line no-console + console.error('[Passkey] Removal error', error) + toast.error(i18next.t('Failed to remove Passkey')) return false + } finally { + setRemoving(null) } - - toast.success(i18next.t('Passkey removed successfully')) - await fetchStatus() - return true - } catch (error) { - // eslint-disable-next-line no-console - console.error('[Passkey] Removal error', error) - toast.error(i18next.t('Failed to remove Passkey')) - return false - } finally { - setRemoving(false) - } - }, [fetchStatus]) - - const enabled = useMemo(() => Boolean(status?.enabled), [status]) - const lastUsed = useMemo(() => status?.last_used_at ?? null, [status]) + }, + [fetchStatus] + ) + + const enabled = useMemo( + () => Boolean(status?.enabled) && (status?.credentials?.length ?? 0) > 0, + [status] + ) + const credentials = useMemo( + () => status?.credentials ?? [], + [status] + ) return { status, @@ -179,7 +188,7 @@ export function usePasskeyManagement( removing, supported, enabled, - lastUsed, + credentials, fetchStatus, register, remove, diff --git a/web/default/src/features/auth/passkey/types.ts b/web/default/src/features/auth/passkey/types.ts index a331a549c14..a8028b24f57 100644 --- a/web/default/src/features/auth/passkey/types.ts +++ b/web/default/src/features/auth/passkey/types.ts @@ -22,11 +22,18 @@ export interface ApiResponse { data?: T } -export interface PasskeyStatus { - enabled: boolean +export interface PasskeyCredential { + credential_id: string + created_at: string last_used_at?: string | null backup_eligible?: boolean backup_state?: boolean + attachment?: string +} + +export interface PasskeyStatus { + enabled: boolean + credentials: PasskeyCredential[] [key: string]: unknown } diff --git a/web/default/src/features/profile/components/passkey-card.tsx b/web/default/src/features/profile/components/passkey-card.tsx index ce5abe467ce..7147a55dfea 100644 --- a/web/default/src/features/profile/components/passkey-card.tsx +++ b/web/default/src/features/profile/components/passkey-card.tsx @@ -56,7 +56,9 @@ interface PasskeyCardProps { export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { const { t } = useTranslation() - const [confirmOpen, setConfirmOpen] = useState(false) + const [confirmCredentialId, setConfirmCredentialId] = useState( + null + ) const [restrictedMethod, setRestrictedMethod] = useState(null) @@ -67,7 +69,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { removing, supported, enabled, - lastUsed, + credentials, register, remove, } = usePasskeyManagement() @@ -107,8 +109,6 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { const methods = await fetchVerificationMethods() if (!methods.has2FA) { - // Without 2FA enabled, register directly. The browser-level Passkey prompt - // is itself a strong proof of presence, so no extra verification is needed. await register() return } @@ -123,38 +123,41 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { }) }, [fetchVerificationMethods, register, startVerification, supported, t]) - const handleRemove = useCallback(async () => { - const methods = await fetchVerificationMethods() - const required: VerificationMethod | null = methods.has2FA - ? '2fa' - : methods.hasPasskey - ? 'passkey' - : null - - if (!required) { - toast.error( - t( - 'Please enable Two-factor Authentication or Passkey before proceeding' + const handleRemove = useCallback( + async (credentialId: string) => { + const methods = await fetchVerificationMethods() + const required: VerificationMethod | null = methods.has2FA + ? '2fa' + : methods.hasPasskey + ? 'passkey' + : null + + if (!required) { + toast.error( + t( + 'Please enable Two-factor Authentication or Passkey before proceeding' + ) ) - ) - return - } + return + } - if (required === 'passkey' && !methods.passkeySupported) { - toast.info(t('This device does not support Passkey')) - return - } + if (required === 'passkey' && !methods.passkeySupported) { + toast.info(t('This device does not support Passkey')) + return + } - setConfirmOpen(false) - setRestrictedMethod(required) - await startVerification(remove, { - preferredMethod: required, - title: t('Security verification'), - description: t( - 'Confirm your identity before removing this Passkey from your account.' - ), - }) - }, [fetchVerificationMethods, remove, startVerification, t]) + setConfirmCredentialId(null) + setRestrictedMethod(required) + await startVerification(() => remove(credentialId), { + preferredMethod: required, + title: t('Security verification'), + description: t( + 'Confirm your identity before removing this Passkey from your account.' + ), + }) + }, + [fetchVerificationMethods, remove, startVerification, t] + ) const handleVerificationCancel = useCallback(() => { setRestrictedMethod(null) @@ -171,9 +174,6 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { [setVerificationOpen] ) - // Adapt the hook's `Promise` return into the dialog's - // `void | Promise` signature without losing error propagation - // semantics (errors are surfaced via toast inside the hook). const handleDialogVerify = useCallback( async (method: VerificationMethod, code?: string) => { try { @@ -199,11 +199,6 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { ) } - const formattedLastUsed = - lastUsed && !Number.isNaN(Date.parse(lastUsed)) - ? dayjs(lastUsed).fromNow() - : t('Not used yet') - const showUnsupportedNotice = !supported && !enabled return ( @@ -220,48 +215,28 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
-
-
-
- -
-
-
-

{t('Passkey Authentication')}

- - {status?.backup_eligible !== undefined && ( + {!enabled && ( +
+
+
+ +
+
+
+

{t('Passkey Authentication')}

- )} +
+

+ {t('No Passkeys registered')} +

-

- {t('Last used:')} {formattedLastUsed} -

-
- {!enabled && (
+
+ )} {enabled && ( -
- - - } +
+
+
+
+ +
+

+ {t('Registered Passkeys')} ({credentials.length}) +

+
+ +
+ +
+ {credentials.map((cred) => ( + setConfirmCredentialId(cred.credential_id)} + t={t} + /> + ))} +
)} @@ -344,6 +306,45 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { + {/* Remove confirmation dialog */} + { + if (!open) setConfirmCredentialId(null) + }} + > + + + {t('Remove Passkey?')} + + {t( + 'Removing this Passkey will prevent sign-in with this device. You can re-register anytime.' + )} + + + + setConfirmCredentialId(null)} + > + {t('Cancel')} + + { + event.preventDefault() + if (confirmCredentialId) { + handleRemove(confirmCredentialId) + } + }} + > + {t('Remove')} + + + + + ) } + +function PasskeyCredentialItem({ + credential, + removing, + onRemove, + t, +}: { + credential: { + credential_id: string + created_at: string + last_used_at?: string | null + backup_eligible?: boolean + backup_state?: boolean + attachment?: string + } + removing: boolean + onRemove: () => void + t: (key: string) => string +}) { + const formattedLastUsed = + credential.last_used_at && !Number.isNaN(Date.parse(credential.last_used_at)) + ? dayjs(credential.last_used_at).fromNow() + : t('Not used yet') + + const deviceType = + credential.attachment === 'platform' + ? t('Built-in Device') + : credential.attachment === 'cross-platform' + ? t('External Device') + : t('Unknown Device') + + return ( +
+
+
+

{deviceType}

+ {credential.backup_eligible !== undefined && ( + + )} +
+
+

+ {t('Last used:')} {formattedLastUsed} +

+

+ {t('Created:')}{' '} + {dayjs(credential.created_at).format('YYYY-MM-DD HH:mm')} +

+
+
+ + +
+ ) +}