From 582cd7eb4402016ee2a3a84b11d22cbc5f8d06fd Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Wed, 2 Apr 2025 13:13:42 -0400 Subject: [PATCH] [Admin] Add BusinessUnitConverterController --- src/Admin/Admin.csproj | 3 - .../BusinessUnitConversionController.cs | 183 ++++++++++++++++++ .../Models/BusinessUnitConversionModel.cs | 25 +++ .../Views/BusinessUnitConversion/Index.cshtml | 75 +++++++ 4 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 src/Admin/Billing/Controllers/BusinessUnitConversionController.cs create mode 100644 src/Admin/Billing/Models/BusinessUnitConversionModel.cs create mode 100644 src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index 4a255eefb2..cd30e841b4 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -14,9 +14,6 @@ - - - diff --git a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs new file mode 100644 index 0000000000..d48319b440 --- /dev/null +++ b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs @@ -0,0 +1,183 @@ +#nullable enable +using Bit.Admin.Billing.Models; +using Bit.Admin.Enums; +using Bit.Admin.Utilities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Services; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Admin.Billing.Controllers; + +[Authorize] +[Route("organizations/billing/{organizationId:guid}/business-unit")] +public class BusinessUnitConversionController( + IBusinessUnitConverter businessUnitConverter, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) : Controller +{ + [HttpGet] + [RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task IndexAsync([FromRoute] Guid organizationId) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + throw new NotFoundException(); + } + + var model = new BusinessUnitConversionModel { Organization = organization }; + + var invitedProviderAdmin = await GetInvitedProviderAdminAsync(organization); + + if (invitedProviderAdmin != null) + { + model.ProviderAdminEmail = invitedProviderAdmin.Email; + model.ProviderId = invitedProviderAdmin.ProviderId; + } + + var success = ReadSuccessMessage(); + + if (!string.IsNullOrEmpty(success)) + { + model.Success = success; + } + + var errors = ReadErrorMessages(); + + if (errors is { Count: > 0 }) + { + model.Errors = errors; + } + + return View(model); + } + + [HttpPost] + [RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task InitiateAsync( + [FromRoute] Guid organizationId, + BusinessUnitConversionModel model) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + throw new NotFoundException(); + } + + var result = await businessUnitConverter.InitiateConversion( + organization, + model.ProviderAdminEmail!); + + return result.Match( + providerId => RedirectToAction("Edit", "Providers", new { id = providerId }), + errors => + { + PersistErrorMessages(errors); + return RedirectToAction("Index", new { organizationId }); + }); + } + + [HttpPost("reset")] + [RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task ResetAsync( + [FromRoute] Guid organizationId, + BusinessUnitConversionModel model) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + throw new NotFoundException(); + } + + await businessUnitConverter.ResetConversion(organization, model.ProviderAdminEmail!); + + PersistSuccessMessage("Business unit conversion was successfully reset."); + + return RedirectToAction("Index", new { organizationId }); + } + + [HttpPost("resend-invite")] + [RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task ResendInviteAsync( + [FromRoute] Guid organizationId, + BusinessUnitConversionModel model) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + throw new NotFoundException(); + } + + await businessUnitConverter.ResendConversionInvite(organization, model.ProviderAdminEmail!); + + PersistSuccessMessage($"Invite was successfully resent to {model.ProviderAdminEmail}."); + + return RedirectToAction("Index", new { organizationId }); + } + + private async Task GetInvitedProviderAdminAsync( + Organization organization) + { + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + if (provider is not + { + Type: ProviderType.BusinessUnit, + Status: ProviderStatusType.Pending + }) + { + return null; + } + + var providerUsers = + await providerUserRepository.GetManyByProviderAsync(provider.Id, ProviderUserType.ProviderAdmin); + + if (providerUsers.Count != 1) + { + return null; + } + + var providerUser = providerUsers.First(); + + return providerUser is + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Invited, + UserId: not null + } ? providerUser : null; + } + + private const string _errors = "errors"; + private const string _success = "Success"; + + private void PersistSuccessMessage(string message) => TempData[_success] = message; + private void PersistErrorMessages(List errors) + { + var input = string.Join("|", errors); + TempData[_errors] = input; + } + private string? ReadSuccessMessage() => ReadTempData(_success); + private List? ReadErrorMessages() + { + var output = ReadTempData(_errors); + return string.IsNullOrEmpty(output) ? null : output.Split('|').ToList(); + } + + private T? ReadTempData(string key) => TempData.TryGetValue(key, out var obj) && obj is T value ? value : default; +} diff --git a/src/Admin/Billing/Models/BusinessUnitConversionModel.cs b/src/Admin/Billing/Models/BusinessUnitConversionModel.cs new file mode 100644 index 0000000000..2ea94d6cc8 --- /dev/null +++ b/src/Admin/Billing/Models/BusinessUnitConversionModel.cs @@ -0,0 +1,25 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Bit.Admin.Billing.Models; + +public class BusinessUnitConversionModel +{ + [Required] + [EmailAddress] + [Display(Name = "Provider Admin Email")] + public string? ProviderAdminEmail { get; set; } + + [BindNever] + public required Organization Organization { get; set; } + + [BindNever] + public Guid? ProviderId { get; set; } + + [BindNever] + public string? Success { get; set; } + + [BindNever] public List? Errors { get; set; } = []; +} diff --git a/src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml b/src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml new file mode 100644 index 0000000000..4ec4c97d02 --- /dev/null +++ b/src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml @@ -0,0 +1,75 @@ +@model Bit.Admin.Billing.Models.BusinessUnitConversionModel + +@{ + ViewData["Title"] = "Convert Organization to Business Unit"; +} + +@if (!string.IsNullOrEmpty(Model.ProviderAdminEmail)) +{ +

Convert @Model.Organization.Name to Business Unit

+ @if (!string.IsNullOrEmpty(Model.Success)) + { + + } + @if (Model.Errors?.Any() ?? false) + { + @foreach (var error in Model.Errors) + { + + } + } +

This organization has a business unit conversion in progress.

+ +
+ + +
+ +
+
+ + +
+
+ + +
+ @if (Model.ProviderId.HasValue) + { + + Go to Provider + + } +
+} +else +{ +

Convert @Model.Organization.Name to Business Unit

+ @if (Model.Errors?.Any() ?? false) + { + @foreach (var error in Model.Errors) + { + + } + } +
+
+
+ + +
+ +
+}