mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
[PM-7004] Org Admin Initiate Delete (#3905)
* org delete * move org id to URL path * tweaks * lint fixes * Update src/Core/Services/Implementations/HandlebarsMailService.cs Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * Update src/Core/Services/Implementations/HandlebarsMailService.cs Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * PR feedback * fix id * [PM-7004] Move OrgDeleteTokenable to AdminConsole ownership * [PM-7004] Add consolidated billing logic into organization delete request acceptance endpoint * [PM-7004] Delete unused IOrganizationService.DeleteAsync(Organization organization, string token) method * [PM-7004] Fix unit tests * [PM-7004] Update delete organization request email templates * Add success message when initiating organization deletion * Refactor OrganizationsController request delete initiation action to handle exceptions --------- Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> Co-authored-by: Rui Tome <rtome@bitwarden.com>
This commit is contained in:
parent
56c523f76f
commit
4264fc0729
@ -269,6 +269,35 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Org_Delete)]
|
||||
public async Task<IActionResult> DeleteInitiation(Guid id, OrganizationInitiateDeleteModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["Error"] = ModelState.GetErrorMessage();
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
if (organization != null)
|
||||
{
|
||||
await _organizationService.InitiateDeleteAsync(organization, model.AdminEmail);
|
||||
TempData["Success"] = "The request to initiate deletion of the organization has been sent.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TempData["Error"] = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> TriggerBillingSync(Guid id)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
|
@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class OrganizationInitiateDeleteModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[StringLength(256)]
|
||||
[Display(Name = "Admin Email")]
|
||||
public string AdminEmail { get; set; }
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Admin.Models
|
||||
@using Bit.Core.Enums
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||
@ -18,24 +18,43 @@
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
document.getElementById('teams-trial').addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
}
|
||||
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
|
||||
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
|
||||
});
|
||||
document.getElementById('enterprise-trial').addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
}
|
||||
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
|
||||
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
|
||||
});
|
||||
const treamsTrialButton = document.getElementById('teams-trial');
|
||||
if (treamsTrialButton != null) {
|
||||
treamsTrialButton.addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
}
|
||||
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
|
||||
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
|
||||
});
|
||||
}
|
||||
|
||||
const entTrialButton = document.getElementById('enterprise-trial');
|
||||
if (entTrialButton != null) {
|
||||
entTrialButton.addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
}
|
||||
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
|
||||
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
|
||||
});
|
||||
}
|
||||
|
||||
const initDeleteButton = document.getElementById('initiate-delete-form');
|
||||
if (initDeleteButton != null) {
|
||||
initDeleteButton.addEventListener('submit', (e) => {
|
||||
const email = prompt('Enter the email address of the owner/admin that your want to ' +
|
||||
'request the organization delete verification process with.');
|
||||
document.getElementById('AdminEmail').value = email;
|
||||
if (email == null || email === '') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setTrialDefaults(planType) {
|
||||
// Plan
|
||||
@ -76,7 +95,7 @@
|
||||
{
|
||||
<h2>Billing Information</h2>
|
||||
@await Html.PartialAsync("_BillingInformation",
|
||||
new BillingInformationModel { BillingInfo = Model.BillingInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" })
|
||||
new BillingInformationModel { BillingInfo = Model.BillingInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" })
|
||||
}
|
||||
|
||||
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
|
||||
@ -95,18 +114,20 @@
|
||||
}
|
||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||
{
|
||||
<button
|
||||
class="btn btn-outline-danger mr-2"
|
||||
onclick="return unlinkProvider('@Model.Organization.Id');"
|
||||
>
|
||||
<button class="btn btn-outline-danger mr-2"
|
||||
onclick="return unlinkProvider('@Model.Organization.Id');">
|
||||
Unlink provider
|
||||
</button>
|
||||
}
|
||||
@if (canDelete)
|
||||
{
|
||||
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
|
||||
<input type="hidden" name="AdminEmail" id="AdminEmail" />
|
||||
<button class="btn btn-danger mr-2" type="submit">Request Delete</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
|
||||
onsubmit="return confirm('Are you sure you want to delete this organization?')">
|
||||
<button class="btn btn-danger" type="submit">Delete</button>
|
||||
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
|
||||
<button class="btn btn-outline-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
@ -174,6 +174,14 @@
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
toastr.success("@TempData["Success"]")
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@RenderSection("Scripts", required: false)
|
||||
</body>
|
||||
|
@ -11,6 +11,7 @@ using Bit.Api.Models.Request.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces;
|
||||
@ -26,6 +27,7 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -54,6 +56,7 @@ public class OrganizationsController : Controller
|
||||
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IScaleSeatsCommand _scaleSeatsCommand;
|
||||
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
||||
|
||||
public OrganizationsController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -73,7 +76,8 @@ public class OrganizationsController : Controller
|
||||
IPushNotificationService pushNotificationService,
|
||||
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand,
|
||||
IProviderRepository providerRepository,
|
||||
IScaleSeatsCommand scaleSeatsCommand)
|
||||
IScaleSeatsCommand scaleSeatsCommand,
|
||||
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -93,6 +97,7 @@ public class OrganizationsController : Controller
|
||||
_organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand;
|
||||
_providerRepository = providerRepository;
|
||||
_scaleSeatsCommand = scaleSeatsCommand;
|
||||
_orgDeleteTokenDataFactory = orgDeleteTokenDataFactory;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -279,6 +284,37 @@ public class OrganizationsController : Controller
|
||||
await _organizationService.DeleteAsync(organization);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/delete-recover-token")]
|
||||
[AllowAnonymous]
|
||||
public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!_orgDeleteTokenDataFactory.TryUnprotect(model.Token, out var data) || !data.IsValid(organization))
|
||||
{
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
if (provider.IsBillable())
|
||||
{
|
||||
await _scaleSeatsCommand.ScalePasswordManagerSeats(
|
||||
provider,
|
||||
organization.PlanType,
|
||||
-organization.Seats ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
await _organizationService.DeleteAsync(organization);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/import")]
|
||||
public async Task Import(string id, [FromBody] ImportOrganizationUsersRequestModel model)
|
||||
{
|
||||
|
@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
|
||||
public class OrganizationVerifyDeleteRecoverRequestModel
|
||||
{
|
||||
[Required]
|
||||
public string Token { get; set; }
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
|
||||
public class OrgDeleteTokenable : Tokens.ExpiringTokenable
|
||||
{
|
||||
public const string ClearTextPrefix = "";
|
||||
public const string DataProtectorPurpose = "OrgDeleteDataProtector";
|
||||
public const string TokenIdentifier = "OrgDelete";
|
||||
public string Identifier { get; set; } = TokenIdentifier;
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[JsonConstructor]
|
||||
public OrgDeleteTokenable(DateTime expirationDate)
|
||||
{
|
||||
ExpirationDate = expirationDate;
|
||||
}
|
||||
|
||||
public OrgDeleteTokenable(Organization organization, int hoursTillExpiration)
|
||||
{
|
||||
Id = organization.Id;
|
||||
ExpirationDate = DateTime.UtcNow.AddHours(hoursTillExpiration);
|
||||
}
|
||||
|
||||
public bool IsValid(Organization organization)
|
||||
{
|
||||
return Id == organization.Id;
|
||||
}
|
||||
|
||||
protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default;
|
||||
}
|
@ -34,6 +34,7 @@ public interface IOrganizationService
|
||||
/// </summary>
|
||||
Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner,
|
||||
string ownerKey, string collectionName, string publicKey, string privateKey);
|
||||
Task InitiateDeleteAsync(Organization organization, string orgAdminEmail);
|
||||
Task DeleteAsync(Organization organization);
|
||||
Task EnableAsync(Guid organizationId, DateTime? expirationDate);
|
||||
Task DisableAsync(Guid organizationId, DateTime? expirationDate);
|
||||
|
@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -60,6 +61,7 @@ public class OrganizationService : IOrganizationService
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
@ -94,6 +96,7 @@ public class OrganizationService : IOrganizationService
|
||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
|
||||
IProviderRepository providerRepository,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
@ -123,6 +126,7 @@ public class OrganizationService : IOrganizationService
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
_orgDeleteTokenDataFactory = orgDeleteTokenDataFactory;
|
||||
_providerRepository = providerRepository;
|
||||
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
@ -811,6 +815,23 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InitiateDeleteAsync(Organization organization, string orgAdminEmail)
|
||||
{
|
||||
var orgAdmin = await _userRepository.GetByEmailAsync(orgAdminEmail);
|
||||
if (orgAdmin == null)
|
||||
{
|
||||
throw new BadRequestException("Org admin not found.");
|
||||
}
|
||||
var orgAdminOrgUser = await _organizationUserRepository.GetDetailsByUserAsync(orgAdmin.Id, organization.Id);
|
||||
if (orgAdminOrgUser == null || orgAdminOrgUser.Status != OrganizationUserStatusType.Confirmed ||
|
||||
(orgAdminOrgUser.Type != OrganizationUserType.Admin && orgAdminOrgUser.Type != OrganizationUserType.Owner))
|
||||
{
|
||||
throw new BadRequestException("Org admin not found.");
|
||||
}
|
||||
var token = _orgDeleteTokenDataFactory.Protect(new OrgDeleteTokenable(organization, 1));
|
||||
await _mailService.SendInitiateDeleteOrganzationEmailAsync(orgAdminEmail, organization, token);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Organization organization)
|
||||
{
|
||||
await ValidateDeleteOrganizationAsync(organization);
|
||||
|
@ -0,0 +1,39 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
|
||||
We recently received your request to permanently delete the following Bitwarden organization:
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Name:</b> {{OrganizationName}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">ID:</b> {{OrganizationId}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Created:</b> {{OrganizationCreationDate}} at {{OrganizationCreationTime}} {{TimeZone}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Plan:</b> {{OrganizationPlan}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Number of seats:</b> {{OrganizationSeats}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Billing email address:</b> {{OrganizationBillingEmail}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
|
||||
Click the link below to delete your Bitwarden organization.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
|
||||
If you did not request this email to delete your Bitwarden organization, please contact us.
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
|
||||
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
Delete Your Organization
|
||||
</a>
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
@ -0,0 +1,17 @@
|
||||
{{#>BasicTextLayout}}
|
||||
We recently received your request to permanently delete the following Bitwarden organization:
|
||||
|
||||
- Name: {{OrganizationName}}
|
||||
- ID: {{OrganizationId}}
|
||||
- Created: {{OrganizationCreationDate}} at {{OrganizationCreationTime}} {{TimeZone}}
|
||||
- Plan: {{OrganizationPlan}}
|
||||
- Number of seats: {{OrganizationSeats}}
|
||||
- Billing email address: {{OrganizationBillingEmail}}
|
||||
|
||||
Click the link below to complete the deletion of your organization.
|
||||
|
||||
If you did not request this email to delete your Bitwarden organization, please contact us.
|
||||
|
||||
{{{Url}}}
|
||||
|
||||
{{/BasicTextLayout}}
|
23
src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs
Normal file
23
src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using Bit.Core.Models.Mail;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Mail;
|
||||
|
||||
public class OrganizationInitiateDeleteModel : BaseMailModel
|
||||
{
|
||||
public string Url => string.Format("{0}/verify-recover-delete-org?orgId={1}&token={2}&name={3}",
|
||||
WebVaultUrl,
|
||||
OrganizationId,
|
||||
Token,
|
||||
OrganizationNameUrlEncoded);
|
||||
|
||||
public string Token { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string OrganizationName { get; set; }
|
||||
public string OrganizationNameUrlEncoded { get; set; }
|
||||
public string OrganizationPlan { get; set; }
|
||||
public string OrganizationSeats { get; set; }
|
||||
public string OrganizationBillingEmail { get; set; }
|
||||
public string OrganizationCreationDate { get; set; }
|
||||
public string OrganizationCreationTime { get; set; }
|
||||
public string TimeZone { get; set; }
|
||||
}
|
@ -79,5 +79,6 @@ public interface IMailService
|
||||
Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier);
|
||||
Task SendTrialInitiationEmailAsync(string email);
|
||||
Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token);
|
||||
Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token);
|
||||
}
|
||||
|
||||
|
@ -1002,6 +1002,30 @@ public class HandlebarsMailService : IMailService
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token)
|
||||
{
|
||||
var message = CreateDefaultMessage("Request to Delete Your Organization", email);
|
||||
var model = new OrganizationInitiateDeleteModel
|
||||
{
|
||||
Token = WebUtility.UrlEncode(token),
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||
SiteName = _globalSettings.SiteName,
|
||||
OrganizationId = organization.Id,
|
||||
OrganizationName = CoreHelpers.SanitizeForEmail(organization.DisplayName(), false),
|
||||
OrganizationNameUrlEncoded = WebUtility.UrlEncode(organization.Name),
|
||||
OrganizationBillingEmail = organization.BillingEmail,
|
||||
OrganizationPlan = organization.Plan,
|
||||
OrganizationSeats = organization.Seats.ToString(),
|
||||
OrganizationCreationDate = organization.CreationDate.ToLongDateString(),
|
||||
OrganizationCreationTime = organization.CreationDate.ToShortTimeString(),
|
||||
TimeZone = _utcTimeZoneDisplay,
|
||||
};
|
||||
await AddMessageContentAsync(message, "InitiateDeleteOrganzation", model);
|
||||
message.MetaData.Add("SendGridBypassListManagement", true);
|
||||
message.Category = "InitiateDeleteOrganzation";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
private static string GetUserIdentifier(string email, string userName)
|
||||
{
|
||||
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);
|
||||
|
@ -270,5 +270,10 @@ public class NoopMailService : IMailService
|
||||
}
|
||||
|
||||
public Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) => throw new NotImplementedException();
|
||||
|
||||
public Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
16
src/Core/Utilities/ModelStateExtensions.cs
Normal file
16
src/Core/Utilities/ModelStateExtensions.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Bit.Core.Utilities;
|
||||
|
||||
public static class ModelStateExtensions
|
||||
{
|
||||
public static string GetErrorMessage(this ModelStateDictionary modelState)
|
||||
{
|
||||
var errors = modelState.Values
|
||||
.SelectMany(v => v.Errors)
|
||||
.Select(e => e.ErrorMessage)
|
||||
.ToList();
|
||||
|
||||
return string.Join("; ", errors);
|
||||
}
|
||||
}
|
@ -150,6 +150,13 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
public static void AddTokenizers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDataProtectorTokenFactory<OrgDeleteTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<OrgDeleteTokenable>(
|
||||
OrgDeleteTokenable.ClearTextPrefix,
|
||||
OrgDeleteTokenable.DataProtectorPurpose,
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<OrgDeleteTokenable>>>())
|
||||
);
|
||||
services.AddSingleton<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<EmergencyAccessInviteTokenable>(
|
||||
EmergencyAccessInviteTokenable.ClearTextPrefix,
|
||||
|
@ -5,6 +5,7 @@ using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -20,6 +21,7 @@ using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
@ -47,6 +49,7 @@ public class OrganizationsControllerTests : IDisposable
|
||||
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IScaleSeatsCommand _scaleSeatsCommand;
|
||||
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
|
||||
|
||||
private readonly OrganizationsController _sut;
|
||||
|
||||
@ -70,6 +73,7 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_organizationEnableCollectionEnhancementsCommand = Substitute.For<IOrganizationEnableCollectionEnhancementsCommand>();
|
||||
_providerRepository = Substitute.For<IProviderRepository>();
|
||||
_scaleSeatsCommand = Substitute.For<IScaleSeatsCommand>();
|
||||
_orgDeleteTokenDataFactory = Substitute.For<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
|
||||
|
||||
_sut = new OrganizationsController(
|
||||
_organizationRepository,
|
||||
@ -89,7 +93,8 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_pushNotificationService,
|
||||
_organizationEnableCollectionEnhancementsCommand,
|
||||
_providerRepository,
|
||||
_scaleSeatsCommand);
|
||||
_scaleSeatsCommand,
|
||||
_orgDeleteTokenDataFactory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
Loading…
Reference in New Issue
Block a user