Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<MemberEmailChangedNotificationView>
{
public override string Subject { get; set; } = "Your Bitwarden account email was updated";
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,10 +10,12 @@
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;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using CommandError = Bit.Core.AdminConsole.Utilities.v2.Error;

Expand All @@ -31,7 +34,9 @@ public class UpdateOrganizationUserCommand(
IUserRepository userRepository,
IChangeEmailCommand changeEmailCommand,
IPushNotificationService pushNotificationService,
TimeProvider timeProvider)
IMailer mailer,
TimeProvider timeProvider,
ILogger<UpdateOrganizationUserCommand> logger)
: IUpdateOrganizationUserCommand
{
public async Task<CommandResult> UpdateUserAsync(UpdateOrganizationUserRequest request)
Expand Down Expand Up @@ -107,8 +112,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 TrySendEmailChangedNotificationAsync(previousEmail, request);
}
else
{
Expand All @@ -135,6 +144,22 @@ await collectionRepository.CreateDefaultCollectionsAsync(
_ => new EmailChangeFailedError(ex.Message)
};

private async Task TrySendEmailChangedNotificationAsync(string previousEmail, UpdateOrganizationUserRequest request)
{
try
{
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);
}
Comment on lines +157 to +160
}

private async Task<CommandError?> TryEnablingSecretsManagerAsync(UpdateOrganizationUserRequest request)
{
var organization = request.Organization;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml" />
<mj-include path="../components/admin-console-head.mjml" />
</mj-head>

<mj-body>
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-ac-hero
title="Your Bitwarden account email was updated by an admin"
img-src="https://assets.bitwarden.com/email/v1/spot-enterprise-gear.svg"
/>
</mj-wrapper>

<!-- Main Content -->
<mj-wrapper padding="5px 20px 8px 20px">
<mj-section background-color="#fff" padding="10px 10px 16px 10px">
<mj-column>
<mj-text font-size="16px" line-height="24px" padding="15px 15px 0px 15px">
Use the new email to log in to your account.
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="16px 15px 0px 15px">
<b>Your new email</b><br />
{{NewEmail}}
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="16px 15px 0px 15px">
If this change doesn't seem right, contact your admin.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>

<!-- Learn More Section -->
<mj-wrapper padding="8px 20px 10px 20px">
<mj-bw-ac-learn-more-footer />
</mj-wrapper>

<!-- Footer -->
<mj-include path="../../../components/footer.mjml" />
</mj-body>
</mjml>
Original file line number Diff line number Diff line change
@@ -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<ILogger<HandlebarMailRenderer>>(),
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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -56,6 +58,31 @@ await sutProvider.GetDependency<IOrganizationUserRepository>()
.ReplaceAsync(organizationUser, Arg.Any<IEnumerable<CollectionAccessSelection>>());
}

[Theory]
[BitAutoData]
public async Task UpdateUserAsync_WhenEmailChanged_NotifiesMemberAtPreviousEmail(
SutProvider<UpdateOrganizationUserCommand> 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<IUserRepository>()
.GetByIdAsync(organizationUser.UserId!.Value)
.Returns(userToUpdate);

var result = await sutProvider.Sut.UpdateUserAsync(request);

Assert.True(result.IsSuccess);
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<MemberEmailChangedNotificationMail>(mail =>
mail.ToEmails.Contains("old@claimed.example.com")
&& mail.View.NewEmail == "new@claimed.example.com"));
}

[Theory]
[BitAutoData]
public async Task UpdateUserAsync_WhenNoEmailRequested_DoesNotLoadUserOrChangeEmail(
Expand Down Expand Up @@ -103,6 +130,9 @@ await sutProvider.GetDependency<IChangeEmailCommand>()
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncSettingsAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<MemberEmailChangedNotificationView>(default);
}

[Theory]
Expand Down Expand Up @@ -146,6 +176,9 @@ await sutProvider.GetDependency<IEventService>()
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncSettingsAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<MemberEmailChangedNotificationView>(default);
}

[Theory]
Expand Down Expand Up @@ -177,6 +210,9 @@ await sutProvider.GetDependency<IPushNotificationService>()
await sutProvider.GetDependency<IChangeEmailCommand>()
.DidNotReceiveWithAnyArgs()
.ChangeEmailAsync(Arg.Any<User>(), Arg.Any<string>());
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<MemberEmailChangedNotificationView>(default);
}

[Theory]
Expand Down Expand Up @@ -279,6 +315,11 @@ await sutProvider.GetDependency<IUserRepository>()
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushSyncSettingsAsync(userToUpdate.Id);
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<MemberEmailChangedNotificationMail>(mail =>
mail.ToEmails.Contains("old@claimed.example.com")
&& mail.View.NewEmail == "new@claimed.example.com"));
}

[Theory]
Expand Down
Loading