1
0
mirror of https://github.com/bitwarden/server synced 2025-12-11 13:53:40 +00:00

Merge remote-tracking branch 'origin/main' into dbops/dbops-31/csv-import

This commit is contained in:
Mark Kincaid
2025-11-17 15:28:36 -08:00
70 changed files with 8996 additions and 556 deletions

View File

@@ -46,8 +46,10 @@ jobs:
permissions:
security-events: write
id-token: write
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 5
matrix:
include:
- project_name: Admin
@@ -122,7 +124,7 @@ jobs:
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -159,7 +161,7 @@ jobs:
ls -atlh ../../../
- name: Upload project artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: ${{ matrix.dotnet }}
with:
name: ${{ matrix.project_name }}.zip
@@ -364,7 +366,7 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: docker-stub-US.zip
path: docker-stub-US.zip
@@ -374,7 +376,7 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: docker-stub-EU.zip
path: docker-stub-EU.zip
@@ -386,21 +388,21 @@ jobs:
pwsh ./generate_openapi_files.ps1
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: swagger.json
path: api.public.json
if-no-files-found: error
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: internal.json
path: api.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: identity.json
path: identity.json
@@ -446,7 +448,7 @@ jobs:
- name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@@ -454,7 +456,7 @@ jobs:
- name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility

View File

@@ -15,6 +15,7 @@ jobs:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: read
id-token: write
pull-requests: write

View File

@@ -197,7 +197,7 @@ jobs:
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: sql.dacpac
path: Sql.dacpac
@@ -223,7 +223,7 @@ jobs:
shell: pwsh
- name: Report validation results
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: report.xml
path: |

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.11.0</Version>
<Version>2025.11.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -651,7 +651,23 @@ public class AccountController : Controller
EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30)
};
await _registerUserCommand.RegisterUser(newUser);
/*
The feature flag is checked here so that we can send the new MJML welcome email templates.
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
TODO: Remove Feature flag: PM-28221
*/
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
{
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
}
else
{
await _registerUserCommand.RegisterUser(newUser);
}
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =

View File

@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
@@ -18,6 +19,7 @@ using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
@@ -1008,4 +1010,131 @@ public class AccountControllerTest
_output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}");
}
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-new-user";
var email = "newuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// Feature flag enabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
// Mock the RegisterSSOAutoProvisionedUserAsync to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "New User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterSSOAutoProvisionedUserAsync(
Arg.Is<User>(u => u.Email == email && u.Name == "New User"),
Arg.Is<Organization>(o => o.Id == orgId && o.Name == "Test Org"));
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
Assert.Equal(organization.Id, result.organization.Id);
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-legacy-user";
var email = "legacyuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// Feature flag disabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(false);
// Mock the RegisterUser to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterUser(Arg.Any<User>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "Legacy User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterUser(Arg.Is<User>(u => u.Email == email && u.Name == "Legacy User"));
// Verify the new method was NOT called
await sutProvider.GetDependency<IRegisterUserCommand>().DidNotReceive()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>());
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
}
}

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using System.Net;
using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response;
@@ -8,6 +6,7 @@ using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.Context;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
@@ -22,6 +21,9 @@ public class EventsController : Controller
private readonly IEventRepository _eventRepository;
private readonly ICipherRepository _cipherRepository;
private readonly ICurrentContext _currentContext;
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
private readonly IUserService _userService;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
@@ -29,12 +31,18 @@ public class EventsController : Controller
IEventRepository eventRepository,
ICipherRepository cipherRepository,
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository,
IUserService userService,
ILogger<EventsController> logger,
IFeatureService featureService)
{
_eventRepository = eventRepository;
_cipherRepository = cipherRepository;
_currentContext = currentContext;
_secretRepository = secretRepository;
_projectRepository = projectRepository;
_userService = userService;
_logger = logger;
_featureService = featureService;
}
@@ -50,35 +58,76 @@ public class EventsController : Controller
[ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
{
if (!_currentContext.OrganizationId.HasValue)
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
var organizationId = _currentContext.OrganizationId.Value;
var dateRange = request.ToDateRange();
var result = new PagedResult<IEvent>();
if (request.ActingUserId.HasValue)
{
result = await _eventRepository.GetManyByOrganizationActingUserAsync(
_currentContext.OrganizationId.Value, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
organizationId, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else if (request.ItemId.HasValue)
{
var cipher = await _cipherRepository.GetByIdAsync(request.ItemId.Value);
if (cipher != null && cipher.OrganizationId == _currentContext.OrganizationId.Value)
if (cipher != null && cipher.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyByCipherAsync(
cipher, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
}
else if (request.SecretId.HasValue)
{
var secret = await _secretRepository.GetByIdAsync(request.SecretId.Value);
if (secret == null)
{
secret = new Core.SecretsManager.Entities.Secret { Id = request.SecretId.Value, OrganizationId = organizationId };
}
if (secret.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyBySecretAsync(
secret, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
}
else if (request.ProjectId.HasValue)
{
var project = await _projectRepository.GetByIdAsync(request.ProjectId.Value);
if (project != null && project.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyByProjectAsync(
project, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
}
else
{
result = await _eventRepository.GetManyByOrganizationAsync(
_currentContext.OrganizationId.Value, dateRange.Item1, dateRange.Item2,
organizationId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
var eventResponses = result.Data.Select(e => new EventResponseModel(e));
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken ?? "");
_logger.LogAggregateData(_featureService, organizationId, response, request);
_logger.LogAggregateData(_featureService, _currentContext.OrganizationId!.Value, response, request);
return new JsonResult(response);
}
}

View File

@@ -24,6 +24,14 @@ public class EventFilterRequestModel
/// </summary>
public Guid? ItemId { get; set; }
/// <summary>
/// The unique identifier of the related secret that the event describes.
/// </summary>
public Guid? SecretId { get; set; }
/// <summary>
/// The unique identifier of the related project that the event describes.
/// </summary>
public Guid? ProjectId { get; set; }
/// <summary>
/// A cursor for use in pagination.
/// </summary>
public string ContinuationToken { get; set; }

View File

@@ -4,6 +4,7 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
@@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers;
public class AccountsController(
IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery) : Controller
IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller
{
[HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync(
@@ -84,16 +86,24 @@ public class AccountsController(
throw new UnauthorizedAccessException();
}
if (!globalSettings.SelfHosted && user.Gateway != null)
// Only cloud-hosted users with payment gateways have subscription and discount information
if (!globalSettings.SelfHosted)
{
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license);
}
else if (!globalSettings.SelfHosted)
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
if (user.Gateway != null)
{
// Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341).
// This specific implementation (PM-26682) adds discount display functionality as part of that initiative.
// The feature flag controls the broader Milestone 2 feature set, not just this specific task.
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
}
else
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
}
}
else
{

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Api;
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
public class SubscriptionResponseModel : ResponseModel
{
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license)
/// <param name="user">The user entity containing storage and premium subscription information</param>
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
/// <param name="license">The user's license containing expiration and feature entitlements</param>
/// <param name="includeMilestone2Discount">
/// Whether to include discount information in the response.
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
/// you want to expose Milestone 2 discount information to the client.
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
/// </param>
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false)
: base("subscription")
{
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
@@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel
MaxStorageGb = user.MaxStorageGb;
License = license;
Expiration = License.Expires;
// Only display the Milestone 2 subscription discount on the subscription page.
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount)
? new BillingCustomerDiscount(subscription.CustomerDiscount!)
: null;
}
public SubscriptionResponseModel(User user, UserLicense license = null)
public SubscriptionResponseModel(User user, UserLicense? license = null)
: base("subscription")
{
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
@@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel
}
}
public string StorageName { get; set; }
public string? StorageName { get; set; }
public double? StorageGb { get; set; }
public short? MaxStorageGb { get; set; }
public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; }
public BillingSubscription Subscription { get; set; }
public UserLicense License { get; set; }
public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; }
public BillingSubscription? Subscription { get; set; }
/// <summary>
/// Customer discount information from Stripe for the Milestone 2 subscription discount.
/// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration).
/// This is for display purposes only and does not affect Stripe's automatic discount application.
/// Other discounts may still apply in Stripe billing but are not included in this response.
/// <para>
/// Null when:
/// - The PM23341_Milestone_2 feature flag is disabled
/// - There is no active discount
/// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1)
/// - The instance is self-hosted
/// </para>
/// </summary>
public BillingCustomerDiscount? CustomerDiscount { get; set; }
public UserLicense? License { get; set; }
public DateTime? Expiration { get; set; }
/// <summary>
/// Determines whether the Milestone 2 discount should be included in the response.
/// </summary>
/// <param name="includeMilestone2Discount">Whether the feature flag is enabled and discount should be considered.</param>
/// <param name="customerDiscount">The customer discount from subscription info, if any.</param>
/// <returns>True if the discount should be included; false otherwise.</returns>
private static bool ShouldIncludeMilestone2Discount(
bool includeMilestone2Discount,
SubscriptionInfo.BillingCustomerDiscount? customerDiscount)
{
return includeMilestone2Discount &&
customerDiscount != null &&
customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount &&
customerDiscount.Active;
}
}
public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
/// <summary>
/// Customer discount information from Stripe billing.
/// </summary>
public class BillingCustomerDiscount
{
public string Id { get; } = discount.Id;
public bool Active { get; } = discount.Active;
public decimal? PercentOff { get; } = discount.PercentOff;
public List<string> AppliesTo { get; } = discount.AppliesTo;
/// <summary>
/// The Stripe coupon ID (e.g., "cm3nHfO1").
/// </summary>
public string? Id { get; }
/// <summary>
/// Whether the discount is a recurring/perpetual discount with no expiration date.
/// <para>
/// This property is true only when the discount has no end date, meaning it applies
/// indefinitely to all future renewals. This is a product decision for Milestone 2
/// to only display perpetual discounts in the UI.
/// </para>
/// <para>
/// Note: This does NOT indicate whether the discount is "currently active" in the billing sense.
/// A discount with a future end date is functionally active and will be applied by Stripe,
/// but this property will be false because it has an expiration date.
/// </para>
/// </summary>
public bool Active { get; }
/// <summary>
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
/// Null if this is an amount-based discount.
/// </summary>
public decimal? PercentOff { get; }
/// <summary>
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
/// Null if this is a percentage-based discount.
/// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD.
/// </summary>
public decimal? AmountOff { get; }
/// <summary>
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
/// <para>
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
/// Non-empty list: discount applies only to the specified product IDs.
/// </para>
/// </summary>
public IReadOnlyList<string>? AppliesTo { get; }
/// <summary>
/// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount.
/// </summary>
/// <param name="discount">The discount to convert. Must not be null.</param>
/// <exception cref="ArgumentNullException">Thrown when discount is null.</exception>
public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
{
ArgumentNullException.ThrowIfNull(discount);
Id = discount.Id;
Active = discount.Active;
PercentOff = discount.PercentOff;
AmountOff = discount.AmountOff;
AppliesTo = discount.AppliesTo;
}
}
public class BillingSubscription
@@ -83,10 +184,10 @@ public class BillingSubscription
public DateTime? PeriodEndDate { get; set; }
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string Status { get; set; }
public string? Status { get; set; }
public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public string CollectionMethod { get; set; }
public string? CollectionMethod { get; set; }
public DateTime? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; }
public int? GracePeriod { get; set; }
@@ -104,11 +205,11 @@ public class BillingSubscription
AddonSubscriptionItem = item.AddonSubscriptionItem;
}
public string ProductId { get; set; }
public string Name { get; set; }
public string? ProductId { get; set; }
public string? Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string Interval { get; set; }
public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; }
public bool AddonSubscriptionItem { get; set; }
}

View File

