diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 7485f8da84..e528fe067e 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.Billing.Models.Responses; +using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response.Organizations; +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,7 @@ public class OrganizationSponsorshipsController : Controller private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand; private readonly ICurrentContext _currentContext; private readonly IUserService _userService; + private readonly IPolicyRepository _policyRepository; public OrganizationSponsorshipsController( IOrganizationSponsorshipRepository organizationSponsorshipRepository, @@ -45,7 +49,8 @@ public class OrganizationSponsorshipsController : Controller IRemoveSponsorshipCommand removeSponsorshipCommand, ICloudSyncSponsorshipsCommand syncSponsorshipsCommand, IUserService userService, - ICurrentContext currentContext) + ICurrentContext currentContext, + IPolicyRepository policyRepository) { _organizationSponsorshipRepository = organizationSponsorshipRepository; _organizationRepository = organizationRepository; @@ -60,6 +65,7 @@ public class OrganizationSponsorshipsController : Controller _syncSponsorshipsCommand = syncSponsorshipsCommand; _userService = userService; _currentContext = currentContext; + _policyRepository = policyRepository; } [Authorize("Application")] @@ -187,5 +193,21 @@ public class OrganizationSponsorshipsController : Controller return new OrganizationSponsorshipSyncStatusResponseModel(lastSyncDate); } + [HttpGet("{sponsoredEmail}")] + public async Task GetSponsoringSponsoredEmailAsync(string sponsoredEmail) + { + if (string.IsNullOrWhiteSpace(sponsoredEmail)) + { + throw new NotFoundException(); + } + + var sponsorship = await _organizationSponsorshipRepository.GetBySponsoredOrganizationUserEmailAsync(sponsoredEmail); + var policy = await _policyRepository.GetByOrganizationIdTypeAsync((Guid)sponsorship.SponsoringOrganizationId, + PolicyType.FreeFamiliesSponsorshipPolicy); + var response = OrganizationSponsorshipResponse.From(policy); + + return TypedResults.Ok(response); + } + private Task CurrentUser => _userService.GetUserByIdAsync(_currentContext.UserId.Value); } diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index bdde3e424e..80ab18e174 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/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index 072aa82834..4b29a367dd 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -34,6 +34,7 @@ public class PolicyService : IPolicyService private readonly ISavePolicyCommand _savePolicyCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; + private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; public PolicyService( IApplicationCacheService applicationCacheService, @@ -48,7 +49,8 @@ public class PolicyService : IPolicyService IFeatureService featureService, ISavePolicyCommand savePolicyCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, + IOrganizationSponsorshipRepository organizationSponsorshipRepository) { _applicationCacheService = applicationCacheService; _eventService = eventService; @@ -63,6 +65,7 @@ public class PolicyService : IPolicyService _savePolicyCommand = savePolicyCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _organizationSponsorshipRepository = organizationSponsorshipRepository; } public async Task SaveAsync(Policy policy, Guid? savingUserId) @@ -116,6 +119,10 @@ public class PolicyService : IPolicyService } await EnablePolicyAsync(policy, org, savingUserId); + if (policy.Type == PolicyType.FreeFamiliesSponsorshipPolicy && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship)) + { + await NotifiesUserWithApplicablePoliciesAsync(policy, org.Name); + } } public async Task GetMasterPasswordPolicyForUserAsync(User user) @@ -151,6 +158,25 @@ public class PolicyService : IPolicyService return result.Any(); } + private async Task NotifiesUserWithApplicablePoliciesAsync(Policy policy, string organizationName) + { + var organizationSponsorships = (await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(policy.OrganizationId)) + .Where(p => p.SponsoredOrganizationId is not null) + .ToList(); + if (string.IsNullOrWhiteSpace(organizationName)) + { + var organization = await _organizationRepository.GetByIdAsync(policy.OrganizationId); + 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); + } + } + private async Task> QueryOrganizationUserPolicyDetailsAsync(Guid userId, PolicyType policyType, OrganizationUserStatusType minStatus = OrganizationUserStatusType.Accepted) { var organizationUserPolicyDetails = await _organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(userId, policyType); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 52931582e7..777175f560 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -148,6 +148,7 @@ public static class FeatureFlagKeys public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split"; public const string GeneratorToolsModernization = "generator-tools-modernization"; public const string NewDeviceVerification = "new-device-verification"; + 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 0000000000..fad595a6de --- /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}} \ No newline at end of file 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 0000000000..b6f904e09e --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/FamiliesForEnterprise/FamiliesForEnterpriseRemovedFromFamilyUser.text.hbs @@ -0,0 +1,9 @@ +{{#>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: {{{link SubscriptionUrl}}} + +Contact your organization administrators for more information. + +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs new file mode 100644 index 0000000000..46cbb4d0a0 --- /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/Repositories/IOrganizationSponsorshipRepository.cs b/src/Core/Repositories/IOrganizationSponsorshipRepository.cs index 30e6ee4a33..a0c12b41ff 100644 --- a/src/Core/Repositories/IOrganizationSponsorshipRepository.cs +++ b/src/Core/Repositories/IOrganizationSponsorshipRepository.cs @@ -14,4 +14,5 @@ public interface IOrganizationSponsorshipRepository : IRepository GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId); Task GetBySponsoredOrganizationIdAsync(Guid sponsoredOrganizationId); Task GetLatestSyncDateBySponsoringOrganizationIdAsync(Guid sponsoringOrganizationId); + Task GetBySponsoredOrganizationUserEmailAsync(string email); } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 5e786bbe09..9ae275719e 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -76,6 +76,8 @@ public interface IMailService Task SendFamiliesForEnterpriseOfferEmailAsync(string sponsorOrgName, string email, bool existingAccount, string token); Task BulkSendFamiliesForEnterpriseOfferEmailAsync(string SponsorOrgName, IEnumerable<(string Email, bool ExistingAccount, string Token)> invites); Task SendFamiliesForEnterpriseRedeemedEmailsAsync(string familyUserEmail, string sponsorEmail); + Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, + string organizationName); Task SendFamiliesForEnterpriseSponsorshipRevertingEmailAsync(string email, DateTime expirationDate); Task SendOTPEmailAsync(string email, string token); Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 455b775c28..b22be06228 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -937,6 +937,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); + } + public async Task SendFamiliesForEnterpriseSponsorshipRevertingEmailAsync(string email, DateTime expirationDate) { var message = CreateDefaultMessage("Your Families Sponsorship was Removed", email); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index f637ae9043..469673057f 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -236,6 +236,13 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, + string organizationId, + string organizationName) + { + return Task.FromResult(0); + } + public Task SendFamiliesForEnterpriseSponsorshipRevertingEmailAsync(string email, DateTime expirationDate) { return Task.FromResult(0); diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationSponsorshipRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationSponsorshipRepository.cs index cebf4b55c6..5c311e438f 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationSponsorshipRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationSponsorshipRepository.cs @@ -145,4 +145,15 @@ public class OrganizationSponsorshipRepository : Repository GetBySponsoredOrganizationUserEmailAsync(string email) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + "[dbo].[OrganizationSponsorship_ReadByOfferedToEmail]", + new { OfferedToEmail = email }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationSponsorshipRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationSponsorshipRepository.cs index 0f76772c57..da4a8d881e 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationSponsorshipRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationSponsorshipRepository.cs @@ -140,4 +140,13 @@ public class OrganizationSponsorshipRepository : Repository GetBySponsoredOrganizationUserEmailAsync(string email) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var orgSponsorship = await GetDbSet(dbContext).Where(e => e.OfferedToEmail == email) + .FirstOrDefaultAsync(); + return orgSponsorship; + } + }