Skip to content
53 changes: 52 additions & 1 deletion src/Api/AdminConsole/Public/Controllers/MembersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,28 @@
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.Commands;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;

namespace Bit.Api.AdminConsole.Public.Controllers;

Expand All @@ -36,6 +44,10 @@ public class MembersController : Controller
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IFeatureService _featureService;
private readonly IInviteOrganizationUsersCommand _inviteOrganizationUsersCommand;
private readonly IPricingClient _pricingClient;
private readonly TimeProvider _timeProvider;

public MembersController(
IOrganizationUserRepository organizationUserRepository,
Expand All @@ -50,7 +62,11 @@ public MembersController(
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IFeatureService featureService,
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
IPricingClient pricingClient,
TimeProvider timeProvider)
{
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
Expand All @@ -65,6 +81,10 @@ public MembersController(
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_featureService = featureService;
_inviteOrganizationUsersCommand = inviteOrganizationUsersCommand;
_pricingClient = pricingClient;
_timeProvider = timeProvider;
}

/// <summary>
Expand Down Expand Up @@ -156,6 +176,10 @@ public async Task<IActionResult> Post([FromBody] MemberCreateRequestModel model)
}

var invite = model.ToOrganizationUserInvite();
if (_featureService.IsEnabled(FeatureFlagKeys.PublicMembersInviteRefactor))
{
return await PostInviteUserAsync_vNext(model, organization!, hasStandaloneSecretsManager);
}

invite.AccessSecretsManager = hasStandaloneSecretsManager;

Expand All @@ -165,6 +189,33 @@ public async Task<IActionResult> Post([FromBody] MemberCreateRequestModel model)
return new JsonResult(response);
}

private async Task<IActionResult> PostInviteUserAsync_vNext(
MemberCreateRequestModel model,
Core.AdminConsole.Entities.Organization organization,
bool hasStandaloneSecretsManager)
{
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var inviteOrganization = new InviteOrganization(organization, plan);
var request = model.ToInviteRequest(inviteOrganization, hasStandaloneSecretsManager, Guid.Empty, _timeProvider.GetUtcNow());

var result = await _inviteOrganizationUsersCommand.InviteImportedOrganizationUsersAsync(request);

switch (result)
{
case Success<InviteOrganizationUsersResponse> success:
var user = success.Value.InvitedUsers.First();
var collections = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
var response = new MemberResponseModel(user, collections);
return new JsonResult(response);
case Failure<InviteOrganizationUsersResponse> { Error.Message: NoUsersToInviteError.Code }:
throw new BadRequestException("This user has already been invited.");
case Failure<InviteOrganizationUsersResponse> failure:
throw MapToBitException(failure.Error);
default:
throw new InvalidOperationException();
}
}

/// <summary>
/// Update a member.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
#nullable disable

using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;

namespace Bit.Api.AdminConsole.Public.Models.Request;
Expand Down Expand Up @@ -43,4 +46,32 @@ public OrganizationUserInvite ToOrganizationUserInvite()

return invite;
}

public InviteOrganizationUsersRequest ToInviteRequest(
InviteOrganization inviteOrganization,
bool accessSecretsManager,
Guid performedBy,
DateTimeOffset performedAt)
{
// Permissions property is optional for backwards compatibility with existing usage
var permissions = (Type is OrganizationUserType.Custom && Permissions is not null)
? Permissions.ToData()
: new Permissions();

return new InviteOrganizationUsersRequest(
invites:
[
new OrganizationUserInviteCommandModel(
email: Email,
assignedCollections: Collections?.Select(c => c.ToCollectionAccessSelection()) ?? [],
groups: Groups ?? [],
type: Type!.Value,
permissions: permissions,
externalId: ExternalId,
accessSecretsManager: accessSecretsManager)
],
inviteOrganization: inviteOrganization,
performedBy: performedBy,
performedAt: performedAt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(IE

private async Task<string> GetInviterEmailAsync(Guid? invitingUserId)
{
if (!invitingUserId.HasValue)
if (!invitingUserId.HasValue || invitingUserId.Value == Guid.Empty)
{
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ public class UpdateOrganizationSubscriptionCommand(
{
private static readonly List<string> _validSubscriptionStatusesForUpdate =
[
SubscriptionStatus.Trialing, SubscriptionStatus.Active, SubscriptionStatus.PastDue
SubscriptionStatus.Trialing,
SubscriptionStatus.Active,
SubscriptionStatus.PastDue
];

private readonly ILogger<UpdateOrganizationSubscriptionCommand> _logger = logger;
Expand Down
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ public static class FeatureFlagKeys
public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance";
public const string BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements";
public const string RefactorOrgAcceptInit = "pm-33082-refactor-org-accept-init";
public const string PublicMembersInviteRefactor = "pm-33398-refactor-members-invite-org-users-command";

/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;

namespace Bit.Api.IntegrationTest.AdminConsole.Public.Controllers;
Expand All @@ -28,6 +31,7 @@ public class MembersControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
public MembersControllerTests(ApiApplicationFactory factory)
{
_factory = factory;
_factory.SubstituteService<IFeatureService>(_ => { });
_client = factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}
Expand Down Expand Up @@ -398,4 +402,80 @@ public async Task Restore_DifferentOrganization_ReturnsNotFound()

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task Post_CustomMember_WithPublicMembersInviteRefactor_Success()
{
var featureService = _factory.GetService<IFeatureService>();
featureService
.IsEnabled(FeatureFlagKeys.PublicMembersInviteRefactor)
.Returns(true);

var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
var request = new MemberCreateRequestModel
{
Email = email,
Type = OrganizationUserType.Custom,
ExternalId = "myCustomUser",
Collections = [],
Groups = []
};

var response = await _client.PostAsync("/public/members", JsonContent.Create(request));

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<MemberResponseModel>();
Assert.NotNull(result);

Assert.Equal(email, result.Email);
Assert.Equal(OrganizationUserType.Custom, result.Type);
Assert.Equal("myCustomUser", result.ExternalId);
Assert.Empty(result.Collections);

var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
var orgUser = await organizationUserRepository.GetByIdAsync(result.Id);

Assert.NotNull(orgUser);
Assert.Equal(email, orgUser.Email);
Assert.Equal(OrganizationUserType.Custom, orgUser.Type);
Assert.Equal("myCustomUser", orgUser.ExternalId);
Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status);
Assert.Equal(_organization.Id, orgUser.OrganizationId);
}

[Fact]
public async Task Post_UserMember_WithPublicMembersInviteRefactor_Success()
{
var featureService = _factory.GetService<IFeatureService>();
featureService
.IsEnabled(FeatureFlagKeys.PublicMembersInviteRefactor)
.Returns(true);

var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
var request = new MemberCreateRequestModel
{
Email = email,
Type = OrganizationUserType.User,
Collections = [],
Groups = []
};

var response = await _client.PostAsync("/public/members", JsonContent.Create(request));

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<MemberResponseModel>();
Assert.NotNull(result);

Assert.Equal(email, result.Email);
Assert.Equal(OrganizationUserType.User, result.Type);

var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
var orgUser = await organizationUserRepository.GetByIdAsync(result.Id);

Assert.NotNull(orgUser);
Assert.Equal(email, orgUser.Email);
Assert.Equal(OrganizationUserType.User, orgUser.Type);
Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status);
Assert.Equal(_organization.Id, orgUser.OrganizationId);
}
}
Loading