@@ -402,8 +402,9 @@ public class CiphersController : Controller
{
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin" or if we're not a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
// If we're not an "admin" we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true }))
{
return false;
}
@@ -416,8 +417,9 @@ public class CiphersController : Controller
{
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin" or if we're a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
// If we're not an "admin" we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true }))
{
return false;
}

View File

@@ -1,11 +1,8 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Queries;
@@ -17,11 +14,13 @@ using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Stripe;
using static Bit.Core.Billing.Constants.StripeConstants;
using Event = Stripe.Event;
using Plan = Bit.Core.Models.StaticStore.Plan;
namespace Bit.Billing.Services.Implementations;
using static StripeConstants;
public class UpcomingInvoiceHandler(
IGetPaymentMethodQuery getPaymentMethodQuery,
ILogger<StripeEventProcessor> logger,
@@ -57,204 +56,88 @@ public class UpcomingInvoiceHandler(
if (organizationId.HasValue)
{
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null)
{
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.IsAnnual)
{
return;
}
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid =
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
if (!sponsorshipIsValid)
{
/*
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
* price. Given that this is the case, we need the new invoice amount
*/
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
}
}
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice);
/*
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
* Disabling this as part of a hot fix. It needs to check whether the organization
* belongs to a Reseller provider and only send an email to the organization owners if it does.
* It also requires a new email template as the current one contains too much billing information.
*/
// var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);
// await SendEmails(ownerEmails);
await HandleOrganizationUpcomingInvoiceAsync(
organizationId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (userId.HasValue)
{
var user = await userRepository.GetByIdAsync(userId.Value);
if (user == null)
{
return;
}
if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
}
}
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
if (milestone2Feature)
{
await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user);
}
if (user.Premium)
{
await (milestone2Feature
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
}
await HandlePremiumUsersUpcomingInvoiceAsync(
userId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (providerId.HasValue)
{
var provider = await providerRepository.GetByIdAsync(providerId.Value);
if (provider == null)
{
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
await HandleProviderUpcomingInvoiceAsync(
providerId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
}
private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user)
#region Organizations
private async Task HandleOrganizationUpcomingInvoiceAsync(
Guid organizationId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var pricingItem =
subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
if (pricingItem != null)
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
try
logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})",
organizationId, @event.Type, @event.Id);
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
await AlignOrganizationSubscriptionConcernsAsync(
organization,
@event,
subscription,
plan,
milestone3);
// Don't send the upcoming invoice email unless the organization's on an annual plan.
if (!plan.IsAnnual)
{
return;
}
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid =
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
if (!sponsorshipIsValid)
{
var plan = await pricingClient.GetAvailablePremiumPlan();
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId }
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
],
ProrationBehavior = "none"
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
/*
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
* price. Given that this is the case, we need the new invoice amount
*/
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
}
}
}
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
{
await mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
true);
}
}
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
{
ToEmails = validEmails,
View = new UpdatedInvoiceUpcomingView()
};
await mailer.SendEmail(updatedUpcomingEmail);
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
Subscription subscription, Guid providerId)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.FormatForProvider(subscription);
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId);
return;
}
var collectionMethod = subscription.CollectionMethod;
var paymentMethod = await getPaymentMethodQuery.Run(provider);
var hasPaymentMethod = paymentMethod != null;
var paymentMethodDescription = paymentMethod?.Match(
bankAccount => $"Bank account ending in {bankAccount.Last4}",
card => $"{card.Brand} ending in {card.Last4}",
payPal => $"PayPal account {payPal.Email}"
);
await mailService.SendProviderInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
collectionMethod,
hasPaymentMethod,
paymentMethodDescription);
}
await (milestone3
? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail])
: SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice));
}
private async Task AlignOrganizationTaxConcernsAsync(
@@ -305,6 +188,228 @@ public class UpcomingInvoiceHandler(
}
}
private async Task AlignOrganizationSubscriptionConcernsAsync(
Organization organization,
Event @event,
Subscription subscription,
Plan plan,
bool milestone3)
{
// currently these are the only plans that need aligned and both require the same flag and share most of the logic
if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025))
{
return;
}
var passwordManagerItem =
subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId);
if (passwordManagerItem == null)
{
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
organization.Id, @event.Type, @event.Id);
return;
}
var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
organization.PlanType = families.Type;
organization.Plan = families.Name;
organization.UsersGetPremium = families.UsersGetPremium;
organization.Seats = families.PasswordManager.BaseSeats;
var options = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = families.PasswordManager.StripePlanId
}
],
ProrationBehavior = ProrationBehavior.None
};
if (plan.Type == PlanType.FamiliesAnnually2019)
{
options.Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
];
var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item =>
item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId);
if (premiumAccessAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = premiumAccessAddOnItem.Id,
Deleted = true
});
}
var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually");
if (seatAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = seatAddOnItem.Id,
Deleted = true
});
}
}
try
{
await organizationRepository.ReplaceAsync(organization);
await stripeFacade.UpdateSubscription(subscription.Id, options);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})",
organization.Id,
@event.Type,
@event.Id);
}
}
#endregion
#region Premium Users
private async Task HandlePremiumUsersUpcomingInvoiceAsync(
Guid userId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var user = await userRepository.GetByIdAsync(userId);
if (user == null)
{
logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})",
userId, @event.Type, @event.Id);
return;
}
await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription);
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
if (milestone2Feature)
{
await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
}
if (user.Premium)
{
await (milestone2Feature
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
}
}
private async Task AlignPremiumUsersTaxConcernsAsync(
User user,
Event @event,
Customer customer,
Subscription subscription)
{
if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
@event.Id);
}
}
}
private async Task AlignPremiumUsersSubscriptionConcernsAsync(
User user,
Event @event,
Subscription subscription)
{
var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
if (premiumItem == null)
{
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
user.Id, @event.Type, @event.Id);
return;
}
try
{
var plan = await pricingClient.GetAvailablePremiumPlan();
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId }
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
],
ProrationBehavior = ProrationBehavior.None
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
user.Id,
@event.Id);
}
}
#endregion
#region Providers
private async Task HandleProviderUpcomingInvoiceAsync(
Guid providerId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})",
providerId, @event.Type, @event.Id);
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id);
if (!string.IsNullOrEmpty(provider.BillingEmail))
{
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId);
}
}
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
@@ -349,4 +454,75 @@ public class UpcomingInvoiceHandler(
}
}
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
Subscription subscription, Guid providerId)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.FormatForProvider(subscription);
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId);
return;
}
var collectionMethod = subscription.CollectionMethod;
var paymentMethod = await getPaymentMethodQuery.Run(provider);
var hasPaymentMethod = paymentMethod != null;
var paymentMethodDescription = paymentMethod?.Match(
bankAccount => $"Bank account ending in {bankAccount.Last4}",
card => $"{card.Brand} ending in {card.Last4}",
payPal => $"PayPal account {payPal.Email}"
);
await mailService.SendProviderInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
collectionMethod,
hasPaymentMethod,
paymentMethodDescription);
}
}
#endregion
#region Shared
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 })
{
await mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
true);
}
}
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
{
ToEmails = validEmails,
View = new UpdatedInvoiceUpcomingView()
};
await mailer.SendEmail(updatedUpcomingEmail);
}
#endregion
}

View File

@@ -75,8 +75,7 @@ public class CloudOrganizationSignUpCommand(
PlanType = plan!.Type,
Seats = (short)(plan.PasswordManager.BaseSeats + signup.AdditionalSeats),
MaxCollections = plan.PasswordManager.MaxCollections,
MaxStorageGb = !plan.PasswordManager.BaseStorageGb.HasValue ?
(short?)null : (short)(plan.PasswordManager.BaseStorageGb.Value + signup.AdditionalStorageGb),
MaxStorageGb = (short)(plan.PasswordManager.BaseStorageGb + signup.AdditionalStorageGb),
UsePolicies = plan.HasPolicies,
UseSso = plan.HasSso,
UseGroups = plan.HasGroups,

View File

@@ -73,7 +73,7 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati
PlanType = plan!.Type,
Seats = signup.AdditionalSeats,
MaxCollections = plan.PasswordManager.MaxCollections,
MaxStorageGb = 1,
MaxStorageGb = plan.PasswordManager.BaseStorageGb,
UsePolicies = plan.HasPolicies,
UseSso = plan.HasSso,
UseOrganizationDomains = plan.HasOrganizationDomains,

View File

@@ -9,6 +9,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
/// <summary>
/// Defines behavior and functionality for a given PolicyType.
/// </summary>
/// <remarks>
/// All methods defined in this interface are for the PolicyService#SavePolicy method. This needs to be supported until
/// we successfully refactor policy validators over to policy validation handlers
/// </remarks>
public interface IPolicyValidator
{
/// <summary>

View File

@@ -53,6 +53,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, AutomaticUserConfirmationPolicyEventHandler>();
}
private static void AddPolicyRequirements(this IServiceCollection services)

View File

@@ -2,6 +2,13 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Represents all policies required to be enabled before the given policy can be enabled.
/// </summary>
/// <remarks>
/// This interface is intended for policy event handlers that mandate the activation of other policies
/// as prerequisites for enabling the associated policy.
/// </remarks>
public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent
{
/// <summary>

View File

@@ -3,6 +3,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Represents all side effects that should be executed before a policy is upserted.
/// </summary>
/// <remarks>
/// This should be added to policy handlers that need to perform side effects before policy upserts.
/// </remarks>
public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent
{
/// <summary>

View File

@@ -2,6 +2,12 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Represents the policy to be upserted.
/// </summary>
/// <remarks>
/// This is used for the VNextSavePolicyCommand. All policy handlers should implement this interface.
/// </remarks>
public interface IPolicyUpdateEvent
{
/// <summary>

View File

@@ -3,12 +3,17 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Represents all validations that need to be run to enable or disable the given policy.
/// </summary>
/// <remarks>
/// This is used for the VNextSavePolicyCommand. This optional but should be implemented for all policies that have
/// certain requirements for the given organization.
/// </remarks>
public interface IPolicyValidationEvent : IPolicyUpdateEvent
{
/// <summary>
/// Performs side effects after a policy is validated but before it is saved.
/// For example, this can be used to remove non-compliant users from the organization.
/// Implementation is optional; by default, it will not perform any side effects.
/// Performs any validations required to enable or disable the policy.
/// </summary>
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
/// <param name="currentPolicy">The current policy, if any</param>

View File

@@ -0,0 +1,131 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
/// <summary>
/// Represents an event handler for the Automatic User Confirmation policy.
///
/// This class validates that the following conditions are met:
/// <ul>
/// <li>The Single organization policy is enabled</li>
/// <li>All organization users are compliant with the Single organization policy</li>
/// <li>No provider users exist</li>
/// </ul>
///
/// This class also performs side effects when the policy is being enabled or disabled. They are:
/// <ul>
/// <li>Sets the UseAutomaticUserConfirmation organization feature to match the policy update</li>
/// </ul>
/// </summary>
public class AutomaticUserConfirmationPolicyEventHandler(
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
IPolicyRepository policyRepository,
IOrganizationRepository organizationRepository,
TimeProvider timeProvider)
: IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) =>
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
private const string _singleOrgPolicyNotEnabledErrorMessage =
"The Single organization policy must be enabled before enabling the Automatically confirm invited users policy.";
private const string _usersNotCompliantWithSingleOrgErrorMessage =
"All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations.";
private const string _providerUsersExistErrorMessage =
"The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy.";
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
var isNotEnablingPolicy = policyUpdate is not { Enabled: true };
var policyAlreadyEnabled = currentPolicy is { Enabled: true };
if (isNotEnablingPolicy || policyAlreadyEnabled)
{
return string.Empty;
}
return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId);
}
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId);
if (organization is not null)
{
organization.UseAutomaticUserConfirmation = policyUpdate.Enabled;
organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await organizationRepository.UpsertAsync(organization);
}
}
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
{
var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId);
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
{
return singleOrgValidationError;
}
var providerValidationError = await ValidateNoProviderUsersAsync(organizationId);
if (!string.IsNullOrWhiteSpace(providerValidationError))
{
return providerValidationError;
}
return string.Empty;
}
private async Task<string> ValidateSingleOrgPolicyComplianceAsync(Guid organizationId)
{
var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg);
if (singleOrgPolicy is not { Enabled: true })
{
return _singleOrgPolicyNotEnabledErrorMessage;
}
return await ValidateUserComplianceWithSingleOrgAsync(organizationId);
}
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId)
{
var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId))
.Where(ou => ou.Status != OrganizationUserStatusType.Invited &&
ou.Status != OrganizationUserStatusType.Revoked &&
ou.UserId.HasValue)
.ToList();
if (organizationUsers.Count == 0)
{
return string.Empty;
}
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
organizationUsers.Select(ou => ou.UserId!.Value)))
.Any(uo => uo.OrganizationId != organizationId &&
uo.Status != OrganizationUserStatusType.Invited);
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
}
private async Task<string> ValidateNoProviderUsersAsync(Guid organizationId)
{
var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId);
return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty;
}
}

View File

@@ -148,7 +148,7 @@ public class OrganizationService : IOrganizationService
}
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb,
plan.PasswordManager.StripeStoragePlanId);
plan.PasswordManager.StripeStoragePlanId, plan.PasswordManager.BaseStorageGb);
await ReplaceAndUpdateCacheAsync(organization);
return secret;
}

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.Entities;
using Bit.Core.Tokens;
@@ -26,7 +23,7 @@ public class OrgUserInviteTokenable : ExpiringTokenable
public string Identifier { get; set; } = TokenIdentifier;
public Guid OrgUserId { get; set; }
public string OrgUserEmail { get; set; }
public string? OrgUserEmail { get; set; }
[JsonConstructor]
public OrgUserInviteTokenable()

