1
0
mirror of https://github.com/bitwarden/server synced 2026-01-03 00:53:37 +00:00

[PM-18770] Convert Organization to Business Unit (#5610)

* [NO LOGIC] Rename MultiOrganizationEnterprise to BusinessUnit

* [Core] Add IMailService.SendBusinessUnitConversionInviteAsync

* [Core] Add BusinessUnitConverter

* [Admin] Add new permission

* [Admin] Add BusinessUnitConverterController

* [Admin] Add Convert to Business Unit button to Organization edit page

* [Api] Add OrganizationBillingController.SetupBusinessUnitAsync action

* [Multi] Propagate provider type to sync response

* [Multi] Put updates behind feature flag

* [Tests] BusinessUnitConverterTests

* Run dotnet format

* Fixing post-main merge compilation failure
This commit is contained in:
Alex Morask
2025-04-10 10:06:16 -04:00
committed by GitHub
parent d85807e94f
commit 54e7fac4d9
41 changed files with 1513 additions and 64 deletions

View File

@@ -133,10 +133,10 @@ public class ProvidersController : Controller
return View(new CreateResellerProviderModel());
}
[HttpGet("providers/create/multi-organization-enterprise")]
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
[HttpGet("providers/create/business-unit")]
public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null)
{
return View(new CreateMultiOrganizationEnterpriseProviderModel
return View(new CreateBusinessUnitProviderModel
{
OwnerEmail = ownerEmail,
EnterpriseSeatMinimum = enterpriseMinimumSeats
@@ -157,7 +157,7 @@ public class ProvidersController : Controller
{
ProviderType.Msp => RedirectToAction("CreateMsp"),
ProviderType.Reseller => RedirectToAction("CreateReseller"),
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"),
_ => View(model)
};
}
@@ -198,10 +198,10 @@ public class ProvidersController : Controller
return RedirectToAction("Edit", new { id = provider.Id });
}
[HttpPost("providers/create/multi-organization-enterprise")]
[HttpPost("providers/create/business-unit")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
public async Task<IActionResult> CreateBusinessUnit(CreateBusinessUnitProviderModel model)
{
if (!ModelState.IsValid)
{
@@ -209,7 +209,7 @@ public class ProvidersController : Controller
}
var provider = model.ToProvider();
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
await _createProviderCommand.CreateBusinessUnitAsync(
provider,
model.OwnerEmail,
model.Plan.Value,
@@ -307,7 +307,7 @@ public class ProvidersController : Controller
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
break;
case ProviderType.MultiOrganizationEnterprise:
case ProviderType.BusinessUnit:
{
var existingMoePlan = providerPlans.Single();

View File

@@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
public class CreateBusinessUnitProviderModel : IValidatableObject
{
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
@@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{
return new Provider
{
Type = ProviderType.MultiOrganizationEnterprise
Type = ProviderType.BusinessUnit
};
}
@@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (EnterpriseSeatMinimum < 0)
{
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.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<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan);
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(Plan);
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
}
}

View File

@@ -34,7 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type;
if (Type == ProviderType.MultiOrganizationEnterprise)
if (Type == ProviderType.BusinessUnit)
{
var plan = providerPlans.SingleOrDefault();
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
@@ -100,7 +100,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
break;
case ProviderType.MultiOrganizationEnterprise:
case ProviderType.BusinessUnit:
if (Plan == null)
{
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);

View File

@@ -40,7 +40,7 @@ public class ProviderViewModel
ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats));
}
}
else if (Provider.Type == ProviderType.MultiOrganizationEnterprise)
else if (Provider.Type == ProviderType.BusinessUnit)
{
var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly)
.Sum(po => po.OccupiedSeats).GetValueOrDefault(0);

View File

@@ -1,8 +1,13 @@
@using Bit.Admin.Enums;
@using Bit.Admin.Models
@using Bit.Core
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums
@using Bit.Core.Enums
@using Bit.Core.Billing.Extensions
@using Bit.Core.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject IFeatureService FeatureService
@model OrganizationEditModel
@{
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name;
@@ -13,6 +18,13 @@
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
var canConvertToBusinessUnit =
FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) &&
AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) &&
Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise &&
!string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) &&
Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending };
}
@section Scripts {
@@ -114,6 +126,15 @@
Enterprise Trial
</button>
}
@if (canConvertToBusinessUnit)
{
<a asp-controller="BusinessUnitConversion"
asp-action="Index"
asp-route-organizationId="@Model.Organization.Id"
class="btn btn-secondary me-2">
Convert to Business Unit
</a>
}
@if (canUnlinkFromProvider && Model.Provider is not null)
{
<button class="btn btn-outline-danger me-2"

View File

@@ -1,15 +1,15 @@
@using Bit.Core.Billing.Enums
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model CreateMultiOrganizationEnterpriseProviderModel
@model CreateBusinessUnitProviderModel
@{
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
ViewData["Title"] = "Create Business Unit Provider";
}
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
<h1 class="mb-4">Create Business Unit Provider</h1>
<div>
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
<form method="post" asp-action="CreateBusinessUnit">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></label>
@@ -19,14 +19,14 @@
<div class="col-sm">
<div class="mb-3">
@{
var multiOrgPlans = new List<PlanType>
var businessUnitPlanTypes = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan" class="form-label"></label>
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
<option value="">--</option>
</select>
</div>

View File

@@ -74,20 +74,20 @@
</div>
break;
}
case ProviderType.MultiOrganizationEnterprise:
case ProviderType.BusinessUnit:
{
<div class="row">
<div class="col-sm">
<div class="mb-3">
@{
var multiOrgPlans = new List<PlanType>
var businessUnitPlanTypes = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan" class="form-label"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
<option value="">--</option>
</select>
</div>