Skip to content

Commit f7642f5

Browse files
feat(change-email): Added bulk edit.
1 parent 05681ca commit f7642f5

6 files changed

Lines changed: 214 additions & 1 deletion

File tree

src/Api/AdminConsole/Controllers/OrganizationUsersController.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
8585
private readonly AccountRecoveryV2.IAdminRecoverAccountCommand _adminRecoverAccountCommandV2;
8686
private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand;
8787
private readonly IChangeEmailForPasswordlessOrgUserCommand _changeEmailForPasswordlessOrgUserCommand;
88+
private readonly IBulkChangeEmailForPasswordlessOrgUserCommand _bulkChangeEmailForPasswordlessOrgUserCommand;
8889

8990
public OrganizationUsersController(IOrganizationRepository organizationRepository,
9091
IOrganizationUserRepository organizationUserRepository,
@@ -118,7 +119,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
118119
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
119120
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext,
120121
ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand,
121-
IChangeEmailForPasswordlessOrgUserCommand changeEmailForPasswordlessOrgUserCommand)
122+
IChangeEmailForPasswordlessOrgUserCommand changeEmailForPasswordlessOrgUserCommand,
123+
IBulkChangeEmailForPasswordlessOrgUserCommand bulkChangeEmailForPasswordlessOrgUserCommand)
122124
{
123125
_organizationRepository = organizationRepository;
124126
_organizationUserRepository = organizationUserRepository;
@@ -153,6 +155,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
153155
_adminRecoverAccountCommandV2 = adminRecoverAccountCommandV2;
154156
_selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand;
155157
_changeEmailForPasswordlessOrgUserCommand = changeEmailForPasswordlessOrgUserCommand;
158+
_bulkChangeEmailForPasswordlessOrgUserCommand = bulkChangeEmailForPasswordlessOrgUserCommand;
156159
}
157160

158161
[HttpGet("{id}")]
@@ -577,6 +580,20 @@ public async Task<IResult> ChangeEmailForPasswordlessUser(
577580
return TypedResults.NoContent();
578581
}
579582

