diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 7485f8da8..a7a4c3905 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -1,6 +1,9 @@ using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response.Organizations; +using Bit.Core; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -31,6 +34,8 @@ public class OrganizationSponsorshipsController : Controller private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand; private readonly ICurrentContext _currentContext; private readonly IUserService _userService; + private readonly IPolicyRepository _policyRepository; + private readonly IFeatureService _featureService; public OrganizationSponsorshipsController( IOrganizationSponsorshipRepository organizationSponsorshipRepository, @@ -45,7 +50,9 @@ public class OrganizationSponsorshipsController : Controller IRemoveSponsorshipCommand removeSponsorshipCommand, ICloudSyncSponsorshipsCommand syncSponsorshipsCommand, IUserService userService, - ICurrentContext currentContext) + ICurrentContext currentContext, + IPolicyRepository policyRepository, + IFeatureService featureService) { _organizationSponsorshipRepository = organizationSponsorshipRepository; _organizationRepository = organizationRepository; @@ -60,6 +67,8 @@ public class OrganizationSponsorshipsController : Controller _syncSponsorshipsCommand = syncSponsorshipsCommand; _userService = userService; _currentContext = currentContext; + _policyRepository = policyRepository; + _featureService = featureService; } [Authorize("Application")] @@ -94,9 +103,20 @@ public class OrganizationSponsorshipsController : Controller [Authorize("Application")] [HttpPost("validate-token")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task PreValidateSponsorshipToken([FromQuery] string sponsorshipToken) + public async Task PreValidateSponsorshipToken([FromQuery] string sponsorshipToken) { - return (await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email)).valid; + var isFreeFamilyPolicyEnabled = false; + var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email); + if (isValid && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship) && sponsorship.SponsoringOrganizationId.HasValue) + { + var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value, + PolicyType.FreeFamiliesSponsorshipPolicy); + isFreeFamilyPolicyEnabled = policy?.Enabled ?? false; + } + + var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled); + + return response; } [Authorize("Application")] diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index bdde3e424..80ab18e17 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -15,6 +15,7 @@ public enum PolicyType : byte DisablePersonalVaultExport = 10, ActivateAutofill = 11, AutomaticAppLogIn = 12, + FreeFamiliesSponsorshipPolicy = 13 } public static class PolicyTypeExtensions @@ -40,6 +41,7 @@ public static class PolicyTypeExtensions PolicyType.DisablePersonalVaultExport => "Remove individual vault export", PolicyType.ActivateAutofill => "Active auto-fill", PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications", + PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship" }; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 81096ef60..4e88976c1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -18,5 +18,6 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidator.cs new file mode 100644 index 000000000..57db4962e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidator.cs @@ -0,0 +1,46 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class FreeFamiliesForEnterprisePolicyValidator( + IOrganizationSponsorshipRepository organizationSponsorshipRepository, + IMailService mailService, + IOrganizationRepository organizationRepository) + : IPolicyValidator +{ + public PolicyType Type => PolicyType.FreeFamiliesSponsorshipPolicy; + public IEnumerable RequiredPolicies => []; + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) + { + await NotifiesUserWithApplicablePoliciesAsync(policyUpdate); + } + } + + private async Task NotifiesUserWithApplicablePoliciesAsync(PolicyUpdate policy) + { + var organizationSponsorships = (await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(policy.OrganizationId)) + .Where(p => p.SponsoredOrganizationId is not null) + .ToList(); + + var organization = await organizationRepository.GetByIdAsync(policy.OrganizationId); + var organizationName = organization?.Name; + + foreach (var org in organizationSponsorships) + { + var offerAcceptanceDate = org.ValidUntil!.Value.AddDays(-7).ToString("MM/dd/yyyy"); + await mailService.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(org.FriendlyName, offerAcceptanceDate, + org.SponsoredOrganizationId.ToString(), organizationName); + } + } + + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3e40eb7a1..3de1060e1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -154,6 +154,7 @@ public static class FeatureFlagKeys public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string SecurityTasks = "security-tasks"; public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update"; + public const string DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship"; public static List GetAllKeys() { diff --git a/src/Core/MailTemplates/Handlebars/FamiliesForEnterprise/FamiliesForEnterpriseRemovedFromFamilyUser.html.hbs b/src/Core/MailTemplates/Handlebars/FamiliesForEnterprise/FamiliesForEnterpriseRemovedFromFamilyUser.html.hbs new file mode 100644 index 000000000..b8841bc68 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/FamiliesForEnterprise/FamiliesForEnterpriseRemovedFromFamilyUser.html.hbs @@ -0,0 +1,22 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ {{SponsoringOrgName}} has removed the Free Bitwarden Families plan sponsorship. +
+ Here’s what that means:
+ Your Free Bitwarden Families sponsorship will charge your stored payment method on {{OfferAcceptanceDate}}. To avoid any disruption in your service, please ensure your payment method on the Subscription page is up to date. +
+ Contact your organization administrators for more information. +
+
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/FamiliesForEnterprise/FamiliesForEnterpriseRemovedFromFamilyUser.text.hbs b/src/Core/MailTemplates/Handlebars/FamiliesForEnterprise/FamiliesForEnterpriseRemovedFromFamilyUser.text.hbs new file mode 100644 index 000000000..c55d121a6 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/FamiliesForEnterprise/FamiliesForEnterpriseRemovedFromFamilyUser.text.hbs @@ -0,0 +1,6 @@ +{{#>BasicTextLayout}} + {{SponsoringOrgName}} has removed the Free Bitwarden Families plan sponsorship. + Here’s what that means: + Your Free Bitwarden Families sponsorship will charge your stored payment method on {{OfferAcceptanceDate}}. To avoid any disruption in your service, please ensure your payment method on the Subscription page is up to date. Or click the following link: {{{SubscriptionUrl}}} + Contact your organization administrators for more information. +{{/BasicTextLayout}} diff --git a/src/Core/Models/Api/Response/OrganizationSponsorships/PreValidateSponsorshipResponseModel.cs b/src/Core/Models/Api/Response/OrganizationSponsorships/PreValidateSponsorshipResponseModel.cs new file mode 100644 index 000000000..a387a446a --- /dev/null +++ b/src/Core/Models/Api/Response/OrganizationSponsorships/PreValidateSponsorshipResponseModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Models.Api.Response.OrganizationSponsorships; + +public record PreValidateSponsorshipResponseModel( + bool IsTokenValid, + bool IsFreeFamilyPolicyEnabled) +{ + public static PreValidateSponsorshipResponseModel From(bool validToken, bool policyStatus) + => new(validToken, policyStatus); +} diff --git a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs new file mode 100644 index 000000000..46cbb4d0a --- /dev/null +++ b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Models.Mail.FamiliesForEnterprise; + +public class FamiliesForEnterpriseRemoveOfferViewModel : BaseMailModel +{ + public string SponsoringOrgName { get; set; } + public string SponsoredOrganizationId { get; set; } + public string OfferAcceptanceDate { get; set; } + public string SubscriptionUrl => + $"{WebVaultUrl}/organizations/{SponsoredOrganizationId}/billing/subscription"; +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 15ed9e2ea..5514cd507 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -89,5 +89,7 @@ public interface IMailService Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token); Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token); Task SendRequestSMAccessToAdminEmailAsync(IEnumerable adminEmails, string organizationName, string userRequestingAccess, string emailContent); + Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, + string organizationName); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index dbf056c02..e1943b0e3 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -1095,6 +1095,22 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, + string organizationName) + { + var message = CreateDefaultMessage("Removal of Free Bitwarden Families plan", email); + var model = new FamiliesForEnterpriseRemoveOfferViewModel + { + SponsoredOrganizationId = organizationId, + SponsoringOrgName = CoreHelpers.SanitizeForEmail(organizationName), + OfferAcceptanceDate = offerAcceptanceDate, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash + }; + await AddMessageContentAsync(message, "FamiliesForEnterprise.FamiliesForEnterpriseRemovedFromFamilyUser", model); + message.Category = "FamiliesForEnterpriseRemovedFromFamilyUser"; + await _mailDeliveryService.SendEmailAsync(message); + } + private static string GetUserIdentifier(string email, string userName) { return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 9b8a9abee..a56858fb9 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -296,5 +296,12 @@ public class NoopMailService : IMailService return Task.FromResult(0); } public Task SendRequestSMAccessToAdminEmailAsync(IEnumerable adminEmails, string organizationName, string userRequestingAccess, string emailContent) => throw new NotImplementedException(); + + public Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, + string organizationId, + string organizationName) + { + return Task.FromResult(0); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs new file mode 100644 index 000000000..0aa670297 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs @@ -0,0 +1,75 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class FreeFamiliesForEnterprisePolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_DoesNotNotifyUserWhenPolicyDisabled( + Organization organization, + List organizationSponsorships, + [PolicyUpdate(PolicyType.FreeFamiliesSponsorshipPolicy)] PolicyUpdate policyUpdate, + [Policy(PolicyType.FreeFamiliesSponsorshipPolicy, true)] Policy policy, + SutProvider sutProvider) + { + + policy.Enabled = true; + policyUpdate.Enabled = false; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + sutProvider.GetDependency() + .GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId) + .Returns(organizationSponsorships); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + await sutProvider.GetDependency().DidNotReceive() + .SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(organizationSponsorships[0].FriendlyName, organizationSponsorships[0].ValidUntil.ToString(), + organizationSponsorships[0].SponsoredOrganizationId.ToString(), organization.DisplayName()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_DoesNotifyUserWhenPolicyDisabled( + Organization organization, + List organizationSponsorships, + [PolicyUpdate(PolicyType.FreeFamiliesSponsorshipPolicy)] PolicyUpdate policyUpdate, + [Policy(PolicyType.FreeFamiliesSponsorshipPolicy, true)] Policy policy, + SutProvider sutProvider) + { + + policy.Enabled = false; + policyUpdate.Enabled = true; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + sutProvider.GetDependency() + .GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId) + .Returns(organizationSponsorships); + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + // Assert + var offerAcceptanceDate = organizationSponsorships[0].ValidUntil!.Value.AddDays(-7).ToString("MM/dd/yyyy"); + await sutProvider.GetDependency().Received(1) + .SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(organizationSponsorships[0].FriendlyName, offerAcceptanceDate, + organizationSponsorships[0].SponsoredOrganizationId.ToString(), organization.Name); + + } +}