diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 8ecadfdf..dfdf327b 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -45,6 +45,14 @@ type stubAdminService struct { sortOrder string calls int } + lastListUsers struct { + page int + pageSize int + filters service.UserListFilters + sortBy string + sortOrder string + calls int + } lastListProxies struct { protocol 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) { + 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 } diff --git a/backend/internal/handler/admin/user_handler_activity_test.go b/backend/internal/handler/admin/user_handler_activity_test.go new file mode 100644 index 00000000..ac29086d --- /dev/null +++ b/backend/internal/handler/admin/user_handler_activity_test.go @@ -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) +} diff --git a/backend/internal/handler/auth_current_user_test.go b/backend/internal/handler/auth_current_user_test.go index dab95e29..31d92a36 100644 --- a/backend/internal/handler/auth_current_user_test.go +++ b/backend/internal/handler/auth_current_user_test.go @@ -71,8 +71,15 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T require.True(t, ok) require.Equal(t, true, linuxdoBinding["bound"]) - _, hasAvatarSource := resp.Data["avatar_source"] - require.False(t, hasAvatarSource) - _, hasProfileSources := resp.Data["profile_sources"] - require.False(t, hasProfileSources) + 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"]) + + 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"]) } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 843b0bd9..b1ade5c0 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -2,6 +2,7 @@ package handler import ( "context" + "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -352,16 +353,22 @@ func userProfileResponseFromService(user *service.User, identities service.UserI return userProfileResponse{} } bindings := userProfileBindingMap(identities) + profileSources, avatarSource, usernameSource := inferUserProfileSources(user, identities) return userProfileResponse{ - User: *base, - AvatarURL: user.AvatarURL, - Identities: identities, - AuthBindings: bindings, - IdentityBindings: bindings, - EmailBound: identities.Email.Bound, - LinuxDoBound: identities.LinuxDo.Bound, - OIDCBound: identities.OIDC.Bound, - WeChatBound: identities.WeChat.Bound, + User: *base, + AvatarURL: user.AvatarURL, + AvatarSource: avatarSource, + UsernameSource: usernameSource, + DisplayNameSource: usernameSource, + NicknameSource: usernameSource, + ProfileSources: profileSources, + Identities: identities, + AuthBindings: bindings, + IdentityBindings: bindings, + EmailBound: identities.Email.Bound, + LinuxDoBound: identities.LinuxDo.Bound, + OIDCBound: identities.OIDC.Bound, + WeChatBound: identities.WeChat.Bound, } } @@ -373,3 +380,66 @@ func userProfileBindingMap(identities service.UserIdentitySummarySet) map[string "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, + } +} diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 7c6460e8..aacfc332 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -285,6 +285,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) { require.Equal(t, false, resp.Data["wechat_bound"]) 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) require.True(t, ok) linuxdoBinding, ok := authBindings["linuxdo"].(map[string]any) @@ -298,10 +303,12 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) { require.True(t, ok) require.Equal(t, true, emailBinding["bound"]) - _, hasAvatarSource := resp.Data["avatar_source"] - require.False(t, hasAvatarSource) - _, hasProfileSources := resp.Data["profile_sources"] - require.False(t, hasProfileSources) + 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"]) } func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) { diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 7c2ca2d0..6994ec6c 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -161,6 +161,10 @@ type userAuthIdentityReader interface { ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) } +type userProfileIdentityTxRunner interface { + WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error +} + // ChangePasswordRequest 修改密码请求 type ChangePasswordRequest struct { CurrentPassword string `json:"current_password"` @@ -249,9 +253,38 @@ func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUs // UpdateProfile 更新用户资料 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) if err != nil { - return nil, fmt.Errorf("get user: %w", err) + return nil, 0, fmt.Errorf("get user: %w", err) } 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) 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 { - return nil, ErrEmailExists + return nil, oldConcurrency, ErrEmailExists } user.Email = *req.Email } @@ -275,7 +308,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat if req.AvatarURL != nil { avatar, err := s.SetAvatar(ctx, userID, *req.AvatarURL) if err != nil { - return nil, err + return nil, oldConcurrency, err } 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 { - return nil, fmt.Errorf("update user: %w", err) - } - if s.authCacheInvalidator != nil && user.Concurrency != oldConcurrency { - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + return nil, oldConcurrency, fmt.Errorf("update user: %w", err) } - return user, nil + return user, oldConcurrency, nil } func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) { diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index e13fb95d..c0cc36cb 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -31,13 +31,26 @@ type mockUserRepo struct { deleteAvatarFn func(ctx context.Context, userID int64) error deleteAvatarIDs []int64 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) GetByID(context.Context, int64) (*User, error) { +func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) { if m.getByIDErr != nil { 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 { cloned := *m.getByIDUser return &cloned, nil @@ -61,6 +74,27 @@ func (m *mockUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAv return nil, nil } 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) if m.upsertAvatarFn != nil { return m.upsertAvatarFn(ctx, userID, input) @@ -75,6 +109,20 @@ func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input }, nil } 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) if m.deleteAvatarFn != nil { return m.deleteAvatarFn(ctx, userID) @@ -116,6 +164,26 @@ func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, 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 --- type mockAuthCacheInvalidator struct { @@ -360,6 +428,33 @@ func TestUpdateProfile_DeletesAvatarOnEmptyString(t *testing.T) { 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) { repo := &mockUserRepo{ getByIDUser: &User{ diff --git a/frontend/src/api/__tests__/users.migrationReports.spec.ts b/frontend/src/api/__tests__/users.migrationReports.spec.ts index c9375cd4..ca889600 100644 --- a/frontend/src/api/__tests__/users.migrationReports.spec.ts +++ b/frontend/src/api/__tests__/users.migrationReports.spec.ts @@ -13,6 +13,7 @@ vi.mock('@/api/client', () => ({ })) import { + bindUserAuthIdentity, getAuthIdentityMigrationReportSummary, listAuthIdentityMigrationReports, resolveAuthIdentityMigrationReport, @@ -81,4 +82,31 @@ describe('admin users auth identity migration reports API', () => { }) 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) + }) }) diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 2d154cf8..be30eddc 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -24,6 +24,30 @@ export interface AuthIdentityMigrationReportSummary { by_type: Record } +export interface AdminBindAuthIdentityChannelRequest { + channel: string + channel_app_id?: string + channel_subject: string + metadata?: Record +} + +export interface AdminBindAuthIdentityRequest { + provider_type: string + provider_key: string + provider_subject: string + issuer?: string + metadata?: Record + channel?: AdminBindAuthIdentityChannelRequest +} + +export interface AdminBoundAuthIdentity { + identity_id: number + provider_type: string + provider_key: string + provider_subject: string + channel_id?: number | null +} + export interface ListAuthIdentityMigrationReportsParams { page?: number pageSize?: number @@ -308,6 +332,17 @@ export async function resolveAuthIdentityMigrationReport( return data } +export async function bindUserAuthIdentity( + userId: number, + input: AdminBindAuthIdentityRequest +): Promise { + const { data } = await apiClient.post( + `/admin/users/${userId}/auth-identities`, + input + ) + return data +} + export const usersAPI = { list, getById, @@ -321,6 +356,7 @@ export const usersAPI = { getUserUsageStats, getUserBalanceHistory, replaceGroup, + bindUserAuthIdentity, getAuthIdentityMigrationReportSummary, listAuthIdentityMigrationReports, resolveAuthIdentityMigrationReport diff --git a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts index 238a898e..6e9e4dd5 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts @@ -62,6 +62,8 @@ vi.mock('vue-i18n', async (importOriginal) => { if (key === 'profile.authBindings.providers.linuxdo') return 'LinuxDo' if (key === 'profile.authBindings.providers.wechat') return 'WeChat' 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.delete') return 'Delete' return key @@ -169,4 +171,29 @@ describe('ProfileInfoCard', () => { expect(authStoreState.user?.avatar_url).toBeNull() 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') + }) }) diff --git a/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue b/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue index 35c232b6..12fbf1b9 100644 --- a/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue +++ b/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue @@ -238,6 +238,83 @@ {{ resolving ? copy.resolving : copy.resolveAction }} + +
+

+ {{ copy.remediationTitle }} +

+

+ {{ copy.remediationSubtitle }} +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
@@ -249,6 +326,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api/admin' import type { + AdminBindAuthIdentityRequest, AuthIdentityMigrationReport, AuthIdentityMigrationReportSummary, } from '@/api/admin/users' @@ -294,6 +372,15 @@ const copy = computed(() => ({ resolvePlaceholder: text('填写本次处理动作、用户沟通结果或后续追踪信息。', 'Describe the action taken, user communication, or follow-up context.'), resolveAction: text('提交 Resolve', 'Submit resolve'), 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({ @@ -308,6 +395,7 @@ const resolutionNote = ref('') const loading = ref(false) const summaryLoading = ref(false) const resolving = ref(false) +const binding = ref(false) const filters = reactive({ reportType: '', @@ -319,6 +407,13 @@ const pagination = reactive({ total: 0, }) const knownReportTypes = ref([]) +const remediation = reactive({ + userID: '', + providerType: '', + providerKey: '', + providerSubject: '', + issuer: '', +}) const columns: Column[] = [ { 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) => { const merged = new Set(knownReportTypes.value) for (const value of values) { @@ -392,6 +499,7 @@ const loadReports = async () => { if (selectedReport.value) { const refreshed = response.items.find((report) => report.id === selectedReport.value?.id) ?? null selectedReport.value = refreshed + applyRemediationDefaults(refreshed) resolutionNote.value = refreshed?.resolved_at ? refreshed.resolution_note ?? '' : resolutionNote.value @@ -427,6 +535,7 @@ const handlePageSizeChange = async (pageSize: number) => { const selectReport = (report: AuthIdentityMigrationReport) => { selectedReport.value = report resolutionNote.value = report.resolution_note ?? '' + applyRemediationDefaults(report) } const formatDetailsJson = (details: Record) => JSON.stringify(details ?? {}, null, 2) @@ -458,6 +567,63 @@ const getDetailHighlights = (details: Record) => { .map(([key, value]) => ({ key, value: String(value) })) } +const stringDetailValue = (details: Record, key: string) => { + const value = details[key] + return typeof value === 'string' ? value.trim() : '' +} + +const numericDetailValue = (details: Record, 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 () => { if (!selectedReport.value) { 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 () => { await refreshAll() }) diff --git a/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts b/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts index 406baaf1..20f57fa1 100644 --- a/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts +++ b/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts @@ -4,7 +4,13 @@ import { defineComponent, h } from '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(), listAuthIdentityMigrationReports: vi.fn(), resolveAuthIdentityMigrationReport: vi.fn(), @@ -18,6 +24,7 @@ const { showError, showSuccess } = vi.hoisted(() => ({ vi.mock('@/api/admin', () => ({ adminAPI: { users: { + bindUserAuthIdentity, getAuthIdentityMigrationReportSummary, listAuthIdentityMigrationReports, resolveAuthIdentityMigrationReport, @@ -156,6 +163,7 @@ describe('AuthIdentityMigrationReportsView', () => { getAuthIdentityMigrationReportSummary.mockReset() listAuthIdentityMigrationReports.mockReset() resolveAuthIdentityMigrationReport.mockReset() + bindUserAuthIdentity.mockReset() showError.mockReset() showSuccess.mockReset() @@ -167,6 +175,12 @@ describe('AuthIdentityMigrationReportsView', () => { resolved_by_user_id: 100, resolution_note: 'resolved by admin', }) + bindUserAuthIdentity.mockResolvedValue({ + identity_id: 77, + provider_type: 'oidc', + provider_key: 'https://issuer.example', + provider_subject: 'subject-123', + }) }) 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 () => { getAuthIdentityMigrationReportSummary.mockRejectedValueOnce(new Error('summary failed')) listAuthIdentityMigrationReports.mockResolvedValueOnce(listResponse)