583+
[HttpPost("bulk-change-email-for-passwordless-user")]
584+
[Authorize<ManageUsersRequirement>]
585+
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkChangeEmailForPasswordlessUser(
586+
Guid orgId,
587+
[FromBody] OrganizationUserBulkChangeEmailRequestModel model)
588+
{
589+
var requests = model.Requests.Select(r => (r.Id, r.NewEmail));
590+
var results = await _bulkChangeEmailForPasswordlessOrgUserCommand
591+
.BulkChangeOrganizationUserEmailAsync(orgId, requests);
592+
593+
return new ListResponseModel<OrganizationUserBulkResponseModel>(
594+
results.Select(r => new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
595+
}
596+
580597
[HttpDelete("{id}")]
581598
[Authorize<ManageUsersRequirement>]
582599
public async Task Remove(Guid orgId, Guid id)

src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,20 @@ public class OrganizationUserChangeEmailRequestModel
141141
[StringLength(256)]
142142
public string NewEmail { get; set; }
143143
}
144+
145+
public class OrganizationUserBulkChangeEmailRequestModelEntry
146+
{
147+
[Required]
148+
public Guid Id { get; set; }
149+
150+
[Required]
151+
[EmailAddress]
152+
[StringLength(256)]
153+
public string NewEmail { get; set; }
154+
}
155+
156+
public class OrganizationUserBulkChangeEmailRequestModel
157+
{
158+
[Required, MinLength(1)]
159+
public IEnumerable<OrganizationUserBulkChangeEmailRequestModelEntry> Requests { get; set; }
160+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
2+
using Bit.Core.Exceptions;
3+
using Bit.Core.Repositories;
4+
5+
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
6+
7+
public class BulkChangeEmailForPasswordlessOrgUserCommand : IBulkChangeEmailForPasswordlessOrgUserCommand
8+
{
9+
private readonly IOrganizationUserRepository _organizationUserRepository;
10+
private readonly IChangeEmailForPasswordlessOrgUserCommand _changeEmailForPasswordlessOrgUserCommand;
11+
12+
public BulkChangeEmailForPasswordlessOrgUserCommand(
13+
IOrganizationUserRepository organizationUserRepository,
14+
IChangeEmailForPasswordlessOrgUserCommand changeEmailForPasswordlessOrgUserCommand)
15+
{
16+
_organizationUserRepository = organizationUserRepository;
17+
_changeEmailForPasswordlessOrgUserCommand = changeEmailForPasswordlessOrgUserCommand;
18+
}
19+
20+
public async Task<IEnumerable<(Guid OrganizationUserId, string ErrorMessage)>> BulkChangeOrganizationUserEmailAsync(
21+
Guid organizationId,
22+
IEnumerable<(Guid OrganizationUserId, string NewEmail)> requests)
23+
{
24+
var results = new List<(Guid OrganizationUserId, string ErrorMessage)>();
25+
26+
foreach (var (organizationUserId, newEmail) in requests)
27+
{
28+
try
29+
{
30+
var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
31+
if (organizationUser == null || organizationUser.OrganizationId != organizationId)
32+
{
33+
throw new NotFoundException();
34+
}
35+
36+
await _changeEmailForPasswordlessOrgUserCommand.ChangeOrganizationUserEmailAsync(
37+
organizationId, organizationUser, newEmail);
38+
39+
results.Add((organizationUserId, string.Empty));
40+
}
41+
catch (Exception e)
42+
{
43+
results.Add((organizationUserId, e.Message));
44+
}
45+
}
46+
47+
return results;
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
2+
3+
public interface IBulkChangeEmailForPasswordlessOrgUserCommand
4+
{
5+
Task<IEnumerable<(Guid OrganizationUserId, string ErrorMessage)>> BulkChangeOrganizationUserEmailAsync(
6+
Guid organizationId,
7+
IEnumerable<(Guid OrganizationUserId, string NewEmail)> requests);
8+
}

src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ private static void AddOrganizationUserCommands(this IServiceCollection services
164164

165165
services.AddScoped<ISelfRevokeOrganizationUserCommand, SelfRevokeOrganizationUserCommand>();
166166
services.AddScoped<IChangeEmailForPasswordlessOrgUserCommand, ChangeEmailForPasswordlessOrgUserCommand>();
167+
services.AddScoped<IBulkChangeEmailForPasswordlessOrgUserCommand, BulkChangeEmailForPasswordlessOrgUserCommand>();
167168
}
168169

169170
private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
2+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
3+
using Bit.Core.Entities;
4+
using Bit.Core.Exceptions;
5+
using Bit.Core.Repositories;
6+
using Bit.Test.Common.AutoFixture;
7+
using Bit.Test.Common.AutoFixture.Attributes;
8+
using NSubstitute;
9+
using NSubstitute.ExceptionExtensions;
10+
using Xunit;
11+
12+
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers;
13+
14+
[SutProviderCustomize]
15+
public class BulkChangeEmailForPasswordlessOrgUserCommandTests
16+
{
17+
[Theory, BitAutoData]
18+
public async Task BulkChangeOrganizationUserEmailAsync_AllSucceed_ReturnsEmptyErrors(
19+
Guid organizationId,
20+
OrganizationUser orgUser1,
21+
OrganizationUser orgUser2,
22+
SutProvider<BulkChangeEmailForPasswordlessOrgUserCommand> sutProvider)
23+
{
24+
orgUser1.OrganizationId = organizationId;
25+
orgUser2.OrganizationId = organizationId;
26+
27+
sutProvider.GetDependency<IOrganizationUserRepository>()
28+
.GetByIdAsync(orgUser1.Id).Returns(orgUser1);
29+
sutProvider.GetDependency<IOrganizationUserRepository>()
30+
.GetByIdAsync(orgUser2.Id).Returns(orgUser2);
31+
32+
var requests = new[]
33+
{
34+
(orgUser1.Id, "user1@example.com"),
35+
(orgUser2.Id, "user2@example.com"),
36+
};
37+
38+
var results = (await sutProvider.Sut.BulkChangeOrganizationUserEmailAsync(organizationId, requests)).ToList();
39+
40+
Assert.Equal(2, results.Count);
41+
Assert.All(results, r => Assert.Equal(string.Empty, r.ErrorMessage));
42+
await sutProvider.GetDependency<IChangeEmailForPasswordlessOrgUserCommand>()
43+
.Received(2)
44+
.ChangeOrganizationUserEmailAsync(organizationId, Arg.Any<OrganizationUser>(), Arg.Any<string>());
45+
}
46+
47+
[Theory, BitAutoData]
48+
public async Task BulkChangeOrganizationUserEmailAsync_PartialFailure_ReturnsPerItemErrors(
49+
Guid organizationId,
50+
OrganizationUser orgUser1,
51+
OrganizationUser orgUser2,
52+
SutProvider<BulkChangeEmailForPasswordlessOrgUserCommand> sutProvider)
53+
{
54+
orgUser1.OrganizationId = organizationId;
55+
orgUser2.OrganizationId = organizationId;
56+
57+
sutProvider.GetDependency<IOrganizationUserRepository>()
58+
.GetByIdAsync(orgUser1.Id).Returns(orgUser1);
59+
sutProvider.GetDependency<IOrganizationUserRepository>()
60+
.GetByIdAsync(orgUser2.Id).Returns(orgUser2);
61+
62+
sutProvider.GetDependency<IChangeEmailForPasswordlessOrgUserCommand>()
63+
.ChangeOrganizationUserEmailAsync(organizationId, orgUser1, Arg.Any<string>())
64+
.ThrowsAsync(new BadRequestException("User has a master password."));
65+
66+
var requests = new[]
67+
{
68+
(orgUser1.Id, "user1@example.com"),
69+
(orgUser2.Id, "user2@example.com"),
70+
};
71+
72+
var results = (await sutProvider.Sut.BulkChangeOrganizationUserEmailAsync(organizationId, requests)).ToList();
73+
74+
Assert.Equal(2, results.Count);
75+
Assert.Equal("User has a master password.", results[0].ErrorMessage);
76+
Assert.Equal(string.Empty, results[1].ErrorMessage);
77+
}
78+
79+
[Theory, BitAutoData]
80+
public async Task BulkChangeOrganizationUserEmailAsync_OrgUserNotFound_ReturnsError(
81+
Guid organizationId,
82+
Guid unknownOrgUserId,
83+
SutProvider<BulkChangeEmailForPasswordlessOrgUserCommand> sutProvider)
84+
{
85+
sutProvider.GetDependency<IOrganizationUserRepository>()
86+
.GetByIdAsync(unknownOrgUserId).Returns((OrganizationUser)null);
87+
88+
var requests = new[] { (unknownOrgUserId, "user@example.com") };
89+
90+
var results = (await sutProvider.Sut.BulkChangeOrganizationUserEmailAsync(organizationId, requests)).ToList();
91+
92+
Assert.Single(results);
93+
Assert.NotEmpty(results[0].ErrorMessage);
94+
await sutProvider.GetDependency<IChangeEmailForPasswordlessOrgUserCommand>()
95+
.DidNotReceive()
96+
.ChangeOrganizationUserEmailAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUser>(), Arg.Any<string>());
97+
}
98+
99+
[Theory, BitAutoData]
100+
public async Task BulkChangeOrganizationUserEmailAsync_OrgUserBelongsToDifferentOrg_ReturnsError(
101+
Guid organizationId,
102+
OrganizationUser orgUserFromOtherOrg,
103+
SutProvider<BulkChangeEmailForPasswordlessOrgUserCommand> sutProvider)
104+
{
105+
// OrganizationId on the fetched user does not match the requested org.
106+
orgUserFromOtherOrg.OrganizationId = Guid.NewGuid();
107+
108+
sutProvider.GetDependency<IOrganizationUserRepository>()
109+
.GetByIdAsync(orgUserFromOtherOrg.Id).Returns(orgUserFromOtherOrg);
110+
111+
var requests = new[] { (orgUserFromOtherOrg.Id, "user@example.com") };
112+
113+
var results = (await sutProvider.Sut.BulkChangeOrganizationUserEmailAsync(organizationId, requests)).ToList();
114+
115+
Assert.Single(results);
116+
Assert.NotEmpty(results[0].ErrorMessage);
117+
await sutProvider.GetDependency<IChangeEmailForPasswordlessOrgUserCommand>()
118+
.DidNotReceive()
119+
.ChangeOrganizationUserEmailAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUser>(), Arg.Any<string>());
120+
}
121+
}

0 commit comments

Comments
 (0)