fix profile activity and migration remediation
This commit is contained in:
@@ -45,6 +45,14 @@ type stubAdminService struct {
|
|||||||
sortOrder string
|
sortOrder string
|
||||||
calls int
|
calls int
|
||||||
}
|
}
|
||||||
|
lastListUsers struct {
|
||||||
|
page int
|
||||||
|
pageSize int
|
||||||
|
filters service.UserListFilters
|
||||||
|
sortBy string
|
||||||
|
sortOrder string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
lastListProxies struct {
|
lastListProxies struct {
|
||||||
protocol string
|
protocol string
|
||||||
status string
|
status string
|
||||||
@@ -139,6 +147,12 @@ func newStubAdminService() *stubAdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters, sortBy, sortOrder string) ([]service.User, int64, error) {
|
func (s *stubAdminService) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters, sortBy, sortOrder string) ([]service.User, int64, error) {
|
||||||
|
s.lastListUsers.page = page
|
||||||
|
s.lastListUsers.pageSize = pageSize
|
||||||
|
s.lastListUsers.filters = filters
|
||||||
|
s.lastListUsers.sortBy = sortBy
|
||||||
|
s.lastListUsers.sortOrder = sortOrder
|
||||||
|
s.lastListUsers.calls++
|
||||||
return s.users, int64(len(s.users)), nil
|
return s.users, int64(len(s.users)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
backend/internal/handler/admin/user_handler_activity_test.go
Normal file
120
backend/internal/handler/admin/user_handler_activity_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
lastLoginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)
|
||||||
|
lastActiveAt := lastLoginAt.Add(30 * time.Minute)
|
||||||
|
lastUsedAt := lastLoginAt.Add(90 * time.Minute)
|
||||||
|
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
adminSvc.users = []service.User{
|
||||||
|
{
|
||||||
|
ID: 7,
|
||||||
|
Email: "activity@example.com",
|
||||||
|
Username: "activity-user",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
LastLoginAt: &lastLoginAt,
|
||||||
|
LastActiveAt: &lastActiveAt,
|
||||||
|
LastUsedAt: &lastUsedAt,
|
||||||
|
CreatedAt: lastLoginAt.Add(-24 * time.Hour),
|
||||||
|
UpdatedAt: lastLoginAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewUserHandler(adminSvc, nil)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/admin/users?sort_by=last_used_at&sort_order=asc&search=activity",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.List(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
require.Equal(t, "last_used_at", adminSvc.lastListUsers.sortBy)
|
||||||
|
require.Equal(t, "asc", adminSvc.lastListUsers.sortOrder)
|
||||||
|
require.Equal(t, "activity", adminSvc.lastListUsers.filters.Search)
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Items []struct {
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
|
LastActiveAt *time.Time `json:"last_active_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.Len(t, resp.Data.Items, 1)
|
||||||
|
require.WithinDuration(t, lastLoginAt, *resp.Data.Items[0].LastLoginAt, time.Second)
|
||||||
|
require.WithinDuration(t, lastActiveAt, *resp.Data.Items[0].LastActiveAt, time.Second)
|
||||||
|
require.WithinDuration(t, lastUsedAt, *resp.Data.Items[0].LastUsedAt, time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
lastLoginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)
|
||||||
|
lastActiveAt := lastLoginAt.Add(30 * time.Minute)
|
||||||
|
lastUsedAt := lastLoginAt.Add(90 * time.Minute)
|
||||||
|
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
adminSvc.users = []service.User{
|
||||||
|
{
|
||||||
|
ID: 8,
|
||||||
|
Email: "detail@example.com",
|
||||||
|
Username: "detail-user",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
LastLoginAt: &lastLoginAt,
|
||||||
|
LastActiveAt: &lastActiveAt,
|
||||||
|
LastUsedAt: &lastUsedAt,
|
||||||
|
CreatedAt: lastLoginAt.Add(-24 * time.Hour),
|
||||||
|
UpdatedAt: lastLoginAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewUserHandler(adminSvc, nil)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: "8"}}
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/8", nil)
|
||||||
|
|
||||||
|
handler.GetByID(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
|
LastActiveAt *time.Time `json:"last_active_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.WithinDuration(t, lastLoginAt, *resp.Data.LastLoginAt, time.Second)
|
||||||
|
require.WithinDuration(t, lastActiveAt, *resp.Data.LastActiveAt, time.Second)
|
||||||
|
require.WithinDuration(t, lastUsedAt, *resp.Data.LastUsedAt, time.Second)
|
||||||
|
}
|
||||||
@@ -71,8 +71,15 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
|
|||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Equal(t, true, linuxdoBinding["bound"])
|
require.Equal(t, true, linuxdoBinding["bound"])
|
||||||
|
|
||||||
_, hasAvatarSource := resp.Data["avatar_source"]
|
avatarSource, ok := resp.Data["avatar_source"].(map[string]any)
|
||||||
require.False(t, hasAvatarSource)
|
require.True(t, ok)
|
||||||
_, hasProfileSources := resp.Data["profile_sources"]
|
require.Equal(t, "linuxdo", avatarSource["provider"])
|
||||||
require.False(t, hasProfileSources)
|
require.Equal(t, "linuxdo", avatarSource["source"])
|
||||||
|
|
||||||
|
profileSources, ok := resp.Data["profile_sources"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
usernameSource, ok := profileSources["username"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "linuxdo", usernameSource["provider"])
|
||||||
|
require.Equal(t, "linuxdo", usernameSource["source"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
@@ -352,9 +353,15 @@ func userProfileResponseFromService(user *service.User, identities service.UserI
|
|||||||
return userProfileResponse{}
|
return userProfileResponse{}
|
||||||
}
|
}
|
||||||
bindings := userProfileBindingMap(identities)
|
bindings := userProfileBindingMap(identities)
|
||||||
|
profileSources, avatarSource, usernameSource := inferUserProfileSources(user, identities)
|
||||||
return userProfileResponse{
|
return userProfileResponse{
|
||||||
User: *base,
|
User: *base,
|
||||||
AvatarURL: user.AvatarURL,
|
AvatarURL: user.AvatarURL,
|
||||||
|
AvatarSource: avatarSource,
|
||||||
|
UsernameSource: usernameSource,
|
||||||
|
DisplayNameSource: usernameSource,
|
||||||
|
NicknameSource: usernameSource,
|
||||||
|
ProfileSources: profileSources,
|
||||||
Identities: identities,
|
Identities: identities,
|
||||||
AuthBindings: bindings,
|
AuthBindings: bindings,
|
||||||
IdentityBindings: bindings,
|
IdentityBindings: bindings,
|
||||||
@@ -373,3 +380,66 @@ func userProfileBindingMap(identities service.UserIdentitySummarySet) map[string
|
|||||||
"wechat": identities.WeChat,
|
"wechat": identities.WeChat,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inferUserProfileSources(user *service.User, identities service.UserIdentitySummarySet) (
|
||||||
|
map[string]*userProfileSourceContext,
|
||||||
|
*userProfileSourceContext,
|
||||||
|
*userProfileSourceContext,
|
||||||
|
) {
|
||||||
|
if user == nil {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
thirdParty := thirdPartyIdentityProviders(identities)
|
||||||
|
var avatarSource *userProfileSourceContext
|
||||||
|
if strings.TrimSpace(user.AvatarURL) != "" && len(thirdParty) == 1 {
|
||||||
|
avatarSource = buildUserProfileSourceContext(thirdParty[0].Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameValue := strings.TrimSpace(user.Username)
|
||||||
|
var usernameSource *userProfileSourceContext
|
||||||
|
for _, summary := range thirdParty {
|
||||||
|
if usernameValue != "" && usernameValue == strings.TrimSpace(summary.DisplayName) {
|
||||||
|
usernameSource = buildUserProfileSourceContext(summary.Provider)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usernameSource == nil && usernameValue != "" && len(thirdParty) == 1 {
|
||||||
|
usernameSource = buildUserProfileSourceContext(thirdParty[0].Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
profileSources := map[string]*userProfileSourceContext{}
|
||||||
|
if avatarSource != nil {
|
||||||
|
profileSources["avatar"] = avatarSource
|
||||||
|
}
|
||||||
|
if usernameSource != nil {
|
||||||
|
profileSources["username"] = usernameSource
|
||||||
|
profileSources["display_name"] = usernameSource
|
||||||
|
profileSources["nickname"] = usernameSource
|
||||||
|
}
|
||||||
|
if len(profileSources) == 0 {
|
||||||
|
return nil, avatarSource, usernameSource
|
||||||
|
}
|
||||||
|
return profileSources, avatarSource, usernameSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func thirdPartyIdentityProviders(identities service.UserIdentitySummarySet) []service.UserIdentitySummary {
|
||||||
|
out := make([]service.UserIdentitySummary, 0, 3)
|
||||||
|
for _, summary := range []service.UserIdentitySummary{identities.LinuxDo, identities.OIDC, identities.WeChat} {
|
||||||
|
if summary.Bound {
|
||||||
|
out = append(out, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUserProfileSourceContext(provider string) *userProfileSourceContext {
|
||||||
|
provider = strings.TrimSpace(provider)
|
||||||
|
if provider == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &userProfileSourceContext{
|
||||||
|
Provider: provider,
|
||||||
|
Source: provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -285,6 +285,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
|
|||||||
require.Equal(t, false, resp.Data["wechat_bound"])
|
require.Equal(t, false, resp.Data["wechat_bound"])
|
||||||
require.Equal(t, "https://cdn.example.com/linuxdo.png", resp.Data["avatar_url"])
|
require.Equal(t, "https://cdn.example.com/linuxdo.png", resp.Data["avatar_url"])
|
||||||
|
|
||||||
|
avatarSource, ok := resp.Data["avatar_source"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "linuxdo", avatarSource["provider"])
|
||||||
|
require.Equal(t, "linuxdo", avatarSource["source"])
|
||||||
|
|
||||||
authBindings, ok := resp.Data["auth_bindings"].(map[string]any)
|
authBindings, ok := resp.Data["auth_bindings"].(map[string]any)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
linuxdoBinding, ok := authBindings["linuxdo"].(map[string]any)
|
linuxdoBinding, ok := authBindings["linuxdo"].(map[string]any)
|
||||||
@@ -298,10 +303,12 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
|
|||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Equal(t, true, emailBinding["bound"])
|
require.Equal(t, true, emailBinding["bound"])
|
||||||
|
|
||||||
_, hasAvatarSource := resp.Data["avatar_source"]
|
profileSources, ok := resp.Data["profile_sources"].(map[string]any)
|
||||||
require.False(t, hasAvatarSource)
|
require.True(t, ok)
|
||||||
_, hasProfileSources := resp.Data["profile_sources"]
|
usernameSource, ok := profileSources["username"].(map[string]any)
|
||||||
require.False(t, hasProfileSources)
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "linuxdo", usernameSource["provider"])
|
||||||
|
require.Equal(t, "linuxdo", usernameSource["source"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
|
func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ type userAuthIdentityReader interface {
|
|||||||
ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
|
ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userProfileIdentityTxRunner interface {
|
||||||
|
WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error
|
||||||
|
}
|
||||||
|
|
||||||
// ChangePasswordRequest 修改密码请求
|
// ChangePasswordRequest 修改密码请求
|
||||||
type ChangePasswordRequest struct {
|
type ChangePasswordRequest struct {
|
||||||
CurrentPassword string `json:"current_password"`
|
CurrentPassword string `json:"current_password"`
|
||||||
@@ -249,9 +253,38 @@ func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUs
|
|||||||
|
|
||||||
// UpdateProfile 更新用户资料
|
// UpdateProfile 更新用户资料
|
||||||
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
|
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
|
||||||
|
if txRunner, ok := s.userRepo.(userProfileIdentityTxRunner); ok {
|
||||||
|
var (
|
||||||
|
updated *User
|
||||||
|
oldConcurrency int
|
||||||
|
)
|
||||||
|
if err := txRunner.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error {
|
||||||
|
var err error
|
||||||
|
updated, oldConcurrency, err = s.updateProfile(txCtx, userID, req)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.authCacheInvalidator != nil && updated != nil && updated.Concurrency != oldConcurrency {
|
||||||
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, oldConcurrency, err := s.updateProfile(ctx, userID, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.authCacheInvalidator != nil && updated.Concurrency != oldConcurrency {
|
||||||
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) updateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, int, error) {
|
||||||
user, err := s.userRepo.GetByID(ctx, userID)
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
return nil, 0, fmt.Errorf("get user: %w", err)
|
||||||
}
|
}
|
||||||
oldConcurrency := user.Concurrency
|
oldConcurrency := user.Concurrency
|
||||||
|
|
||||||
@@ -260,10 +293,10 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
|||||||
// 检查新邮箱是否已被使用
|
// 检查新邮箱是否已被使用
|
||||||
exists, err := s.userRepo.ExistsByEmail(ctx, *req.Email)
|
exists, err := s.userRepo.ExistsByEmail(ctx, *req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("check email exists: %w", err)
|
return nil, oldConcurrency, fmt.Errorf("check email exists: %w", err)
|
||||||
}
|
}
|
||||||
if exists && *req.Email != user.Email {
|
if exists && *req.Email != user.Email {
|
||||||
return nil, ErrEmailExists
|
return nil, oldConcurrency, ErrEmailExists
|
||||||
}
|
}
|
||||||
user.Email = *req.Email
|
user.Email = *req.Email
|
||||||
}
|
}
|
||||||
@@ -275,7 +308,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
|||||||
if req.AvatarURL != nil {
|
if req.AvatarURL != nil {
|
||||||
avatar, err := s.SetAvatar(ctx, userID, *req.AvatarURL)
|
avatar, err := s.SetAvatar(ctx, userID, *req.AvatarURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, oldConcurrency, err
|
||||||
}
|
}
|
||||||
applyUserAvatar(user, avatar)
|
applyUserAvatar(user, avatar)
|
||||||
}
|
}
|
||||||
@@ -296,13 +329,10 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
return nil, fmt.Errorf("update user: %w", err)
|
return nil, oldConcurrency, fmt.Errorf("update user: %w", err)
|
||||||
}
|
|
||||||
if s.authCacheInvalidator != nil && user.Concurrency != oldConcurrency {
|
|
||||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, oldConcurrency, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) {
|
func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) {
|
||||||
|
|||||||
@@ -31,13 +31,26 @@ type mockUserRepo struct {
|
|||||||
deleteAvatarFn func(ctx context.Context, userID int64) error
|
deleteAvatarFn func(ctx context.Context, userID int64) error
|
||||||
deleteAvatarIDs []int64
|
deleteAvatarIDs []int64
|
||||||
getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error)
|
getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error)
|
||||||
|
txCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockUserRepoTxKey struct{}
|
||||||
|
|
||||||
|
type mockUserRepoTxState struct {
|
||||||
|
getByIDUser *User
|
||||||
|
upsertAvatarArgs []UpsertUserAvatarInput
|
||||||
|
deleteAvatarIDs []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
|
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
|
||||||
func (m *mockUserRepo) GetByID(context.Context, int64) (*User, error) {
|
func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) {
|
||||||
if m.getByIDErr != nil {
|
if m.getByIDErr != nil {
|
||||||
return nil, m.getByIDErr
|
return nil, m.getByIDErr
|
||||||
}
|
}
|
||||||
|
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil && txState.getByIDUser != nil {
|
||||||
|
cloned := *txState.getByIDUser
|
||||||
|
return &cloned, nil
|
||||||
|
}
|
||||||
if m.getByIDUser != nil {
|
if m.getByIDUser != nil {
|
||||||
cloned := *m.getByIDUser
|
cloned := *m.getByIDUser
|
||||||
return &cloned, nil
|
return &cloned, nil
|
||||||
@@ -61,6 +74,27 @@ func (m *mockUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAv
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
|
func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
|
||||||
|
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
|
||||||
|
txState.upsertAvatarArgs = append(txState.upsertAvatarArgs, input)
|
||||||
|
if txState.getByIDUser != nil {
|
||||||
|
txState.getByIDUser.AvatarURL = input.URL
|
||||||
|
txState.getByIDUser.AvatarSource = input.StorageProvider
|
||||||
|
txState.getByIDUser.AvatarMIME = input.ContentType
|
||||||
|
txState.getByIDUser.AvatarByteSize = input.ByteSize
|
||||||
|
txState.getByIDUser.AvatarSHA256 = input.SHA256
|
||||||
|
}
|
||||||
|
if m.upsertAvatarFn != nil {
|
||||||
|
return m.upsertAvatarFn(ctx, userID, input)
|
||||||
|
}
|
||||||
|
return &UserAvatar{
|
||||||
|
StorageProvider: input.StorageProvider,
|
||||||
|
StorageKey: input.StorageKey,
|
||||||
|
URL: input.URL,
|
||||||
|
ContentType: input.ContentType,
|
||||||
|
ByteSize: input.ByteSize,
|
||||||
|
SHA256: input.SHA256,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
m.upsertAvatarArgs = append(m.upsertAvatarArgs, input)
|
m.upsertAvatarArgs = append(m.upsertAvatarArgs, input)
|
||||||
if m.upsertAvatarFn != nil {
|
if m.upsertAvatarFn != nil {
|
||||||
return m.upsertAvatarFn(ctx, userID, input)
|
return m.upsertAvatarFn(ctx, userID, input)
|
||||||
@@ -75,6 +109,20 @@ func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
func (m *mockUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
func (m *mockUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
||||||
|
if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
|
||||||
|
txState.deleteAvatarIDs = append(txState.deleteAvatarIDs, userID)
|
||||||
|
if txState.getByIDUser != nil {
|
||||||
|
txState.getByIDUser.AvatarURL = ""
|
||||||
|
txState.getByIDUser.AvatarSource = ""
|
||||||
|
txState.getByIDUser.AvatarMIME = ""
|
||||||
|
txState.getByIDUser.AvatarByteSize = 0
|
||||||
|
txState.getByIDUser.AvatarSHA256 = ""
|
||||||
|
}
|
||||||
|
if m.deleteAvatarFn != nil {
|
||||||
|
return m.deleteAvatarFn(ctx, userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
m.deleteAvatarIDs = append(m.deleteAvatarIDs, userID)
|
m.deleteAvatarIDs = append(m.deleteAvatarIDs, userID)
|
||||||
if m.deleteAvatarFn != nil {
|
if m.deleteAvatarFn != nil {
|
||||||
return m.deleteAvatarFn(ctx, userID)
|
return m.deleteAvatarFn(ctx, userID)
|
||||||
@@ -116,6 +164,26 @@ func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error {
|
||||||
|
m.txCalls++
|
||||||
|
txState := &mockUserRepoTxState{
|
||||||
|
upsertAvatarArgs: append([]UpsertUserAvatarInput(nil), m.upsertAvatarArgs...),
|
||||||
|
deleteAvatarIDs: append([]int64(nil), m.deleteAvatarIDs...),
|
||||||
|
}
|
||||||
|
if m.getByIDUser != nil {
|
||||||
|
userCopy := *m.getByIDUser
|
||||||
|
txState.getByIDUser = &userCopy
|
||||||
|
}
|
||||||
|
err := fn(context.WithValue(ctx, mockUserRepoTxKey{}, txState))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.getByIDUser = txState.getByIDUser
|
||||||
|
m.upsertAvatarArgs = txState.upsertAvatarArgs
|
||||||
|
m.deleteAvatarIDs = txState.deleteAvatarIDs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- mock: APIKeyAuthCacheInvalidator ---
|
// --- mock: APIKeyAuthCacheInvalidator ---
|
||||||
|
|
||||||
type mockAuthCacheInvalidator struct {
|
type mockAuthCacheInvalidator struct {
|
||||||
@@ -360,6 +428,33 @@ func TestUpdateProfile_DeletesAvatarOnEmptyString(t *testing.T) {
|
|||||||
require.Empty(t, updated.AvatarSource)
|
require.Empty(t, updated.AvatarSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateProfile_RollsBackAvatarMutationWhenUserUpdateFails(t *testing.T) {
|
||||||
|
repo := &mockUserRepo{
|
||||||
|
getByIDUser: &User{
|
||||||
|
ID: 11,
|
||||||
|
Email: "rollback@example.com",
|
||||||
|
AvatarURL: "https://cdn.example.com/original.png",
|
||||||
|
AvatarSource: "remote_url",
|
||||||
|
},
|
||||||
|
updateFn: func(context.Context, *User) error {
|
||||||
|
return errors.New("write user failed")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewUserService(repo, nil, nil, nil)
|
||||||
|
|
||||||
|
remoteURL := "https://cdn.example.com/new.png"
|
||||||
|
_, err := svc.UpdateProfile(context.Background(), 11, UpdateProfileRequest{
|
||||||
|
AvatarURL: &remoteURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.EqualError(t, err, "update user: write user failed")
|
||||||
|
require.Equal(t, 1, repo.txCalls)
|
||||||
|
require.Empty(t, repo.upsertAvatarArgs)
|
||||||
|
require.Empty(t, repo.deleteAvatarIDs)
|
||||||
|
require.Equal(t, "https://cdn.example.com/original.png", repo.getByIDUser.AvatarURL)
|
||||||
|
require.Equal(t, "remote_url", repo.getByIDUser.AvatarSource)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetProfile_HydratesAvatarFromRepository(t *testing.T) {
|
func TestGetProfile_HydratesAvatarFromRepository(t *testing.T) {
|
||||||
repo := &mockUserRepo{
|
repo := &mockUserRepo{
|
||||||
getByIDUser: &User{
|
getByIDUser: &User{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock('@/api/client', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
bindUserAuthIdentity,
|
||||||
getAuthIdentityMigrationReportSummary,
|
getAuthIdentityMigrationReportSummary,
|
||||||
listAuthIdentityMigrationReports,
|
listAuthIdentityMigrationReports,
|
||||||
resolveAuthIdentityMigrationReport,
|
resolveAuthIdentityMigrationReport,
|
||||||
@@ -81,4 +82,31 @@ describe('admin users auth identity migration reports API', () => {
|
|||||||
})
|
})
|
||||||
expect(result).toBe(response)
|
expect(result).toBe(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('binds a canonical auth identity to a user for remediation', async () => {
|
||||||
|
const response = {
|
||||||
|
identity_id: 11,
|
||||||
|
provider_type: 'oidc',
|
||||||
|
provider_key: 'https://issuer.example',
|
||||||
|
provider_subject: 'subject-123',
|
||||||
|
}
|
||||||
|
post.mockResolvedValue({ data: response })
|
||||||
|
|
||||||
|
const result = await bindUserAuthIdentity(42, {
|
||||||
|
provider_type: 'oidc',
|
||||||
|
provider_key: 'https://issuer.example',
|
||||||
|
provider_subject: 'subject-123',
|
||||||
|
issuer: 'https://issuer.example',
|
||||||
|
metadata: { source: 'migration-report' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith('/admin/users/42/auth-identities', {
|
||||||
|
provider_type: 'oidc',
|
||||||
|
provider_key: 'https://issuer.example',
|
||||||
|
provider_subject: 'subject-123',
|
||||||
|
issuer: 'https://issuer.example',
|
||||||
|
metadata: { source: 'migration-report' },
|
||||||
|
})
|
||||||
|
expect(result).toBe(response)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,30 @@ export interface AuthIdentityMigrationReportSummary {
|
|||||||
by_type: Record<string, number>
|
by_type: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminBindAuthIdentityChannelRequest {
|
||||||
|
channel: string
|
||||||
|
channel_app_id?: string
|
||||||
|
channel_subject: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBindAuthIdentityRequest {
|
||||||
|
provider_type: string
|
||||||
|
provider_key: string
|
||||||
|
provider_subject: string
|
||||||
|
issuer?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
channel?: AdminBindAuthIdentityChannelRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBoundAuthIdentity {
|
||||||
|
identity_id: number
|
||||||
|
provider_type: string
|
||||||
|
provider_key: string
|
||||||
|
provider_subject: string
|
||||||
|
channel_id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListAuthIdentityMigrationReportsParams {
|
export interface ListAuthIdentityMigrationReportsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
@@ -308,6 +332,17 @@ export async function resolveAuthIdentityMigrationReport(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bindUserAuthIdentity(
|
||||||
|
userId: number,
|
||||||
|
input: AdminBindAuthIdentityRequest
|
||||||
|
): Promise<AdminBoundAuthIdentity> {
|
||||||
|
const { data } = await apiClient.post<AdminBoundAuthIdentity>(
|
||||||
|
`/admin/users/${userId}/auth-identities`,
|
||||||
|
input
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const usersAPI = {
|
export const usersAPI = {
|
||||||
list,
|
list,
|
||||||
getById,
|
getById,
|
||||||
@@ -321,6 +356,7 @@ export const usersAPI = {
|
|||||||
getUserUsageStats,
|
getUserUsageStats,
|
||||||
getUserBalanceHistory,
|
getUserBalanceHistory,
|
||||||
replaceGroup,
|
replaceGroup,
|
||||||
|
bindUserAuthIdentity,
|
||||||
getAuthIdentityMigrationReportSummary,
|
getAuthIdentityMigrationReportSummary,
|
||||||
listAuthIdentityMigrationReports,
|
listAuthIdentityMigrationReports,
|
||||||
resolveAuthIdentityMigrationReport
|
resolveAuthIdentityMigrationReport
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
|||||||
if (key === 'profile.authBindings.providers.linuxdo') return 'LinuxDo'
|
if (key === 'profile.authBindings.providers.linuxdo') return 'LinuxDo'
|
||||||
if (key === 'profile.authBindings.providers.wechat') return 'WeChat'
|
if (key === 'profile.authBindings.providers.wechat') return 'WeChat'
|
||||||
if (key === 'profile.authBindings.providers.oidc') return params?.providerName || 'OIDC'
|
if (key === 'profile.authBindings.providers.oidc') return params?.providerName || 'OIDC'
|
||||||
|
if (key === 'profile.authBindings.source.avatar') return `Avatar synced from ${params?.providerName || 'provider'}`
|
||||||
|
if (key === 'profile.authBindings.source.username') return `Username synced from ${params?.providerName || 'provider'}`
|
||||||
if (key === 'common.save') return 'Save'
|
if (key === 'common.save') return 'Save'
|
||||||
if (key === 'common.delete') return 'Delete'
|
if (key === 'common.delete') return 'Delete'
|
||||||
return key
|
return key
|
||||||
@@ -169,4 +171,29 @@ describe('ProfileInfoCard', () => {
|
|||||||
expect(authStoreState.user?.avatar_url).toBeNull()
|
expect(authStoreState.user?.avatar_url).toBeNull()
|
||||||
expect(showSuccessMock).toHaveBeenCalledWith('Avatar removed')
|
expect(showSuccessMock).toHaveBeenCalledWith('Avatar removed')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders third-party source hints from profile_sources', () => {
|
||||||
|
authStoreState.user = createUser({
|
||||||
|
avatar_url: 'https://cdn.example.com/linuxdo.png',
|
||||||
|
profile_sources: {
|
||||||
|
avatar: { provider: 'linuxdo', source: 'linuxdo' },
|
||||||
|
username: { provider: 'linuxdo', source: 'linuxdo' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(ProfileInfoCard, {
|
||||||
|
props: {
|
||||||
|
user: authStoreState.user
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Icon: true,
|
||||||
|
ProfileIdentityBindingsSection: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Avatar synced from LinuxDo')
|
||||||
|
expect(wrapper.text()).toContain('Username synced from LinuxDo')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -238,6 +238,83 @@
|
|||||||
{{ resolving ? copy.resolving : copy.resolveAction }}
|
{{ resolving ? copy.resolving : copy.resolveAction }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 border-t border-gray-200 pt-6 dark:border-dark-700">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ copy.remediationTitle }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ copy.remediationSubtitle }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="input-label" for="remediation-user-id">{{ copy.remediationUserID }}</label>
|
||||||
|
<input
|
||||||
|
id="remediation-user-id"
|
||||||
|
v-model="remediation.userID"
|
||||||
|
data-test="remediation-user-id"
|
||||||
|
class="input"
|
||||||
|
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||||
|
inputmode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label" for="remediation-provider-type">{{ copy.remediationProviderType }}</label>
|
||||||
|
<input
|
||||||
|
id="remediation-provider-type"
|
||||||
|
v-model="remediation.providerType"
|
||||||
|
data-test="remediation-provider-type"
|
||||||
|
class="input"
|
||||||
|
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label" for="remediation-provider-key">{{ copy.remediationProviderKey }}</label>
|
||||||
|
<input
|
||||||
|
id="remediation-provider-key"
|
||||||
|
v-model="remediation.providerKey"
|
||||||
|
data-test="remediation-provider-key"
|
||||||
|
class="input"
|
||||||
|
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label" for="remediation-provider-subject">{{ copy.remediationProviderSubject }}</label>
|
||||||
|
<input
|
||||||
|
id="remediation-provider-subject"
|
||||||
|
v-model="remediation.providerSubject"
|
||||||
|
data-test="remediation-provider-subject"
|
||||||
|
class="input"
|
||||||
|
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label" for="remediation-issuer">{{ copy.remediationIssuer }}</label>
|
||||||
|
<input
|
||||||
|
id="remediation-issuer"
|
||||||
|
v-model="remediation.issuer"
|
||||||
|
data-test="remediation-issuer"
|
||||||
|
class="input"
|
||||||
|
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
data-test="remediation-submit"
|
||||||
|
:disabled="!canBindRemediation"
|
||||||
|
@click="submitRemediationBinding"
|
||||||
|
>
|
||||||
|
{{ binding ? copy.remediationSubmitting : copy.remediationAction }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +326,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type {
|
import type {
|
||||||
|
AdminBindAuthIdentityRequest,
|
||||||
AuthIdentityMigrationReport,
|
AuthIdentityMigrationReport,
|
||||||
AuthIdentityMigrationReportSummary,
|
AuthIdentityMigrationReportSummary,
|
||||||
} from '@/api/admin/users'
|
} from '@/api/admin/users'
|
||||||
@@ -294,6 +372,15 @@ const copy = computed(() => ({
|
|||||||
resolvePlaceholder: text('填写本次处理动作、用户沟通结果或后续追踪信息。', 'Describe the action taken, user communication, or follow-up context.'),
|
resolvePlaceholder: text('填写本次处理动作、用户沟通结果或后续追踪信息。', 'Describe the action taken, user communication, or follow-up context.'),
|
||||||
resolveAction: text('提交 Resolve', 'Submit resolve'),
|
resolveAction: text('提交 Resolve', 'Submit resolve'),
|
||||||
resolving: text('提交中...', 'Submitting...'),
|
resolving: text('提交中...', 'Submitting...'),
|
||||||
|
remediationTitle: text('修复绑定', 'Remediation binding'),
|
||||||
|
remediationSubtitle: text('可直接把迁移报告中的身份信息绑定到指定用户;已识别字段会自动预填。', 'Bind the migrated identity directly to a user. Recognized fields are prefilled automatically.'),
|
||||||
|
remediationUserID: text('目标用户 ID', 'Target user ID'),
|
||||||
|
remediationProviderType: text('Provider Type', 'Provider type'),
|
||||||
|
remediationProviderKey: text('Provider Key', 'Provider key'),
|
||||||
|
remediationProviderSubject: text('Provider Subject', 'Provider subject'),
|
||||||
|
remediationIssuer: text('Issuer', 'Issuer'),
|
||||||
|
remediationAction: text('提交绑定修复', 'Submit remediation binding'),
|
||||||
|
remediationSubmitting: text('提交中...', 'Submitting...'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const summary = ref<AuthIdentityMigrationReportSummary>({
|
const summary = ref<AuthIdentityMigrationReportSummary>({
|
||||||
@@ -308,6 +395,7 @@ const resolutionNote = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const summaryLoading = ref(false)
|
const summaryLoading = ref(false)
|
||||||
const resolving = ref(false)
|
const resolving = ref(false)
|
||||||
|
const binding = ref(false)
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
reportType: '',
|
reportType: '',
|
||||||
@@ -319,6 +407,13 @@ const pagination = reactive({
|
|||||||
total: 0,
|
total: 0,
|
||||||
})
|
})
|
||||||
const knownReportTypes = ref<string[]>([])
|
const knownReportTypes = ref<string[]>([])
|
||||||
|
const remediation = reactive({
|
||||||
|
userID: '',
|
||||||
|
providerType: '',
|
||||||
|
providerKey: '',
|
||||||
|
providerSubject: '',
|
||||||
|
issuer: '',
|
||||||
|
})
|
||||||
|
|
||||||
const columns: Column[] = [
|
const columns: Column[] = [
|
||||||
{ key: 'status', label: text('状态', 'Status') },
|
{ key: 'status', label: text('状态', 'Status') },
|
||||||
@@ -352,6 +447,18 @@ const canResolve = computed(() =>
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const canBindRemediation = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
selectedReport.value &&
|
||||||
|
!selectedReport.value.resolved_at &&
|
||||||
|
remediation.userID.trim() &&
|
||||||
|
remediation.providerType.trim() &&
|
||||||
|
remediation.providerKey.trim() &&
|
||||||
|
remediation.providerSubject.trim() &&
|
||||||
|
!binding.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const mergeKnownReportTypes = (...values: Array<string | null | undefined>) => {
|
const mergeKnownReportTypes = (...values: Array<string | null | undefined>) => {
|
||||||
const merged = new Set(knownReportTypes.value)
|
const merged = new Set(knownReportTypes.value)
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
@@ -392,6 +499,7 @@ const loadReports = async () => {
|
|||||||
if (selectedReport.value) {
|
if (selectedReport.value) {
|
||||||
const refreshed = response.items.find((report) => report.id === selectedReport.value?.id) ?? null
|
const refreshed = response.items.find((report) => report.id === selectedReport.value?.id) ?? null
|
||||||
selectedReport.value = refreshed
|
selectedReport.value = refreshed
|
||||||
|
applyRemediationDefaults(refreshed)
|
||||||
resolutionNote.value = refreshed?.resolved_at
|
resolutionNote.value = refreshed?.resolved_at
|
||||||
? refreshed.resolution_note ?? ''
|
? refreshed.resolution_note ?? ''
|
||||||
: resolutionNote.value
|
: resolutionNote.value
|
||||||
@@ -427,6 +535,7 @@ const handlePageSizeChange = async (pageSize: number) => {
|
|||||||
const selectReport = (report: AuthIdentityMigrationReport) => {
|
const selectReport = (report: AuthIdentityMigrationReport) => {
|
||||||
selectedReport.value = report
|
selectedReport.value = report
|
||||||
resolutionNote.value = report.resolution_note ?? ''
|
resolutionNote.value = report.resolution_note ?? ''
|
||||||
|
applyRemediationDefaults(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDetailsJson = (details: Record<string, unknown>) => JSON.stringify(details ?? {}, null, 2)
|
const formatDetailsJson = (details: Record<string, unknown>) => JSON.stringify(details ?? {}, null, 2)
|
||||||
@@ -458,6 +567,63 @@ const getDetailHighlights = (details: Record<string, unknown>) => {
|
|||||||
.map(([key, value]) => ({ key, value: String(value) }))
|
.map(([key, value]) => ({ key, value: String(value) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stringDetailValue = (details: Record<string, unknown>, key: string) => {
|
||||||
|
const value = details[key]
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericDetailValue = (details: Record<string, unknown>, key: string) => {
|
||||||
|
const value = details[key]
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return String(Math.trunc(value))
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferProviderTypeFromReport = (report: AuthIdentityMigrationReport) => {
|
||||||
|
const explicit = stringDetailValue(report.details, 'provider_type')
|
||||||
|
if (explicit) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
if (report.report_type.includes('oidc')) {
|
||||||
|
return 'oidc'
|
||||||
|
}
|
||||||
|
if (report.report_type.includes('wechat')) {
|
||||||
|
return 'wechat'
|
||||||
|
}
|
||||||
|
if (report.report_type.includes('linuxdo')) {
|
||||||
|
return 'linuxdo'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferProviderKeyFromReport = (report: AuthIdentityMigrationReport, providerType: string) => {
|
||||||
|
const explicit = stringDetailValue(report.details, 'provider_key')
|
||||||
|
if (explicit) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
if (providerType === 'wechat') {
|
||||||
|
return 'wechat-main'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferProviderSubjectFromReport = (report: AuthIdentityMigrationReport) =>
|
||||||
|
stringDetailValue(report.details, 'provider_subject')
|
||||||
|
|| stringDetailValue(report.details, 'subject')
|
||||||
|
|| stringDetailValue(report.details, 'unionid')
|
||||||
|
|
||||||
|
const applyRemediationDefaults = (report: AuthIdentityMigrationReport | null) => {
|
||||||
|
remediation.userID = report ? numericDetailValue(report.details, 'user_id') : ''
|
||||||
|
remediation.providerType = report ? inferProviderTypeFromReport(report) : ''
|
||||||
|
remediation.providerKey = report ? inferProviderKeyFromReport(report, remediation.providerType) : ''
|
||||||
|
remediation.providerSubject = report ? inferProviderSubjectFromReport(report) : ''
|
||||||
|
remediation.issuer = report ? stringDetailValue(report.details, 'issuer') : ''
|
||||||
|
}
|
||||||
|
|
||||||
const submitResolve = async () => {
|
const submitResolve = async () => {
|
||||||
if (!selectedReport.value) {
|
if (!selectedReport.value) {
|
||||||
appStore.showError(text('请先选择一条报告', 'Select a report first'))
|
appStore.showError(text('请先选择一条报告', 'Select a report first'))
|
||||||
@@ -485,6 +651,38 @@ const submitResolve = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitRemediationBinding = async () => {
|
||||||
|
if (!selectedReport.value) {
|
||||||
|
appStore.showError(text('请先选择一条报告', 'Select a report first'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userID = Number.parseInt(remediation.userID.trim(), 10)
|
||||||
|
if (!Number.isFinite(userID) || userID <= 0) {
|
||||||
|
appStore.showError(text('请输入有效的目标用户 ID', 'Enter a valid target user ID'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: AdminBindAuthIdentityRequest = {
|
||||||
|
provider_type: remediation.providerType.trim(),
|
||||||
|
provider_key: remediation.providerKey.trim(),
|
||||||
|
provider_subject: remediation.providerSubject.trim(),
|
||||||
|
issuer: remediation.issuer.trim() || undefined,
|
||||||
|
metadata: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.value = true
|
||||||
|
try {
|
||||||
|
await adminAPI.users.bindUserAuthIdentity(userID, payload)
|
||||||
|
appStore.showSuccess(text('修复绑定已提交', 'Remediation binding submitted'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit auth identity remediation binding:', error)
|
||||||
|
appStore.showError(text('提交修复绑定失败', 'Failed to submit remediation binding'))
|
||||||
|
} finally {
|
||||||
|
binding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { defineComponent, h } from 'vue'
|
|||||||
|
|
||||||
import AuthIdentityMigrationReportsView from '../AuthIdentityMigrationReportsView.vue'
|
import AuthIdentityMigrationReportsView from '../AuthIdentityMigrationReportsView.vue'
|
||||||
|
|
||||||
const { getAuthIdentityMigrationReportSummary, listAuthIdentityMigrationReports, resolveAuthIdentityMigrationReport } = vi.hoisted(() => ({
|
const {
|
||||||
|
bindUserAuthIdentity,
|
||||||
|
getAuthIdentityMigrationReportSummary,
|
||||||
|
listAuthIdentityMigrationReports,
|
||||||
|
resolveAuthIdentityMigrationReport,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
bindUserAuthIdentity: vi.fn(),
|
||||||
getAuthIdentityMigrationReportSummary: vi.fn(),
|
getAuthIdentityMigrationReportSummary: vi.fn(),
|
||||||
listAuthIdentityMigrationReports: vi.fn(),
|
listAuthIdentityMigrationReports: vi.fn(),
|
||||||
resolveAuthIdentityMigrationReport: vi.fn(),
|
resolveAuthIdentityMigrationReport: vi.fn(),
|
||||||
@@ -18,6 +24,7 @@ const { showError, showSuccess } = vi.hoisted(() => ({
|
|||||||
vi.mock('@/api/admin', () => ({
|
vi.mock('@/api/admin', () => ({
|
||||||
adminAPI: {
|
adminAPI: {
|
||||||
users: {
|
users: {
|
||||||
|
bindUserAuthIdentity,
|
||||||
getAuthIdentityMigrationReportSummary,
|
getAuthIdentityMigrationReportSummary,
|
||||||
listAuthIdentityMigrationReports,
|
listAuthIdentityMigrationReports,
|
||||||
resolveAuthIdentityMigrationReport,
|
resolveAuthIdentityMigrationReport,
|
||||||
@@ -156,6 +163,7 @@ describe('AuthIdentityMigrationReportsView', () => {
|
|||||||
getAuthIdentityMigrationReportSummary.mockReset()
|
getAuthIdentityMigrationReportSummary.mockReset()
|
||||||
listAuthIdentityMigrationReports.mockReset()
|
listAuthIdentityMigrationReports.mockReset()
|
||||||
resolveAuthIdentityMigrationReport.mockReset()
|
resolveAuthIdentityMigrationReport.mockReset()
|
||||||
|
bindUserAuthIdentity.mockReset()
|
||||||
showError.mockReset()
|
showError.mockReset()
|
||||||
showSuccess.mockReset()
|
showSuccess.mockReset()
|
||||||
|
|
||||||
@@ -167,6 +175,12 @@ describe('AuthIdentityMigrationReportsView', () => {
|
|||||||
resolved_by_user_id: 100,
|
resolved_by_user_id: 100,
|
||||||
resolution_note: 'resolved by admin',
|
resolution_note: 'resolved by admin',
|
||||||
})
|
})
|
||||||
|
bindUserAuthIdentity.mockResolvedValue({
|
||||||
|
identity_id: 77,
|
||||||
|
provider_type: 'oidc',
|
||||||
|
provider_key: 'https://issuer.example',
|
||||||
|
provider_subject: 'subject-123',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountView = () =>
|
const mountView = () =>
|
||||||
@@ -241,6 +255,35 @@ describe('AuthIdentityMigrationReportsView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('pre-fills and submits remediation binding for the selected report', async () => {
|
||||||
|
const wrapper = mountView()
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('[data-test="select-report-1"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect((wrapper.get('[data-test="remediation-user-id"]').element as HTMLInputElement).value).toBe('42')
|
||||||
|
expect((wrapper.get('[data-test="remediation-provider-type"]').element as HTMLInputElement).value).toBe('oidc')
|
||||||
|
expect((wrapper.get('[data-test="remediation-provider-key"]').element as HTMLInputElement).value).toBe(
|
||||||
|
'https://issuer.example'
|
||||||
|
)
|
||||||
|
expect((wrapper.get('[data-test="remediation-provider-subject"]').element as HTMLInputElement).value).toBe(
|
||||||
|
'subject-123'
|
||||||
|
)
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="remediation-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(bindUserAuthIdentity).toHaveBeenCalledWith(42, {
|
||||||
|
provider_type: 'oidc',
|
||||||
|
provider_key: 'https://issuer.example',
|
||||||
|
provider_subject: 'subject-123',
|
||||||
|
issuer: undefined,
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
expect(showSuccess).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps report type filter options available from list data when summary fails', async () => {
|
it('keeps report type filter options available from list data when summary fails', async () => {
|
||||||
getAuthIdentityMigrationReportSummary.mockRejectedValueOnce(new Error('summary failed'))
|
getAuthIdentityMigrationReportSummary.mockRejectedValueOnce(new Error('summary failed'))
|
||||||
listAuthIdentityMigrationReports.mockResolvedValueOnce(listResponse)
|
listAuthIdentityMigrationReports.mockResolvedValueOnce(listResponse)
|
||||||
|
|||||||
Reference in New Issue
Block a user