diff --git a/bitwarden_license/src/Commercial.Core/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/Providers/CreateProviderCommand.cs new file mode 100644 index 0000000000..36b6fe85c8 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/Providers/CreateProviderCommand.cs @@ -0,0 +1,62 @@ +using Bit.Core.Entities.Provider; +using Bit.Core.Enums.Provider; +using Bit.Core.Exceptions; +using Bit.Core.Providers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Commercial.Core.Providers; + +public class CreateProviderCommand : ICreateProviderCommand +{ + private readonly IProviderRepository _providerRepository; + private readonly IProviderUserRepository _providerUserRepository; + private readonly IProviderService _providerService; + private readonly IUserRepository _userRepository; + + public CreateProviderCommand( + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IProviderService providerService, + IUserRepository userRepository) + { + _providerRepository = providerRepository; + _providerUserRepository = providerUserRepository; + _providerService = providerService; + _userRepository = userRepository; + } + + public async Task CreateMspAsync(Provider provider, string ownerEmail) + { + var owner = await _userRepository.GetByEmailAsync(ownerEmail); + if (owner == null) + { + throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user."); + } + + await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending); + + var providerUser = new ProviderUser + { + ProviderId = provider.Id, + UserId = owner.Id, + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Confirmed, + }; + await _providerUserRepository.CreateAsync(providerUser); + await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email); + } + + public async Task CreateResellerAsync(Provider provider) + { + await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created); + } + + private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status) + { + provider.Status = status; + provider.Enabled = true; + provider.UseEvents = true; + await _providerRepository.CreateAsync(provider); + } +} diff --git a/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs index cf3b68efa5..3a6e47f240 100644 --- a/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs @@ -53,33 +53,6 @@ public class ProviderService : IProviderService _currentContext = currentContext; } - public async Task CreateAsync(string ownerEmail) - { - var owner = await _userRepository.GetByEmailAsync(ownerEmail); - if (owner == null) - { - throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user."); - } - - var provider = new Provider - { - Status = ProviderStatusType.Pending, - Enabled = true, - UseEvents = true, - }; - await _providerRepository.CreateAsync(provider); - - var providerUser = new ProviderUser - { - ProviderId = provider.Id, - UserId = owner.Id, - Type = ProviderUserType.ProviderAdmin, - Status = ProviderUserStatusType.Confirmed, - }; - await _providerUserRepository.CreateAsync(providerUser); - await SendProviderSetupInviteEmailAsync(provider, owner.Email); - } - public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key) { var owner = await _userService.GetUserByIdAsync(ownerUserId); @@ -456,7 +429,7 @@ public class ProviderService : IProviderService await SendProviderSetupInviteEmailAsync(provider, owner.Email); } - private async Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail) + public async Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail) { var token = _dataProtector.Protect($"ProviderSetupInvite {provider.Id} {ownerEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); await _mailService.SendProviderSetupInviteEmailAsync(provider, token, ownerEmail); diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 6440f27ab2..b9d21915bf 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ -using Bit.Commercial.Core.Services; +using Bit.Commercial.Core.Providers; +using Bit.Commercial.Core.Services; +using Bit.Core.Providers.Interfaces; using Bit.Core.Services; using Microsoft.Extensions.DependencyInjection; @@ -9,5 +11,6 @@ public static class ServiceCollectionExtensions public static void AddCommercialCoreServices(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/ProviderFeatures/CreateProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/ProviderFeatures/CreateProviderCommandTests.cs new file mode 100644 index 0000000000..721ed24d9c --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/ProviderFeatures/CreateProviderCommandTests.cs @@ -0,0 +1,52 @@ +using Bit.Commercial.Core.Providers; +using Bit.Core.Entities; +using Bit.Core.Entities.Provider; +using Bit.Core.Enums.Provider; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Core.Test.ProviderFeatures; + +[SutProviderCustomize] +public class CreateProviderCommandTests +{ + [Theory, BitAutoData] + public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider sutProvider) + { + provider.Type = ProviderType.Msp; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateMspAsync(provider, default)); + Assert.Contains("Invalid owner.", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider sutProvider) + { + provider.Type = ProviderType.Msp; + + var userRepository = sutProvider.GetDependency(); + userRepository.GetByEmailAsync(user.Email).Returns(user); + + await sutProvider.Sut.CreateMspAsync(provider, user.Email); + + await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(default); + await sutProvider.GetDependency().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); + } + + [Theory, BitAutoData] + public async Task CreateResellerAsync_Success(Provider provider, SutProvider sutProvider) + { + provider.Type = ProviderType.Reseller; + + await sutProvider.Sut.CreateResellerAsync(provider); + + await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default); + } +} diff --git a/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs index 52da691864..12c7dcf222 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.DataProtection; using NSubstitute; using NSubstitute.ReturnsExtensions; using Xunit; +using Provider = Bit.Core.Entities.Provider.Provider; using ProviderUser = Bit.Core.Entities.Provider.ProviderUser; namespace Bit.Commercial.Core.Test.Services; @@ -24,26 +25,6 @@ namespace Bit.Commercial.Core.Test.Services; [SutProviderCustomize] public class ProviderServiceTests { - [Theory, BitAutoData] - public async Task CreateAsync_UserIdIsInvalid_Throws(SutProvider sutProvider) - { - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CreateAsync(default)); - Assert.Contains("Invalid owner.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CreateAsync_Success(User user, SutProvider sutProvider) - { - var userRepository = sutProvider.GetDependency(); - userRepository.GetByEmailAsync(user.Email).Returns(user); - - await sutProvider.Sut.CreateAsync(user.Email); - - await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(default); - await sutProvider.GetDependency().ReceivedWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default, default); - } - [Theory, BitAutoData] public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider sutProvider) { @@ -229,6 +210,14 @@ public class ProviderServiceTests Assert.True(result.All(r => r.Item2 == "")); } + [Theory, BitAutoData] + public async Task SendProviderSetupInviteEmailAsync_Success(Provider provider, string email, SutProvider sutProvider) + { + await sutProvider.Sut.SendProviderSetupInviteEmailAsync(provider, email); + + await sutProvider.GetDependency().Received(1).SendProviderSetupInviteEmailAsync(provider, Arg.Any(), email); + } + [Theory, BitAutoData] public async Task AcceptUserAsync_UserIsInvalid_Throws(SutProvider sutProvider) { diff --git a/src/Admin/Controllers/ProvidersController.cs b/src/Admin/Controllers/ProvidersController.cs index a141b9fd02..8d89115f54 100644 --- a/src/Admin/Controllers/ProvidersController.cs +++ b/src/Admin/Controllers/ProvidersController.cs @@ -1,5 +1,7 @@ using Bit.Admin.Models; using Bit.Core.Entities.Provider; +using Bit.Core.Enums.Provider; +using Bit.Core.Providers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -19,10 +21,16 @@ public class ProvidersController : Controller private readonly GlobalSettings _globalSettings; private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderService _providerService; + private readonly ICreateProviderCommand _createProviderCommand; - public ProvidersController(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, - IProviderOrganizationRepository providerOrganizationRepository, IProviderService providerService, - GlobalSettings globalSettings, IApplicationCacheService applicationCacheService) + public ProvidersController( + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IProviderOrganizationRepository providerOrganizationRepository, + IProviderService providerService, + GlobalSettings globalSettings, + IApplicationCacheService applicationCacheService, + ICreateProviderCommand createProviderCommand) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -30,6 +38,7 @@ public class ProvidersController : Controller _providerService = providerService; _globalSettings = globalSettings; _applicationCacheService = applicationCacheService; + _createProviderCommand = createProviderCommand; } public async Task Index(string name = null, string userEmail = null, int page = 1, int count = 25) @@ -75,9 +84,18 @@ public class ProvidersController : Controller return View(model); } - await _providerService.CreateAsync(model.OwnerEmail); + var provider = model.ToProvider(); + switch (provider.Type) + { + case ProviderType.Msp: + await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail); + break; + case ProviderType.Reseller: + await _createProviderCommand.CreateResellerAsync(provider); + break; + } - return RedirectToAction("Index"); + return RedirectToAction("Edit", new { id = provider.Id }); } public async Task View(Guid id) diff --git a/src/Admin/Models/CreateProviderModel.cs b/src/Admin/Models/CreateProviderModel.cs index 9bcbf1f75b..a9a2f4ac43 100644 --- a/src/Admin/Models/CreateProviderModel.cs +++ b/src/Admin/Models/CreateProviderModel.cs @@ -1,12 +1,59 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities.Provider; +using Bit.Core.Enums.Provider; +using Bit.SharedWeb.Utilities; namespace Bit.Admin.Models; -public class CreateProviderModel +public class CreateProviderModel : IValidatableObject { public CreateProviderModel() { } + [Display(Name = "Provider Type")] + public ProviderType Type { get; set; } + [Display(Name = "Owner Email")] - [Required] public string OwnerEmail { get; set; } + + [Display(Name = "Business Name")] + public string BusinessName { get; set; } + + [Display(Name = "Primary Billing Email")] + public string BillingEmail { get; set; } + + public virtual Provider ToProvider() + { + return new Provider() + { + Type = Type, + BusinessName = BusinessName, + BillingEmail = BillingEmail?.ToLowerInvariant().Trim() + }; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + switch (Type) + { + case ProviderType.Msp: + if (string.IsNullOrWhiteSpace(OwnerEmail)) + { + var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName(); + yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); + } + break; + case ProviderType.Reseller: + if (string.IsNullOrWhiteSpace(BusinessName)) + { + var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute()?.GetName(); + yield return new ValidationResult($"The {businessNameDisplayName} field is required."); + } + if (string.IsNullOrWhiteSpace(BillingEmail)) + { + var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute()?.GetName(); + yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); + } + break; + } + } } diff --git a/src/Admin/Views/Providers/Create.cshtml b/src/Admin/Views/Providers/Create.cshtml index 0369ca0179..2263da06e1 100644 --- a/src/Admin/Views/Providers/Create.cshtml +++ b/src/Admin/Views/Providers/Create.cshtml @@ -1,16 +1,55 @@ -@model CreateProviderModel +@using Bit.SharedWeb.Utilities +@model CreateProviderModel @{ ViewData["Title"] = "Create Provider"; } +@section Scripts { + +} +

