1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-22 12:15:36 +01:00

[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 <cokeke@bitwarden.com>

* add the feature flag

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add featureflag for create and edit html pages

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

---------

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>
This commit is contained in:
cyprain-okeke 2024-04-05 15:50:28 +01:00 committed by GitHub
parent 108d22f484
commit 5bd2c424aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 162 additions and 12 deletions

View File

@ -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.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; 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.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -14,21 +19,28 @@ public class CreateProviderCommand : ICreateProviderCommand
private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderService _providerService; private readonly IProviderService _providerService;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IFeatureService _featureService;
public CreateProviderCommand( public CreateProviderCommand(
IProviderRepository providerRepository, IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IProviderService providerService, IProviderService providerService,
IUserRepository userRepository) IUserRepository userRepository,
IProviderPlanRepository providerPlanRepository,
IFeatureService featureService)
{ {
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
_providerService = providerService; _providerService = providerService;
_userRepository = userRepository; _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); var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null) if (owner == null)
{ {
@ -44,8 +56,24 @@ public class CreateProviderCommand : ICreateProviderCommand
Type = ProviderUserType.ProviderAdmin, Type = ProviderUserType.ProviderAdmin,
Status = ProviderUserStatusType.Confirmed, Status = ProviderUserStatusType.Confirmed,
}; };
if (isConsolidatedBillingEnabled)
{
var providerPlans = new List<ProviderPlan>
{
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 _providerUserRepository.CreateAsync(providerUser);
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email); await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
} }
public async Task CreateResellerAsync(Provider provider) public async Task CreateResellerAsync(Provider provider)
@ -60,4 +88,16 @@ public class CreateProviderCommand : ICreateProviderCommand
provider.UseEvents = true; provider.UseEvents = true;
await _providerRepository.CreateAsync(provider); 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
};
}
} }

View File

@ -22,7 +22,7 @@ public class CreateProviderCommandTests
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
var exception = await Assert.ThrowsAsync<BadRequestException>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMspAsync(provider, default)); () => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
Assert.Contains("Invalid owner.", exception.Message); Assert.Contains("Invalid owner.", exception.Message);
} }
@ -34,7 +34,7 @@ public class CreateProviderCommandTests
var userRepository = sutProvider.GetDependency<IUserRepository>(); var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user); 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<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);

View File

