From a952d106374d838b1b526b3289a8d152240c1904 Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Wed, 23 Oct 2024 18:10:50 +0200 Subject: [PATCH] [PM-13447] Add Multi Org Enterprise providers to Admin Console (#4920) --- .../Providers/CreateProviderCommand.cs | 37 ++- .../CreateProviderCommandTests.cs | 49 ++++ .../Controllers/ProvidersController.cs | 106 +++++++- .../Models/CreateMspProviderModel.cs | 45 ++++ ...ultiOrganizationEnterpriseProviderModel.cs | 47 ++++ .../Models/CreateProviderModel.cs | 80 +----- .../Models/CreateResellerProviderModel.cs | 48 ++++ .../Views/Providers/Create.cshtml | 80 ++---- .../Views/Providers/CreateMsp.cshtml | 39 +++ .../CreateMultiOrganizationEnterprise.cshtml | 43 +++ .../Views/Providers/CreateReseller.cshtml | 25 ++ src/Admin/Enums/HtmlHelperExtensions.cs | 19 ++ .../Enums/Provider/ProviderType.cs | 6 +- .../Interfaces/ICreateProviderCommand.cs | 2 + src/Core/Constants.cs | 1 + .../Controllers/ProvidersControllerTests.cs | 251 ++++++++++++++++++ 16 files changed, 717 insertions(+), 161 deletions(-) create mode 100644 src/Admin/AdminConsole/Models/CreateMspProviderModel.cs create mode 100644 src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs create mode 100644 src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs create mode 100644 src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml create mode 100644 src/Admin/AdminConsole/Views/Providers/CreateMultiOrganizationEnterprise.cshtml create mode 100644 src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml create mode 100644 src/Admin/Enums/HtmlHelperExtensions.cs create mode 100644 test/Admin.Test/AdminConsole/Controllers/ProvidersControllerTests.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 09157d72c..d192073d4 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -40,6 +40,32 @@ public class CreateProviderCommand : ICreateProviderCommand } public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats) + { + var providerPlans = new List + { + CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats), + CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats) + }; + + await CreateProviderAsync(provider, ownerEmail, providerPlans); + } + + public async Task CreateResellerAsync(Provider provider) + { + await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created); + } + + public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats) + { + var providerPlans = new List + { + CreateProviderPlan(provider.Id, plan, minimumSeats) + }; + + await CreateProviderAsync(provider, ownerEmail, providerPlans); + } + + private async Task CreateProviderAsync(Provider provider, string ownerEmail, List providerPlans) { var owner = await _userRepository.GetByEmailAsync(ownerEmail); if (owner == null) @@ -66,12 +92,6 @@ public class CreateProviderCommand : ICreateProviderCommand 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); @@ -82,11 +102,6 @@ public class CreateProviderCommand : ICreateProviderCommand 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; 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 787d5a17b..e354e4417 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -19,23 +20,30 @@ public class CreateProviderCommandTests [Theory, BitAutoData] public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider sutProvider) { + // Arrange provider.Type = ProviderType.Msp; + // Act var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.CreateMspAsync(provider, default, default, default)); + + // Assert Assert.Contains("Invalid owner.", exception.Message); } [Theory, BitAutoData] public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider sutProvider) { + // Arrange provider.Type = ProviderType.Msp; var userRepository = sutProvider.GetDependency(); userRepository.GetByEmailAsync(user.Email).Returns(user); + // Act await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default); + // Assert await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); } @@ -43,11 +51,52 @@ public class CreateProviderCommandTests [Theory, BitAutoData] public async Task CreateResellerAsync_Success(Provider provider, SutProvider sutProvider) { + // Arrange provider.Type = ProviderType.Reseller; + // Act await sutProvider.Sut.CreateResellerAsync(provider); + // Assert await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default); } + + [Theory, BitAutoData] + public async Task CreateMultiOrganizationEnterpriseAsync_Success( + Provider provider, + User user, + PlanType plan, + int minimumSeats, + SutProvider sutProvider) + { + // Arrange + provider.Type = ProviderType.MultiOrganizationEnterprise; + + var userRepository = sutProvider.GetDependency(); + userRepository.GetByEmailAsync(user.Email).Returns(user); + + // Act + await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats); + + // Assert + await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(provider); + await sutProvider.GetDependency().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); + } + + [Theory, BitAutoData] + public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Type = ProviderType.Msp; + + // Act + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default)); + + // Assert + Assert.Contains("Invalid owner.", exception.Message); + } } diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 12e2c4d43..a7c49b214 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -107,9 +107,15 @@ public class ProvidersController : Controller }); } - public IActionResult Create(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null) + public IActionResult Create() { - return View(new CreateProviderModel + return View(new CreateProviderModel()); + } + + [HttpGet("providers/create/msp")] + public IActionResult CreateMsp(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null) + { + return View(new CreateMspProviderModel { OwnerEmail = ownerEmail, TeamsMonthlySeatMinimum = teamsMinimumSeats, @@ -117,10 +123,50 @@ public class ProvidersController : Controller }); } + [HttpGet("providers/create/reseller")] + public IActionResult CreateReseller() + { + return View(new CreateResellerProviderModel()); + } + + [HttpGet("providers/create/multi-organization-enterprise")] + public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises)) + { + return RedirectToAction("Create"); + } + + return View(new CreateMultiOrganizationEnterpriseProviderModel + { + OwnerEmail = ownerEmail, + EnterpriseSeatMinimum = enterpriseMinimumSeats + }); + } + [HttpPost] [ValidateAntiForgeryToken] [RequirePermission(Permission.Provider_Create)] - public async Task Create(CreateProviderModel model) + public IActionResult Create(CreateProviderModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + return model.Type switch + { + ProviderType.Msp => RedirectToAction("CreateMsp"), + ProviderType.Reseller => RedirectToAction("CreateReseller"), + ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"), + _ => View(model) + }; + } + + [HttpPost("providers/create/msp")] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Provider_Create)] + public async Task CreateMsp(CreateMspProviderModel model) { if (!ModelState.IsValid) { @@ -128,19 +174,51 @@ public class ProvidersController : Controller } var provider = model.ToProvider(); - switch (provider.Type) + + await _createProviderCommand.CreateMspAsync( + provider, + model.OwnerEmail, + model.TeamsMonthlySeatMinimum, + model.EnterpriseMonthlySeatMinimum); + + return RedirectToAction("Edit", new { id = provider.Id }); + } + + [HttpPost("providers/create/reseller")] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Provider_Create)] + public async Task CreateReseller(CreateResellerProviderModel model) + { + if (!ModelState.IsValid) { - case ProviderType.Msp: - await _createProviderCommand.CreateMspAsync( - provider, - model.OwnerEmail, - model.TeamsMonthlySeatMinimum, - model.EnterpriseMonthlySeatMinimum); - break; - case ProviderType.Reseller: - await _createProviderCommand.CreateResellerAsync(provider); - break; + return View(model); } + var provider = model.ToProvider(); + await _createProviderCommand.CreateResellerAsync(provider); + + return RedirectToAction("Edit", new { id = provider.Id }); + } + + [HttpPost("providers/create/multi-organization-enterprise")] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Provider_Create)] + public async Task CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + var provider = model.ToProvider(); + + if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises)) + { + return RedirectToAction("Create"); + } + await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync( + provider, + model.OwnerEmail, + model.Plan.Value, + model.EnterpriseSeatMinimum); return RedirectToAction("Edit", new { id = provider.Id }); } diff --git a/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs b/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs new file mode 100644 index 000000000..f48cf2176 --- /dev/null +++ b/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.SharedWeb.Utilities; + +namespace Bit.Admin.AdminConsole.Models; + +public class CreateMspProviderModel : IValidatableObject +{ + [Display(Name = "Owner Email")] + public string OwnerEmail { get; set; } + + [Display(Name = "Teams (Monthly) Seat Minimum")] + public int TeamsMonthlySeatMinimum { get; set; } + + [Display(Name = "Enterprise (Monthly) Seat Minimum")] + public int EnterpriseMonthlySeatMinimum { get; set; } + + public virtual Provider ToProvider() + { + return new Provider + { + Type = ProviderType.Msp + }; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(OwnerEmail)) + { + var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); + yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); + } + if (TeamsMonthlySeatMinimum < 0) + { + var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(TeamsMonthlySeatMinimum); + yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative."); + } + if (EnterpriseMonthlySeatMinimum < 0) + { + var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum); + yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative."); + } + } +} diff --git a/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs b/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs new file mode 100644 index 000000000..ef7210a9e --- /dev/null +++ b/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Enums; +using Bit.SharedWeb.Utilities; + +namespace Bit.Admin.AdminConsole.Models; + +public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject +{ + [Display(Name = "Owner Email")] + public string OwnerEmail { get; set; } + + [Display(Name = "Enterprise Seat Minimum")] + public int EnterpriseSeatMinimum { get; set; } + + [Display(Name = "Plan")] + [Required] + public PlanType? Plan { get; set; } + + public virtual Provider ToProvider() + { + return new Provider + { + Type = ProviderType.MultiOrganizationEnterprise + }; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(OwnerEmail)) + { + var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); + yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); + } + if (EnterpriseSeatMinimum < 0) + { + var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseSeatMinimum); + yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative."); + } + if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly) + { + var planDisplayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); + yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly."); + } + } +} diff --git a/src/Admin/AdminConsole/Models/CreateProviderModel.cs b/src/Admin/AdminConsole/Models/CreateProviderModel.cs index 07bb1b6e4..da73787a9 100644 --- a/src/Admin/AdminConsole/Models/CreateProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateProviderModel.cs @@ -1,84 +1,8 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums.Provider; -using Bit.SharedWeb.Utilities; +using Bit.Core.AdminConsole.Enums.Provider; namespace Bit.Admin.AdminConsole.Models; -public class CreateProviderModel : IValidatableObject +public class CreateProviderModel { - public CreateProviderModel() { } - - [Display(Name = "Provider Type")] public ProviderType Type { get; set; } - - [Display(Name = "Owner Email")] - public string OwnerEmail { get; set; } - - [Display(Name = "Name")] - public string Name { get; set; } - - [Display(Name = "Business Name")] - public string BusinessName { get; set; } - - [Display(Name = "Primary Billing Email")] - public string BillingEmail { get; set; } - - [Display(Name = "Teams (Monthly) Seat Minimum")] - public int TeamsMonthlySeatMinimum { get; set; } - - [Display(Name = "Enterprise (Monthly) Seat Minimum")] - public int EnterpriseMonthlySeatMinimum { get; set; } - - public virtual Provider ToProvider() - { - return new Provider() - { - Type = Type, - Name = Name, - 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() ?? nameof(OwnerEmail); - yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); - } - if (TeamsMonthlySeatMinimum < 0) - { - var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(TeamsMonthlySeatMinimum); - yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative."); - } - if (EnterpriseMonthlySeatMinimum < 0) - { - var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum); - yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative."); - } - break; - case ProviderType.Reseller: - if (string.IsNullOrWhiteSpace(Name)) - { - var nameDisplayName = nameof(Name).GetDisplayAttribute()?.GetName() ?? nameof(Name); - yield return new ValidationResult($"The {nameDisplayName} field is required."); - } - if (string.IsNullOrWhiteSpace(BusinessName)) - { - var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute()?.GetName() ?? nameof(BusinessName); - yield return new ValidationResult($"The {businessNameDisplayName} field is required."); - } - if (string.IsNullOrWhiteSpace(BillingEmail)) - { - var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute()?.GetName() ?? nameof(BillingEmail); - yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); - } - break; - } - } } diff --git a/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs b/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs new file mode 100644 index 000000000..958faf3f8 --- /dev/null +++ b/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.SharedWeb.Utilities; + +namespace Bit.Admin.AdminConsole.Models; + +public class CreateResellerProviderModel : IValidatableObject +{ + [Display(Name = "Name")] + public string Name { 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 + { + Name = Name, + BusinessName = BusinessName, + BillingEmail = BillingEmail?.ToLowerInvariant().Trim(), + Type = ProviderType.Reseller + }; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Name)) + { + var nameDisplayName = nameof(Name).GetDisplayAttribute()?.GetName() ?? nameof(Name); + yield return new ValidationResult($"The {nameDisplayName} field is required."); + } + if (string.IsNullOrWhiteSpace(BusinessName)) + { + var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute()?.GetName() ?? nameof(BusinessName); + yield return new ValidationResult($"The {businessNameDisplayName} field is required."); + } + if (string.IsNullOrWhiteSpace(BillingEmail)) + { + var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute()?.GetName() ?? nameof(BillingEmail); + yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); + } + } +} diff --git a/src/Admin/AdminConsole/Views/Providers/Create.cshtml b/src/Admin/AdminConsole/Views/Providers/Create.cshtml index 41855895e..8f43a4f85 100644 --- a/src/Admin/AdminConsole/Views/Providers/Create.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Create.cshtml @@ -1,80 +1,48 @@ @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"; -} -@section Scripts { - + var providerTypes = Enum.GetValues() + .OrderBy(x => x.GetDisplayAttribute().Order) + .ToList(); + + if (!FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises)) + { + providerTypes.Remove(ProviderType.MultiOrganizationEnterprise); + } }

Create Provider

- -
+
-
- @foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType))) + @foreach (var providerType in providerTypes) { 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

-
- - -
- @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) - { -
-
-
- - +
+
+
+
+ @Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" }) + @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 align-top", @for = $"providerType-{providerTypeValue}" })
}
- -
-