View File

@@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.UserFeatures.Registration;
@@ -14,6 +15,15 @@ public interface IRegisterUserCommand
/// <returns><see cref="IdentityResult"/></returns>
public Task<IdentityResult> RegisterUser(User user);
/// <summary>
/// Creates a new user, sends a welcome email, and raises the signup reference event.
/// This method is used by SSO auto-provisioned organization Users.
/// </summary>
/// <param name="user">The <see cref="User"/> to create</param>
/// <param name="organization">The <see cref="Organization"/> associated with the user</param>
/// <returns><see cref="IdentityResult"/></returns>
Task<IdentityResult> RegisterSSOAutoProvisionedUserAsync(User user, Organization organization);
/// <summary>
/// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path),
/// and raises the signup reference event. Optionally accepts an org invite token and org user id to associate

View File

@@ -1,11 +1,10 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -24,6 +23,7 @@ public class RegisterUserCommand : IRegisterUserCommand
{
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
@@ -37,24 +37,27 @@ public class RegisterUserCommand : IRegisterUserCommand
private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand;
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _emergencyAccessInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator.";
public RegisterUserCommand(
IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository,
IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
IUserService userService,
IMailService mailService,
IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory
)
IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
IUserService userService,
IMailService mailService,
IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory,
IFeatureService featureService)
{
_globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_policyRepository = policyRepository;
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
@@ -69,9 +72,9 @@ public class RegisterUserCommand : IRegisterUserCommand
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
_providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
_featureService = featureService;
}
public async Task<IdentityResult> RegisterUser(User user)
{
var result = await _userService.CreateUserAsync(user);
@@ -83,11 +86,22 @@ public class RegisterUserCommand : IRegisterUserCommand
return result;
}
public async Task<IdentityResult> RegisterSSOAutoProvisionedUserAsync(User user, Organization organization)
{
var result = await _userService.CreateUserAsync(user);
if (result == IdentityResult.Success)
{
await SendWelcomeEmailAsync(user, organization);
}
return result;
}
public async Task<IdentityResult> RegisterUserViaOrganizationInviteToken(User user, string masterPasswordHash,
string orgInviteToken, Guid? orgUserId)
{
ValidateOrgInviteToken(orgInviteToken, orgUserId, user);
await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user);
TryValidateOrgInviteToken(orgInviteToken, orgUserId, user);
var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user);
user.ApiKey = CoreHelpers.SecureRandomString(30);
@@ -97,16 +111,17 @@ public class RegisterUserCommand : IRegisterUserCommand
}
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
var organization = await GetOrganizationUserOrganization(orgUserId ?? Guid.Empty, orgUser);
if (result == IdentityResult.Success)
{
var sentWelcomeEmail = false;
if (!string.IsNullOrEmpty(user.ReferenceData))
{
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData);
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData) ?? [];
if (referenceData.TryGetValue("initiationPath", out var value))
{
var initiationPath = value.ToString();
await SendAppropriateWelcomeEmailAsync(user, initiationPath);
var initiationPath = value.ToString() ?? string.Empty;
await SendAppropriateWelcomeEmailAsync(user, initiationPath, organization);
sentWelcomeEmail = true;
if (!string.IsNullOrEmpty(initiationPath))
{
@@ -117,14 +132,22 @@ public class RegisterUserCommand : IRegisterUserCommand
if (!sentWelcomeEmail)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user, organization);
}
}
return result;
}
private void ValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user)
/// <summary>
/// This method attempts to validate the org invite token if provided. If the token is invalid an exception is thrown.
/// If there is no exception it is assumed the token is valid or not provided and open registration is allowed.
/// </summary>
/// <param name="orgInviteToken">The organization invite token.</param>
/// <param name="orgUserId">The organization user ID.</param>
/// <param name="user">The user being registered.</param>
/// <exception cref="BadRequestException">If validation fails then an exception is thrown.</exception>
private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user)
{
var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken);
@@ -137,7 +160,6 @@ public class RegisterUserCommand : IRegisterUserCommand
}
// Token data is invalid
if (_globalSettings.DisableUserRegistration)
{
throw new BadRequestException(_disabledUserRegistrationExceptionMsg);
@@ -147,7 +169,6 @@ public class RegisterUserCommand : IRegisterUserCommand
}
// no token data or missing token data
// Throw if open registration is disabled and there isn't an org invite token or an org user id
// as you can't register without them.
if (_globalSettings.DisableUserRegistration)
@@ -171,12 +192,20 @@ public class RegisterUserCommand : IRegisterUserCommand
// If both orgInviteToken && orgUserId are missing, then proceed with open registration
}
/// <summary>
/// Validates the org invite token using the new tokenable logic first, then falls back to the old token validation logic for backwards compatibility.
/// Will set the out parameter organizationWelcomeEmailDetails if the new token is valid. If the token is invalid then no welcome email needs to be sent
/// so the out parameter is set to null.
/// </summary>
/// <param name="orgInviteToken">Invite token</param>
/// <param name="orgUserId">Inviting Organization UserId</param>
/// <param name="userEmail">User email</param>
/// <returns>true if the token is valid false otherwise</returns>
private bool IsOrgInviteTokenValid(string orgInviteToken, Guid orgUserId, string userEmail)
{
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail);
return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid(
_organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings);
}
@@ -187,11 +216,12 @@ public class RegisterUserCommand : IRegisterUserCommand
/// </summary>
/// <param name="orgUserId">The optional org user id</param>
/// <param name="user">The newly created user object which could be modified</param>
private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user)
/// <returns>The organization user if one exists for the provided org user id, null otherwise</returns>
private async Task<OrganizationUser?> SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user)
{
if (!orgUserId.HasValue)
{
return;
return null;
}
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
@@ -213,10 +243,11 @@ public class RegisterUserCommand : IRegisterUserCommand
_userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email);
}
}
return orgUser;
}
private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath)
private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath, Organization? organization)
{
var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial");
@@ -226,16 +257,14 @@ public class RegisterUserCommand : IRegisterUserCommand
}
else
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user, organization);
}
}
public async Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash,
string emailVerificationToken)
{
ValidateOpenRegistrationAllowed();
var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email);
user.EmailVerified = true;
@@ -245,7 +274,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user);
}
return result;
@@ -263,7 +292,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user);
}
return result;
@@ -283,7 +312,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user);
}
return result;
@@ -301,7 +330,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user);
}
return result;
@@ -357,4 +386,59 @@ public class RegisterUserCommand : IRegisterUserCommand
return tokenable;
}
/// <summary>
/// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the
/// email isn't present we send the standard individual welcome email.
/// </summary>
/// <param name="user">Target user for the email</param>
/// <param name="organization">this value is nullable</param>
/// <returns></returns>
private async Task SendWelcomeEmailAsync(User user, Organization? organization = null)
{
// Check if feature is enabled
// TODO: Remove Feature flag: PM-28221
if (!_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
{
await _mailService.SendWelcomeEmailAsync(user);
return;
}
// Most emails are probably for non organization users so we default to that experience
if (organization == null)
{
await _mailService.SendIndividualUserWelcomeEmailAsync(user);
}
// We need to make sure that the organization email has the correct data to display otherwise we just send the standard welcome email
else if (!string.IsNullOrEmpty(organization.DisplayName()))
{
// If the organization is Free or Families plan, send families welcome email
if (organization.PlanType is PlanType.FamiliesAnnually
or PlanType.FamiliesAnnually2019
or PlanType.Free)
{
await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName());
}
else
{
await _mailService.SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName());
}
}
// If the organization data isn't present send the standard welcome email
else
{
await _mailService.SendIndividualUserWelcomeEmailAsync(user);
}
}
private async Task<Organization?> GetOrganizationUserOrganization(Guid orgUserId, OrganizationUser? orgUser = null)
{
var organizationUser = orgUser ?? await _organizationUserRepository.GetByIdAsync(orgUserId);
if (organizationUser == null)
{
return null;
}
return await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
}
}

View File

