From 5bd2c424aab1e1cbe3e604284c0d2769958a740b Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Fri, 5 Apr 2024 15:50:28 +0100 Subject: [PATCH] [AC-2262] As a Bitwarden Admin, I need a ways to set and update an MSP's minimum seats (#3956) * initial commit Signed-off-by: Cy Okeke * add the feature flag Signed-off-by: Cy Okeke * Add featureflag for create and edit html pages Signed-off-by: Cy Okeke --------- Signed-off-by: Cy Okeke --- .../Providers/CreateProviderCommand.cs | 46 +++++++++++++++++-- .../CreateProviderCommandTests.cs | 4 +- .../Controllers/ProvidersController.cs | 35 ++++++++++++-- .../Models/CreateProviderModel.cs | 16 +++++++ .../AdminConsole/Models/ProviderEditModel.cs | 33 ++++++++++++- .../Views/Providers/Create.cshtml | 19 ++++++++ .../AdminConsole/Views/Providers/Edit.cshtml | 19 ++++++++ .../Interfaces/ICreateProviderCommand.cs | 2 +- 8 files changed, 162 insertions(+), 12 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 738723a81..720317578 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -1,10 +1,15 @@ -using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Repositories; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Services; namespace Bit.Commercial.Core.AdminConsole.Providers; @@ -14,21 +19,28 @@ public class CreateProviderCommand : ICreateProviderCommand private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderService _providerService; private readonly IUserRepository _userRepository; + private readonly IProviderPlanRepository _providerPlanRepository; + private readonly IFeatureService _featureService; public CreateProviderCommand( IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderService providerService, - IUserRepository userRepository) + IUserRepository userRepository, + IProviderPlanRepository providerPlanRepository, + IFeatureService featureService) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; _providerService = providerService; _userRepository = userRepository; + _providerPlanRepository = providerPlanRepository; + _featureService = featureService; } - public async Task CreateMspAsync(Provider provider, string ownerEmail) + public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats) { + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); var owner = await _userRepository.GetByEmailAsync(ownerEmail); if (owner == null) { @@ -44,8 +56,24 @@ public class CreateProviderCommand : ICreateProviderCommand Type = ProviderUserType.ProviderAdmin, Status = ProviderUserStatusType.Confirmed, }; + + if (isConsolidatedBillingEnabled) + { + var providerPlans = new List + { + CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats), + CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats) + }; + + foreach (var providerPlan in providerPlans) + { + await _providerPlanRepository.CreateAsync(providerPlan); + } + } + await _providerUserRepository.CreateAsync(providerUser); await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email); + } public async Task CreateResellerAsync(Provider provider) @@ -60,4 +88,16 @@ public class CreateProviderCommand : ICreateProviderCommand provider.UseEvents = true; await _providerRepository.CreateAsync(provider); } + + private ProviderPlan CreateProviderPlan(Guid providerId, PlanType planType, int seatMinimum) + { + return new ProviderPlan + { + ProviderId = providerId, + PlanType = planType, + SeatMinimum = seatMinimum, + PurchasedSeats = 0, + AllocatedSeats = 0 + }; + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs index 399ed6ea1..787d5a17b 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs @@ -22,7 +22,7 @@ public class CreateProviderCommandTests provider.Type = ProviderType.Msp; var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CreateMspAsync(provider, default)); + () => sutProvider.Sut.CreateMspAsync(provider, default, default, default)); Assert.Contains("Invalid owner.", exception.Message); } @@ -34,7 +34,7 @@ public class CreateProviderCommandTests var userRepository = sutProvider.GetDependency(); userRepository.GetByEmailAsync(user.Email).Returns(user); - await sutProvider.Sut.CreateMspAsync(provider, user.Email); + await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default); await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 47631829e..59b4ef658 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -8,6 +8,8 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Repositories; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -34,6 +36,7 @@ public class ProvidersController : Controller private readonly IUserService _userService; private readonly ICreateProviderCommand _createProviderCommand; private readonly IFeatureService _featureService; + private readonly IProviderPlanRepository _providerPlanRepository; public ProvidersController( IOrganizationRepository organizationRepository, @@ -47,7 +50,8 @@ public class ProvidersController : Controller IReferenceEventService referenceEventService, IUserService userService, ICreateProviderCommand createProviderCommand, - IFeatureService featureService) + IFeatureService featureService, + IProviderPlanRepository providerPlanRepository) { _organizationRepository = organizationRepository; _organizationService = organizationService; @@ -61,6 +65,7 @@ public class ProvidersController : Controller _userService = userService; _createProviderCommand = createProviderCommand; _featureService = featureService; + _providerPlanRepository = providerPlanRepository; } [RequirePermission(Permission.Provider_List_View)] @@ -90,11 +95,13 @@ public class ProvidersController : Controller }); } - public IActionResult Create(string ownerEmail = null) + public IActionResult Create(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null) { return View(new CreateProviderModel { - OwnerEmail = ownerEmail + OwnerEmail = ownerEmail, + TeamsMinimumSeats = teamsMinimumSeats, + EnterpriseMinimumSeats = enterpriseMinimumSeats }); } @@ -112,7 +119,8 @@ public class ProvidersController : Controller switch (provider.Type) { case ProviderType.Msp: - await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail); + await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail, model.TeamsMinimumSeats, + model.EnterpriseMinimumSeats); break; case ProviderType.Reseller: await _createProviderCommand.CreateResellerAsync(provider); @@ -139,6 +147,7 @@ public class ProvidersController : Controller [SelfHosted(NotSelfHostedOnly = true)] public async Task Edit(Guid id) { + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); var provider = await _providerRepository.GetByIdAsync(id); if (provider == null) { @@ -147,7 +156,12 @@ public class ProvidersController : Controller var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id); var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id); - return View(new ProviderEditModel(provider, users, providerOrganizations)); + if (isConsolidatedBillingEnabled) + { + var providerPlan = await _providerPlanRepository.GetByProviderId(id); + return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlan)); + } + return View(new ProviderEditModel(provider, users, providerOrganizations, new List())); } [HttpPost] @@ -156,6 +170,8 @@ public class ProvidersController : Controller [RequirePermission(Permission.Provider_Edit)] public async Task Edit(Guid id, ProviderEditModel model) { + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + var providerPlans = await _providerPlanRepository.GetByProviderId(id); var provider = await _providerRepository.GetByIdAsync(id); if (provider == null) { @@ -165,6 +181,15 @@ public class ProvidersController : Controller model.ToProvider(provider); await _providerRepository.ReplaceAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); + if (isConsolidatedBillingEnabled) + { + model.ToProviderPlan(providerPlans); + foreach (var providerPlan in providerPlans) + { + await _providerPlanRepository.ReplaceAsync(providerPlan); + } + } + return RedirectToAction("Edit", new { id }); } diff --git a/src/Admin/AdminConsole/Models/CreateProviderModel.cs b/src/Admin/AdminConsole/Models/CreateProviderModel.cs index 7efd34feb..2efbbb54f 100644 --- a/src/Admin/AdminConsole/Models/CreateProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateProviderModel.cs @@ -24,6 +24,12 @@ public class CreateProviderModel : IValidatableObject [Display(Name = "Primary Billing Email")] public string BillingEmail { get; set; } + [Display(Name = "Teams minimum seats")] + public int TeamsMinimumSeats { get; set; } + + [Display(Name = "Enterprise minimum seats")] + public int EnterpriseMinimumSeats { get; set; } + public virtual Provider ToProvider() { return new Provider() @@ -45,6 +51,16 @@ public class CreateProviderModel : IValidatableObject var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); } + if (TeamsMinimumSeats < 0) + { + var teamsMinimumSeatsDisplayName = nameof(TeamsMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(TeamsMinimumSeats); + yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative."); + } + if (EnterpriseMinimumSeats < 0) + { + var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMinimumSeats); + yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative."); + } break; case ProviderType.Reseller: if (string.IsNullOrWhiteSpace(Name)) diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 7480a24b3..1055d0cba 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Billing.Entities; +using Bit.Core.Enums; namespace Bit.Admin.AdminConsole.Models; @@ -8,13 +10,16 @@ public class ProviderEditModel : ProviderViewModel { public ProviderEditModel() { } - public ProviderEditModel(Provider provider, IEnumerable providerUsers, IEnumerable organizations) + public ProviderEditModel(Provider provider, IEnumerable providerUsers, + IEnumerable organizations, IEnumerable providerPlans) : base(provider, providerUsers, organizations) { Name = provider.DisplayName(); BusinessName = provider.DisplayBusinessName(); BillingEmail = provider.BillingEmail; BillingPhone = provider.BillingPhone; + TeamsMinimumSeats = GetMinimumSeats(providerPlans, PlanType.TeamsMonthly); + EnterpriseMinimumSeats = GetMinimumSeats(providerPlans, PlanType.EnterpriseMonthly); } [Display(Name = "Billing Email")] @@ -24,12 +29,38 @@ public class ProviderEditModel : ProviderViewModel [Display(Name = "Business Name")] public string BusinessName { get; set; } public string Name { get; set; } + [Display(Name = "Teams minimum seats")] + public int TeamsMinimumSeats { get; set; } + + [Display(Name = "Enterprise minimum seats")] + public int EnterpriseMinimumSeats { get; set; } [Display(Name = "Events")] + public IEnumerable ToProviderPlan(IEnumerable existingProviderPlans) + { + var providerPlans = existingProviderPlans.ToList(); + foreach (var existingProviderPlan in providerPlans) + { + existingProviderPlan.SeatMinimum = existingProviderPlan.PlanType switch + { + PlanType.TeamsMonthly => TeamsMinimumSeats, + PlanType.EnterpriseMonthly => EnterpriseMinimumSeats, + _ => existingProviderPlan.SeatMinimum + }; + } + return providerPlans; + } + public Provider ToProvider(Provider existingProvider) { existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant()?.Trim(); return existingProvider; } + + + private int GetMinimumSeats(IEnumerable providerPlans, PlanType planType) + { + return (from providerPlan in providerPlans where providerPlan.PlanType == planType select (int)providerPlan.SeatMinimum).FirstOrDefault(); + } } diff --git a/src/Admin/AdminConsole/Views/Providers/Create.cshtml b/src/Admin/AdminConsole/Views/Providers/Create.cshtml index 2e69da3ad..7b10de372 100644 --- a/src/Admin/AdminConsole/Views/Providers/Create.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Create.cshtml @@ -1,6 +1,8 @@ @using Bit.SharedWeb.Utilities @using Bit.Core.AdminConsole.Enums.Provider +@using Bit.Core @model CreateProviderModel +@inject Bit.Core.Services.IFeatureService FeatureService @{ ViewData["Title"] = "Create Provider"; } @@ -39,6 +41,23 @@ + @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + { +
+
+
+ + +
+
+
+
+ + +
+
+
+ }
diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index cca0a2af2..2f652aaac 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -1,5 +1,7 @@ @using Bit.Admin.Enums; +@using Bit.Core @inject Bit.Admin.Services.IAccessControlService AccessControlService +@inject Bit.Core.Services.IFeatureService FeatureService @model ProviderEditModel @{ @@ -41,6 +43,23 @@
+ @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + { +
+
+
+ + +
+
+
+
+ + +
+
+
+ } @await Html.PartialAsync("Organizations", Model) @if (canEdit) diff --git a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs index 93b3e387a..800ec1405 100644 --- a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs +++ b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs @@ -4,6 +4,6 @@ namespace Bit.Core.AdminConsole.Providers.Interfaces; public interface ICreateProviderCommand { - Task CreateMspAsync(Provider provider, string ownerEmail); + Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats); Task CreateResellerAsync(Provider provider); }