From dafa2ff3e0b78bdebbe5ae9ef40e0f5655e4937b Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 13 Jul 2026 10:49:35 -0500 Subject: [PATCH 1/8] Add admin change member name to v2 UpdateOrganizationUser command --- .../v2/UpdateOrganizationUserCommandTests.cs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index 1998873cb4d1..d99027b063e5 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -305,6 +305,163 @@ await sutProvider.GetDependency() .PushSyncSettingsAsync(Arg.Any()); } + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameChanging_LoadsUserPersistsNameAndPushesSync( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Old Name" }; + var request = Setup(sutProvider, organization, organizationUser, newName: "New Name"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + Assert.Equal("New Name", userToUpdate.Name); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(userToUpdate); + await sutProvider.GetDependency() + .Received(1) + .PushSyncSettingsAsync(userToUpdate.Id); + // A name-only change never touches the email command. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ChangeEmailAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameBlank_ClearsNameToNull( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Old Name" }; + var request = Setup(sutProvider, organization, organizationUser, newName: " "); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + Assert.Null(userToUpdate.Name); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(userToUpdate); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameUnchanged_DoesNotPersistUserOrPushSync( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Same Name" }; + var request = Setup(sutProvider, organization, organizationUser, newName: "Same Name"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(default(User)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncSettingsAsync(default); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameNull_DoesNotLoadUserOrPersistName( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + var request = Setup(sutProvider, organization, organizationUser, newName: null); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetByIdAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(default(User)); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameAndEmailChanging_WritesAccountOnceViaChangeEmailAndPushesOnce( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "old@claimed.example.com", Name = "Old Name" }; + var request = Setup(sutProvider, organization, organizationUser, + newEmail: "new@claimed.example.com", newName: "New Name"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + Assert.Equal("New Name", userToUpdate.Name); + // The email command persists the account (name included); we must not also call ReplaceAsync directly. + await sutProvider.GetDependency() + .Received(1) + .ChangeEmailAsync(userToUpdate, "new@claimed.example.com"); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(default(User)); + await sutProvider.GetDependency() + .Received(1) + .PushSyncSettingsAsync(userToUpdate.Id); + } + + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenNameRequestedButMemberHasNoAccount_SkipsNameChange( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = null; + var request = Setup(sutProvider, organization, organizationUser, newName: "New Name"); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetByIdAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ReplaceAsync(default(User)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncSettingsAsync(default); + } + private static UpdateOrganizationUserRequest Setup( SutProvider sutProvider, Organization organization, From d4d732fdc17a7b54b25ddf205bf7ed993db9a05a Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 14 Jul 2026 16:20:07 -0500 Subject: [PATCH 2/8] feat: [PM-36011] add member email change notification email --- .../MemberEmailChangedNotificationView.cs | 13 +++++ ...emberEmailChangedNotificationView.html.hbs | 51 +++++++++++++++++++ ...emberEmailChangedNotificationView.text.hbs | 7 +++ .../member-email-changed-notification.mjml | 43 ++++++++++++++++ ...MemberEmailChangedNotificationViewTests.cs | 25 +++++++++ 5 files changed, 139 insertions(+) create mode 100644 src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.cs create mode 100644 src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs create mode 100644 src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.text.hbs create mode 100644 src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml create mode 100644 test/Core.Test/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationViewTests.cs diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.cs new file mode 100644 index 000000000000..71043d4a3773 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.cs @@ -0,0 +1,13 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; + +public class MemberEmailChangedNotificationView : BaseMailView +{ + public required string NewEmail { get; set; } +} + +public class MemberEmailChangedNotificationMail : BaseMail +{ + public override string Subject { get; set; } = "Your Bitwarden account email was updated"; +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs new file mode 100644 index 000000000000..bba91b844a64 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs @@ -0,0 +1,51 @@ +

Your Bitwarden account email was updated by an admin

Use the new email to log in to your account.
Your new email
{{NewEmail}}
If this change doesn't seem right, contact your admin.

Learn more about Bitwarden

Find user guides, product documentation, and videos on the Bitwarden Help Center.

© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.text.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.text.hbs new file mode 100644 index 000000000000..acf2c2c15f9e --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.text.hbs @@ -0,0 +1,7 @@ +Your Bitwarden account email was updated by an admin. + +Use the new email to log in to your account. + +Your new email: {{NewEmail}} + +If this change doesn't seem right, contact your admin. diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml new file mode 100644 index 000000000000..0815ce7cf0ed --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + Use the new email to log in to your account. + + + Your new email
+ {{NewEmail}} +
+ + If this change doesn't seem right, contact your admin. + +
+
+
+ + + + + + + + +
+
diff --git a/test/Core.Test/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationViewTests.cs b/test/Core.Test/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationViewTests.cs new file mode 100644 index 000000000000..44d16048298c --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationViewTests.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; + +public class MemberEmailChangedNotificationViewTests +{ + [Fact] + public async Task RenderAsync_PopulatesNewEmailInBothTemplates() + { + var renderer = new HandlebarMailRenderer( + Substitute.For>(), + new GlobalSettings()); + + var (html, text) = await renderer.RenderAsync( + new MemberEmailChangedNotificationView { NewEmail = "new@acme.com" }); + + Assert.Contains("new@acme.com", html); + Assert.Contains("new@acme.com", text); + } +} From 75ef2e92f7ee647240af2233fa3663d1aa1f5d5e Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Wed, 15 Jul 2026 15:09:22 -0500 Subject: [PATCH 3/8] updated with asset --- .../MemberEmailChanged/member-email-changed-notification.mjml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml index 0815ce7cf0ed..7c763f35702f 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml @@ -10,7 +10,7 @@ From 94fde2ebf29626497f532ceffe4af9056d42857f Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Wed, 15 Jul 2026 15:14:34 -0500 Subject: [PATCH 4/8] chore: [PM-36011] rebuild html template for updated asset --- .../MemberEmailChangedNotificationView.html.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs index bba91b844a64..494efb374d1f 100644 --- a/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/MemberEmailChanged/MemberEmailChangedNotificationView.html.hbs @@ -48,4 +48,4 @@ .hide-mobile { display: none !important; } - }