@@ -22,7 +22,8 @@ public static class StripeConstants
{
public const string LegacyMSPDiscount = "msp-discount-35";
public const string SecretsManagerStandalone = "sm-standalone";
public const string Milestone2SubscriptionDiscount = "cm3nHfO1";
public const string Milestone2SubscriptionDiscount = "milestone-2c";
public const string Milestone3SubscriptionDiscount = "milestone-3";
public static class MSPDiscounts
{

View File

@@ -97,7 +97,7 @@ public abstract record Plan
public decimal PremiumAccessOptionPrice { get; init; }
public short? MaxSeats { get; init; }
// Storage
public short? BaseStorageGb { get; init; }
public short BaseStorageGb { get; init; }
public bool HasAdditionalStorageOption { get; init; }
public decimal AdditionalStoragePricePerGb { get; init; }
public string StripeStoragePlanId { get; init; }

View File

@@ -80,6 +80,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
return new BadRequest("Additional storage must be greater than 0.");
}
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
Customer? customer;
/*
@@ -107,7 +109,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
customer = await ReconcileBillingLocationAsync(customer, billingAddress);
var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null);
var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, additionalStorageGb > 0 ? additionalStorageGb : null);
paymentMethod.Switch(
tokenized =>
@@ -140,7 +142,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
user.GatewaySubscriptionId = subscription.Id;
user.MaxStorageGb = (short)(1 + additionalStorageGb);
user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + additionalStorageGb);
user.LicenseKey = CoreHelpers.SecureRandomString(20);
user.RevisionDate = DateTime.UtcNow;
@@ -304,9 +306,9 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
private async Task<Subscription> CreateSubscriptionAsync(
Guid userId,
Customer customer,
Pricing.Premium.Plan premiumPlan,
int? storage)
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{

View File

@@ -99,11 +99,19 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
_ => true);
var baseSeats = GetBaseSeats(plan.Seats);
var maxSeats = GetMaxSeats(plan.Seats);
var baseStorageGb = (short?)plan.Storage?.Provided;
var baseStorageGb = (short)(plan.Storage?.Provided ?? 0);
var hasAdditionalStorageOption = plan.Storage != null;
var additionalStoragePricePerGb = plan.Storage?.Price ?? 0;
var stripeStoragePlanId = plan.Storage?.StripePriceId;
short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null;
var stripePremiumAccessPlanId =
plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceId", out var premiumAccessAddOnPriceIdValue)
? premiumAccessAddOnPriceIdValue
: null;
var premiumAccessOptionPrice =
plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceAmount", out var premiumAccessAddOnPriceAmountValue)
? decimal.Parse(premiumAccessAddOnPriceAmountValue)
: 0;
return new PasswordManagerPlanFeatures
{
@@ -121,7 +129,9 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
HasAdditionalStorageOption = hasAdditionalStorageOption,
AdditionalStoragePricePerGb = additionalStoragePricePerGb,
StripeStoragePlanId = stripeStoragePlanId,
MaxCollections = maxCollections
MaxCollections = maxCollections,
StripePremiumAccessPlanId = stripePremiumAccessPlanId,
PremiumAccessOptionPrice = premiumAccessOptionPrice
};
}

View File

@@ -4,4 +4,5 @@ public class Purchasable
{
public string StripePriceId { get; init; } = null!;
public decimal Price { get; init; }
public int Provided { get; init; }
}

View File

@@ -186,6 +186,6 @@ public class PricingClient(
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal }
Storage = new Purchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 }
};
}

View File

@@ -101,7 +101,9 @@ public class PremiumUserBillingService(
*/
customer = await ReconcileBillingLocationAsync(customer, customerSetup.TaxInformation);
var subscription = await CreateSubscriptionAsync(user.Id, customer, storage);
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, storage);
switch (customerSetup.TokenizedPaymentSource)
{
@@ -119,6 +121,7 @@ public class PremiumUserBillingService(
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
user.GatewaySubscriptionId = subscription.Id;
user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + (storage ?? 0));
await userRepository.ReplaceAsync(user);
}
@@ -301,9 +304,9 @@ public class PremiumUserBillingService(
private async Task<Subscription> CreateSubscriptionAsync(
Guid userId,
Customer customer,
Pricing.Premium.Plan premiumPlan,
int? storage)
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{

View File

@@ -162,6 +162,8 @@ public static class FeatureFlagKeys
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
public const string MjmlWelcomeEmailTemplates = "mjml-welcome-email-templates";
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
/* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";

View File

@@ -68,6 +68,9 @@
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.1.2" />
<PackageReference Include="ZiggyCreatures.FusionCache" Version="2.0.2" />
<PackageReference Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis" Version="2.0.2" />
<PackageReference Include="ZiggyCreatures.FusionCache.Serialization.SystemTextJson" Version="2.0.2" />
</ItemGroup>
<ItemGroup Label="Pinned transitive dependencies">

View File

@@ -29,8 +29,8 @@
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
@@ -45,19 +45,33 @@
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-90 { width:90% !important; max-width: 90%; }
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
@@ -65,258 +79,252 @@
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
@media only screen and (max-width: 480px) {
.hide-small-img {
display: none !important;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
</style>
<!-- Responsive icon visibility -->
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
Verify your email to access this Bitwarden Send
</h1></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Your verification code is:</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
</td>
</tr>
<tr>
<td style="font-size:0px;word-break:break-word;">
<div style="height:20px;line-height:20px;">&#8202;</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need to
verify your email again.</div>
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need
to verify your email again.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 0px 20px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="send-bubble-outlook" style="vertical-align:top;width:558px;" ><![endif]-->
<div class="mj-column-per-90 mj-outlook-group-fix send-bubble" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#DBE5F6;border-radius:16px;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><p>
Bitwarden Send transmits sensitive, temporary information to
others easily and securely. Learn more about
@@ -325,160 +333,160 @@
<a href="https://bitwarden.com/signup" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">sign up</a>
to try it today.
</p></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
@@ -493,15 +501,15 @@
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
@@ -516,15 +524,15 @@
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
@@ -539,15 +547,15 @@
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
@@ -562,15 +570,15 @@
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
@@ -585,15 +593,15 @@
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
@@ -608,15 +616,15 @@
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
@@ -631,20 +639,20 @@
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
@@ -655,28 +663,29 @@
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,915 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-15 { width:15% !important; max-width: 15%; }
.mj-column-per-85 { width:85% !important; max-width: 85%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-15 { width:15% !important; max-width: 15%; }
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
</style>
<style type="text/css">
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
@media only screen and (max-width: 480px) {
.hide-small-img {
display: none !important;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
}
@media only screen and (max-width: 480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<!-- Responsive icon visibility -->
<!-- Responsive styling for mj-bw-icon-row -->
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
Welcome to Bitwarden!
</h1>
<mj-text color="#fff" padding-top="0" padding-bottom="0">
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
Let's get set up to autofill.
</h2>
</mj-text></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/account-fill.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">A <b>{{OrganizationName}}</b> administrator will approve you
before you can share passwords. While you wait for approval, get
started with Bitwarden Password Manager:</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Browser Extension Icon" src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/download/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Get the browser extension
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">With the Bitwarden extension, you can fill passwords with one click.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Install Icon" src="https://assets.bitwarden.com/email/v1/icon-install.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/import-data/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Add passwords to your vault
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Quickly transfer existing passwords to Bitwarden using the importer.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Devices Icon" src="https://assets.bitwarden.com/email/v1/icon-devices.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/download/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Download Bitwarden on all devices
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Take your passwords with you anywhere.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,19 @@
{{#>FullTextLayout}}
Welcome to Bitwarden!
Let's get you set up with autofill.
A {{OrganizationName}} administrator will approve you before you can share passwords.
While you wait for approval, get started with Bitwarden Password Manager:
Get the browser extension:
With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download)
Add passwords to your vault:
Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/)
Download Bitwarden on all devices:
Take your passwords with you anywhere. (https://www.bitwarden.com/download)
Learn more about Bitwarden
Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/)
{{/FullTextLayout}}

View File

@@ -0,0 +1,914 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-15 { width:15% !important; max-width: 15%; }
.mj-column-per-85 { width:85% !important; max-width: 85%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-15 { width:15% !important; max-width: 15%; }
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
</style>
<style type="text/css">
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
@media only screen and (max-width: 480px) {
.hide-small-img {
display: none !important;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
}
@media only screen and (max-width: 480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<!-- Responsive icon visibility -->
<!-- Responsive styling for mj-bw-icon-row -->
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
Welcome to Bitwarden!
</h1>
<mj-text color="#fff" padding-top="0" padding-bottom="0">
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
Let's get set up to autofill.
</h2>
</mj-text></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/account-fill.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Follow these simple steps to get up and running with Bitwarden
Password Manager:</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Browser Extension Icon" src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/download/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Get the browser extension
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">With the Bitwarden extension, you can fill passwords with one click.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Install Icon" src="https://assets.bitwarden.com/email/v1/icon-install.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/import-data/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Add passwords to your vault
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Quickly transfer existing passwords to Bitwarden using the importer.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Devices Icon" src="https://assets.bitwarden.com/email/v1/icon-devices.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/download/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Download Bitwarden on all devices
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Take your passwords with you anywhere.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,18 @@
{{#>FullTextLayout}}
Welcome to Bitwarden!
Let's get you set up with autofill.
Follow these simple steps to get up and running with Bitwarden Password Manager:
Get the browser extension:
With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download)
Add passwords to your vault:
Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/)
Download Bitwarden on all devices:
Take your passwords with you anywhere. (https://bitwarden.com/help/auto-fill-browser/)
Learn more about Bitwarden
Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/)
{{/FullTextLayout}}

View File

@@ -0,0 +1,915 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-15 { width:15% !important; max-width: 15%; }
.mj-column-per-85 { width:85% !important; max-width: 85%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-15 { width:15% !important; max-width: 15%; }
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
</style>
<style type="text/css">
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
@media only screen and (max-width: 480px) {
.hide-small-img {
display: none !important;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
}
@media only screen and (max-width: 480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<!-- Responsive icon visibility -->
<!-- Responsive styling for mj-bw-icon-row -->
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
Welcome to Bitwarden!
</h1>
<mj-text color="#fff" padding-top="0" padding-bottom="0">
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
Let's get set up to autofill.
</h2>
</mj-text></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/account-fill.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">A <b>{{OrganizationName}}</b> administrator will need to confirm
you before you can share passwords. Get started with Bitwarden
Password Manager:</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Browser Extension Icon" src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/download/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Get the browser extension
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">With the Bitwarden extension, you can fill passwords with one click.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Install Icon" src="https://assets.bitwarden.com/email/v1/icon-install.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/import-data/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Add passwords to your vault
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Quickly transfer existing passwords to Bitwarden using the importer.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Autofill Icon" src="https://assets.bitwarden.com/email/v1/icon-autofill.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/auto-fill-browser/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Try Bitwarden autofill
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Fill your passwords securely with one click.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{{#>FullTextLayout}}
Welcome to Bitwarden!
Let's get you set up with autofill.
A {{OrganizationName}} administrator will approve you before you can share passwords.
Get started with Bitwarden Password Manager:
Get the browser extension:
With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download)
Add passwords to your vault:
Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/)
Try Bitwarden autofill:
Fill your passwords securely with one click. (https://bitwarden.com/help/auto-fill-browser/)
Learn more about Bitwarden
Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/)
{{/FullTextLayout}}

View File

@@ -1,7 +1,13 @@
<mjml>
<mj-head>
<mj-include path="../../components/head.mjml" />
<mj-style> </mj-style>
<mj-style>
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
</mj-style>
</mj-head>
<mj-body css-class="border-fix">
@@ -18,18 +24,17 @@
<mj-section background-color="#fff">
<mj-column padding="0px">
<mj-text> Your verification code is: </mj-text>
<mj-text font-size="32px"> <b>{{Token}}</b> </mj-text>
<mj-text font-size="32px">
<b>{{Token}}</b>
</mj-text>
<mj-spacer height="20px" />
<mj-text>
This code expires in {{Expiry}} minutes. After that, you'll need to
verify your email again.
This code expires in {{Expiry}} minutes. After that, you'll need
to verify your email again.
</mj-text>
</mj-column>
</mj-section>
<mj-section
background-color="#fff"
padding="0px 0px 20px 0px"
>
<mj-section background-color="#fff" padding="0px 0px 20px 0px">
<mj-column
css-class="send-bubble"
width="90%"

View File

@@ -299,7 +299,7 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
? organization.SmServiceAccounts - plan.SecretsManager.BaseServiceAccount
: 0,
PurchasedAdditionalStorage = organization.MaxStorageGb.HasValue
? organization.MaxStorageGb.Value - (plan.PasswordManager.BaseStorageGb ?? 0) :
? organization.MaxStorageGb.Value - plan.PasswordManager.BaseStorageGb :
0
};
}

View File

@@ -1,58 +1,118 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Extensions;
using Stripe;
#nullable enable
namespace Bit.Core.Models.Business;
public class SubscriptionInfo
{
public BillingCustomerDiscount CustomerDiscount { get; set; }
public BillingSubscription Subscription { get; set; }
public BillingUpcomingInvoice UpcomingInvoice { get; set; }
/// <summary>
/// Converts Stripe's minor currency units (cents) to major currency units (dollars).
/// IMPORTANT: Only supports USD. All Bitwarden subscriptions are USD-only.
/// </summary>
private const decimal StripeMinorUnitDivisor = 100M;
/// <summary>
/// Converts Stripe's minor currency units (cents) to major currency units (dollars).
/// Preserves null semantics to distinguish between "no amount" (null) and "zero amount" (0.00m).
/// </summary>
/// <param name="amountInCents">The amount in Stripe's minor currency units (e.g., cents for USD).</param>
/// <returns>The amount in major currency units (e.g., dollars for USD), or null if the input is null.</returns>
private static decimal? ConvertFromStripeMinorUnits(long? amountInCents)
{
return amountInCents.HasValue ? amountInCents.Value / StripeMinorUnitDivisor : null;
}
public BillingCustomerDiscount? CustomerDiscount { get; set; }
public BillingSubscription? Subscription { get; set; }
public BillingUpcomingInvoice? UpcomingInvoice { get; set; }
/// <summary>
/// Represents customer discount information from Stripe billing.
/// </summary>
public class BillingCustomerDiscount
{
public BillingCustomerDiscount() { }
/// <summary>
/// Creates a BillingCustomerDiscount from a Stripe Discount object.
/// </summary>
/// <param name="discount">The Stripe discount containing coupon and expiration information.</param>
public BillingCustomerDiscount(Discount discount)
{
Id = discount.Coupon?.Id;
// Active = true only for perpetual/recurring discounts (no end date)
// This is intentional for Milestone 2 - only perpetual discounts are shown in UI
Active = discount.End == null;
PercentOff = discount.Coupon?.PercentOff;
AppliesTo = discount.Coupon?.AppliesTo?.Products ?? [];
AmountOff = ConvertFromStripeMinorUnits(discount.Coupon?.AmountOff);
// Stripe's CouponAppliesTo.Products is already IReadOnlyList<string>, so no conversion needed
AppliesTo = discount.Coupon?.AppliesTo?.Products;
}
public string Id { get; set; }
/// <summary>
/// The Stripe coupon ID (e.g., "cm3nHfO1").
/// Note: Only specific coupon IDs are displayed in the UI based on feature flag configuration,
/// though Stripe may apply additional discounts that are not shown.
/// </summary>
public string? Id { get; set; }
/// <summary>
/// True only for perpetual/recurring discounts (End == null).
/// False for any discount with an expiration date, even if not yet expired.
/// Product decision for Milestone 2: only show perpetual discounts in UI.
/// </summary>
public bool Active { get; set; }
/// <summary>
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
/// Null if this is an amount-based discount.
/// </summary>
public decimal? PercentOff { get; set; }
public List<string> AppliesTo { get; set; }
/// <summary>
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
/// Null if this is a percentage-based discount.
/// </summary>
public decimal? AmountOff { get; set; }
/// <summary>
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
/// <para>
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
/// Non-empty list: discount applies only to the specified product IDs.
/// </para>
/// </summary>
public IReadOnlyList<string>? AppliesTo { get; set; }
}
public class BillingSubscription
{
public BillingSubscription(Subscription sub)
{
Status = sub.Status;
TrialStartDate = sub.TrialStart;
TrialEndDate = sub.TrialEnd;
var currentPeriod = sub.GetCurrentPeriod();
Status = sub?.Status;
TrialStartDate = sub?.TrialStart;
TrialEndDate = sub?.TrialEnd;
var currentPeriod = sub?.GetCurrentPeriod();
if (currentPeriod != null)
{
var (start, end) = currentPeriod.Value;
PeriodStartDate = start;
PeriodEndDate = end;
}
CancelledDate = sub.CanceledAt;
CancelAtEndDate = sub.CancelAtPeriodEnd;
Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired";
if (sub.Items?.Data != null)
CancelledDate = sub?.CanceledAt;
CancelAtEndDate = sub?.CancelAtPeriodEnd ?? false;
var status = sub?.Status;
Cancelled = status == "canceled" || status == "unpaid" || status == "incomplete_expired";
if (sub?.Items?.Data != null)
{
Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i));
}
CollectionMethod = sub.CollectionMethod;
GracePeriod = sub.CollectionMethod == "charge_automatically"
CollectionMethod = sub?.CollectionMethod;
GracePeriod = sub?.CollectionMethod == "charge_automatically"
? 14
: 30;
}
@@ -64,10 +124,10 @@ public class SubscriptionInfo
public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate;
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string Status { get; set; }
public string? Status { get; set; }
public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public string CollectionMethod { get; set; }
public string? CollectionMethod { get; set; }
public DateTime? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; }
public int GracePeriod { get; set; }
@@ -80,7 +140,7 @@ public class SubscriptionInfo
{
ProductId = item.Plan.ProductId;
Name = item.Plan.Nickname;
Amount = item.Plan.Amount.GetValueOrDefault() / 100M;
Amount = ConvertFromStripeMinorUnits(item.Plan.Amount) ?? 0;
Interval = item.Plan.Interval;
if (item.Metadata != null)
@@ -90,15 +150,15 @@ public class SubscriptionInfo
}
Quantity = (int)item.Quantity;
SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id);
SponsoredSubscriptionItem = item.Plan != null && Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id);
}
public bool AddonSubscriptionItem { get; set; }
public string ProductId { get; set; }
public string Name { get; set; }
public string? ProductId { get; set; }
public string? Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string Interval { get; set; }
public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; }
}
}
@@ -109,7 +169,7 @@ public class SubscriptionInfo
public BillingUpcomingInvoice(Invoice inv)
{
Amount = inv.AmountDue / 100M;
Amount = ConvertFromStripeMinorUnits(inv.AmountDue) ?? 0;
Date = inv.Created;
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Core.Models.Mail.Auth;
public class OrganizationWelcomeEmailViewModel : BaseMailModel
{
public required string OrganizationName { get; set; }
}

View File

@@ -254,9 +254,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
organization.UseApi = newPlan.HasApi;
organization.SelfHost = newPlan.HasSelfHost;
organization.UsePolicies = newPlan.HasPolicies;
organization.MaxStorageGb = !newPlan.PasswordManager.BaseStorageGb.HasValue
? (short?)null
: (short)(newPlan.PasswordManager.BaseStorageGb.Value + upgrade.AdditionalStorageGb);
organization.MaxStorageGb = (short)(newPlan.PasswordManager.BaseStorageGb + upgrade.AdditionalStorageGb);
organization.UseGroups = newPlan.HasGroups;
organization.UseDirectory = newPlan.HasDirectory;
organization.UseEvents = newPlan.HasEvents;

View File

@@ -424,6 +424,8 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
// TODO: DO NOT move to IMailer implementation: PM-27852
[Obsolete("Use SendIndividualUserWelcomeEmailAsync instead")]
public async Task SendWelcomeEmailAsync(User user)
{
var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email);
@@ -437,6 +439,50 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
// TODO: Move to IMailer implementation: PM-27852
public async Task SendIndividualUserWelcomeEmailAsync(User user)
{
var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email);
var model = new BaseMailModel
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};
await AddMessageContentAsync(message, "MJML.Auth.Onboarding.welcome-individual-user", model);
message.Category = "Welcome";
await _mailDeliveryService.SendEmailAsync(message);
}
// TODO: Move to IMailer implementation: PM-27852
public async Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName)
{
var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email);
var model = new OrganizationWelcomeEmailViewModel
{
OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};
await AddMessageContentAsync(message, "MJML.Auth.Onboarding.welcome-org-user", model);
message.Category = "Welcome";
await _mailDeliveryService.SendEmailAsync(message);
}
// TODO: Move to IMailer implementation: PM-27852
public async Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName)
{
var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email);
var model = new OrganizationWelcomeEmailViewModel
{
OrganizationName = CoreHelpers.SanitizeForEmail(familyOrganizationName, false),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};
await AddMessageContentAsync(message, "MJML.Auth.Onboarding.welcome-family-user", model);
message.Category = "Welcome";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendTrialInitiationEmailAsync(string userEmail)
{
var message = CreateDefaultMessage("Welcome to Bitwarden; 3 steps to get started!", userEmail);

View File

@@ -15,7 +15,28 @@ namespace Bit.Core.Services;
[Obsolete("The IMailService has been deprecated in favor of the IMailer. All new emails should be sent with an IMailer implementation.")]
public interface IMailService
{
[Obsolete("Use SendIndividualUserWelcomeEmailAsync instead")]
Task SendWelcomeEmailAsync(User user);
/// <summary>
/// Email sent to users who have created a new account as an individual user.
/// </summary>
/// <param name="user">The new User</param>
/// <returns>Task</returns>
Task SendIndividualUserWelcomeEmailAsync(User user);
/// <summary>
/// Email sent to users who have been confirmed to an organization.
/// </summary>
/// <param name="user">The User</param>
/// <param name="organizationName">The Organization user is being added to</param>
/// <returns>Task</returns>
Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName);
/// <summary>
/// Email sent to users who have been confirmed to a free or families organization.
/// </summary>
/// <param name="user">The User</param>
/// <param name="familyOrganizationName">The Families Organization user is being added to</param>
/// <returns>Task</returns>
Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName);
Task SendVerifyEmailEmailAsync(string email, Guid userId, string token);
Task SendRegistrationVerificationEmailAsync(string email, string token);
Task SendTrialInitiationSignupEmailAsync(

View File

@@ -114,6 +114,20 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendIndividualUserWelcomeEmailAsync(User user)
{
return Task.FromResult(0);
}
public Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName)
{
return Task.FromResult(0);
}
public Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName)
{
return Task.FromResult(0);
}
public Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token)
{
return Task.FromResult(0);

View File

@@ -643,9 +643,21 @@ public class StripePaymentService : IPaymentService
var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] });
if (subscription == null)
{
return subscriptionInfo;
}
subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription);
var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault();
// Discount selection priority:
// 1. Customer-level discount (applies to all subscriptions for the customer)
// 2. First subscription-level discount (if multiple exist, FirstOrDefault() selects the first one)
// Note: When multiple subscription-level discounts exist, only the first one is used.
// This matches Stripe's behavior where the first discount in the list is applied.
// Defensive null checks: Even though we expand "customer" and "discounts", external APIs
// may not always return the expected data structure, so we use null-safe operators.
var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault();
if (discount != null)
{

View File

@@ -904,7 +904,6 @@ public class UserService : UserManager<User>, IUserService
}
else
{
user.MaxStorageGb = (short)(1 + additionalStorageGb);
user.LicenseKey = CoreHelpers.SecureRandomString(20);
}
@@ -977,7 +976,8 @@ public class UserService : UserManager<User>, IUserService
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, premiumPlan.Storage.StripePriceId);
var baseStorageGb = (short)premiumPlan.Storage.Provided;
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, premiumPlan.Storage.StripePriceId, baseStorageGb);
await SaveUserAsync(user);
return secret;
}

View File

@@ -783,6 +783,19 @@ public class GlobalSettings : IGlobalSettings
{
public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
public virtual IConnectionStringSettings Cosmos { get; set; } = new ConnectionStringSettings();
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30);
public bool IsFailSafeEnabled { get; set; } = true;
public TimeSpan FailSafeMaxDuration { get; set; } = TimeSpan.FromHours(2);
public TimeSpan FailSafeThrottleDuration { get; set; } = TimeSpan.FromSeconds(30);
public float? EagerRefreshThreshold { get; set; } = 0.9f;
public TimeSpan FactorySoftTimeout { get; set; } = TimeSpan.FromMilliseconds(100);
public TimeSpan FactoryHardTimeout { get; set; } = TimeSpan.FromMilliseconds(1500);
public TimeSpan DistributedCacheSoftTimeout { get; set; } = TimeSpan.FromSeconds(1);
public TimeSpan DistributedCacheHardTimeout { get; set; } = TimeSpan.FromSeconds(2);
public bool AllowBackgroundDistributedCacheOperations { get; set; } = true;
public TimeSpan JitterMaxDuration { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan DistributedCacheCircuitBreakerDuration { get; set; } = TimeSpan.FromSeconds(30);
}
public class WebPushSettings : IWebPushSettings

View File

@@ -150,17 +150,34 @@ public class ImportCiphersCommand : IImportCiphersCommand
foreach (var collection in collections)
{
if (!organizationCollectionsIds.Contains(collection.Id))
// If the collection already exists, skip it
if (organizationCollectionsIds.Contains(collection.Id))
{
collection.SetNewId();
newCollections.Add(collection);
newCollectionUsers.Add(new CollectionUser
{
CollectionId = collection.Id,
OrganizationUserId = importingOrgUser.Id,
Manage = true
});
continue;
}
// Create new collections if not already present
collection.SetNewId();
newCollections.Add(collection);
/*
* If the organization was created by a Provider, the organization may have zero members (users)
* In this situation importingOrgUser will be null, and accessing importingOrgUser.Id will
* result in a null reference exception.
*
* Avoid user assignment, but proceed with adding the collection.
*/
if (importingOrgUser == null)
{
continue;
}
newCollectionUsers.Add(new CollectionUser
{
CollectionId = collection.Id,
OrganizationUserId = importingOrgUser.Id,
Manage = true
});
}
// Create associations based on the newly assigned ids

View File

@@ -7,7 +7,7 @@ namespace Bit.Core.Utilities;
public static class BillingHelpers
{
internal static async Task<string> AdjustStorageAsync(IPaymentService paymentService, IStorableSubscriber storableSubscriber,
short storageAdjustmentGb, string storagePlanId)
short storageAdjustmentGb, string storagePlanId, short baseStorageGb)
{
if (storableSubscriber == null)
{
@@ -30,9 +30,9 @@ public static class BillingHelpers
}
var newStorageGb = (short)(storableSubscriber.MaxStorageGb.Value + storageAdjustmentGb);
if (newStorageGb < 1)
if (newStorageGb < baseStorageGb)
{
newStorageGb = 1;
newStorageGb = baseStorageGb;
}
if (newStorageGb > 100)
@@ -48,7 +48,7 @@ public static class BillingHelpers
"Delete some stored data first.");
}
var additionalStorage = newStorageGb - 1;
var additionalStorage = newStorageGb - baseStorageGb;
var paymentIntentClientSecret = await paymentService.AdjustStorageAsync(storableSubscriber,
additionalStorage, storagePlanId);
storableSubscriber.MaxStorageGb = newStorageGb;