Reseller Info

-
- - -
-
- - -
-
- - -
-
- - + diff --git a/src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml b/src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml new file mode 100644 index 000000000..dde62b58a --- /dev/null +++ b/src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml @@ -0,0 +1,39 @@ +@using Bit.Core.AdminConsole.Enums.Provider +@using Bit.Core + +@model CreateMspProviderModel + +@inject Bit.Core.Services.IFeatureService FeatureService + +@{ + ViewData["Title"] = "Create Managed Service Provider"; +} + +

Create Managed Service Provider

+
+
+
+
+ + +
+ @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + { +
+
+
+ + +
+
+
+
+ + +
+
+
+ } + +
+
diff --git a/src/Admin/AdminConsole/Views/Providers/CreateMultiOrganizationEnterprise.cshtml b/src/Admin/AdminConsole/Views/Providers/CreateMultiOrganizationEnterprise.cshtml new file mode 100644 index 000000000..997fa32ef --- /dev/null +++ b/src/Admin/AdminConsole/Views/Providers/CreateMultiOrganizationEnterprise.cshtml @@ -0,0 +1,43 @@ +@using Bit.Core.Billing.Enums +@using Microsoft.AspNetCore.Mvc.TagHelpers + +@model CreateMultiOrganizationEnterpriseProviderModel + +@{ + ViewData["Title"] = "Create Multi-organization Enterprise Provider"; +} + +