Your Bitwarden account email was updated by an admin

Use the new email to log in to your account.
Your new email
{{NewEmail}}
If this change doesn't seem right, contact your admin.

Learn more about Bitwarden

Find user guides, product documentation, and videos on the Bitwarden Help Center.

© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file + }

Your Bitwarden account email was updated by an admin

Use the new email to log in to your account.
Your new email
{{NewEmail}}
If this change doesn't seem right, contact your admin.

Learn more about Bitwarden

Find user guides, product documentation, and videos on the Bitwarden Help Center.

© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file From de606b0765b7f2263749c8718c0ec3e3c795d893 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 23 Jul 2026 15:00:57 -0500 Subject: [PATCH 5/8] removing todo. --- .../MemberEmailChanged/member-email-changed-notification.mjml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml index 7c763f35702f..1090ecefb7f5 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/MemberEmailChanged/member-email-changed-notification.mjml @@ -7,7 +7,6 @@ - Date: Thu, 23 Jul 2026 15:19:31 -0500 Subject: [PATCH 6/8] Sending the email --- .../v2/UpdateOrganizationUserCommand.cs | 16 ++++++- .../v2/UpdateOrganizationUserCommandTests.cs | 43 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs index cc43ad3518fc..922a89d58f66 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; +using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -9,6 +10,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -31,6 +33,7 @@ public class UpdateOrganizationUserCommand( IUserRepository userRepository, IChangeEmailCommand changeEmailCommand, IPushNotificationService pushNotificationService, + IMailer mailer, TimeProvider timeProvider) : IUpdateOrganizationUserCommand { @@ -107,8 +110,12 @@ await collectionRepository.CreateDefaultCollectionsAsync( if (request.IsEmailChanged()) { + var previousEmail = request.UserToUpdate.Email; + // ChangeEmailAsync persists the account (including any name change above) and syncs Stripe. await changeEmailCommand.ChangeEmailAsync(request.UserToUpdate, request.NewEmail!); + + await SendEmailChangedNotificationAsync(previousEmail, request.NewEmail!); } else { @@ -135,6 +142,13 @@ await collectionRepository.CreateDefaultCollectionsAsync( _ => new EmailChangeFailedError(ex.Message) }; + private async Task SendEmailChangedNotificationAsync(string previousEmail, string newEmail) => + await mailer.SendEmail(new MemberEmailChangedNotificationMail + { + ToEmails = [previousEmail], + View = new MemberEmailChangedNotificationView { NewEmail = newEmail } + }); + private async Task TryEnablingSecretsManagerAsync(UpdateOrganizationUserRequest request) { var organization = request.Organization; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index d99027b063e5..7f4f57b009f6 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.Models.Mail.Mailer.MemberEmailChanged; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2; using Bit.Core.AdminConsole.Utilities.v2.Validation; @@ -8,6 +9,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -56,6 +58,31 @@ await sutProvider.GetDependency() .ReplaceAsync(organizationUser, Arg.Any>()); } + [Theory] + [BitAutoData] + public async Task UpdateUserAsync_WhenEmailChanged_NotifiesMemberAtPreviousEmail( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + organizationUser.UserId = Guid.NewGuid(); + var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "old@claimed.example.com" }; + var request = Setup(sutProvider, organization, organizationUser, newEmail: "new@claimed.example.com"); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.UserId!.Value) + .Returns(userToUpdate); + + var result = await sutProvider.Sut.UpdateUserAsync(request); + + Assert.True(result.IsSuccess); + await sutProvider.GetDependency() + .Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains("old@claimed.example.com") + && mail.View.NewEmail == "new@claimed.example.com")); + } + [Theory] [BitAutoData] public async Task UpdateUserAsync_WhenNoEmailRequested_DoesNotLoadUserOrChangeEmail( @@ -103,6 +130,9 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .PushSyncSettingsAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); } [Theory] @@ -146,6 +176,9 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .PushSyncSettingsAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); } [Theory] @@ -177,6 +210,9 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .ChangeEmailAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); } [Theory] @@ -279,6 +315,11 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) .PushSyncSettingsAsync(userToUpdate.Id); + await sutProvider.GetDependency() + .Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains("old@claimed.example.com") + && mail.View.NewEmail == "new@claimed.example.com")); } [Theory] @@ -490,7 +531,7 @@ private static UpdateOrganizationUserRequest Setup( type, null, targetAccessSecretsManager, - collections, + (new List(), collections), groups, newEmail, newName, From c76d02cdc26e9c3299848f8636341f34392c997f Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 24 Jul 2026 12:20:30 -0500 Subject: [PATCH 7/8] Reabase correction --- .../v2/UpdateOrganizationUserCommandTests.cs | 159 +----------------- 1 file changed, 1 insertion(+), 158 deletions(-) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs index 7f4f57b009f6..61a527ff6c73 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommandTests.cs @@ -346,163 +346,6 @@ await sutProvider.GetDependency() .PushSyncSettingsAsync(Arg.Any()); } - [Theory] - [BitAutoData] - public async Task UpdateUserAsync_WhenNameChanging_LoadsUserPersistsNameAndPushesSync( - SutProvider sutProvider, - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - organizationUser.UserId = Guid.NewGuid(); - var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Old Name" }; - var request = Setup(sutProvider, organization, organizationUser, newName: "New Name"); - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.UserId!.Value) - .Returns(userToUpdate); - - var result = await sutProvider.Sut.UpdateUserAsync(request); - - Assert.True(result.IsSuccess); - Assert.Equal("New Name", userToUpdate.Name); - await sutProvider.GetDependency() - .Received(1) - .ReplaceAsync(userToUpdate); - await sutProvider.GetDependency() - .Received(1) - .PushSyncSettingsAsync(userToUpdate.Id); - // A name-only change never touches the email command. - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .ChangeEmailAsync(default, default); - } - - [Theory] - [BitAutoData] - public async Task UpdateUserAsync_WhenNameBlank_ClearsNameToNull( - SutProvider sutProvider, - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - organizationUser.UserId = Guid.NewGuid(); - var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Old Name" }; - var request = Setup(sutProvider, organization, organizationUser, newName: " "); - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.UserId!.Value) - .Returns(userToUpdate); - - var result = await sutProvider.Sut.UpdateUserAsync(request); - - Assert.True(result.IsSuccess); - Assert.Null(userToUpdate.Name); - await sutProvider.GetDependency() - .Received(1) - .ReplaceAsync(userToUpdate); - } - - [Theory] - [BitAutoData] - public async Task UpdateUserAsync_WhenNameUnchanged_DoesNotPersistUserOrPushSync( - SutProvider sutProvider, - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - organizationUser.UserId = Guid.NewGuid(); - var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "member@claimed.example.com", Name = "Same Name" }; - var request = Setup(sutProvider, organization, organizationUser, newName: "Same Name"); - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.UserId!.Value) - .Returns(userToUpdate); - - var result = await sutProvider.Sut.UpdateUserAsync(request); - - Assert.True(result.IsSuccess); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .ReplaceAsync(default(User)); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncSettingsAsync(default); - } - - [Theory] - [BitAutoData] - public async Task UpdateUserAsync_WhenNameNull_DoesNotLoadUserOrPersistName( - SutProvider sutProvider, - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - var request = Setup(sutProvider, organization, organizationUser, newName: null); - - var result = await sutProvider.Sut.UpdateUserAsync(request); - - Assert.True(result.IsSuccess); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetByIdAsync(default); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .ReplaceAsync(default(User)); - } - - [Theory] - [BitAutoData] - public async Task UpdateUserAsync_WhenNameAndEmailChanging_WritesAccountOnceViaChangeEmailAndPushesOnce( - SutProvider sutProvider, - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - organizationUser.UserId = Guid.NewGuid(); - var userToUpdate = new User { Id = organizationUser.UserId!.Value, Email = "old@claimed.example.com", Name = "Old Name" }; - var request = Setup(sutProvider, organization, organizationUser, - newEmail: "new@claimed.example.com", newName: "New Name"); - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.UserId!.Value) - .Returns(userToUpdate); - - var result = await sutProvider.Sut.UpdateUserAsync(request); - - Assert.True(result.IsSuccess); - Assert.Equal("New Name", userToUpdate.Name); - // The email command persists the account (name included); we must not also call ReplaceAsync directly. - await sutProvider.GetDependency() - .Received(1) - .ChangeEmailAsync(userToUpdate, "new@claimed.example.com"); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .ReplaceAsync(default(User)); - await sutProvider.GetDependency() - .Received(1) - .PushSyncSettingsAsync(userToUpdate.Id); - } - - [Theory] - [BitAutoData] - public async Task UpdateUserAsync_WhenNameRequestedButMemberHasNoAccount_SkipsNameChange( - SutProvider sutProvider, - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) - { - organizationUser.UserId = null; - var request = Setup(sutProvider, organization, organizationUser, newName: "New Name"); - - var result = await sutProvider.Sut.UpdateUserAsync(request); - - Assert.True(result.IsSuccess); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetByIdAsync(default); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .ReplaceAsync(default(User)); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncSettingsAsync(default); - } - private static UpdateOrganizationUserRequest Setup( SutProvider sutProvider, Organization organization, @@ -531,7 +374,7 @@ private static UpdateOrganizationUserRequest Setup( type, null, targetAccessSecretsManager, - (new List(), collections), + collections, groups, newEmail, newName, From b628a981eba45a926997a4012f5d284986a2f51d Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Sat, 25 Jul 2026 10:12:53 -0500 Subject: [PATCH 8/8] wrapped email sending in try catch --- .../v2/UpdateOrganizationUserCommand.cs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs index 922a89d58f66..a14c6614c7c1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateUser/v2/UpdateOrganizationUserCommand.cs @@ -15,6 +15,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Microsoft.Extensions.Logging; using OneOf.Types; using CommandError = Bit.Core.AdminConsole.Utilities.v2.Error; @@ -34,7 +35,8 @@ public class UpdateOrganizationUserCommand( IChangeEmailCommand changeEmailCommand, IPushNotificationService pushNotificationService, IMailer mailer, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILogger logger) : IUpdateOrganizationUserCommand { public async Task UpdateUserAsync(UpdateOrganizationUserRequest request) @@ -115,7 +117,7 @@ await collectionRepository.CreateDefaultCollectionsAsync( // ChangeEmailAsync persists the account (including any name change above) and syncs Stripe. await changeEmailCommand.ChangeEmailAsync(request.UserToUpdate, request.NewEmail!); - await SendEmailChangedNotificationAsync(previousEmail, request.NewEmail!); + await TrySendEmailChangedNotificationAsync(previousEmail, request); } else { @@ -142,12 +144,21 @@ await collectionRepository.CreateDefaultCollectionsAsync( _ => new EmailChangeFailedError(ex.Message) }; - private async Task SendEmailChangedNotificationAsync(string previousEmail, string newEmail) => - await mailer.SendEmail(new MemberEmailChangedNotificationMail + private async Task TrySendEmailChangedNotificationAsync(string previousEmail, UpdateOrganizationUserRequest request) + { + try { - ToEmails = [previousEmail], - View = new MemberEmailChangedNotificationView { NewEmail = newEmail } - }); + await mailer.SendEmail(new MemberEmailChangedNotificationMail + { + ToEmails = [previousEmail], + View = new MemberEmailChangedNotificationView { NewEmail = request.NewEmail! } + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send member email-change notification for organization user {OrganizationUserId}.", request.OrganizationUserToUpdate.Id); + } + } private async Task TryEnablingSecretsManagerAsync(UpdateOrganizationUserRequest request) {