View File

@@ -0,0 +1,90 @@
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StackExchange.Redis;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Backplane;
using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis;
using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson;
namespace Microsoft.Extensions.DependencyInjection;
public static class ExtendedCacheServiceCollectionExtensions
{
/// <summary>
/// Add Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
/// collection.<br/>
/// <br/>
/// If Redis is configured, it uses Redis for an L2 cache and backplane. If not, it simply uses in-memory caching.
/// </summary>
public static IServiceCollection TryAddExtendedCacheServices(this IServiceCollection services, GlobalSettings globalSettings)
{
if (services.Any(s => s.ServiceType == typeof(IFusionCache)))
{
return services;
}
var fusionCacheBuilder = services.AddFusionCache()
.WithOptions(options =>
{
options.DistributedCacheCircuitBreakerDuration = globalSettings.DistributedCache.DistributedCacheCircuitBreakerDuration;
})
.WithDefaultEntryOptions(new FusionCacheEntryOptions
{
Duration = globalSettings.DistributedCache.Duration,
IsFailSafeEnabled = globalSettings.DistributedCache.IsFailSafeEnabled,
FailSafeMaxDuration = globalSettings.DistributedCache.FailSafeMaxDuration,
FailSafeThrottleDuration = globalSettings.DistributedCache.FailSafeThrottleDuration,
EagerRefreshThreshold = globalSettings.DistributedCache.EagerRefreshThreshold,
FactorySoftTimeout = globalSettings.DistributedCache.FactorySoftTimeout,
FactoryHardTimeout = globalSettings.DistributedCache.FactoryHardTimeout,
DistributedCacheSoftTimeout = globalSettings.DistributedCache.DistributedCacheSoftTimeout,
DistributedCacheHardTimeout = globalSettings.DistributedCache.DistributedCacheHardTimeout,
AllowBackgroundDistributedCacheOperations = globalSettings.DistributedCache.AllowBackgroundDistributedCacheOperations,
JitterMaxDuration = globalSettings.DistributedCache.JitterMaxDuration
})
.WithSerializer(
new FusionCacheSystemTextJsonSerializer()
);
if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString))
{
return services;
}
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(globalSettings.DistributedCache.Redis.ConnectionString));
fusionCacheBuilder
.WithDistributedCache(sp =>
{
var cache = sp.GetService<IDistributedCache>();
if (cache is not null)
{
return cache;
}
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisCache(new RedisCacheOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
})
.WithBackplane(sp =>
{
var backplane = sp.GetService<IFusionCacheBackplane>();
if (backplane is not null)
{
return backplane;
}
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisBackplane(new RedisBackplaneOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
});
return services;
}
}

