Skip to content

Commit 582cd7e

Browse files
[Admin] Add BusinessUnitConverterController
1 parent f478c71 commit 582cd7e

File tree

4 files changed

+283
-3
lines changed

4 files changed

+283
-3
lines changed

src/Admin/Admin.csproj

-3
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@
1414
<ProjectReference Include="..\Core\Core.csproj" />
1515
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
1616
</ItemGroup>
17-
<ItemGroup>
18-
<Folder Include="Billing\Controllers\" />
19-
</ItemGroup>
2017

2118
<Choose>
2219
<When Condition="!$(DefineConstants.Contains('OSS'))">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#nullable enable
2+
using Bit.Admin.Billing.Models;
3+
using Bit.Admin.Enums;
4+
using Bit.Admin.Utilities;
5+
using Bit.Core.AdminConsole.Entities;
6+
using Bit.Core.AdminConsole.Entities.Provider;
7+
using Bit.Core.AdminConsole.Enums.Provider;
8+
using Bit.Core.AdminConsole.Repositories;
9+
using Bit.Core.Billing.Services;
10+
using Bit.Core.Exceptions;
11+
using Bit.Core.Repositories;
12+
using Bit.Core.Utilities;
13+
using Microsoft.AspNetCore.Authorization;
14+
using Microsoft.AspNetCore.Mvc;
15+
16+
namespace Bit.Admin.Billing.Controllers;
17+
18+
[Authorize]
19+
[Route("organizations/billing/{organizationId:guid}/business-unit")]
20+
public class BusinessUnitConversionController(
21+
IBusinessUnitConverter businessUnitConverter,
22+
IOrganizationRepository organizationRepository,
23+
IProviderRepository providerRepository,
24+
IProviderUserRepository providerUserRepository) : Controller
25+
{
26+
[HttpGet]
27+
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
28+
[SelfHosted(NotSelfHostedOnly = true)]
29+
public async Task<IActionResult> IndexAsync([FromRoute] Guid organizationId)
30+
{
31+
var organization = await organizationRepository.GetByIdAsync(organizationId);
32+
33+
if (organization == null)
34+
{
35+
throw new NotFoundException();
36+
}
37+
38+
var model = new BusinessUnitConversionModel { Organization = organization };
39+
40+
var invitedProviderAdmin = await GetInvitedProviderAdminAsync(organization);
41+
42+
if (invitedProviderAdmin != null)
43+
{
44+
model.ProviderAdminEmail = invitedProviderAdmin.Email;
45+
model.ProviderId = invitedProviderAdmin.ProviderId;
46+
}
47+
48+
var success = ReadSuccessMessage();
49+
50+
if (!string.IsNullOrEmpty(success))
51+
{
52+
model.Success = success;
53+
}
54+
55+
var errors = ReadErrorMessages();
56+
57+
if (errors is { Count: > 0 })
58+
{
59+
model.Errors = errors;
60+
}
61+
62+
return View(model);
63+
}
64+
65+
[HttpPost]
66+
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
67+
[SelfHosted(NotSelfHostedOnly = true)]
68+
public async Task<IActionResult> InitiateAsync(
69+
[FromRoute] Guid organizationId,
70+
BusinessUnitConversionModel model)
71+
{
72+
var organization = await organizationRepository.GetByIdAsync(organizationId);
73+
74+
if (organization == null)
75+
{
76+
throw new NotFoundException();
77+
}
78+
79+
var result = await businessUnitConverter.InitiateConversion(
80+
organization,
81+
model.ProviderAdminEmail!);
82+
83+
return result.Match(
84+
providerId => RedirectToAction("Edit", "Providers", new { id = providerId }),
85+
errors =>
86+
{
87+
PersistErrorMessages(errors);
88+
return RedirectToAction("Index", new { organizationId });
89+
});
90+
}
91+
92+
[HttpPost("reset")]
93+
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
94+
[SelfHosted(NotSelfHostedOnly = true)]
95+
public async Task<IActionResult> ResetAsync(
96+
[FromRoute] Guid organizationId,
97+
BusinessUnitConversionModel model)
98+
{
99+
var organization = await organizationRepository.GetByIdAsync(organizationId);
100+
101+
if (organization == null)
102+
{
103+
throw new NotFoundException();
104+
}
105+
106+
await businessUnitConverter.ResetConversion(organization, model.ProviderAdminEmail!);
107+
108+
PersistSuccessMessage("Business unit conversion was successfully reset.");
109+
110+
return RedirectToAction("Index", new { organizationId });
111+
}
112+
113+
[HttpPost("resend-invite")]
114+
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
115+
[SelfHosted(NotSelfHostedOnly = true)]
116+
public async Task<IActionResult> ResendInviteAsync(
117+
[FromRoute] Guid organizationId,
118+
BusinessUnitConversionModel model)
119+
{
120+
var organization = await organizationRepository.GetByIdAsync(organizationId);
121+
122+
if (organization == null)
123+
{
124+
throw new NotFoundException();
125+
}
126+
127+
await businessUnitConverter.ResendConversionInvite(organization, model.ProviderAdminEmail!);
128+
129+
PersistSuccessMessage($"Invite was successfully resent to {model.ProviderAdminEmail}.");
130+
131+
return RedirectToAction("Index", new { organizationId });
132+
}
133+
134+
private async Task<ProviderUser?> GetInvitedProviderAdminAsync(
135+
Organization organization)
136+
{
137+
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
138+
139+
if (provider is not
140+
{
141+
Type: ProviderType.BusinessUnit,
142+
Status: ProviderStatusType.Pending
143+
})
144+
{
145+
return null;
146+
}
147+
148+
var providerUsers =
149+
await providerUserRepository.GetManyByProviderAsync(provider.Id, ProviderUserType.ProviderAdmin);
150+
151+
if (providerUsers.Count != 1)
152+
{
153+
return null;
154+
}
155+
156+
var providerUser = providerUsers.First();
157+
158+
return providerUser is
159+
{
160+
Type: ProviderUserType.ProviderAdmin,
161+
Status: ProviderUserStatusType.Invited,
162+
UserId: not null
163+
} ? providerUser : null;
164+
}
165+
166+
private const string _errors = "errors";
167+
private const string _success = "Success";
168+
169+
private void PersistSuccessMessage(string message) => TempData[_success] = message;
170+
private void PersistErrorMessages(List<string> errors)
171+
{
172+
var input = string.Join("|", errors);
173+
TempData[_errors] = input;
174+
}
175+
private string? ReadSuccessMessage() => ReadTempData<string>(_success);
176+
private List<string>? ReadErrorMessages()
177+
{
178+
var output = ReadTempData<string>(_errors);
179+
return string.IsNullOrEmpty(output) ? null : output.Split('|').ToList();
180+
}
181+
182+
private T? ReadTempData<T>(string key) => TempData.TryGetValue(key, out var obj) && obj is T value ? value : default;
183+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#nullable enable
2+
using System.ComponentModel.DataAnnotations;
3+
using Bit.Core.AdminConsole.Entities;
4+
using Microsoft.AspNetCore.Mvc.ModelBinding;
5+
6+
namespace Bit.Admin.Billing.Models;
7+
8+
public class BusinessUnitConversionModel
9+
{
10+
[Required]
11+
[EmailAddress]
12+
[Display(Name = "Provider Admin Email")]
13+
public string? ProviderAdminEmail { get; set; }
14+
15+
[BindNever]
16+
public required Organization Organization { get; set; }
17+
18+
[BindNever]
19+
public Guid? ProviderId { get; set; }
20+
21+
[BindNever]
22+
public string? Success { get; set; }
23+
24+
[BindNever] public List<string>? Errors { get; set; } = [];
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
@model Bit.Admin.Billing.Models.BusinessUnitConversionModel
2+
3+
@{
4+
ViewData["Title"] = "Convert Organization to Business Unit";
5+
}
6+
7+
@if (!string.IsNullOrEmpty(Model.ProviderAdminEmail))
8+
{
9+
<h1>Convert @Model.Organization.Name to Business Unit</h1>
10+
@if (!string.IsNullOrEmpty(Model.Success))
11+
{
12+
<div class="alert alert-success alert-dismissible fade show mb-3" role="alert">
13+
@Model.Success
14+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
15+
</div>
16+
}
17+
@if (Model.Errors?.Any() ?? false)
18+
{
19+
@foreach (var error in Model.Errors)
20+
{
21+
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
22+
@error
23+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
24+
</div>
25+
}
26+
}
27+
<p>This organization has a business unit conversion in progress.</p>
28+
29+
<div class="mb-3">
30+
<label asp-for="ProviderAdminEmail" class="form-label"></label>
31+
<input type="email" class="form-control" asp-for="ProviderAdminEmail" disabled></input>
32+
</div>
33+
34+
<div class="d-flex gap-2">
35+
<form method="post" asp-controller="BusinessUnitConversion" asp-action="ResendInvite" asp-route-organizationId="@Model.Organization.Id">
36+
<input type="hidden" asp-for="ProviderAdminEmail" />
37+
<button type="submit" class="btn btn-primary mb-2">Resend Invite</button>
38+
</form>
39+
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Reset" asp-route-organizationId="@Model.Organization.Id">
40+
<input type="hidden" asp-for="ProviderAdminEmail" />
41+
<button type="submit" class="btn btn-danger mb-2">Reset Conversion</button>
42+
</form>
43+
@if (Model.ProviderId.HasValue)
44+
{
45+
<a asp-controller="Providers"
46+
asp-action="Edit"
47+
asp-route-id="@Model.ProviderId"
48+
class="btn btn-secondary mb-2">
49+
Go to Provider
50+
</a>
51+
}
52+
</div>
53+
}
54+
else
55+
{
56+
<h1>Convert @Model.Organization.Name to Business Unit</h1>
57+
@if (Model.Errors?.Any() ?? false)
58+
{
59+
@foreach (var error in Model.Errors)
60+
{
61+
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
62+
@error
63+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
64+
</div>
65+
}
66+
}
67+
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Initiate" asp-route-organizationId="@Model.Organization.Id">
68+
<div asp-validation-summary="All" class="alert alert-danger"></div>
69+
<div class="mb-3">
70+
<label asp-for="ProviderAdminEmail" class="form-label"></label>
71+
<input type="email" class="form-control" asp-for="ProviderAdminEmail" />
72+
</div>
73+
<button type="submit" class="btn btn-primary mb-2">Convert</button>
74+
</form>
75+
}

0 commit comments

Comments
 (0)