1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 16:12:49 -05:00

[EC-459 / EC-428] Admin panel: Add Provider Type to list and creation flow (#2593)

* [EC-427] Add columns 'Type' and 'BillingPhone' to Provider table

* [EC-427] Provider table Type and BillingPhone MySql migrations

* [EC-427] Provider table Type and BillingPhone Postgres migrations

* [EC-427] Add mysql migration script

* [EC-427] Add mysql migration script

* [EC-427] Updated Provider sql script to include default column value

* [EC-427] Removed default value from Provider.Type column

* [EC-427] Changed migration script to include a default value constraint instead of updating the null type

* [EC-427] Updated Sql project Provider table script

* [EC-427] Changed migration script to use 'Create OR Alter' for views and sprocs

* [EC-427] Added default values for 'BillingPhone' and 'Type' fields on sprocs [dbo].[Provider_Create] and [dbo].[Provider_Update]

* [EC-427] Adjusting metadata in migration script

* [EC-427] Updated Provider sprocs SQL script files

* [EC-427] Fixed migration script

* [EC-427] Added sqlite migration

* [EC-427] Add missing Provider_Update sproc default value

* [EC-427] Added missing GO action to migration script

* [EC-459] Added Type column to Providers list

* [EC-428] Added Type, BusinessName and BillingEmail to CreateProviderModel

* [EC-428] Updated Create Provider view to include new fields

* [EC-428] Updated ProviderService to not create a ProviderUser for the type Reseller

* [EC-428] Added custom validation for Provider fields depending on selected Type

* [EC-428] Redirect to Edit after creating Provider

* [EC-428] Setting Provider status as Created for Resellers

* [EC-428] Redirect on Provider creation depending if self host server

* [EC-428] Split ProviderService.CreateAsync into two methods: CreateMspAsync and CreateResellerAsync

* [EC-428] Created ICreateProviderCommand and added service for injection on Admin.Startup

* [EC-428] Modified Provider views to use DisplayName attribute values

* [EC-428] Moved ICreateProviderCommand to Core project

* [EC-428] Adding ICreateProviderCommand injection next to IProviderService

* [EC-428] Moved CreateProviderCommand to Commercial.Core project

* [EC-459] Added Type column to Providers list

* [EC-428] Added Type, BusinessName and BillingEmail to CreateProviderModel

* [EC-428] Updated Create Provider view to include new fields

* [EC-428] Updated ProviderService to not create a ProviderUser for the type Reseller

* [EC-428] Added custom validation for Provider fields depending on selected Type

* [EC-428] Redirect to Edit after creating Provider

* [EC-428] Setting Provider status as Created for Resellers

* [EC-428] Redirect on Provider creation depending if self host server

* [EC-428] Split ProviderService.CreateAsync into two methods: CreateMspAsync and CreateResellerAsync

* [EC-428] Created ICreateProviderCommand and added service for injection on Admin.Startup

* [EC-428] Modified Provider views to use DisplayName attribute values

* [EC-428] Moved ICreateProviderCommand to Core project

* [EC-428] Adding ICreateProviderCommand injection next to IProviderService

* [EC-428] Moved CreateProviderCommand to Commercial.Core project

* [EC-428] Moved CreateProviderCommand to namespace Bit.Commercial.Core.Providers
This commit is contained in:
Rui Tomé
2023-02-07 10:27:41 +00:00
committed by GitHub
parent 89ad63d378
commit 7290776871
14 changed files with 283 additions and 64 deletions

View File

@ -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);
}
}

View File

@ -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<Provider> 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);

View File

@ -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<IProviderService, ProviderService>();
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
}
}

View File

@ -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<CreateProviderCommand> sutProvider)
{
provider.Type = ProviderType.Msp;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMspAsync(provider, default));
Assert.Contains("Invalid owner.", exception.Message);
}
[Theory, BitAutoData]
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
{
provider.Type = ProviderType.Msp;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
await sutProvider.Sut.CreateMspAsync(provider, user.Email);
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
}
[Theory, BitAutoData]
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{
provider.Type = ProviderType.Reseller;
await sutProvider.Sut.CreateResellerAsync(provider);
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
}
}

View File

@ -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<ProviderService> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAsync(default));
Assert.Contains("Invalid owner.", exception.Message);
}
[Theory, BitAutoData]
public async Task CreateAsync_Success(User user, SutProvider<ProviderService> sutProvider)
{
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
await sutProvider.Sut.CreateAsync(user.Email);
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IMailService>().ReceivedWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default, default);
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> 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<ProviderService> sutProvider)
{
await sutProvider.Sut.SendProviderSetupInviteEmailAsync(provider, email);
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderSetupInviteEmailAsync(provider, Arg.Any<string>(), email);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_UserIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
{

View File

@ -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<IActionResult> 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<IActionResult> View(Guid id)

View File

@ -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<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
{
case ProviderType.Msp:
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName();
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
break;
case ProviderType.Reseller:
if (string.IsNullOrWhiteSpace(BusinessName))
{
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName();
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BillingEmail))
{
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName();
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
break;
}
}
}

View File

@ -1,16 +1,55 @@
@model CreateProviderModel
@using Bit.SharedWeb.Utilities
@model CreateProviderModel
@{
ViewData["Title"] = "Create Provider";
}
@section Scripts {
<script>
function toggleProviderTypeInfo(value) {
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
document.getElementById('info-' + value).classList.remove('d-none');
}
</script>
}
<h1>Create Provider</h1>
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
<label asp-for="Type" class="h2"></label>
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType)))
{
var providerTypeValue = (int)providerType;
<div class="form-check">
@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}" })
<br/>
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @for = $"providerType-{providerTypeValue}" })
</div>
}
</div>
<div id="@($"info-{(int)ProviderType.Msp}")" class="form-group @(Model.Type != ProviderType.Msp ? "d-none" : string.Empty)">
<h2>MSP Info</h2>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
</div>
<div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)">
<h2>Reseller Info</h2>
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
<div class="form-group">
<label asp-for="BillingEmail"></label>
<input type="text" class="form-control" asp-for="BillingEmail">
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>

View File

@ -1,4 +1,5 @@
@model ProvidersModel
@using Bit.SharedWeb.Utilities
@model ProvidersModel
@{
ViewData["Title"] = "Providers";
}
@ -25,6 +26,7 @@
<thead>
<tr>
<th>Name</th>
<th style="width: 190px;">Provider Type</th>
<th style="width: 190px;">Status</th>
<th style="width: 150px;">Created</th>
</tr>
@ -44,6 +46,7 @@
<td>
<a asp-action="@Model.Action" asp-route-id="@provider.Id">@(provider.Name ?? "Pending")</a>
</td>
<td>@provider.Type.GetDisplayAttribute()?.GetShortName()</td>
<td>@provider.Status</td>
<td>
<span title="@provider.CreationDate.ToString()">

View File

@ -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,
}

View File

@ -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);
}

View File

@ -7,7 +7,6 @@ namespace Bit.Core.Services;
public interface IProviderService
{
Task CreateAsync(string ownerEmail);
Task<Provider> 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);
}

View File

@ -7,8 +7,6 @@ namespace Bit.Core.Services;
public class NoopProviderService : IProviderService
{
public Task CreateAsync(string ownerEmail) => throw new NotImplementedException();
public Task<Provider> 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();
}

View File

@ -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<DisplayAttribute>();
}
public static DisplayAttribute GetDisplayAttribute<T>(this string property)
{
MemberInfo propertyInfo = typeof(T).GetProperty(property);
return propertyInfo?.GetCustomAttribute(typeof(DisplayAttribute)) as DisplayAttribute;
}
}