View File

@@ -23,4 +23,10 @@ BEGIN
DECLARE @UpdateCollectionsSuccess INT
EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
-- Bump the account revision date AFTER collections are assigned.
IF @UpdateCollectionsSuccess = 0
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
END
END

View File

@@ -0,0 +1,800 @@
using System.Security.Claims;
using Bit.Api.Billing.Controllers;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Business;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Api.Test.Billing.Controllers;
[SubscriptionInfoCustomize]
public class AccountsControllerTests : IDisposable
{
private const string TestMilestone2CouponId = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly IPaymentService _paymentService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly GlobalSettings _globalSettings;
private readonly AccountsController _sut;
public AccountsControllerTests()
{
_userService = Substitute.For<IUserService>();
_featureService = Substitute.For<IFeatureService>();
_paymentService = Substitute.For<IPaymentService>();
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
_globalSettings = new GlobalSettings { SelfHosted = false };
_sut = new AccountsController(
_userService,
_twoFactorIsEnabledQuery,
_userAccountKeysQuery,
_featureService
);
}
public void Dispose()
{
_sut?.Dispose();
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenFeatureFlagEnabled_IncludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenFeatureFlagDisabled_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when feature flag is disabled
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNonMatchingCouponId_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = "different-coupon-id", // Non-matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when coupon ID doesn't match
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenSelfHosted_ReturnsBasicResponse(User user)
{
// Arrange
var selfHostedSettings = new GlobalSettings { SelfHosted = true };
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
// Act
var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenNoGateway_ExcludesDiscount(User user, UserLicense license)
{
// Arrange
user.Gateway = null; // No gateway configured
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GenerateLicenseAsync(user).Returns(license);
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when no gateway
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithInactiveDiscount_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = false, // Inactive discount
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when discount is inactive
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_FullPipeline_ConvertsStripeDiscountToApiResponse(
User user,
UserLicense license)
{
// Arrange - Create a Stripe Discount object with real structure
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 25m,
AmountOff = 1400, // 1400 cents = $14.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families" }
}
},
End = null // Active discount
};
// Convert Stripe Discount to BillingCustomerDiscount (simulating what StripePaymentService does)
var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify full pipeline conversion
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
// Verify Stripe data correctly converted to API response
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
// Verify cents-to-dollars conversion (1400 cents -> $14.00)
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff);
// Verify AppliesTo products are preserved
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count());
Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_FullPipeline_WithFeatureFlagToggle_ControlsVisibility(
User user,
UserLicense license)
{
// Arrange - Create Stripe Discount
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 20m
},
End = null
};
var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act & Assert - Feature flag ENABLED
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
var resultWithFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
Assert.NotNull(resultWithFlag.CustomerDiscount);
// Act & Assert - Feature flag DISABLED
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
var resultWithoutFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
Assert.Null(resultWithoutFlag.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineFromStripeToApiResponse(
User user,
UserLicense license)
{
// Arrange - Create a real Stripe Discount object as it would come from Stripe API
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 30m,
AmountOff = 2000, // 2000 cents = $20.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families", "prod_teams" }
}
},
End = null // Active discount (no end date)
};
// Step 1: Map Stripe Discount through SubscriptionInfo.BillingCustomerDiscount
// This simulates what StripePaymentService.GetSubscriptionAsync does
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
// Verify the mapping worked correctly
Assert.Equal(TestMilestone2CouponId, billingCustomerDiscount.Id);
Assert.True(billingCustomerDiscount.Active);
Assert.Equal(30m, billingCustomerDiscount.PercentOff);
Assert.Equal(20.00m, billingCustomerDiscount.AmountOff); // Converted from cents
Assert.NotNull(billingCustomerDiscount.AppliesTo);
Assert.Equal(3, billingCustomerDiscount.AppliesTo.Count);
// Step 2: Create SubscriptionInfo with the mapped discount
// This simulates what StripePaymentService returns
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
// Step 3: Set up controller dependencies
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act - Step 4: Call AccountsController.GetSubscriptionAsync
// This exercises the complete pipeline:
// - Retrieves subscriptionInfo from paymentService (with discount from Stripe)
// - Maps through SubscriptionInfo.BillingCustomerDiscount (already done above)
// - Filters in SubscriptionResponseModel constructor (based on feature flag, coupon ID, active status)
// - Returns via AccountsController
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify the complete pipeline worked end-to-end
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
// Verify Stripe Discount → SubscriptionInfo.BillingCustomerDiscount mapping
// (verified above, but confirming it made it through)
// Verify SubscriptionInfo.BillingCustomerDiscount → SubscriptionResponseModel.BillingCustomerDiscount filtering
// The filter should pass because:
// - includeMilestone2Discount = true (feature flag enabled)
// - subscription.CustomerDiscount != null
// - subscription.CustomerDiscount.Id == Milestone2SubscriptionDiscount
// - subscription.CustomerDiscount.Active = true
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(30m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Verify cents-to-dollars conversion
// Verify AppliesTo products are preserved through the entire pipeline
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(3, result.CustomerDiscount.AppliesTo.Count());
Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_teams", result.CustomerDiscount.AppliesTo);
// Verify the payment service was called correctly
await _paymentService.Received(1).GetSubscriptionAsync(user);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_MultipleDiscountsInSubscription_PrefersCustomerDiscount(
User user,
UserLicense license)
{
// Arrange - Create Stripe subscription with multiple discounts
// Customer discount should be preferred over subscription discounts
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 30m,
AmountOff = null
},
End = null
};
var subscriptionDiscount1 = new Discount
{
Coupon = new Coupon
{
Id = "other-coupon-1",
PercentOff = 10m
},
End = null
};
var subscriptionDiscount2 = new Discount
{
Coupon = new Coupon
{
Id = "other-coupon-2",
PercentOff = 15m
},
End = null
};
// Map through SubscriptionInfo.BillingCustomerDiscount
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customerDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Should use customer discount, not subscription discounts
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(30m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BothPercentOffAndAmountOffPresent_HandlesEdgeCase(
User user,
UserLicense license)
{
// Arrange - Edge case: Stripe coupon with both PercentOff and AmountOff
// This tests the scenario mentioned in BillingCustomerDiscountTests.cs line 212-232
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 25m,
AmountOff = 2000, // 2000 cents = $20.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium" }
}
},
End = null
};
// Map through SubscriptionInfo.BillingCustomerDiscount
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Both values should be preserved through the pipeline
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Converted from cents
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BillingSubscriptionMapsThroughPipeline(
User user,
UserLicense license)
{
// Arrange - Create Stripe subscription with subscription details
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
TrialStart = DateTime.UtcNow.AddDays(-30),
TrialEnd = DateTime.UtcNow.AddDays(-20),
CanceledAt = null,
CancelAtPeriodEnd = false,
CollectionMethod = "charge_automatically"
};
// Map through SubscriptionInfo.BillingSubscription
var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription);
var subscriptionInfo = new SubscriptionInfo
{
Subscription = billingSubscription,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m
}
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify BillingSubscription mapped through pipeline
Assert.NotNull(result);
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BillingUpcomingInvoiceMapsThroughPipeline(
User user,
UserLicense license)
{
// Arrange - Create Stripe invoice for upcoming invoice
var stripeInvoice = new Invoice
{
AmountDue = 2000, // 2000 cents = $20.00
Created = DateTime.UtcNow.AddDays(1)
};
// Map through SubscriptionInfo.BillingUpcomingInvoice
var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice);
var subscriptionInfo = new SubscriptionInfo
{
UpcomingInvoice = billingUpcomingInvoice,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m
}
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify BillingUpcomingInvoice mapped through pipeline
Assert.NotNull(result);
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(20.00m, result.UpcomingInvoice.Amount); // Converted from cents
Assert.NotNull(result.UpcomingInvoice.Date);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineWithAllComponents(
User user,
UserLicense license)
{
// Arrange - Complete Stripe objects for full pipeline test
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 20m,
AmountOff = 1000, // $10.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families" }
}
},
End = null
};
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically"
};
var stripeInvoice = new Invoice
{
AmountDue = 1500, // $15.00
Created = DateTime.UtcNow.AddDays(7)
};
// Map through SubscriptionInfo (simulating StripePaymentService)
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription);
var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount,
Subscription = billingSubscription,
UpcomingInvoice = billingUpcomingInvoice
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify all components mapped correctly through the pipeline
Assert.NotNull(result);
// Verify discount
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Equal(10.00m, result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count());
// Verify subscription
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod);
// Verify upcoming invoice
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(15.00m, result.UpcomingInvoice.Amount);
Assert.NotNull(result.UpcomingInvoice.Date);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_SelfHosted_WithDiscountFlagEnabled_NeverIncludesDiscount(User user)
{
// Arrange - Self-hosted user with discount flag enabled (should still return null)
var selfHostedSettings = new GlobalSettings { SelfHosted = true };
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
// Act
var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService);
// Assert - Should never include discount for self-hosted, even with flag enabled
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_NullGateway_WithDiscountFlagEnabled_NeverIncludesDiscount(
User user,
UserLicense license)
{
// Arrange - User with null gateway and discount flag enabled (should still return null)
user.Gateway = null; // No gateway configured
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GenerateLicenseAsync(user).Returns(license);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Should never include discount when no gateway, even with flag enabled
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
}

View File