Create Provider

- - + + @foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType))) + { + var providerTypeValue = (int)providerType; +
+ @Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input", onclick=$"toggleProviderTypeInfo({providerTypeValue})" }) + @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" }) +
+ @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @for = $"providerType-{providerTypeValue}" }) +
+ } +
+ +
+

MSP Info

+
+ + +
+
+ +
+

Reseller Info

+
+ + +
+
+ + +
diff --git a/src/Admin/Views/Providers/Index.cshtml b/src/Admin/Views/Providers/Index.cshtml index b1d9a7a6b2..f7fedb9232 100644 --- a/src/Admin/Views/Providers/Index.cshtml +++ b/src/Admin/Views/Providers/Index.cshtml @@ -1,4 +1,5 @@ -@model ProvidersModel +@using Bit.SharedWeb.Utilities +@model ProvidersModel @{ ViewData["Title"] = "Providers"; } @@ -25,6 +26,7 @@ Name + Provider Type Status Created @@ -44,6 +46,7 @@ @(provider.Name ?? "Pending") + @provider.Type.GetDisplayAttribute()?.GetShortName() @provider.Status diff --git a/src/Core/Enums/Provider/ProviderType.cs b/src/Core/Enums/Provider/ProviderType.cs index 8cced3b585..020f39eba1 100644 --- a/src/Core/Enums/Provider/ProviderType.cs +++ b/src/Core/Enums/Provider/ProviderType.cs @@ -1,7 +1,11 @@ -namespace Bit.Core.Enums.Provider; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Enums.Provider; public enum ProviderType : byte { + [Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization")] Msp = 0, + [Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing")] Reseller = 1, } diff --git a/src/Core/Providers/Interfaces/ICreateProviderCommand.cs b/src/Core/Providers/Interfaces/ICreateProviderCommand.cs new file mode 100644 index 0000000000..4121b15335 --- /dev/null +++ b/src/Core/Providers/Interfaces/ICreateProviderCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Entities.Provider; + +namespace Bit.Core.Providers.Interfaces; + +public interface ICreateProviderCommand +{ + Task CreateMspAsync(Provider provider, string ownerEmail); + Task CreateResellerAsync(Provider provider); +} diff --git a/src/Core/Services/IProviderService.cs b/src/Core/Services/IProviderService.cs index c5cf039b28..00426c2e72 100644 --- a/src/Core/Services/IProviderService.cs +++ b/src/Core/Services/IProviderService.cs @@ -7,7 +7,6 @@ namespace Bit.Core.Services; public interface IProviderService { - Task CreateAsync(string ownerEmail); Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key); Task UpdateAsync(Provider provider, bool updateBilling = false); @@ -26,5 +25,6 @@ public interface IProviderService Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId); Task LogProviderAccessToOrganizationAsync(Guid organizationId); Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId); + Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail); } diff --git a/src/Core/Services/NoopImplementations/NoopProviderService.cs b/src/Core/Services/NoopImplementations/NoopProviderService.cs index 478c5c6c10..c197ced47d 100644 --- a/src/Core/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/Services/NoopImplementations/NoopProviderService.cs @@ -7,8 +7,6 @@ namespace Bit.Core.Services; public class NoopProviderService : IProviderService { - public Task CreateAsync(string ownerEmail) => throw new NotImplementedException(); - public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key) => throw new NotImplementedException(); public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException(); @@ -34,4 +32,5 @@ public class NoopProviderService : IProviderService public Task LogProviderAccessToOrganizationAsync(Guid organizationId) => throw new NotImplementedException(); public Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid userId) => throw new NotImplementedException(); + public Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail) => throw new NotImplementedException(); } diff --git a/src/SharedWeb/Utilities/DisplayAttributeHelpers.cs b/src/SharedWeb/Utilities/DisplayAttributeHelpers.cs new file mode 100644 index 0000000000..4a48a2d164 --- /dev/null +++ b/src/SharedWeb/Utilities/DisplayAttributeHelpers.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace Bit.SharedWeb.Utilities; + +public static class DisplayAttributeHelpers +{ + public static DisplayAttribute GetDisplayAttribute(this Enum enumValue) + { + return enumValue.GetType() + .GetMember(enumValue.ToString()) + .First() + .GetCustomAttribute(); + } + + public static DisplayAttribute GetDisplayAttribute(this string property) + { + MemberInfo propertyInfo = typeof(T).GetProperty(property); + return propertyInfo?.GetCustomAttribute(typeof(DisplayAttribute)) as DisplayAttribute; + } +}