@ -8,6 +8,8 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Repositories;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@ -34,6 +36,7 @@ public class ProvidersController : Controller
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly ICreateProviderCommand _createProviderCommand; private readonly ICreateProviderCommand _createProviderCommand;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderPlanRepository _providerPlanRepository;
public ProvidersController( public ProvidersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -47,7 +50,8 @@ public class ProvidersController : Controller
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
IUserService userService, IUserService userService,
ICreateProviderCommand createProviderCommand, ICreateProviderCommand createProviderCommand,
IFeatureService featureService) IFeatureService featureService,
IProviderPlanRepository providerPlanRepository)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService; _organizationService = organizationService;
@ -61,6 +65,7 @@ public class ProvidersController : Controller
_userService = userService; _userService = userService;
_createProviderCommand = createProviderCommand; _createProviderCommand = createProviderCommand;
_featureService = featureService; _featureService = featureService;
_providerPlanRepository = providerPlanRepository;
} }
[RequirePermission(Permission.Provider_List_View)] [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 return View(new CreateProviderModel
{ {
OwnerEmail = ownerEmail OwnerEmail = ownerEmail,
TeamsMinimumSeats = teamsMinimumSeats,
EnterpriseMinimumSeats = enterpriseMinimumSeats
}); });
} }
@ -112,7 +119,8 @@ public class ProvidersController : Controller
switch (provider.Type) switch (provider.Type)
{ {
case ProviderType.Msp: case ProviderType.Msp:
await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail); await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail, model.TeamsMinimumSeats,
model.EnterpriseMinimumSeats);
break; break;
case ProviderType.Reseller: case ProviderType.Reseller:
await _createProviderCommand.CreateResellerAsync(provider); await _createProviderCommand.CreateResellerAsync(provider);
@ -139,6 +147,7 @@ public class ProvidersController : Controller
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> Edit(Guid id) public async Task<IActionResult> Edit(Guid id)
{ {
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
var provider = await _providerRepository.GetByIdAsync(id); var provider = await _providerRepository.GetByIdAsync(id);
if (provider == null) if (provider == null)
{ {
@ -147,7 +156,12 @@ public class ProvidersController : Controller
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id); var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
var providerOrganizations = await _providerOrganizationRepository.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<ProviderPlan>()));
} }
[HttpPost] [HttpPost]
@ -156,6 +170,8 @@ public class ProvidersController : Controller
[RequirePermission(Permission.Provider_Edit)] [RequirePermission(Permission.Provider_Edit)]
public async Task<IActionResult> Edit(Guid id, ProviderEditModel model) public async Task<IActionResult> Edit(Guid id, ProviderEditModel model)
{ {
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
var provider = await _providerRepository.GetByIdAsync(id); var provider = await _providerRepository.GetByIdAsync(id);
if (provider == null) if (provider == null)
{ {
@ -165,6 +181,15 @@ public class ProvidersController : Controller
model.ToProvider(provider); model.ToProvider(provider);
await _providerRepository.ReplaceAsync(provider); await _providerRepository.ReplaceAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider);
if (isConsolidatedBillingEnabled)
{
model.ToProviderPlan(providerPlans);
foreach (var providerPlan in providerPlans)
{
await _providerPlanRepository.ReplaceAsync(providerPlan);
}
}
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }

View File

@ -24,6 +24,12 @@ public class CreateProviderModel : IValidatableObject
[Display(Name = "Primary Billing Email")] [Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; } 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() public virtual Provider ToProvider()
{ {
return new Provider() return new Provider()
@ -45,6 +51,16 @@ public class CreateProviderModel : IValidatableObject
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail); var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
} }
if (TeamsMinimumSeats < 0)
{
var teamsMinimumSeatsDisplayName = nameof(TeamsMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(TeamsMinimumSeats);
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
}
if (EnterpriseMinimumSeats < 0)
{
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
}
break; break;
case ProviderType.Reseller: case ProviderType.Reseller:
if (string.IsNullOrWhiteSpace(Name)) if (string.IsNullOrWhiteSpace(Name))

View File

@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Enums;
namespace Bit.Admin.AdminConsole.Models; namespace Bit.Admin.AdminConsole.Models;
@ -8,13 +10,16 @@ public class ProviderEditModel : ProviderViewModel
{ {
public ProviderEditModel() { } public ProviderEditModel() { }
public ProviderEditModel(Provider provider, IEnumerable<ProviderUserUserDetails> providerUsers, IEnumerable<ProviderOrganizationOrganizationDetails> organizations) public ProviderEditModel(Provider provider, IEnumerable<ProviderUserUserDetails> providerUsers,
IEnumerable<ProviderOrganizationOrganizationDetails> organizations, IEnumerable<ProviderPlan> providerPlans)
: base(provider, providerUsers, organizations) : base(provider, providerUsers, organizations)
{ {
Name = provider.DisplayName(); Name = provider.DisplayName();
BusinessName = provider.DisplayBusinessName(); BusinessName = provider.DisplayBusinessName();
BillingEmail = provider.BillingEmail; BillingEmail = provider.BillingEmail;
BillingPhone = provider.BillingPhone; BillingPhone = provider.BillingPhone;
TeamsMinimumSeats = GetMinimumSeats(providerPlans, PlanType.TeamsMonthly);
EnterpriseMinimumSeats = GetMinimumSeats(providerPlans, PlanType.EnterpriseMonthly);
} }
[Display(Name = "Billing Email")] [Display(Name = "Billing Email")]
@ -24,12 +29,38 @@ public class ProviderEditModel : ProviderViewModel
[Display(Name = "Business Name")] [Display(Name = "Business Name")]
public string BusinessName { get; set; } public string BusinessName { get; set; }
public string Name { 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")] [Display(Name = "Events")]
public IEnumerable<ProviderPlan> ToProviderPlan(IEnumerable<ProviderPlan> 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) public Provider ToProvider(Provider existingProvider)
{ {
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant()?.Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant()?.Trim();
return existingProvider; return existingProvider;
} }
private int GetMinimumSeats(IEnumerable<ProviderPlan> providerPlans, PlanType planType)
{
return (from providerPlan in providerPlans where providerPlan.PlanType == planType select (int)providerPlan.SeatMinimum).FirstOrDefault();
}
} }

View File

@ -1,6 +1,8 @@
@using Bit.SharedWeb.Utilities @using Bit.SharedWeb.Utilities
@using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core
@model CreateProviderModel @model CreateProviderModel
@inject Bit.Core.Services.IFeatureService FeatureService
@{ @{
ViewData["Title"] = "Create Provider"; ViewData["Title"] = "Create Provider";
} }
@ -39,6 +41,23 @@
<label asp-for="OwnerEmail"></label> <label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail"> <input type="text" class="form-control" asp-for="OwnerEmail">
</div> </div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="TeamsMinimumSeats"></label>
<input type="number" class="form-control" asp-for="TeamsMinimumSeats">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="EnterpriseMinimumSeats"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
</div>
</div>
</div>
}
</div> </div>
<div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)"> <div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)">

View File

@ -1,5 +1,7 @@
@using Bit.Admin.Enums; @using Bit.Admin.Enums;
@using Bit.Core
@inject Bit.Admin.Services.IAccessControlService AccessControlService @inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService
@model ProviderEditModel @model ProviderEditModel
@{ @{
@ -41,6 +43,23 @@
</div> </div>
</div> </div>
</div> </div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="TeamsMinimumSeats"></label>
<input type="number" class="form-control" asp-for="TeamsMinimumSeats">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="EnterpriseMinimumSeats"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
</div>
</div>
</div>
}
</form> </form>
@await Html.PartialAsync("Organizations", Model) @await Html.PartialAsync("Organizations", Model)
@if (canEdit) @if (canEdit)

View File

@ -4,6 +4,6 @@ namespace Bit.Core.AdminConsole.Providers.Interfaces;
public interface ICreateProviderCommand public interface ICreateProviderCommand
{ {
Task CreateMspAsync(Provider provider, string ownerEmail); Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats);
Task CreateResellerAsync(Provider provider); Task CreateResellerAsync(Provider provider);
} }