@@ -0,0 +1,400 @@
using Bit.Api.Models.Response;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Test.Common.AutoFixture.Attributes;
using Stripe;
using Xunit;
namespace Bit.Api.Test.Models.Response;
public class SubscriptionResponseModelTests
{
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountTrueMatchingCouponId_ReturnsDiscount(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Null(result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Single(result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountTrueNonMatchingCouponId_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = "different-coupon-id", // Non-matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountFalseMatchingCouponId_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: false);
// Assert - Should be null because includeMilestone2Discount is false
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullCustomerDiscount_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = null
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_AmountOffDiscountMatchingCouponId_ReturnsDiscount(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = null,
AmountOff = 14.00m, // Already converted from cents in BillingCustomerDiscount
AppliesTo = new List<string>()
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Null(result.CustomerDiscount.PercentOff);
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_DefaultIncludeMilestone2DiscountParameter_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m
}
};
// Act - Using default parameter (includeMilestone2Discount defaults to false)
var result = new SubscriptionResponseModel(user, subscriptionInfo, license);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullDiscountIdIncludeMilestone2DiscountTrue_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = null, // Null discount ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_MatchingCouponIdInactiveDiscount_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = false, // Inactive discount
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_UserOnly_SetsBasicProperties(User user)
{
// Arrange
user.Storage = 5368709120; // 5 GB in bytes
user.MaxStorageGb = (short)10;
user.PremiumExpirationDate = DateTime.UtcNow.AddMonths(12);
// Act
var result = new SubscriptionResponseModel(user);
// Assert
Assert.NotNull(result.StorageName);
Assert.Equal(5.0, result.StorageGb);
Assert.Equal((short)10, result.MaxStorageGb);
Assert.Equal(user.PremiumExpirationDate, result.Expiration);
Assert.Null(result.License);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_UserAndLicense_IncludesLicense(User user, UserLicense license)
{
// Arrange
user.Storage = 1073741824; // 1 GB in bytes
user.MaxStorageGb = (short)5;
// Act
var result = new SubscriptionResponseModel(user, license);
// Assert
Assert.NotNull(result.License);
Assert.Equal(license, result.License);
Assert.Equal(1.0, result.StorageGb);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullStorage_SetsStorageToZero(User user)
{
// Arrange
user.Storage = null;
// Act
var result = new SubscriptionResponseModel(user);
// Assert
Assert.Null(result.StorageName);
Assert.Equal(0, result.StorageGb);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullLicense_ExcludesLicense(User user)
{
// Act
var result = new SubscriptionResponseModel(user, null);
// Assert
Assert.Null(result.License);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_BothPercentOffAndAmountOffPresent_HandlesEdgeCase(
User user,
UserLicense license)
{
// Arrange - Edge case: Both PercentOff and AmountOff present
// This tests the scenario where Stripe coupon has both discount types
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 25m,
AmountOff = 20.00m, // Already converted from cents
AppliesTo = new List<string> { "prod_premium" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Both values should be preserved
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Single(result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithSubscriptionAndInvoice_MapsAllProperties(
User user,
UserLicense license)
{
// Arrange - Test with Subscription, UpcomingInvoice, and CustomerDiscount
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically"
};
var stripeInvoice = new Invoice
{
AmountDue = 1500, // 1500 cents = $15.00
Created = DateTime.UtcNow.AddDays(7)
};
var subscriptionInfo = new SubscriptionInfo
{
Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription),
UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice),
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "prod_premium" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Verify all properties are mapped correctly
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(15.00m, result.UpcomingInvoice.Amount);
Assert.NotNull(result.UpcomingInvoice.Date);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullSubscriptionAndInvoice_HandlesNullsGracefully(
User user,
UserLicense license)
{
// Arrange - Test with null Subscription and UpcomingInvoice
var subscriptionInfo = new SubscriptionInfo
{
Subscription = null,
UpcomingInvoice = null,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Null Subscription and UpcomingInvoice should be handled gracefully
Assert.Null(result.Subscription);
Assert.Null(result.UpcomingInvoice);
Assert.NotNull(result.CustomerDiscount);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,628 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
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 AutomaticUserConfirmationPolicyEventHandlerTests
{
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns((Policy?)null);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantUserId,
Email = "user@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid userId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = userId,
Email = "test@email.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = null, // invited users do not have a user id
Status = OrganizationUserStatusType.Invited,
Email = orgUser.Email
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var providerUser = new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Status = ProviderUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([providerUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Provider user type", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = Guid.NewGuid(),
Email = "user@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_PolicyAlreadyEnabled_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy);
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_DisablingPolicy_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy);
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantOwnerId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var ownerUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.Owner,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantOwnerId,
Email = "owner@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantOwnerId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([ownerUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var invitedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited,
UserId = Guid.NewGuid(),
Email = "invited@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([invitedUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var revokedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Revoked,
UserId = Guid.NewGuid(),
Email = "revoked@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([revokedUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var acceptedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Accepted,
UserId = nonCompliantUserId,
Email = "accepted@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([acceptedUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == true &&
o.RevisionDate > DateTime.MinValue));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = true;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == false &&
o.RevisionDate > DateTime.MinValue));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns((Organization?)null);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.UpsertAsync(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == policyUpdate.Enabled));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
var originalRevisionDate = DateTime.UtcNow.AddDays(-1);
organization.RevisionDate = originalRevisionDate;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.RevisionDate > originalRevisionDate));
}
}

View File

@@ -7,6 +7,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -80,6 +81,120 @@ public class RegisterUserCommandTests
.SendWelcomeEmailAsync(Arg.Any<User>());
}
// -----------------------------------------------------------------------------------------------
// RegisterSSOAutoProvisionedUserAsync tests
// -----------------------------------------------------------------------------------------------
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_Success(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.Id = Guid.NewGuid();
organization.Id = Guid.NewGuid();
organization.Name = "Test Organization";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
// Act
var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
Assert.True(result.Succeeded);
await sutProvider.GetDependency<IUserService>()
.Received(1)
.CreateUserAsync(user);
}
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_UserRegistrationFails_ReturnsFailedResult(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
var expectedError = new IdentityError();
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Failed(expectedError));
// Act
var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
Assert.False(result.Succeeded);
Assert.Contains(expectedError, result.Errors);
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationUserWelcomeEmailAsync(Arg.Any<User>(), Arg.Any<string>());
}
[Theory]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task RegisterSSOAutoProvisionedUserAsync_EnterpriseOrg_SendsOrganizationWelcomeEmail(
PlanType planType,
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
organization.PlanType = planType;
organization.Name = "Enterprise Org";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserWelcomeEmailAsync(user, organization.Name);
}
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_FeatureFlagDisabled_SendsLegacyWelcomeEmail(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(false);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendWelcomeEmailAsync(user);
}
// -----------------------------------------------------------------------------------------------
// RegisterUserWithOrganizationInviteToken tests
// -----------------------------------------------------------------------------------------------
@@ -646,5 +761,186 @@ public class RegisterUserCommandTests
Assert.Equal("Open registration has been disabled by the system administrator.", result.Message);
}
// -----------------------------------------------------------------------------------------------
// SendWelcomeEmail tests
// -----------------------------------------------------------------------------------------------
[Theory]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2019)]
[BitAutoData(PlanType.Free)]
public async Task SendWelcomeEmail_FamilyOrg_SendsFamilyWelcomeEmail(
PlanType planType,
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
organization.PlanType = planType;
organization.Name = "Family Org";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.Name);
}
[Theory]
[BitAutoData]
public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail(
User user,
OrganizationUser orgUser,
string orgInviteToken,
string masterPasswordHash,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.ReferenceData = null;
orgUser.Email = user.Email;
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user, masterPasswordHash)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns((Policy)null);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgUser.OrganizationId)
.Returns((Organization)null);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = orgInviteTokenable;
return true;
});
// Act
var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendIndividualUserWelcomeEmailAsync(user);
}
[Theory]
[BitAutoData]
public async Task SendWelcomeEmail_OrganizationDisplayNameNull_SendsIndividualWelcomeEmail(
User user,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
Organization organization = new Organization
{
Name = null
};
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendIndividualUserWelcomeEmailAsync(user);
}
[Theory]
[BitAutoData]
public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrganizationWelcomeEmailDetails(
Organization organization,
User user,
OrganizationUser orgUser,
string masterPasswordHash,
string orgInviteToken,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.ReferenceData = null;
orgUser.Email = user.Email;
organization.PlanType = PlanType.EnterpriseAnnually;
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user, masterPasswordHash)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns((Policy)null);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgUser.OrganizationId)
.Returns(organization);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = orgInviteTokenable;
return true;
});
// Act
var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id);
// Assert
Assert.True(result.Succeeded);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.GetByIdAsync(orgUser.OrganizationId);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName());
}
}

View File

@@ -53,7 +53,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal }
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
@@ -720,4 +720,63 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
[Theory, BitAutoData]
public async Task Run_WithAdditionalStorage_SetsCorrectMaxStorageGb(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null;
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
const short additionalStorage = 2;
// Setup premium plan with 5GB provided storage
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
_stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage);
// Assert
Assert.True(result.IsT0);
Assert.Equal((short)3, user.MaxStorageGb); // 1 (provided) + 2 (additional) = 3
await _userService.Received(1).SaveUserAsync(user);
}
}

View File

@@ -0,0 +1,497 @@
using Bit.Core.Models.Business;
using Bit.Test.Common.AutoFixture.Attributes;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class BillingCustomerDiscountTests
{
[Theory]
[BitAutoData]
public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 25.5m,
AmountOff = null,
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "product1", "product2" }
}
},
End = null // Active discount
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.True(result.Active);
Assert.Equal(25.5m, result.PercentOff);
Assert.Null(result.AmountOff);
Assert.NotNull(result.AppliesTo);
Assert.Equal(2, result.AppliesTo.Count);
Assert.Contains("product1", result.AppliesTo);
Assert.Contains("product2", result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId)
{
// Arrange - Stripe sends 1400 cents for $14.00
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = null,
AmountOff = 1400, // 1400 cents
AppliesTo = new CouponAppliesTo
{
Products = new List<string>()
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.True(result.Active);
Assert.Null(result.PercentOff);
Assert.Equal(14.00m, result.AmountOff); // Converted to dollars
Assert.NotNull(result.AppliesTo);
Assert.Empty(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 15m
},
End = DateTime.UtcNow.AddDays(-1) // Expired discount
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.False(result.Active);
Assert.Equal(15m, result.PercentOff);
}
[Fact]
public void Constructor_NullCoupon_SetsDiscountPropertiesToNull()
{
// Arrange
var discount = new Discount
{
Coupon = null,
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.Id);
Assert.True(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AmountOff = null
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 0
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(0m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange - $100.00 discount
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 10000 // 10000 cents = $100.00
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(100.00m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange - $0.50 discount
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 50 // 50 cents = $0.50
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(0.50m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId)
{
// Arrange - Coupon with both percentage and amount (edge case)
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m,
AmountOff = 500 // $5.00
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(20m, result.PercentOff);
Assert.Equal(5.00m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = null
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = new CouponAppliesTo
{
Products = null
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId)
{
// Arrange - 1425 cents = $14.25
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 1425
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(14.25m, result.AmountOff);
}
[Fact]
public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse()
{
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount();
// Assert
Assert.Null(result.Id);
Assert.False(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId)
{
// Arrange - Discount expires in the future
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m
},
End = DateTime.UtcNow.AddDays(30) // Expires in 30 days
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.False(result.Active); // Should be inactive because End is not null
}
[Theory]
[BitAutoData]
public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId)
{
// Arrange - Discount already expired
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m
},
End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.False(result.Active); // Should be inactive because End is not null
}
[Fact]
public void Constructor_WithNullCouponId_SetsIdToNull()
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = null,
PercentOff = 20m
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.Id);
Assert.True(result.Active);
Assert.Equal(20m, result.PercentOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = null,
AmountOff = 1000
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.PercentOff);
Assert.Equal(10.00m, result.AmountOff);
}
[Fact]
public void Constructor_WithCompleteStripeDiscount_MapsAllProperties()
{
// Arrange - Comprehensive test with all Stripe Discount properties set
var discount = new Discount
{
Coupon = new Coupon
{
Id = "premium_discount_2024",
PercentOff = 25m,
AmountOff = 1500, // $15.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_family", "prod_teams" }
}
},
End = null // Active
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert - Verify all properties mapped correctly
Assert.Equal("premium_discount_2024", result.Id);
Assert.True(result.Active);
Assert.Equal(25m, result.PercentOff);
Assert.Equal(15.00m, result.AmountOff);
Assert.NotNull(result.AppliesTo);
Assert.Equal(3, result.AppliesTo.Count);
Assert.Contains("prod_premium", result.AppliesTo);
Assert.Contains("prod_family", result.AppliesTo);
Assert.Contains("prod_teams", result.AppliesTo);
}
[Fact]
public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully()
{
// Arrange - Minimal Stripe Discount with most properties null
var discount = new Discount
{
Coupon = new Coupon
{
Id = null,
PercentOff = null,
AmountOff = null,
AppliesTo = null
},
End = DateTime.UtcNow.AddDays(10) // Has end date
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert - Should handle all nulls gracefully
Assert.Null(result.Id);
Assert.False(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = new CouponAppliesTo
{
Products = new List<string>() // Empty but not null
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.NotNull(result.AppliesTo);
Assert.Empty(result.AppliesTo);
}
}

View File

@@ -0,0 +1,125 @@
using Bit.Core.Models.Business;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class SubscriptionInfoTests
{
[Fact]
public void BillingSubscriptionItem_NullPlan_HandlesGracefully()
{
// Arrange - SubscriptionItem with null Plan
var subscriptionItem = new SubscriptionItem
{
Plan = null,
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should handle null Plan gracefully
Assert.Null(result.ProductId);
Assert.Null(result.Name);
Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null
Assert.Null(result.Interval);
Assert.Equal(1, result.Quantity);
Assert.False(result.SponsoredSubscriptionItem);
Assert.False(result.AddonSubscriptionItem);
}
[Fact]
public void BillingSubscriptionItem_NullAmount_SetsToZero()
{
// Arrange - SubscriptionItem with Plan but null Amount
var subscriptionItem = new SubscriptionItem
{
Plan = new Plan
{
ProductId = "prod_test",
Nickname = "Test Plan",
Amount = null, // Null amount
Interval = "month"
},
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should default to 0 when Amount is null
Assert.Equal("prod_test", result.ProductId);
Assert.Equal("Test Plan", result.Name);
Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null
Assert.Equal("month", result.Interval);
Assert.Equal(1, result.Quantity);
}
[Fact]
public void BillingSubscriptionItem_ZeroAmount_PreservesZero()
{
// Arrange - SubscriptionItem with Plan and zero Amount
var subscriptionItem = new SubscriptionItem
{
Plan = new Plan
{
ProductId = "prod_test",
Nickname = "Test Plan",
Amount = 0, // Zero amount (0 cents)
Interval = "month"
},
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should preserve zero amount
Assert.Equal("prod_test", result.ProductId);
Assert.Equal("Test Plan", result.Name);
Assert.Equal(0m, result.Amount); // Zero amount preserved
Assert.Equal("month", result.Interval);
}
[Fact]
public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero()
{
// Arrange - Invoice with zero AmountDue
// Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0
// The null-coalescing operator (?? 0) in the constructor handles the case where
// ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable,
// this test verifies the conversion path works correctly for zero values
var invoice = new Invoice
{
AmountDue = 0, // Zero amount due (0 cents)
Created = DateTime.UtcNow
};
// Act
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
// Assert - Should convert zero correctly
Assert.Equal(0m, result.Amount);
Assert.NotNull(result.Date);
}
[Fact]
public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly()
{
// Arrange - Invoice with valid AmountDue
var invoice = new Invoice
{
AmountDue = 2500, // 2500 cents = $25.00
Created = DateTime.UtcNow
};
// Act
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
// Assert - Should convert correctly
Assert.Equal(25.00m, result.Amount); // Converted from cents
Assert.NotNull(result.Date);
}
}

View File

@@ -268,4 +268,115 @@ public class HandlebarsMailServiceTests
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any<MailMessage>());
}
[Fact]
public async Task SendIndividualUserWelcomeEmailAsync_SendsCorrectEmail()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "test@example.com"
};
// Act
await _sut.SendIndividualUserWelcomeEmailAsync(user);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("test@example.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.Category == "Welcome"));
}
[Fact]
public async Task SendOrganizationUserWelcomeEmailAsync_SendsCorrectEmailWithOrganizationName()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "user@company.com"
};
var organizationName = "Bitwarden Corp";
// Act
await _sut.SendOrganizationUserWelcomeEmailAsync(user, organizationName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("user@company.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.HtmlContent.Contains("Bitwarden Corp") &&
m.Category == "Welcome"));
}
[Fact]
public async Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync_SendsCorrectEmailWithFamilyTemplate()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "family@example.com"
};
var familyOrganizationName = "Smith Family";
// Act
await _sut.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, familyOrganizationName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("family@example.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.HtmlContent.Contains("Smith Family") &&
m.Category == "Welcome"));
}
[Theory]
[InlineData("Acme Corp", "Acme Corp")]
[InlineData("Company & Associates", "Company &amp; Associates")]
[InlineData("Test \"Quoted\" Org", "Test &quot;Quoted&quot; Org")]
public async Task SendOrganizationUserWelcomeEmailAsync_SanitizesOrganizationNameForEmail(string inputOrgName, string expectedSanitized)
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "test@example.com"
};
// Act
await _sut.SendOrganizationUserWelcomeEmailAsync(user, inputOrgName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.HtmlContent.Contains(expectedSanitized) &&
!m.HtmlContent.Contains("<script>") && // Ensure script tags are removed
m.Category == "Welcome"));
}
[Theory]
[InlineData("test@example.com")]
[InlineData("user+tag@domain.co.uk")]
[InlineData("admin@organization.org")]
public async Task SendIndividualUserWelcomeEmailAsync_HandlesVariousEmailFormats(string email)
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = email
};
// Act
await _sut.SendIndividualUserWelcomeEmailAsync(user);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.ToEmails.Contains(email)));
}
}

