diff --git a/backend/internal/handler/auth_current_user_test.go b/backend/internal/handler/auth_current_user_test.go index 31d92a36..cb3e4ba5 100644 --- a/backend/internal/handler/auth_current_user_test.go +++ b/backend/internal/handler/auth_current_user_test.go @@ -29,18 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T AvatarURL: "https://cdn.example.com/linuxdo.png", AvatarSource: "remote_url", }, - identities: []service.UserAuthIdentityRecord{ - { - ProviderType: "linuxdo", - ProviderKey: "linuxdo", - ProviderSubject: "linuxdo-subject-31", - VerifiedAt: &verifiedAt, - Metadata: map[string]any{ - "username": "linuxdo-handle", + identities: []service.UserAuthIdentityRecord{ + { + ProviderType: "linuxdo", + ProviderKey: "linuxdo", + ProviderSubject: "linuxdo-subject-31", + VerifiedAt: &verifiedAt, + Metadata: map[string]any{ + "username": "linuxdo-handle", + "avatar_url": "https://cdn.example.com/linuxdo.png", + }, }, }, - }, - } + } handler := &AuthHandler{ userService: service.NewUserService(repo, nil, nil, nil), diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 3e5ca080..80dcc5ce 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -258,6 +258,12 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) { response.ErrorFrom(c, err) return } + if h.authService != nil { + if err := h.authService.RevokeAllUserSessions(c.Request.Context(), subject.UserID); err != nil { + response.ErrorFrom(c, err) + return + } + } profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser) if err != nil { @@ -504,8 +510,12 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity thirdParty := thirdPartyIdentityProviders(identities) var avatarSource *userProfileSourceContext - if strings.TrimSpace(user.AvatarURL) != "" && len(thirdParty) == 1 { - avatarSource = buildUserProfileSourceContext(thirdParty[0].Provider) + avatarValue := strings.TrimSpace(user.AvatarURL) + for _, summary := range thirdParty { + if avatarValue != "" && avatarValue == strings.TrimSpace(summary.DisplayName) { + avatarSource = buildUserProfileSourceContext(summary.Provider) + break + } } usernameValue := strings.TrimSpace(user.Username) @@ -516,9 +526,6 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity break } } - if usernameSource == nil && usernameValue != "" && len(thirdParty) == 1 { - usernameSource = buildUserProfileSourceContext(thirdParty[0].Provider) - } profileSources := map[string]*userProfileSourceContext{} if avatarSource != nil { diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 51d5a814..87e168c0 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -270,18 +270,19 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) { AvatarURL: "https://cdn.example.com/linuxdo.png", AvatarSource: "remote_url", }, - identities: []service.UserAuthIdentityRecord{ - { - ProviderType: "linuxdo", - ProviderKey: "linuxdo", - ProviderSubject: "linuxdo-subject-21", - VerifiedAt: &verifiedAt, - Metadata: map[string]any{ - "username": "linuxdo-handle", + identities: []service.UserAuthIdentityRecord{ + { + ProviderType: "linuxdo", + ProviderKey: "linuxdo", + ProviderSubject: "linuxdo-subject-21", + VerifiedAt: &verifiedAt, + Metadata: map[string]any{ + "username": "linuxdo-handle", + "avatar_url": "https://cdn.example.com/linuxdo.png", + }, }, }, - }, - } + } handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil) recorder := httptest.NewRecorder() @@ -331,10 +332,102 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) { require.Equal(t, "linuxdo", usernameSource["source"]) } +func TestUserHandlerGetProfileDoesNotInferEditedProfileSourcesWithoutMatchingIdentityMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &userHandlerRepoStub{ + user: &service.User{ + ID: 22, + Email: "edited-profile@example.com", + Username: "custom-name", + Role: service.RoleUser, + Status: service.StatusActive, + AvatarURL: "https://cdn.example.com/custom.png", + AvatarSource: "remote_url", + }, + identities: []service.UserAuthIdentityRecord{ + { + ProviderType: "linuxdo", + ProviderKey: "linuxdo", + ProviderSubject: "linuxdo-subject-22", + Metadata: map[string]any{ + "username": "linuxdo-handle", + "avatar_url": "https://cdn.example.com/linuxdo.png", + }, + }, + }, + } + handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 22}) + + handler.GetProfile(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var resp struct { + Code int `json:"code"` + Data map[string]any `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.NotContains(t, resp.Data, "avatar_source") + require.NotContains(t, resp.Data, "username_source") + require.NotContains(t, resp.Data, "profile_sources") +} + type userHandlerEmailCacheStub struct { data *service.VerificationCodeData } +type userHandlerRefreshTokenCacheStub struct { + revokedUserIDs []int64 +} + +func (s *userHandlerRefreshTokenCacheStub) StoreRefreshToken(context.Context, string, *service.RefreshTokenData, time.Duration) error { + return nil +} + +func (s *userHandlerRefreshTokenCacheStub) GetRefreshToken(context.Context, string) (*service.RefreshTokenData, error) { + return nil, service.ErrRefreshTokenNotFound +} + +func (s *userHandlerRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error { + return nil +} + +func (s *userHandlerRefreshTokenCacheStub) DeleteUserRefreshTokens(_ context.Context, userID int64) error { + s.revokedUserIDs = append(s.revokedUserIDs, userID) + return nil +} + +func (s *userHandlerRefreshTokenCacheStub) DeleteTokenFamily(context.Context, string) error { + return nil +} + +func (s *userHandlerRefreshTokenCacheStub) AddToUserTokenSet(context.Context, int64, string, time.Duration) error { + return nil +} + +func (s *userHandlerRefreshTokenCacheStub) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error { + return nil +} + +func (s *userHandlerRefreshTokenCacheStub) GetUserTokenHashes(context.Context, int64) ([]string, error) { + return nil, nil +} + +func (s *userHandlerRefreshTokenCacheStub) GetFamilyTokenHashes(context.Context, string) ([]string, error) { + return nil, nil +} + +func (s *userHandlerRefreshTokenCacheStub) IsTokenInFamily(context.Context, string, string) (bool, error) { + return false, nil +} + func (s *userHandlerEmailCacheStub) GetVerificationCode(context.Context, string) (*service.VerificationCodeData, error) { return s.data, nil } @@ -495,6 +588,52 @@ func TestUserHandlerUnbindIdentityReturnsUpdatedProfile(t *testing.T) { require.Equal(t, false, linuxdoBinding["bound"]) } +func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigured(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &userHandlerRepoStub{ + user: &service.User{ + ID: 23, + Email: "identity@example.com", + Username: "identity-user", + Role: service.RoleUser, + Status: service.StatusActive, + }, + identities: []service.UserAuthIdentityRecord{ + { + ProviderType: "email", + ProviderKey: "email", + ProviderSubject: "identity@example.com", + }, + { + ProviderType: "linuxdo", + ProviderKey: "linuxdo", + ProviderSubject: "linuxdo-subject-23", + }, + }, + } + refreshTokenCache := &userHandlerRefreshTokenCacheStub{} + cfg := &config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret", + ExpireHour: 1, + }, + } + authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), authService, nil, nil) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodDelete, "/api/v1/user/account-bindings/linuxdo", nil) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 23}) + c.Params = gin.Params{{Key: "provider", Value: "linuxdo"}} + + handler.UnbindIdentity(c) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, []int64{23}, refreshTokenCache.revokedUserIDs) +} + func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/repository/user_profile_identity_repo.go b/backend/internal/repository/user_profile_identity_repo.go index 2d812394..87094ad7 100644 --- a/backend/internal/repository/user_profile_identity_repo.go +++ b/backend/internal/repository/user_profile_identity_repo.go @@ -301,17 +301,18 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA client := clientFromContext(txCtx, r.client) canonical := input.Canonical - identity, err := client.AuthIdentity.Query(). + identityRecords, err := client.AuthIdentity.Query(). Where( authidentity.ProviderTypeEQ(strings.TrimSpace(canonical.ProviderType)), - authidentity.ProviderKeyEQ(strings.TrimSpace(canonical.ProviderKey)), + authidentity.ProviderKeyIn(compatibleIdentityProviderKeys(canonical.ProviderType, canonical.ProviderKey)...), authidentity.ProviderSubjectEQ(strings.TrimSpace(canonical.ProviderSubject)), ). - Only(txCtx) - if err != nil && !dbent.IsNotFound(err) { + All(txCtx) + if err != nil { return err } - if identity != nil && identity.UserID != input.UserID { + identity := selectOwnedCompatibleIdentity(identityRecords, input.UserID) + if identity == nil && hasCompatibleIdentityConflict(identityRecords, input.UserID) { return ErrAuthIdentityOwnershipConflict } if identity == nil { @@ -346,20 +347,21 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA var channel *dbent.AuthIdentityChannel if input.Channel != nil { - channel, err = client.AuthIdentityChannel.Query(). + channelRecords, err := client.AuthIdentityChannel.Query(). Where( authidentitychannel.ProviderTypeEQ(strings.TrimSpace(input.Channel.ProviderType)), - authidentitychannel.ProviderKeyEQ(strings.TrimSpace(input.Channel.ProviderKey)), + authidentitychannel.ProviderKeyIn(compatibleIdentityProviderKeys(input.Channel.ProviderType, input.Channel.ProviderKey)...), authidentitychannel.ChannelEQ(strings.TrimSpace(input.Channel.Channel)), authidentitychannel.ChannelAppIDEQ(strings.TrimSpace(input.Channel.ChannelAppID)), authidentitychannel.ChannelSubjectEQ(strings.TrimSpace(input.Channel.ChannelSubject)), ). WithIdentity(). - Only(txCtx) - if err != nil && !dbent.IsNotFound(err) { + All(txCtx) + if err != nil { return err } - if channel != nil && channel.Edges.Identity != nil && channel.Edges.Identity.UserID != input.UserID { + channel = selectOwnedCompatibleChannel(channelRecords, input.UserID) + if channel == nil && hasCompatibleChannelConflict(channelRecords, input.UserID) { return ErrAuthIdentityChannelOwnershipConflict } if channel == nil { @@ -397,6 +399,61 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA return result, nil } +func compatibleIdentityProviderKeys(providerType, providerKey string) []string { + providerType = strings.TrimSpace(strings.ToLower(providerType)) + providerKey = strings.TrimSpace(providerKey) + if providerKey == "" { + return []string{providerKey} + } + if providerType != "wechat" { + return []string{providerKey} + } + keys := []string{providerKey} + if !strings.EqualFold(providerKey, "wechat-main") { + keys = append(keys, "wechat-main") + } + if !strings.EqualFold(providerKey, "wechat") { + keys = append(keys, "wechat") + } + return keys +} + +func selectOwnedCompatibleIdentity(records []*dbent.AuthIdentity, userID int64) *dbent.AuthIdentity { + for _, record := range records { + if record.UserID == userID { + return record + } + } + return nil +} + +func hasCompatibleIdentityConflict(records []*dbent.AuthIdentity, userID int64) bool { + for _, record := range records { + if record.UserID != userID { + return true + } + } + return false +} + +func selectOwnedCompatibleChannel(records []*dbent.AuthIdentityChannel, userID int64) *dbent.AuthIdentityChannel { + for _, record := range records { + if record.Edges.Identity != nil && record.Edges.Identity.UserID == userID { + return record + } + } + return nil +} + +func hasCompatibleChannelConflict(records []*dbent.AuthIdentityChannel, userID int64) bool { + for _, record := range records { + if record.Edges.Identity != nil && record.Edges.Identity.UserID != userID { + return true + } + } + return false +} + func (r *userRepository) RecordProviderGrant(ctx context.Context, input ProviderGrantRecordInput) (bool, error) { exec := txAwareSQLExecutor(ctx, r.sql, r.client) if exec == nil { diff --git a/backend/internal/repository/user_profile_identity_repo_contract_test.go b/backend/internal/repository/user_profile_identity_repo_contract_test.go index 697e96a4..69a25fbe 100644 --- a/backend/internal/repository/user_profile_identity_repo_contract_test.go +++ b/backend/internal/repository/user_profile_identity_repo_contract_test.go @@ -186,6 +186,79 @@ func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_IsIdempotentAn s.Require().ErrorIs(err, ErrAuthIdentityChannelOwnershipConflict) } +func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_ReusesLegacyWeChatAliasRecords() { + user := s.mustCreateUser("wechat-legacy-alias") + + legacyIdentity, err := s.client.AuthIdentity.Create(). + SetUserID(user.ID). + SetProviderType("wechat"). + SetProviderKey("wechat"). + SetProviderSubject("union-legacy-123"). + SetMetadata(map[string]any{"source": "legacy-alias"}). + Save(s.ctx) + s.Require().NoError(err) + + legacyChannel, err := s.client.AuthIdentityChannel.Create(). + SetIdentityID(legacyIdentity.ID). + SetProviderType("wechat"). + SetProviderKey("wechat"). + SetChannel("oa"). + SetChannelAppID("wx-app-legacy"). + SetChannelSubject("openid-legacy-123"). + SetMetadata(map[string]any{"scene": "legacy-alias"}). + Save(s.ctx) + s.Require().NoError(err) + + bound, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{ + UserID: user.ID, + Canonical: AuthIdentityKey{ + ProviderType: "wechat", + ProviderKey: "wechat-main", + ProviderSubject: "union-legacy-123", + }, + Channel: &AuthIdentityChannelKey{ + ProviderType: "wechat", + ProviderKey: "wechat-main", + Channel: "oa", + ChannelAppID: "wx-app-legacy", + ChannelSubject: "openid-legacy-123", + }, + Metadata: map[string]any{"source": "canonical-bind"}, + ChannelMetadata: map[string]any{"scene": "canonical-bind"}, + }) + s.Require().NoError(err) + s.Require().NotNil(bound) + s.Require().NotNil(bound.Identity) + s.Require().NotNil(bound.Channel) + s.Require().Equal(legacyIdentity.ID, bound.Identity.ID) + s.Require().Equal(legacyChannel.ID, bound.Channel.ID) + s.Require().Equal("wechat-main", bound.Identity.ProviderKey) + s.Require().Equal("wechat-main", bound.Channel.ProviderKey) + s.Require().Equal("canonical-bind", bound.Identity.Metadata["source"]) + s.Require().Equal("canonical-bind", bound.Channel.Metadata["scene"]) + + identityCount, err := s.client.AuthIdentity.Query(). + Where( + authidentity.UserIDEQ(user.ID), + authidentity.ProviderTypeEQ("wechat"), + authidentity.ProviderSubjectEQ("union-legacy-123"), + ). + Count(s.ctx) + s.Require().NoError(err) + s.Require().Equal(1, identityCount) + + channelCount, err := s.client.AuthIdentityChannel.Query(). + Where( + authidentitychannel.ProviderTypeEQ("wechat"), + authidentitychannel.ChannelEQ("oa"), + authidentitychannel.ChannelAppIDEQ("wx-app-legacy"), + authidentitychannel.ChannelSubjectEQ("openid-legacy-123"), + ). + Count(s.ctx) + s.Require().NoError(err) + s.Require().Equal(1, channelCount) +} + func (s *UserProfileIdentityRepoSuite) TestCreateAuthIdentity_RejectsChannelProviderMismatch() { user := s.mustCreateUser("provider-mismatch-create") diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index c7d301c7..68e51eeb 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -43,6 +43,9 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error if userIn == nil { return nil } + if err := r.ensureNormalizedEmailAvailable(ctx, 0, userIn.Email); err != nil { + return err + } // 统一使用 ent 的事务:保证用户与允许分组的更新原子化, // 并避免基于 *sql.Tx 手动构造 ent client 导致的 ExecQuerier 断言错误。 @@ -146,6 +149,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error if userIn == nil { return nil } + if err := r.ensureNormalizedEmailAvailable(ctx, userIn.ID, userIn.Email); err != nil { + return err + } // 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。 tx, err := r.client.Tx(ctx) @@ -704,6 +710,21 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) } +func (r *userRepository) ensureNormalizedEmailAvailable(ctx context.Context, userID int64, email string) error { + matches, err := r.client.User.Query(). + Where(userEmailLookupPredicate(email)). + All(ctx) + if err != nil { + return err + } + for _, match := range matches { + if match.ID != userID { + return service.ErrEmailExists + } + } + return nil +} + func userEmailLookupPredicate(email string) predicate.User { normalized := strings.ToLower(strings.TrimSpace(email)) if normalized == "" { diff --git a/backend/internal/repository/user_repo_email_lookup_unit_test.go b/backend/internal/repository/user_repo_email_lookup_unit_test.go index d42ce9ac..b2b02ef5 100644 --- a/backend/internal/repository/user_repo_email_lookup_unit_test.go +++ b/backend/internal/repository/user_repo_email_lookup_unit_test.go @@ -67,3 +67,80 @@ func TestUserRepositoryExistsByEmailNormalizesLegacySpacingAndCase(t *testing.T) require.NoError(t, err) require.True(t, exists) } + +func TestUserRepositoryCreateRejectsNormalizedEmailDuplicate(t *testing.T) { + repo, _ := newUserEntRepo(t) + ctx := context.Background() + + err := repo.Create(ctx, &service.User{ + Email: " Existing@Example.com ", + Username: "existing-user", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + }) + require.NoError(t, err) + + err = repo.Create(ctx, &service.User{ + Email: "existing@example.com", + Username: "duplicate-user", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + }) + require.ErrorIs(t, err, service.ErrEmailExists) +} + +func TestUserRepositoryUpdateRejectsNormalizedEmailDuplicate(t *testing.T) { + repo, _ := newUserEntRepo(t) + ctx := context.Background() + + first := &service.User{ + Email: " Existing@Example.com ", + Username: "existing-user", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + } + require.NoError(t, repo.Create(ctx, first)) + + second := &service.User{ + Email: "second@example.com", + Username: "second-user", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + } + require.NoError(t, repo.Create(ctx, second)) + + second.Email = " existing@example.com " + err := repo.Update(ctx, second) + require.ErrorIs(t, err, service.ErrEmailExists) +} + +func TestUserRepositoryGetByEmailReportsNormalizedEmailConflict(t *testing.T) { + repo, client := newUserEntRepo(t) + ctx := context.Background() + + _, err := client.User.Create(). + SetEmail("Conflict@Example.com"). + SetUsername("conflict-user-1"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + + _, err = client.User.Create(). + SetEmail(" conflict@example.com "). + SetUsername("conflict-user-2"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + + _, err = repo.GetByEmail(ctx, "conflict@example.com") + require.Error(t, err) + require.ErrorContains(t, err, "normalized email lookup matched multiple users") +} diff --git a/backend/internal/service/admin_service_auth_identity_binding_test.go b/backend/internal/service/admin_service_auth_identity_binding_test.go index f8ce3935..719199f2 100644 --- a/backend/internal/service/admin_service_auth_identity_binding_test.go +++ b/backend/internal/service/admin_service_auth_identity_binding_test.go @@ -188,6 +188,93 @@ func TestAdminServiceBindUserAuthIdentityIsIdempotentForSameUser(t *testing.T) { require.Equal(t, "second", identities[0].Metadata["source"]) } +func TestAdminServiceBindUserAuthIdentityReusesLegacyWeChatAliasRecords(t *testing.T) { + client := newAdminServiceAuthIdentityBindingTestClient(t) + ctx := context.Background() + + user, err := client.User.Create(). + SetEmail("wechat-alias@example.com"). + SetPasswordHash("hash"). + SetRole(RoleUser). + SetStatus(StatusActive). + Save(ctx) + require.NoError(t, err) + + legacyIdentity, err := client.AuthIdentity.Create(). + SetUserID(user.ID). + SetProviderType("wechat"). + SetProviderKey("wechat"). + SetProviderSubject("union-legacy-123"). + SetMetadata(map[string]any{"source": "legacy"}). + Save(ctx) + require.NoError(t, err) + + legacyChannel, err := client.AuthIdentityChannel.Create(). + SetIdentityID(legacyIdentity.ID). + SetProviderType("wechat"). + SetProviderKey("wechat"). + SetChannel("open"). + SetChannelAppID("wx-open"). + SetChannelSubject("openid-legacy-123"). + SetMetadata(map[string]any{"scene": "legacy"}). + Save(ctx) + require.NoError(t, err) + + svc := &adminServiceImpl{ + userRepo: &userRepoStub{user: &User{ID: user.ID, Email: user.Email, Status: StatusActive}}, + entClient: client, + } + + result, err := svc.BindUserAuthIdentity(ctx, user.ID, AdminBindAuthIdentityInput{ + ProviderType: "wechat", + ProviderKey: "wechat-main", + ProviderSubject: "union-legacy-123", + Metadata: map[string]any{"source": "admin-repair"}, + Channel: &AdminBindAuthIdentityChannelInput{ + Channel: "open", + ChannelAppID: "wx-open", + ChannelSubject: "openid-legacy-123", + Metadata: map[string]any{"scene": "admin-repair"}, + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "wechat-main", result.ProviderKey) + require.NotNil(t, result.Channel) + require.Equal(t, "open", result.Channel.Channel) + + identity, err := client.AuthIdentity.Get(ctx, legacyIdentity.ID) + require.NoError(t, err) + require.Equal(t, "wechat-main", identity.ProviderKey) + require.Equal(t, "admin-repair", identity.Metadata["source"]) + + channel, err := client.AuthIdentityChannel.Get(ctx, legacyChannel.ID) + require.NoError(t, err) + require.Equal(t, "wechat-main", channel.ProviderKey) + require.Equal(t, legacyIdentity.ID, channel.IdentityID) + require.Equal(t, "admin-repair", channel.Metadata["scene"]) + + identityCount, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("wechat"), + authidentity.ProviderSubjectEQ("union-legacy-123"), + ). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, identityCount) + + channelCount, err := client.AuthIdentityChannel.Query(). + Where( + authidentitychannel.ProviderTypeEQ("wechat"), + authidentitychannel.ChannelEQ("open"), + authidentitychannel.ChannelAppIDEQ("wx-open"), + authidentitychannel.ChannelSubjectEQ("openid-legacy-123"), + ). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, channelCount) +} + func TestAdminServiceBindUserAuthIdentityRejectsInvalidProviderType(t *testing.T) { client := newAdminServiceAuthIdentityBindingTestClient(t) ctx := context.Background() diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 109d459d..0ad95356 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -406,13 +406,15 @@ func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testin }, }, } - svc := NewUserService(repo, nil, nil, nil) + invalidator := &mockAuthCacheInvalidator{} + svc := NewUserService(repo, nil, invalidator, nil) user, err := svc.UnbindUserAuthProvider(context.Background(), 12, "linuxdo") require.NoError(t, err) require.Equal(t, []string{"linuxdo"}, repo.unboundProviders) require.Equal(t, int64(12), user.ID) + require.Equal(t, []int64{12}, invalidator.invalidatedUserIDs) summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 12, user) require.NoError(t, err) diff --git a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue index 48b1b879..8a3af858 100644 --- a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue +++ b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue @@ -444,7 +444,14 @@ function providerIconClass(provider: UserAuthProvider): string { function providerSummary(provider: UserAuthProvider): string { if (provider === 'email') { - return currentUser.value?.email || '' + const email = currentUser.value?.email?.trim() || '' + if (!email) { + return '' + } + if (currentUser.value?.email_bound === false && email.endsWith('.invalid')) { + return '' + } + return email } return '' } diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 99559de5..4544c337 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -40,7 +40,7 @@

- {{ user?.email }} + {{ primaryEmailDisplay }}

props.user?.avatar_url?.trim() || '') const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user')) +const primaryEmailDisplay = computed(() => { + const email = props.user?.email?.trim() || '' + if (!email) { + return '' + } + if (props.user?.email_bound === false && email.endsWith('.invalid')) { + return '' + } + return email +}) const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U') const memberSinceLabel = computed(() => { const raw = props.user?.created_at?.trim() @@ -229,7 +239,7 @@ const memberSinceLabel = computed(() => { const providerLabels = computed>(() => ({ email: t('profile.authBindings.providers.email'), linuxdo: t('profile.authBindings.providers.linuxdo'), - oidc: t('profile.authBindings.providers.oidc', { providerName: 'OIDC' }), + oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }), wechat: t('profile.authBindings.providers.wechat') })) diff --git a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts index 9d8c88d4..77d2219e 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts @@ -335,6 +335,29 @@ describe('ProfileIdentityBindingsSection', () => { expect(wrapper.get('[data-testid="profile-binding-email-input"]').exists()).toBe(true) }) + it('does not show a synthetic oauth-only email as the bound email summary', () => { + const wrapper = mount(ProfileIdentityBindingsSection, { + global: { + plugins: [pinia], + }, + props: { + user: createUser({ + email: 'legacy-user@linuxdo-connect.invalid', + email_bound: false, + auth_bindings: { + email: { bound: false }, + }, + }), + linuxdoEnabled: false, + oidcEnabled: false, + wechatEnabled: false, + }, + }) + + expect(wrapper.text()).not.toContain('legacy-user@linuxdo-connect.invalid') + expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound') + }) + it('keeps the email form available for replacing a bound primary email', async () => { userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined) userApiMocks.bindEmailIdentity.mockResolvedValue( diff --git a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts index 229c27cb..c7e60d9b 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts @@ -111,6 +111,47 @@ describe('ProfileInfoCard', () => { expect(wrapper.text()).toContain('Username synced from LinuxDo') }) + it('uses the configured OIDC provider name in source hints', () => { + const wrapper = mount(ProfileInfoCard, { + props: { + user: createUser({ + profile_sources: { + username: { provider: 'oidc', source: 'oidc' } + } + }), + oidcProviderName: 'ExampleID' + }, + global: { + stubs: { + Icon: true + } + } + }) + + expect(wrapper.text()).toContain('Username synced from ExampleID') + }) + + it('does not display synthetic oauth-only emails as a real bound email', () => { + const wrapper = mount(ProfileInfoCard, { + props: { + user: createUser({ + email: 'legacy-user@oidc-connect.invalid', + email_bound: false, + auth_bindings: { + email: { bound: false } + } + }) + }, + global: { + stubs: { + Icon: true + } + } + }) + + expect(wrapper.text()).not.toContain('legacy-user@oidc-connect.invalid') + }) + it('renders the approved overview hero and two-column content shell', () => { const wrapper = mount(ProfileInfoCard, { props: {