Create Multi-organization Enterprise Provider

+
+
+
+
+ + +
+
+
+
+ @{ + var multiOrgPlans = new List + { + PlanType.EnterpriseAnnually, + PlanType.EnterpriseMonthly + }; + } + + +
+
+
+
+ + +
+
+
+ +
+
diff --git a/src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml b/src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml new file mode 100644 index 000000000..320ff7a4b --- /dev/null +++ b/src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml @@ -0,0 +1,25 @@ +@model CreateResellerProviderModel + +@{ + ViewData["Title"] = "Create Reseller Provider"; +} + +

Create Reseller Provider

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
diff --git a/src/Admin/Enums/HtmlHelperExtensions.cs b/src/Admin/Enums/HtmlHelperExtensions.cs new file mode 100644 index 000000000..a5fb89303 --- /dev/null +++ b/src/Admin/Enums/HtmlHelperExtensions.cs @@ -0,0 +1,19 @@ + +using Bit.SharedWeb.Utilities; + +// ReSharper disable once CheckNamespace +namespace Microsoft.AspNetCore.Mvc.Rendering; + +public static class HtmlHelper +{ + public static IEnumerable GetEnumSelectList(this IHtmlHelper htmlHelper, IEnumerable values) + where T : Enum + { + return values.Select(v => new SelectListItem + { + Text = v.GetDisplayAttribute().Name, + Value = v.ToString() + }); + } + +} diff --git a/src/Core/AdminConsole/Enums/Provider/ProviderType.cs b/src/Core/AdminConsole/Enums/Provider/ProviderType.cs index a159fe2b6..50c344ec9 100644 --- a/src/Core/AdminConsole/Enums/Provider/ProviderType.cs +++ b/src/Core/AdminConsole/Enums/Provider/ProviderType.cs @@ -4,8 +4,10 @@ namespace Bit.Core.AdminConsole.Enums.Provider; public enum ProviderType : byte { - [Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization")] + [Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization", Order = 0)] Msp = 0, - [Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing")] + [Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing", Order = 1000)] Reseller = 1, + [Display(ShortName = "MOE", Name = "Multi-organization Enterprise", Description = "Access to multiple organizations", Order = 1)] + MultiOrganizationEnterprise = 2, } diff --git a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs index 800ec1405..bea3c08a8 100644 --- a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs +++ b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Enums; namespace Bit.Core.AdminConsole.Providers.Interfaces; @@ -6,4 +7,5 @@ public interface ICreateProviderCommand { Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats); Task CreateResellerAsync(Provider provider); + Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index f193f7995..b22e2cf91 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -146,6 +146,7 @@ public static class FeatureFlagKeys public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string AccessIntelligence = "pm-13227-access-intelligence"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; + public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises"; public const string Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions"; public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split"; public const string GeneratorToolsModernization = "generator-tools-modernization"; diff --git a/test/Admin.Test/AdminConsole/Controllers/ProvidersControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/ProvidersControllerTests.cs new file mode 100644 index 000000000..be9883ba0 --- /dev/null +++ b/test/Admin.Test/AdminConsole/Controllers/ProvidersControllerTests.cs @@ -0,0 +1,251 @@ +using Bit.Admin.AdminConsole.Controllers; +using Bit.Admin.AdminConsole.Models; +using Bit.Core; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Providers.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using NSubstitute.ReceivedExtensions; + +namespace Admin.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(ProvidersController))] +[SutProviderCustomize] +public class ProvidersControllerTests +{ + #region CreateMspAsync + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task CreateMspAsync_WithValidModel_CreatesProvider( + CreateMspProviderModel model, + SutProvider sutProvider) + { + // Arrange + + // Act + var actual = await sutProvider.Sut.CreateMsp(model); + + // Assert + Assert.NotNull(actual); + await sutProvider.GetDependency() + .Received(Quantity.Exactly(1)) + .CreateMspAsync( + Arg.Is(x => x.Type == ProviderType.Msp), + model.OwnerEmail, + model.TeamsMonthlySeatMinimum, + model.EnterpriseMonthlySeatMinimum); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task CreateMspAsync_RedirectsToExpectedPage_AfterCreatingProvider( + CreateMspProviderModel model, + Guid expectedProviderId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .When(x => + x.CreateMspAsync( + Arg.Is(y => y.Type == ProviderType.Msp), + model.OwnerEmail, + model.TeamsMonthlySeatMinimum, + model.EnterpriseMonthlySeatMinimum)) + .Do(callInfo => + { + var providerArgument = callInfo.ArgAt(0); + providerArgument.Id = expectedProviderId; + }); + + // Act + var actual = await sutProvider.Sut.CreateMsp(model); + + // Assert + Assert.NotNull(actual); + Assert.IsType(actual); + var actualResult = (RedirectToActionResult)actual; + Assert.Equal("Edit", actualResult.ActionName); + Assert.Null(actualResult.ControllerName); + Assert.Equal(expectedProviderId, actualResult.RouteValues["Id"]); + } + #endregion + + #region CreateMultiOrganizationEnterpriseAsync + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task CreateMultiOrganizationEnterpriseAsync_WithValidModel_CreatesProvider( + CreateMultiOrganizationEnterpriseProviderModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) + .Returns(true); + + // Act + var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + + // Assert + Assert.NotNull(actual); + await sutProvider.GetDependency() + .Received(Quantity.Exactly(1)) + .CreateMultiOrganizationEnterpriseAsync( + Arg.Is(x => x.Type == ProviderType.MultiOrganizationEnterprise), + model.OwnerEmail, + Arg.Is(y => y == model.Plan), + model.EnterpriseSeatMinimum); + sutProvider.GetDependency() + .Received(Quantity.Exactly(1)) + .IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToExpectedPage_AfterCreatingProvider( + CreateMultiOrganizationEnterpriseProviderModel model, + Guid expectedProviderId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .When(x => + x.CreateMultiOrganizationEnterpriseAsync( + Arg.Is(y => y.Type == ProviderType.MultiOrganizationEnterprise), + model.OwnerEmail, + Arg.Is(y => y == model.Plan), + model.EnterpriseSeatMinimum)) + .Do(callInfo => + { + var providerArgument = callInfo.ArgAt(0); + providerArgument.Id = expectedProviderId; + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) + .Returns(true); + + // Act + var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + + // Assert + Assert.NotNull(actual); + Assert.IsType(actual); + var actualResult = (RedirectToActionResult)actual; + Assert.Equal("Edit", actualResult.ActionName); + Assert.Null(actualResult.ControllerName); + Assert.Equal(expectedProviderId, actualResult.RouteValues["Id"]); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task CreateMultiOrganizationEnterpriseAsync_ChecksFeatureFlag( + CreateMultiOrganizationEnterpriseProviderModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) + .Returns(true); + + // Act + await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + + // Assert + sutProvider.GetDependency() + .Received(Quantity.Exactly(1)) + .IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToProviderTypeSelectionPage_WhenFeatureFlagIsDisabled( + CreateMultiOrganizationEnterpriseProviderModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) + .Returns(false); + + // Act + var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + + // Assert + sutProvider.GetDependency() + .Received(Quantity.Exactly(1)) + .IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises); + + Assert.IsType(actual); + var actualResult = (RedirectToActionResult)actual; + Assert.Equal("Create", actualResult.ActionName); + Assert.Null(actualResult.ControllerName); + } + #endregion + + #region CreateResellerAsync + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task CreateResellerAsync_WithValidModel_CreatesProvider( + CreateResellerProviderModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) + .Returns(true); + + // Act + var actual = await sutProvider.Sut.CreateReseller(model); + + // Assert + Assert.NotNull(actual); + await sutProvider.GetDependency() + .Received(Quantity.Exactly(1)) + .CreateResellerAsync( + Arg.Is(x => x.Type == ProviderType.Reseller)); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task CreateResellerAsync_RedirectsToExpectedPage_AfterCreatingProvider( + CreateResellerProviderModel model, + Guid expectedProviderId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .When(x => + x.CreateResellerAsync( + Arg.Is(y => y.Type == ProviderType.Reseller))) + .Do(callInfo => + { + var providerArgument = callInfo.ArgAt(0); + providerArgument.Id = expectedProviderId; + }); + + // Act + var actual = await sutProvider.Sut.CreateReseller(model); + + // Assert + Assert.NotNull(actual); + Assert.IsType(actual); + var actualResult = (RedirectToActionResult)actual; + Assert.Equal("Edit", actualResult.ActionName); + Assert.Null(actualResult.ControllerName); + Assert.Equal(expectedProviderId, actualResult.RouteValues["Id"]); + } + #endregion +}