View File

@@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
@@ -515,4 +516,399 @@ public class StripePaymentServiceTests
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
));
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 20m,
AmountOff = 1400
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = customerDiscount
},
Discounts = new List<Discount>(), // Empty list
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithoutCustomerDiscount_FallsBackToSubscriptionDiscounts(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscriptionDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 15m,
AmountOff = null
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
Discounts = new List<Discount> { subscriptionDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should use subscription discount as fallback
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(15m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithBothDiscounts_PrefersCustomerDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 25m
},
End = null
};
var subscriptionDiscount = new Discount
{
Coupon = new Coupon
{
Id = "different-coupon-id",
PercentOff = 10m
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = customerDiscount // Should prefer this
},
Discounts = new List<Discount> { subscriptionDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should prefer customer discount over subscription discount
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNoDiscounts_ReturnsNullDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null
},
Discounts = new List<Discount>(), // Empty list, no discounts
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithMultipleSubscriptionDiscounts_SelectsFirstDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Multiple subscription-level discounts, no customer discount
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var firstDiscount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-10-percent",
PercentOff = 10m
},
End = null
};
var secondDiscount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-20-percent",
PercentOff = 20m
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
// Multiple subscription discounts - FirstOrDefault() should select the first one
Discounts = new List<Discount> { firstDiscount, secondDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should select the first discount from the list (FirstOrDefault() behavior)
Assert.NotNull(result.CustomerDiscount);
Assert.Equal("coupon-10-percent", result.CustomerDiscount.Id);
Assert.Equal(10m, result.CustomerDiscount.PercentOff);
// Verify the second discount was not selected
Assert.NotEqual("coupon-20-percent", result.CustomerDiscount.Id);
Assert.NotEqual(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNullCustomer_HandlesGracefully(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Subscription with null Customer (defensive null check scenario)
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = null, // Customer not expanded or null
Discounts = new List<Discount>(), // Empty discounts
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should handle null Customer gracefully without throwing NullReferenceException
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNullDiscounts_HandlesGracefully(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Subscription with null Discounts (defensive null check scenario)
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
Discounts = null, // Discounts not expanded or null
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should handle null Discounts gracefully without throwing NullReferenceException
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_VerifiesCorrectExpandOptions(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer { Discount = null },
Discounts = new List<Discount>(), // Empty list
Items = new StripeList<SubscriptionItem> { Data = [] }
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter
.SubscriptionGetAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Verify expand options are correct
await stripeAdapter.Received(1).SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Is<SubscriptionGetOptions>(o =>
o.Expand.Contains("customer.discount.coupon.applies_to") &&
o.Expand.Contains("discounts.coupon.applies_to") &&
o.Expand.Contains("test_clock")));
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithEmptyGatewaySubscriptionId_ReturnsEmptySubscriptionInfo(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.GatewaySubscriptionId = null;
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.NotNull(result);
Assert.Null(result.Subscription);
Assert.Null(result.CustomerDiscount);
Assert.Null(result.UpcomingInvoice);
// Verify no Stripe API calls were made
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceive()
.SubscriptionGetAsync(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
}
}

View File

@@ -238,4 +238,55 @@ public class ImportCiphersAsyncCommandTests
Assert.Equal("This organization can only have a maximum of " +
$"{organization.MaxCollections} collections.", exception.Message);
}
[Theory, BitAutoData]
public async Task ImportIntoOrganizationalVaultAsync_WithNullImportingOrgUser_SkipsCollectionUserCreation(
Organization organization,
Guid importingUserId,
List<Collection> collections,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
organization.MaxCollections = null;
foreach (var collection in collections)
{
collection.OrganizationId = organization.Id;
}
foreach (var cipher in ciphers)
{
cipher.OrganizationId = organization.Id;
}
KeyValuePair<int, int>[] collectionRelationships = {
new(0, 0),
new(1, 1),
new(2, 2)
};
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
// Simulate provider-created org with no members - importing user is NOT an org member
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organization.Id, importingUserId)
.Returns((OrganizationUser)null);
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdAsync(organization.Id)
.Returns(new List<Collection>());
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
// Verify ciphers were created but no CollectionUser entries were created (because the organization user (importingUserId) is null)
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(
ciphers,
Arg.Is<IEnumerable<Collection>>(cols => cols.Count() == collections.Count),
Arg.Is<IEnumerable<CollectionCipher>>(cc => cc.Count() == ciphers.Count),
Arg.Is<IEnumerable<CollectionUser>>(cus => !cus.Any()));
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
}

View File

@@ -0,0 +1,171 @@
using Bit.Core.Settings;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using StackExchange.Redis;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.Utilities;
public class ExtendedCacheServiceCollectionExtensionsTests
{
private readonly IServiceCollection _services;
private readonly GlobalSettings _globalSettings;
public ExtendedCacheServiceCollectionExtensionsTests()
{
_services = new ServiceCollection();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
_globalSettings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(_globalSettings);
_services.TryAddSingleton(config);
_services.TryAddSingleton(_globalSettings);
_services.TryAddSingleton<IGlobalSettings>(_globalSettings);
_services.AddLogging();
}
[Fact]
public void TryAddFusionCoreServices_CustomSettings_OverridesDefaults()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Duration", "00:12:00" },
{ "GlobalSettings:DistributedCache:FailSafeMaxDuration", "01:30:00" },
{ "GlobalSettings:DistributedCache:FailSafeThrottleDuration", "00:01:00" },
{ "GlobalSettings:DistributedCache:EagerRefreshThreshold", "0.75" },
{ "GlobalSettings:DistributedCache:FactorySoftTimeout", "00:00:00.020" },
{ "GlobalSettings:DistributedCache:FactoryHardTimeout", "00:00:03" },
{ "GlobalSettings:DistributedCache:DistributedCacheSoftTimeout", "00:00:00.500" },
{ "GlobalSettings:DistributedCache:DistributedCacheHardTimeout", "00:00:01.500" },
{ "GlobalSettings:DistributedCache:JitterMaxDuration", "00:00:05" },
{ "GlobalSettings:DistributedCache:IsFailSafeEnabled", "false" },
{ "GlobalSettings:DistributedCache:AllowBackgroundDistributedCacheOperations", "false" },
});
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
var options = fusionCache.DefaultEntryOptions;
Assert.Equal(TimeSpan.FromMinutes(12), options.Duration);
Assert.False(options.IsFailSafeEnabled);
Assert.Equal(TimeSpan.FromHours(1.5), options.FailSafeMaxDuration);
Assert.Equal(TimeSpan.FromMinutes(1), options.FailSafeThrottleDuration);
Assert.Equal(0.75f, options.EagerRefreshThreshold);
Assert.Equal(TimeSpan.FromMilliseconds(20), options.FactorySoftTimeout);
Assert.Equal(TimeSpan.FromMilliseconds(3000), options.FactoryHardTimeout);
Assert.Equal(TimeSpan.FromSeconds(0.5), options.DistributedCacheSoftTimeout);
Assert.Equal(TimeSpan.FromSeconds(1.5), options.DistributedCacheHardTimeout);
Assert.False(options.AllowBackgroundDistributedCacheOperations);
Assert.Equal(TimeSpan.FromSeconds(5), options.JitterMaxDuration);
}
[Fact]
public void TryAddFusionCoreServices_DefaultSettings_ConfiguresExpectedValues()
{
_services.TryAddExtendedCacheServices(_globalSettings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
var options = fusionCache.DefaultEntryOptions;
Assert.Equal(TimeSpan.FromMinutes(30), options.Duration);
Assert.True(options.IsFailSafeEnabled);
Assert.Equal(TimeSpan.FromHours(2), options.FailSafeMaxDuration);
Assert.Equal(TimeSpan.FromSeconds(30), options.FailSafeThrottleDuration);
Assert.Equal(0.9f, options.EagerRefreshThreshold);
Assert.Equal(TimeSpan.FromMilliseconds(100), options.FactorySoftTimeout);
Assert.Equal(TimeSpan.FromMilliseconds(1500), options.FactoryHardTimeout);
Assert.Equal(TimeSpan.FromSeconds(1), options.DistributedCacheSoftTimeout);
Assert.Equal(TimeSpan.FromSeconds(2), options.DistributedCacheHardTimeout);
Assert.True(options.AllowBackgroundDistributedCacheOperations);
Assert.Equal(TimeSpan.FromSeconds(2), options.JitterMaxDuration);
}
[Fact]
public void TryAddFusionCoreServices_MultipleCalls_OnlyConfiguresOnce()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.TryAddExtendedCacheServices(settings);
_services.TryAddExtendedCacheServices(settings);
_services.TryAddExtendedCacheServices(settings);
var registrations = _services.Where(s => s.ServiceType == typeof(IFusionCache)).ToList();
Assert.Single(registrations);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.NotNull(fusionCache);
}
[Fact]
public void TryAddFusionCoreServices_WithRedis_EnablesDistributedCacheAndBackplane()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.True(fusionCache.HasDistributedCache);
Assert.True(fusionCache.HasBackplane);
}
[Fact]
public void TryAddFusionCoreServices_WithExistingRedis_EnablesDistributedCacheAndBackplane()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.AddSingleton(Substitute.For<IDistributedCache>());
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.True(fusionCache.HasDistributedCache);
Assert.True(fusionCache.HasBackplane);
var distributedCache = provider.GetRequiredService<IDistributedCache>();
Assert.NotNull(distributedCache);
}
[Fact]
public void TryAddFusionCoreServices_WithoutRedis_DisablesDistributedCacheAndBackplane()
{
_services.TryAddExtendedCacheServices(_globalSettings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.False(fusionCache.HasDistributedCache);
Assert.False(fusionCache.HasBackplane);
}
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(data)
.Build();
var settings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(settings);
return settings;
}
}

View File

@@ -0,0 +1,32 @@
CREATE OR ALTER PROCEDURE [dbo].[Cipher_CreateWithCollections]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data NVARCHAR(MAX),
@Favorites NVARCHAR(MAX),
@Folders NVARCHAR(MAX),
@Attachments NVARCHAR(MAX),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@DeletedDate DATETIME2(7),
@Reprompt TINYINT,
@Key VARCHAR(MAX) = NULL,
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@ArchivedDate DATETIME2(7) = NULL
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders,
@Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate
DECLARE @UpdateCollectionsSuccess INT
EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
-- Bump the account revision date AFTER collections are assigned.
IF @UpdateCollectionsSuccess = 0
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
END
END