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

Merge branch 'main' into auth/pm-22975/client-version-validator

This commit is contained in:
Patrick Pimentel
2025-11-17 16:37:47 -05:00
137 changed files with 10715 additions and 873 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,8 +35,9 @@ public class ProviderService : IProviderService
{ {
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [ private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
PlanType.Free, PlanType.Free,
PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2025,
PlanType.FamiliesAnnually2019 PlanType.FamiliesAnnually2019,
PlanType.FamiliesAnnually
]; ];
private readonly IDataProtector _dataProtector; private readonly IDataProtector _dataProtector;

View File

@@ -651,7 +651,23 @@ public class AccountController : Controller
EmailVerified = emailVerified, EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30) ApiKey = CoreHelpers.SecureRandomString(30)
}; };
/*
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); await _registerUserCommand.RegisterUser(newUser);
}
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy = var twoFactorPolicy =

View File

@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@@ -18,6 +19,7 @@ using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NSubstitute; 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}"); _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,16 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")] [Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")] [Authorize("Application")]
public class OrganizationIntegrationConfigurationController( public class OrganizationIntegrationConfigurationController(

View File

@@ -1,18 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations")] [Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")] [Authorize("Application")]
public class OrganizationIntegrationController( public class OrganizationIntegrationController(

View File

@@ -209,23 +209,17 @@ public class PoliciesController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
if (type != model.Type) var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext);
{
throw new BadRequestException("Mismatched policy type");
}
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext);
var policy = await _savePolicyCommand.SaveAsync(policyUpdate); var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
return new PolicyResponseModel(policy); return new PolicyResponseModel(policy);
} }
[HttpPut("{type}/vnext")] [HttpPut("{type}/vnext")]
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)] [RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
[Authorize<ManagePoliciesRequirement>] [Authorize<ManagePoliciesRequirement>]
public async Task<PolicyResponseModel> PutVNext(Guid orgId, [FromBody] SavePolicyRequest model) public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
{ {
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext); var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ? var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) : await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
@@ -233,5 +227,4 @@ public class PoliciesController : Controller
return new PolicyResponseModel(policy); return new PolicyResponseModel(policy);
} }
} }

View File

@@ -1,6 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context; using Bit.Core.Context;
@@ -8,13 +7,11 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations")] [Route("organizations")]
[Authorize("Application")] [Authorize("Application")]
public class SlackIntegrationController( public class SlackIntegrationController(

View File

@@ -1,6 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context; using Bit.Core.Context;
@@ -8,7 +7,6 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Bot.Builder; using Microsoft.Bot.Builder;
@@ -16,7 +14,6 @@ using Microsoft.Bot.Builder.Integration.AspNet.Core;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations")] [Route("organizations")]
[Authorize("Application")] [Authorize("Application")]
public class TeamsIntegrationController( public class TeamsIntegrationController(

View File

@@ -9,20 +9,18 @@ namespace Bit.Api.AdminConsole.Models.Request;
public class PolicyRequestModel public class PolicyRequestModel
{ {
[Required]
public PolicyType? Type { get; set; }
[Required] [Required]
public bool? Enabled { get; set; } public bool? Enabled { get; set; }
public Dictionary<string, object>? Data { get; set; } public Dictionary<string, object>? Data { get; set; }
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext) public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
{ {
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value); var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
return new() return new()
{ {
Type = Type!.Value, Type = type,
OrganizationId = organizationId, OrganizationId = organizationId,
Data = serializedData, Data = serializedData,
Enabled = Enabled.GetValueOrDefault(), Enabled = Enabled.GetValueOrDefault(),

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities; using Bit.Core.AdminConsole.Utilities;
@@ -13,10 +14,10 @@ public class SavePolicyRequest
public Dictionary<string, object>? Metadata { get; set; } public Dictionary<string, object>? Metadata { get; set; }
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext) public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
{ {
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext); var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, type, currentContext);
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value); var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
return new SavePolicyModel(policyUpdate, performedBy, metadata); return new SavePolicyModel(policyUpdate, performedBy, metadata);

View File

@@ -2,8 +2,6 @@
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations; namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
@@ -11,8 +9,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration") public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
: base(obj) : base(obj)
{ {
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
Id = organizationIntegrationConfiguration.Id; Id = organizationIntegrationConfiguration.Id;
Configuration = organizationIntegrationConfiguration.Configuration; Configuration = organizationIntegrationConfiguration.Configuration;
CreationDate = organizationIntegrationConfiguration.CreationDate; CreationDate = organizationIntegrationConfiguration.CreationDate;

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

View File

@@ -24,6 +24,14 @@ public class EventFilterRequestModel
/// </summary> /// </summary>
public Guid? ItemId { get; set; } public Guid? ItemId { get; set; }
/// <summary> /// <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. /// A cursor for use in pagination.
/// </summary> /// </summary>
public string ContinuationToken { get; set; } 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.Request.Accounts;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Utilities; using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Business;
@@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers;
public class AccountsController( public class AccountsController(
IUserService userService, IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery) : Controller IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller
{ {
[HttpPost("premium")] [HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync( public async Task<PaymentResponseModel> PostPremiumAsync(
@@ -84,17 +86,25 @@ public class AccountsController(
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!globalSettings.SelfHosted && user.Gateway != null) // Only cloud-hosted users with payment gateways have subscription and discount information
if (!globalSettings.SelfHosted)
{ {
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 subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license); return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
} }
else if (!globalSettings.SelfHosted) else
{ {
var license = await userService.GenerateLicenseAsync(user); var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license); return new SubscriptionResponseModel(user, license);
} }
}
else else
{ {
return new SubscriptionResponseModel(user); return new SubscriptionResponseModel(user);

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below using Bit.Core.Billing.Constants;
#nullable disable
using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
public class SubscriptionResponseModel : ResponseModel 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") : base("subscription")
{ {
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
@@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel
MaxStorageGb = user.MaxStorageGb; MaxStorageGb = user.MaxStorageGb;
License = license; License = license;
Expiration = License.Expires; 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") : base("subscription")
{ {
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; 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 double? StorageGb { get; set; }
public short? MaxStorageGb { get; set; } public short? MaxStorageGb { get; set; }
public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; } public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; }
public BillingSubscription Subscription { get; set; } public BillingSubscription? Subscription { get; set; }
public UserLicense License { 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; } 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; /// <summary>
public bool Active { get; } = discount.Active; /// The Stripe coupon ID (e.g., "cm3nHfO1").
public decimal? PercentOff { get; } = discount.PercentOff; /// </summary>
public List<string> AppliesTo { get; } = discount.AppliesTo; 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 public class BillingSubscription
@@ -83,10 +184,10 @@ public class BillingSubscription
public DateTime? PeriodEndDate { get; set; } public DateTime? PeriodEndDate { get; set; }
public DateTime? CancelledDate { get; set; } public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; } public bool CancelAtEndDate { get; set; }
public string Status { get; set; } public string? Status { get; set; }
public bool Cancelled { get; set; } public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>(); 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? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; }
public int? GracePeriod { get; set; } public int? GracePeriod { get; set; }
@@ -104,11 +205,11 @@ public class BillingSubscription
AddonSubscriptionItem = item.AddonSubscriptionItem; AddonSubscriptionItem = item.AddonSubscriptionItem;
} }
public string ProductId { get; set; } public string? ProductId { get; set; }
public string Name { get; set; } public string? Name { get; set; }
public decimal Amount { get; set; } public decimal Amount { get; set; }
public int Quantity { get; set; } public int Quantity { get; set; }
public string Interval { get; set; } public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; } public bool SponsoredSubscriptionItem { get; set; }
public bool AddonSubscriptionItem { get; set; } public bool AddonSubscriptionItem { get; set; }
} }

View File

@@ -402,8 +402,9 @@ public class CiphersController : Controller
{ {
var org = _currentContext.GetOrganization(organizationId); 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 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 }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true }))
{ {
return false; return false;
} }
@@ -416,8 +417,9 @@ public class CiphersController : Controller
{ {
var org = _currentContext.GetOrganization(organizationId); 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 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 }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true }))
{ {
return false; return false;
} }

View File

@@ -158,6 +158,7 @@ public class FreshsalesController : Controller
planName = "Free"; planName = "Free";
return true; return true;
case PlanType.FamiliesAnnually: case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2025:
case PlanType.FamiliesAnnually2019: case PlanType.FamiliesAnnually2019:
planName = "Families"; planName = "Families";
return true; return true;

View File

@@ -1,11 +1,8 @@
// FIXME: Update this file to be null safe and then delete the line below using Bit.Core;
#nullable disable
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Payment.Queries;
@@ -17,11 +14,13 @@ using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Stripe; using Stripe;
using static Bit.Core.Billing.Constants.StripeConstants;
using Event = Stripe.Event; using Event = Stripe.Event;
using Plan = Bit.Core.Models.StaticStore.Plan;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
using static StripeConstants;
public class UpcomingInvoiceHandler( public class UpcomingInvoiceHandler(
IGetPaymentMethodQuery getPaymentMethodQuery, IGetPaymentMethodQuery getPaymentMethodQuery,
ILogger<StripeEventProcessor> logger, ILogger<StripeEventProcessor> logger,
@@ -57,17 +56,65 @@ public class UpcomingInvoiceHandler(
if (organizationId.HasValue) if (organizationId.HasValue)
{ {
var organization = await organizationRepository.GetByIdAsync(organizationId.Value); await HandleOrganizationUpcomingInvoiceAsync(
organizationId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (userId.HasValue)
{
await HandlePremiumUsersUpcomingInvoiceAsync(
userId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (providerId.HasValue)
{
await HandleProviderUpcomingInvoiceAsync(
providerId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
}
#region Organizations
private async Task HandleOrganizationUpcomingInvoiceAsync(
Guid organizationId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null) if (organization == null)
{ {
logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})",
organizationId, @event.Type, @event.Id);
return; return;
} }
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id); await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); 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) if (!plan.IsAnnual)
{ {
return; return;
@@ -76,7 +123,7 @@ public class UpcomingInvoiceHandler(
if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{ {
var sponsorshipIsValid = var sponsorshipIsValid =
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
if (!sponsorshipIsValid) if (!sponsorshipIsValid)
{ {
@@ -88,172 +135,9 @@ public class UpcomingInvoiceHandler(
} }
} }
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice); await (milestone3
? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail])
/* : SendUpcomingInvoiceEmailsAsync([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);
}
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));
}
}
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);
}
}
private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user)
{
var pricingItem =
subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
if (pricingItem != null)
{
try
{
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 }
]
});
}
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);
}
}
}
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);
}
} }
private async Task AlignOrganizationTaxConcernsAsync( private async Task AlignOrganizationTaxConcernsAsync(
@@ -304,6 +188,221 @@ public class UpcomingInvoiceHandler(
} }
} }
private async Task AlignOrganizationSubscriptionConcernsAsync(
Organization organization,
Event @event,
Subscription subscription,
Plan plan,
bool milestone3)
{
if (milestone3 && plan.Type == PlanType.FamiliesAnnually2019)
{
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
}
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
],
ProrationBehavior = ProrationBehavior.None
};
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( private async Task AlignProviderTaxConcernsAsync(
Provider provider, Provider provider,
Subscription subscription, Subscription subscription,
@@ -348,4 +447,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

@@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse
public SlackTeam Team { get; set; } = new(); public SlackTeam Team { get; set; } = new();
} }
public class SlackSendMessageResponse : SlackApiResponse
{
[JsonPropertyName("channel")]
public string Channel { get; set; } = string.Empty;
}
public class SlackTeam public class SlackTeam
{ {
public string Id { get; set; } = string.Empty; public string Id { get; set; } = string.Empty;

View File

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

View File

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

View File

@@ -9,6 +9,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
/// <summary> /// <summary>
/// Defines behavior and functionality for a given PolicyType. /// Defines behavior and functionality for a given PolicyType.
/// </summary> /// </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 public interface IPolicyValidator
{ {
/// <summary> /// <summary>

View File

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

View File

@@ -2,6 +2,13 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; 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 public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent
{ {
/// <summary> /// <summary>

View File

@@ -3,6 +3,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; 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 public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent
{ {
/// <summary> /// <summary>

View File

@@ -2,6 +2,12 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; 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 public interface IPolicyUpdateEvent
{ {
/// <summary> /// <summary>

View File

@@ -3,12 +3,17 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; 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 public interface IPolicyValidationEvent : IPolicyUpdateEvent
{ {
/// <summary> /// <summary>
/// Performs side effects after a policy is validated but before it is saved. /// Performs any validations required to enable or disable the policy.
/// 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.
/// </summary> /// </summary>
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param> /// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
/// <param name="currentPolicy">The current policy, if any</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

@@ -1,4 +1,6 @@
namespace Bit.Core.Services; using Bit.Core.Models.Slack;
namespace Bit.Core.Services;
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery, /// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
/// and sending messages.</summary> /// and sending messages.</summary>
@@ -54,6 +56,6 @@ public interface ISlackService
/// <param name="token">A valid Slack OAuth access token.</param> /// <param name="token">A valid Slack OAuth access token.</param>
/// <param name="message">The message text to send.</param> /// <param name="message">The message text to send.</param>
/// <param name="channelId">The channel ID to send the message to.</param> /// <param name="channelId">The channel ID to send the message to.</param>
/// <returns>A task that completes when the message has been sent.</returns> /// <returns>The response from Slack after sending the message.</returns>
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
} }

View File

@@ -1,34 +0,0 @@
using Bit.Core.Models.Data;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Services;
public class EventRouteService(
[FromKeyedServices("broadcast")] IEventWriteService broadcastEventWriteService,
[FromKeyedServices("storage")] IEventWriteService storageEventWriteService,
IFeatureService _featureService) : IEventWriteService
{
public async Task CreateAsync(IEvent e)
{
if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations))
{
await broadcastEventWriteService.CreateAsync(e);
}
else
{
await storageEventWriteService.CreateAsync(e);
}
}
public async Task CreateManyAsync(IEnumerable<IEvent> e)
{
if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations))
{
await broadcastEventWriteService.CreateManyAsync(e);
}
else
{
await storageEventWriteService.CreateManyAsync(e);
}
}
}

View File

@@ -6,14 +6,43 @@ public class SlackIntegrationHandler(
ISlackService slackService) ISlackService slackService)
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails> : IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
{ {
private static readonly HashSet<string> _retryableErrors = new(StringComparer.Ordinal)
{
"internal_error",
"message_limit_exceeded",
"rate_limited",
"ratelimited",
"service_unavailable"
};
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message) public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
{ {
await slackService.SendSlackMessageByChannelIdAsync( var slackResponse = await slackService.SendSlackMessageByChannelIdAsync(
message.Configuration.Token, message.Configuration.Token,
message.RenderedTemplate, message.RenderedTemplate,
message.Configuration.ChannelId message.Configuration.ChannelId
); );
if (slackResponse is null)
{
return new IntegrationHandlerResult(success: false, message: message)
{
FailureReason = "Slack response was null"
};
}
if (slackResponse.Ok)
{
return new IntegrationHandlerResult(success: true, message: message); return new IntegrationHandlerResult(success: true, message: message);
} }
var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error };
if (_retryableErrors.Contains(slackResponse.Error))
{
result.Retryable = true;
}
return result;
}
} }

View File

@@ -1,5 +1,6 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using System.Web; using System.Web;
using Bit.Core.Models.Slack; using Bit.Core.Models.Slack;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -71,7 +72,7 @@ public class SlackService(
public async Task<string> GetDmChannelByEmailAsync(string token, string email) public async Task<string> GetDmChannelByEmailAsync(string token, string email)
{ {
var userId = await GetUserIdByEmailAsync(token, email); var userId = await GetUserIdByEmailAsync(token, email);
return await OpenDmChannel(token, userId); return await OpenDmChannelAsync(token, userId);
} }
public string GetRedirectUrl(string callbackUrl, string state) public string GetRedirectUrl(string callbackUrl, string state)
@@ -97,21 +98,21 @@ public class SlackService(
} }
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access", var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
new FormUrlEncodedContent(new[] new FormUrlEncodedContent([
{
new KeyValuePair<string, string>("client_id", _clientId), new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret), new KeyValuePair<string, string>("client_secret", _clientSecret),
new KeyValuePair<string, string>("code", code), new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("redirect_uri", redirectUrl) new KeyValuePair<string, string>("redirect_uri", redirectUrl)
})); ]));
SlackOAuthResponse? result; SlackOAuthResponse? result;
try try
{ {
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>(); result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
} }
catch catch (JsonException ex)
{ {
logger.LogError(ex, "Error parsing SlackOAuthResponse: invalid JSON");
result = null; result = null;
} }
@@ -129,14 +130,25 @@ public class SlackService(
return result.AccessToken; return result.AccessToken;
} }
public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) public async Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
string channelId)
{ {
var payload = JsonContent.Create(new { channel = channelId, text = message }); var payload = JsonContent.Create(new { channel = channelId, text = message });
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage"); var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload; request.Content = payload;
await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
try
{
return await response.Content.ReadFromJsonAsync<SlackSendMessageResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing Slack message response: invalid JSON");
return null;
}
} }
private async Task<string> GetUserIdByEmailAsync(string token, string email) private async Task<string> GetUserIdByEmailAsync(string token, string email)
@@ -144,7 +156,16 @@ public class SlackService(
var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}"); var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>(); SlackUserResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing SlackUserResponse: invalid JSON");
result = null;
}
if (result is null) if (result is null)
{ {
@@ -160,7 +181,7 @@ public class SlackService(
return result.User.Id; return result.User.Id;
} }
private async Task<string> OpenDmChannel(string token, string userId) private async Task<string> OpenDmChannelAsync(string token, string userId)
{ {
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
return string.Empty; return string.Empty;
@@ -170,7 +191,16 @@ public class SlackService(
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload; request.Content = payload;
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>(); SlackDmResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing SlackDmResponse: invalid JSON");
result = null;
}
if (result is null) if (result is null)
{ {

View File

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

View File

@@ -1,4 +1,5 @@
using Bit.Core.Services; using Bit.Core.Models.Slack;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.Services.NoopImplementations; namespace Bit.Core.AdminConsole.Services.NoopImplementations;
@@ -24,9 +25,10 @@ public class NoopSlackService : ISlackService
return string.Empty; return string.Empty;
} }
public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) public Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
string channelId)
{ {
return Task.FromResult(0); return Task.FromResult<SlackSendMessageResponse?>(null);
} }
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl) public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Text.Json.Serialization;
#nullable disable
using System.Text.Json.Serialization;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Tokens; using Bit.Core.Tokens;
@@ -26,7 +23,7 @@ public class OrgUserInviteTokenable : ExpiringTokenable
public string Identifier { get; set; } = TokenIdentifier; public string Identifier { get; set; } = TokenIdentifier;
public Guid OrgUserId { get; set; } public Guid OrgUserId { get; set; }
public string OrgUserEmail { get; set; } public string? OrgUserEmail { get; set; }
[JsonConstructor] [JsonConstructor]
public OrgUserInviteTokenable() 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; using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.UserFeatures.Registration; namespace Bit.Core.Auth.UserFeatures.Registration;
@@ -14,6 +15,15 @@ public interface IRegisterUserCommand
/// <returns><see cref="IdentityResult"/></returns> /// <returns><see cref="IdentityResult"/></returns>
public Task<IdentityResult> RegisterUser(User user); 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> /// <summary>
/// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path), /// 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 /// 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 using Bit.Core.AdminConsole.Entities;
#nullable disable
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models; using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -24,6 +23,7 @@ public class RegisterUserCommand : IRegisterUserCommand
{ {
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
@@ -37,12 +37,14 @@ public class RegisterUserCommand : IRegisterUserCommand
private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand; private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand;
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _emergencyAccessInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _emergencyAccessInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator.";
public RegisterUserCommand( public RegisterUserCommand(
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IDataProtectionProvider dataProtectionProvider, IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory, IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
@@ -50,11 +52,12 @@ public class RegisterUserCommand : IRegisterUserCommand
IUserService userService, IUserService userService,
IMailService mailService, IMailService mailService,
IValidateRedemptionTokenCommand validateRedemptionTokenCommand, IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory,
) IFeatureService featureService)
{ {
_globalSettings = globalSettings; _globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_policyRepository = policyRepository; _policyRepository = policyRepository;
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector( _organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
@@ -69,9 +72,9 @@ public class RegisterUserCommand : IRegisterUserCommand
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory; _emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
_providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); _providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
_featureService = featureService;
} }
public async Task<IdentityResult> RegisterUser(User user) public async Task<IdentityResult> RegisterUser(User user)
{ {
var result = await _userService.CreateUserAsync(user); var result = await _userService.CreateUserAsync(user);
@@ -83,11 +86,22 @@ public class RegisterUserCommand : IRegisterUserCommand
return result; 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, public async Task<IdentityResult> RegisterUserViaOrganizationInviteToken(User user, string masterPasswordHash,
string orgInviteToken, Guid? orgUserId) string orgInviteToken, Guid? orgUserId)
{ {
ValidateOrgInviteToken(orgInviteToken, orgUserId, user); TryValidateOrgInviteToken(orgInviteToken, orgUserId, user);
await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user);
user.ApiKey = CoreHelpers.SecureRandomString(30); user.ApiKey = CoreHelpers.SecureRandomString(30);
@@ -97,16 +111,17 @@ public class RegisterUserCommand : IRegisterUserCommand
} }
var result = await _userService.CreateUserAsync(user, masterPasswordHash); var result = await _userService.CreateUserAsync(user, masterPasswordHash);
var organization = await GetOrganizationUserOrganization(orgUserId ?? Guid.Empty, orgUser);
if (result == IdentityResult.Success) if (result == IdentityResult.Success)
{ {
var sentWelcomeEmail = false; var sentWelcomeEmail = false;
if (!string.IsNullOrEmpty(user.ReferenceData)) 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)) if (referenceData.TryGetValue("initiationPath", out var value))
{ {
var initiationPath = value.ToString(); var initiationPath = value.ToString() ?? string.Empty;
await SendAppropriateWelcomeEmailAsync(user, initiationPath); await SendAppropriateWelcomeEmailAsync(user, initiationPath, organization);
sentWelcomeEmail = true; sentWelcomeEmail = true;
if (!string.IsNullOrEmpty(initiationPath)) if (!string.IsNullOrEmpty(initiationPath))
{ {
@@ -117,14 +132,22 @@ public class RegisterUserCommand : IRegisterUserCommand
if (!sentWelcomeEmail) if (!sentWelcomeEmail)
{ {
await _mailService.SendWelcomeEmailAsync(user); await SendWelcomeEmailAsync(user, organization);
} }
} }
return result; 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); var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken);
@@ -137,7 +160,6 @@ public class RegisterUserCommand : IRegisterUserCommand
} }
// Token data is invalid // Token data is invalid
if (_globalSettings.DisableUserRegistration) if (_globalSettings.DisableUserRegistration)
{ {
throw new BadRequestException(_disabledUserRegistrationExceptionMsg); throw new BadRequestException(_disabledUserRegistrationExceptionMsg);
@@ -147,7 +169,6 @@ public class RegisterUserCommand : IRegisterUserCommand
} }
// no token data or missing token data // 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 // 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. // as you can't register without them.
if (_globalSettings.DisableUserRegistration) if (_globalSettings.DisableUserRegistration)
@@ -171,12 +192,20 @@ public class RegisterUserCommand : IRegisterUserCommand
// If both orgInviteToken && orgUserId are missing, then proceed with open registration // 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) 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 // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail); _orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail);
return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid( return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid(
_organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings); _organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings);
} }
@@ -187,11 +216,12 @@ public class RegisterUserCommand : IRegisterUserCommand
/// </summary> /// </summary>
/// <param name="orgUserId">The optional org user id</param> /// <param name="orgUserId">The optional org user id</param>
/// <param name="user">The newly created user object which could be modified</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) if (!orgUserId.HasValue)
{ {
return; return null;
} }
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
@@ -213,10 +243,11 @@ public class RegisterUserCommand : IRegisterUserCommand
_userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email); _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"); var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial");
@@ -226,16 +257,14 @@ public class RegisterUserCommand : IRegisterUserCommand
} }
else else
{ {
await _mailService.SendWelcomeEmailAsync(user); await SendWelcomeEmailAsync(user, organization);
} }
} }
public async Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, public async Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash,
string emailVerificationToken) string emailVerificationToken)
{ {
ValidateOpenRegistrationAllowed(); ValidateOpenRegistrationAllowed();
var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email);
user.EmailVerified = true; user.EmailVerified = true;
@@ -245,7 +274,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash); var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success) if (result == IdentityResult.Success)
{ {
await _mailService.SendWelcomeEmailAsync(user); await SendWelcomeEmailAsync(user);
} }
return result; return result;
@@ -263,7 +292,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash); var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success) if (result == IdentityResult.Success)
{ {
await _mailService.SendWelcomeEmailAsync(user); await SendWelcomeEmailAsync(user);
} }
return result; return result;
@@ -283,7 +312,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash); var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success) if (result == IdentityResult.Success)
{ {
await _mailService.SendWelcomeEmailAsync(user); await SendWelcomeEmailAsync(user);
} }
return result; return result;
@@ -301,7 +330,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash); var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success) if (result == IdentityResult.Success)
{ {
await _mailService.SendWelcomeEmailAsync(user); await SendWelcomeEmailAsync(user);
} }
return result; return result;
@@ -357,4 +386,59 @@ public class RegisterUserCommand : IRegisterUserCommand
return tokenable; 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 LegacyMSPDiscount = "msp-discount-35";
public const string SecretsManagerStandalone = "sm-standalone"; 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 public static class MSPDiscounts
{ {

View File

@@ -18,8 +18,8 @@ public enum PlanType : byte
EnterpriseAnnually2019 = 5, EnterpriseAnnually2019 = 5,
[Display(Name = "Custom")] [Display(Name = "Custom")]
Custom = 6, Custom = 6,
[Display(Name = "Families")] [Display(Name = "Families 2025")]
FamiliesAnnually = 7, FamiliesAnnually2025 = 7,
[Display(Name = "Teams (Monthly) 2020")] [Display(Name = "Teams (Monthly) 2020")]
TeamsMonthly2020 = 8, TeamsMonthly2020 = 8,
[Display(Name = "Teams (Annually) 2020")] [Display(Name = "Teams (Annually) 2020")]
@@ -48,4 +48,6 @@ public enum PlanType : byte
EnterpriseAnnually = 20, EnterpriseAnnually = 20,
[Display(Name = "Teams Starter")] [Display(Name = "Teams Starter")]
TeamsStarter = 21, TeamsStarter = 21,
[Display(Name = "Families")]
FamiliesAnnually = 22,
} }

View File

@@ -15,7 +15,7 @@ public static class BillingExtensions
=> planType switch => planType switch
{ {
PlanType.Custom or PlanType.Free => ProductTierType.Free, PlanType.Custom or PlanType.Free => ProductTierType.Free,
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter,
_ when planType.ToString().Contains("Teams") => ProductTierType.Teams, _ when planType.ToString().Contains("Teams") => ProductTierType.Teams,
_ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise,

View File

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

View File

@@ -0,0 +1,47 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Families2025Plan : Plan
{
public Families2025Plan()
{
Type = PlanType.FamiliesAnnually2025;
ProductTier = ProductTierType.Families;
Name = "Families 2025";
IsAnnual = true;
NameLocalizationKey = "planNameFamilies";
DescriptionLocalizationKey = "planDescFamilies";
TrialPeriodDays = 7;
HasSelfHost = true;
HasTotp = true;
UsersGetPremium = true;
UpgradeSortOrder = 1;
DisplaySortOrder = 1;
PasswordManager = new Families2025PasswordManagerFeatures();
}
private record Families2025PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Families2025PasswordManagerFeatures()
{
BaseSeats = 6;
BaseStorageGb = 1;
MaxSeats = 6;
HasAdditionalStorageOption = true;
StripePlanId = "2020-families-org-annually";
StripeStoragePlanId = "personal-storage-gb-annually";
BasePrice = 40;
AdditionalStoragePricePerGb = 4;
AllowSeatAutoscale = false;
}
}
}

View File

@@ -23,12 +23,12 @@ public record FamiliesPlan : Plan
UpgradeSortOrder = 1; UpgradeSortOrder = 1;
DisplaySortOrder = 1; DisplaySortOrder = 1;
PasswordManager = new TeamsPasswordManagerFeatures(); PasswordManager = new FamiliesPasswordManagerFeatures();
} }
private record TeamsPasswordManagerFeatures : PasswordManagerPlanFeatures private record FamiliesPasswordManagerFeatures : PasswordManagerPlanFeatures
{ {
public TeamsPasswordManagerFeatures() public FamiliesPasswordManagerFeatures()
{ {
BaseSeats = 6; BaseSeats = 6;
BaseStorageGb = 1; BaseStorageGb = 1;

View File

@@ -22,11 +22,6 @@ public class GetOrganizationMetadataQuery(
{ {
public async Task<OrganizationMetadata?> Run(Organization organization) public async Task<OrganizationMetadata?> Run(Organization organization)
{ {
if (organization == null)
{
return null;
}
if (globalSettings.SelfHosted) if (globalSettings.SelfHosted)
{ {
return OrganizationMetadata.Default; return OrganizationMetadata.Default;
@@ -42,10 +37,12 @@ public class GetOrganizationMetadataQuery(
}; };
} }
var customer = await subscriberService.GetCustomer(organization, var customer = await subscriberService.GetCustomer(organization);
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
var subscription = await subscriberService.GetSubscription(organization); var subscription = await subscriberService.GetSubscription(organization, new SubscriptionGetOptions
{
Expand = ["discounts.coupon.applies_to"]
});
if (customer == null || subscription == null) if (customer == null || subscription == null)
{ {
@@ -79,16 +76,17 @@ public class GetOrganizationMetadataQuery(
return false; return false;
} }
var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; var coupon = subscription.Discounts?.FirstOrDefault(discount =>
discount.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone)?.Coupon;
if (!hasCoupon) if (coupon == null)
{ {
return false; return false;
} }
var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId);
var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; var couponAppliesTo = coupon.AppliesTo?.Products;
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
} }

View File

@@ -79,10 +79,12 @@ public class OrganizationBillingService(
}; };
} }
var customer = await subscriberService.GetCustomer(organization, var customer = await subscriberService.GetCustomer(organization);
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
var subscription = await subscriberService.GetSubscription(organization); var subscription = await subscriberService.GetSubscription(organization, new SubscriptionGetOptions
{
Expand = ["discounts.coupon.applies_to"]
});
if (customer == null || subscription == null) if (customer == null || subscription == null)
{ {
@@ -542,16 +544,17 @@ public class OrganizationBillingService(
return false; return false;
} }
var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; var coupon = subscription.Discounts?.FirstOrDefault(discount =>
discount.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone)?.Coupon;
if (!hasCoupon) if (coupon == null)
{ {
return false; return false;
} }
var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId);
var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; var couponAppliesTo = coupon.AppliesTo?.Products;
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
} }

View File

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

View File

@@ -58,6 +58,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
"enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020, "enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020,
"enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023, "enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023,
"families" => PlanType.FamiliesAnnually, "families" => PlanType.FamiliesAnnually,
"families-2025" => PlanType.FamiliesAnnually2025,
"families-2019" => PlanType.FamiliesAnnually2019, "families-2019" => PlanType.FamiliesAnnually2019,
"free" => PlanType.Free, "free" => PlanType.Free,
"teams-annually" => PlanType.TeamsAnnually, "teams-annually" => PlanType.TeamsAnnually,
@@ -77,7 +78,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
=> planType switch => planType switch
{ {
PlanType.Free => ProductTierType.Free, PlanType.Free => ProductTierType.Free,
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter,
_ when planType.ToString().Contains("Teams") => ProductTierType.Teams, _ when planType.ToString().Contains("Teams") => ProductTierType.Teams,
_ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise,
@@ -98,11 +99,19 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
_ => true); _ => true);
var baseSeats = GetBaseSeats(plan.Seats); var baseSeats = GetBaseSeats(plan.Seats);
var maxSeats = GetMaxSeats(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 hasAdditionalStorageOption = plan.Storage != null;
var additionalStoragePricePerGb = plan.Storage?.Price ?? 0; var additionalStoragePricePerGb = plan.Storage?.Price ?? 0;
var stripeStoragePlanId = plan.Storage?.StripePriceId; var stripeStoragePlanId = plan.Storage?.StripePriceId;
short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null; 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 return new PasswordManagerPlanFeatures
{ {
@@ -120,7 +129,9 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
HasAdditionalStorageOption = hasAdditionalStorageOption, HasAdditionalStorageOption = hasAdditionalStorageOption,
AdditionalStoragePricePerGb = additionalStoragePricePerGb, AdditionalStoragePricePerGb = additionalStoragePricePerGb,
StripeStoragePlanId = stripeStoragePlanId, 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 string StripePriceId { get; init; } = null!;
public decimal Price { get; init; } public decimal Price { get; init; }
public int Provided { get; init; }
} }

View File

@@ -50,7 +50,7 @@ public class PricingClient(
var plan = await response.Content.ReadFromJsonAsync<Plan>(); var plan = await response.Content.ReadFromJsonAsync<Plan>();
return plan == null return plan == null
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
: new PlanAdapter(plan); : new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan));
} }
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
@@ -91,7 +91,7 @@ public class PricingClient(
var plans = await response.Content.ReadFromJsonAsync<List<Plan>>(); var plans = await response.Content.ReadFromJsonAsync<List<Plan>>();
return plans == null return plans == null
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
: plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList(); : plans.Select(OrganizationPlan (plan) => new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan))).ToList();
} }
throw new BillingException( throw new BillingException(
@@ -123,9 +123,7 @@ public class PricingClient(
return [CurrentPremiumPlan]; return [CurrentPremiumPlan];
} }
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); var response = await httpClient.GetAsync("plans/premium");
var response = await httpClient.GetAsync($"plans/premium?milestone2={milestone2Feature}");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
@@ -137,7 +135,7 @@ public class PricingClient(
message: $"Request to the Pricing Service failed with status {response.StatusCode}"); message: $"Request to the Pricing Service failed with status {response.StatusCode}");
} }
private static string? GetLookupKey(PlanType planType) private string? GetLookupKey(PlanType planType)
=> planType switch => planType switch
{ {
PlanType.EnterpriseAnnually => "enterprise-annually", PlanType.EnterpriseAnnually => "enterprise-annually",
@@ -149,6 +147,10 @@ public class PricingClient(
PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020", PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020",
PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023", PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023",
PlanType.FamiliesAnnually => "families", PlanType.FamiliesAnnually => "families",
PlanType.FamiliesAnnually2025 =>
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3)
? "families-2025"
: "families",
PlanType.FamiliesAnnually2019 => "families-2019", PlanType.FamiliesAnnually2019 => "families-2019",
PlanType.Free => "free", PlanType.Free => "free",
PlanType.TeamsAnnually => "teams-annually", PlanType.TeamsAnnually => "teams-annually",
@@ -164,12 +166,26 @@ public class PricingClient(
_ => null _ => null
}; };
/// <summary>
/// Safeguard used until the feature flag is enabled. Pricing service will return the
/// 2025PreMigration plan with "families" lookup key. When that is detected and the FF
/// is still disabled, set the lookup key to families-2025 so PlanAdapter will assign
/// the correct plan.
/// </summary>
/// <param name="plan">The plan to preprocess</param>
private Plan PreProcessFamiliesPreMigrationPlan(Plan plan)
{
if (plan.LookupKey == "families" && !featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3))
plan.LookupKey = "families-2025";
return plan;
}
private static PremiumPlan CurrentPremiumPlan => new() private static PremiumPlan CurrentPremiumPlan => new()
{ {
Name = "Premium", Name = "Premium",
Available = true, Available = true,
LegacyYear = null, LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, 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); 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) switch (customerSetup.TokenizedPaymentSource)
{ {
@@ -119,6 +121,7 @@ public class PremiumUserBillingService(
user.Gateway = GatewayType.Stripe; user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id; user.GatewayCustomerId = customer.Id;
user.GatewaySubscriptionId = subscription.Id; user.GatewaySubscriptionId = subscription.Id;
user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + (storage ?? 0));
await userRepository.ReplaceAsync(user); await userRepository.ReplaceAsync(user);
} }
@@ -301,9 +304,9 @@ public class PremiumUserBillingService(
private async Task<Subscription> CreateSubscriptionAsync( private async Task<Subscription> CreateSubscriptionAsync(
Guid userId, Guid userId,
Customer customer, Customer customer,
Pricing.Premium.Plan premiumPlan,
int? storage) int? storage)
{ {
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions> var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{ {

View File

@@ -137,7 +137,6 @@ public static class FeatureFlagKeys
/* Admin Console Team */ /* Admin Console Team */
public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string PolicyRequirements = "pm-14439-policy-requirements";
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
@@ -163,6 +162,8 @@ public static class FeatureFlagKeys
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; "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 RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; 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 */ /* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
@@ -195,6 +196,7 @@ public static class FeatureFlagKeys
public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page";
public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service";
public const string PM23341_Milestone_2 = "pm-23341-milestone-2"; public const string PM23341_Milestone_2 = "pm-23341-milestone-2";
public const string PM26462_Milestone_3 = "pm-26462-milestone-3";
/* Key Management Team */ /* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
@@ -252,6 +254,7 @@ public static class FeatureFlagKeys
public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption";
public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium"; public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium";
public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search"; public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search";
public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders";
/* Innovation Team */ /* Innovation Team */
public const string ArchiveVaultItems = "pm-19148-innovation-archive"; public const string ArchiveVaultItems = "pm-19148-innovation-archive";

View File

@@ -68,6 +68,9 @@
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" /> <PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.1.2" /> <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>
<ItemGroup Label="Pinned transitive dependencies"> <ItemGroup Label="Pinned transitive dependencies">

View File

@@ -51,6 +51,20 @@
<style type="text/css"> <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) { @media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; } table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; } td.mj-full-width-mobile { width: auto !important; }
@@ -65,11 +79,13 @@
.border-fix > table > tbody > tr > td { .border-fix > table > tbody > tr > td {
border-radius: 3px; border-radius: 3px;
} }
@media only screen and .send-bubble {
(max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } } padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
</style> </style>
<!-- Responsive icon visibility -->
</head> </head>
<body style="word-spacing:normal;background-color:#e6e9ef;"> <body style="word-spacing:normal;background-color:#e6e9ef;">
@@ -151,14 +167,14 @@
<tbody> <tbody>
<tr> <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;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody> <tbody>
<tr> <tr>
<td style="width:140px;"> <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:140px;width:100%;font-size:16px;" width="140" height="140"> <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> </td>
</tr> </tr>
@@ -260,8 +276,8 @@
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <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 <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
verify your email again.</div> to verify your email again.</div>
</td> </td>
</tr> </tr>
@@ -370,8 +386,8 @@
<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;"> <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> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;"> <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:406px;" ><![endif]--> <!--[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%;"> <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%;">
@@ -381,9 +397,9 @@
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <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;"><h3 style="font-size: 20px; margin: 0; line-height: 28px"> <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 Learn more about Bitwarden
</h3> </p>
Find user guides, product documentation, and videos on the 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> <a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
@@ -395,7 +411,7 @@
</div> </div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:174px;" ><![endif]--> <!--[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%;"> <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%;">
@@ -403,7 +419,7 @@
<tbody> <tbody>
<tr> <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;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody> <tbody>

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,5 +1,7 @@
{ {
"packages": [ "packages": [
"components/mj-bw-hero" "components/mj-bw-hero",
"components/mj-bw-icon-row",
"components/mj-bw-learn-more-footer"
] ]
} }

View File

@@ -45,7 +45,7 @@ When using MJML templating you can use the above [commands](#building-mjml-files
Not all MJML tags have the same attributes, it is highly recommended to review the documentation on the official MJML website to understand the usages of each of the tags. Not all MJML tags have the same attributes, it is highly recommended to review the documentation on the official MJML website to understand the usages of each of the tags.
### Recommended development ### Recommended development - IMailService
#### Mjml email template development #### Mjml email template development
@@ -58,11 +58,17 @@ Not all MJML tags have the same attributes, it is highly recommended to review t
After the email is developed from the [initial step](#mjml-email-template-development) make sure the email `{{variables}}` are populated properly by running it through an `IMailService` implementation. After the email is developed from the [initial step](#mjml-email-template-development) make sure the email `{{variables}}` are populated properly by running it through an `IMailService` implementation.
1. run `npm run build:minify` 1. run `npm run build:hbs`
2. copy built `*.html.hbs` files from the build directory to a location the mail service can consume them 2. copy built `*.html.hbs` files from the build directory to a location the mail service can consume them
1. all files in the `Core/MailTemplates/Mjml/out` directory can be copied to the `src/Core/MailTemplates/Handlebars/MJML` directory. If a shared component is modified it is important to copy and overwrite all files in that directory to capture
changes in the `*.html.hbs`.
3. run code that will send the email 3. run code that will send the email
The minified `html.hbs` artifacts are deliverables and must be placed into the correct `src/Core/MailTemplates/Handlebars/` directories in order to be used by `IMailService` implementations. The minified `html.hbs` artifacts are deliverables and must be placed into the correct `src/Core/MailTemplates/Handlebars/` directories in order to be used by `IMailService` implementations, see 2.1 above.
### Recommended development - IMailer
TBD - PM-26475
### Custom tags ### Custom tags
@@ -110,3 +116,8 @@ You are also able to reference other more static MJML templates in your MJML fil
<mj-include path="../../components/learn-more-footer.mjml" /> <mj-include path="../../components/learn-more-footer.mjml" />
</mj-wrapper> </mj-wrapper>
``` ```
#### `head.mjml`
Currently we include the `head.mjml` file in all MJML templates as it contains shared styling and formatting that ensures consistency across all email implementations.
In the future we may deviate from this practice to support different layouts. At that time we will modify the docs with direction.

View File

@@ -22,9 +22,3 @@
border-radius: 3px; border-radius: 3px;
} }
</mj-style> </mj-style>
<!-- Responsive icon visibility -->
<mj-style>
@media only screen and
(max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } }
</mj-style>

View File

@@ -1,18 +0,0 @@
<mj-section border-radius="0px 0px 4px 4px" background-color="#f6f6f6" padding="5px 20px 10px 20px">
<mj-column width="70%">
<mj-text line-height="24px">
<h3 style="font-size: 20px; margin: 0; line-height: 28px">
Learn more about Bitwarden
</h3>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link"> Bitwarden Help Center</a>.
</mj-text>
</mj-column>
<mj-column width="30%">
<mj-image
src="https://assets.bitwarden.com/email/v1/spot-community.png"
css-class="hide-small-img"
width="94px"
/>
</mj-column>
</mj-section>

View File

@@ -18,27 +18,19 @@ class MjBwHero extends BodyComponent {
static defaultAttributes = {}; static defaultAttributes = {};
componentHeadStyle = breakpoint => {
return `
@media only screen and (max-width:${breakpoint}) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
`
}
render() { render() {
if (this.getAttribute("button-text") && this.getAttribute("button-url")) { const buttonElement = this.getAttribute("button-text") && this.getAttribute("button-url") ?
return this.renderMJML(` `<mj-button
<mj-section
full-width="full-width"
background-color="#175ddc"
border-radius="4px 4px 0px 0px"
>
<mj-column width="70%">
<mj-image
align="left"
src="https://bitwarden.com/images/logo-horizontal-white.png"
width="150px"
height="30px"
></mj-image>
<mj-text color="#fff" padding-top="0" padding-bottom="0">
<h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
${this.getAttribute("title")}
</h1>
</mj-text>
<mj-button
href="${this.getAttribute("button-url")}" href="${this.getAttribute("button-url")}"
background-color="#fff" background-color="#fff"
color="#1A41AC" color="#1A41AC"
@@ -47,22 +39,16 @@ class MjBwHero extends BodyComponent {
> >
${this.getAttribute("button-text")} ${this.getAttribute("button-text")}
</mj-button </mj-button
> >` : "";
</mj-column> const subTitleElement = this.getAttribute("sub-title") ?
<mj-column width="30%" vertical-align="bottom"> `<mj-text color="#fff" padding-top="0" padding-bottom="0">
<mj-image <h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
src="${this.getAttribute("img-src")}" ${this.getAttribute("sub-title")}
alt="" </h2>
width="140px" </mj-text>` : "";
height="140px"
padding="0px" return this.renderMJML(
css-class="hide-small-img" `
/>
</mj-column>
</mj-section>
`);
} else {
return this.renderMJML(`
<mj-section <mj-section
full-width="full-width" full-width="full-width"
background-color="#175ddc" background-color="#175ddc"
@@ -79,21 +65,25 @@ class MjBwHero extends BodyComponent {
<h1 style="font-weight: normal; font-size: 24px; line-height: 32px"> <h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
${this.getAttribute("title")} ${this.getAttribute("title")}
</h1> </h1>
</mj-text> ` +
subTitleElement +
`
</mj-text>` +
buttonElement +
`
</mj-column> </mj-column>
<mj-column width="30%" vertical-align="bottom"> <mj-column width="30%" vertical-align="bottom">
<mj-image <mj-image
src="${this.getAttribute("img-src")}" src="${this.getAttribute("img-src")}"
alt="" alt=""
width="140px" width="155px"
height="140px"
padding="0px" padding="0px"
css-class="hide-small-img" css-class="mj-bw-hero-responsive-img"
/> />
</mj-column> </mj-column>
</mj-section> </mj-section>
`); `,
} );
} }
} }

View File

@@ -0,0 +1,100 @@
const { BodyComponent } = require("mjml-core");
class MjBwIconRow extends BodyComponent {
static dependencies = {
"mj-column": ["mj-bw-icon-row"],
"mj-wrapper": ["mj-bw-icon-row"],
"mj-bw-icon-row": [],
};
static allowedAttributes = {
"icon-src": "string",
"icon-alt": "string",
"head-url-text": "string",
"head-url": "string",
text: "string",
"foot-url-text": "string",
"foot-url": "string",
};
static defaultAttributes = {};
componentHeadStyle = (breakpoint) => {
return `
@media only screen and (max-width:${breakpoint}): {
".mj-bw-icon-row-text": {
padding-left: "5px !important",
line-height: "20px",
},
".mj-bw-icon-row": {
padding: "10px 15px",
width: "fit-content !important",
}
}
`;
};
render() {
const headAnchorElement =
this.getAttribute("head-url-text") && this.getAttribute("head-url")
? `<a href="${this.getAttribute("head-url")}" class="link">
${this.getAttribute("head-url-text")}
<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>`
: "";
const footAnchorElement =
this.getAttribute("foot-url-text") && this.getAttribute("foot-url")
? `<a href="${this.getAttribute("foot-url")}" class="link">
${this.getAttribute("foot-url-text")}
<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>`
: "";
return this.renderMJML(
`
<mj-section background-color="#fff" padding="10px 10px 10px 10px">
<mj-group css-class="mj-bw-icon-row">
<mj-column width="15%" vertical-align="top">
<mj-image
src="${this.getAttribute("icon-src")}"
alt="${this.getAttribute("icon-alt")}"
width="48px"
padding="0px"
border-radius="8px"
/>
</mj-column>
<mj-column width="85%" vertical-align="top">
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
` +
headAnchorElement +
`
</mj-text>
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
${this.getAttribute("text")}
</mj-text>
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
` +
footAnchorElement +
`
</mj-text>
</mj-column>
</mj-group>
</mj-section>
`,
);
}
}
module.exports = MjBwIconRow;

View File

@@ -0,0 +1,51 @@
const { BodyComponent } = require("mjml-core");
class MjBwLearnMoreFooter extends BodyComponent {
static dependencies = {
// Tell the validator which tags are allowed as our component's parent
"mj-column": ["mj-bw-learn-more-footer"],
"mj-wrapper": ["mj-bw-learn-more-footer"],
// Tell the validator which tags are allowed as our component's children
"mj-bw-learn-more-footer": [],
};
static allowedAttributes = {};
static defaultAttributes = {};
componentHeadStyle = (breakpoint) => {
return `
@media only screen and (max-width:${breakpoint}) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
`;
};
render() {
return this.renderMJML(
`
<mj-section border-radius="0px 0px 4px 4px" background-color="#f6f6f6" padding="5px 10px 10px 10px">
<mj-column width="70%">
<mj-text line-height="24px">
<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"> Bitwarden Help Center</a>.
</mj-text>
</mj-column>
<mj-column width="30%">
<mj-image
src="https://assets.bitwarden.com/email/v1/spot-community.png"
css-class="mj-bw-learn-more-footer-responsive-img"
width="94px"
/>
</mj-column>
</mj-section>
`,
);
}
}
module.exports = MjBwLearnMoreFooter;

View File

@@ -0,0 +1,60 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml" />
</mj-head>
<mj-body css-class="border-fix">
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
title="Welcome to Bitwarden!"
sub-title="Let's get set up to autofill."
/>
</mj-wrapper>
<!-- Main Content -->
<mj-wrapper padding="5px 20px 10px 20px">
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
<mj-column>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
An administrator from <b>{{OrganizationName}}</b> will approve you
before you can share passwords. While you wait for approval, get
started with Bitwarden Password Manager:
</mj-text>
</mj-column>
</mj-section>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png"
icon-alt="Browser Extension Icon"
head-url-text="Get the browser extension"
head-url="https://bitwarden.com/download/"
text="With the Bitwarden extension, you can fill passwords with one click."
/>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-install.png"
icon-alt="Install Icon"
head-url-text="Add passwords to your vault"
head-url="https://bitwarden.com/help/import-data/"
text="Quickly transfer existing passwords to Bitwarden using the importer."
/>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-devices.png"
icon-alt="Devices Icon"
head-url-text="Download Bitwarden on all devices"
head-url="https://bitwarden.com/download/"
text="Take your passwords with you anywhere."
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>
</mj-wrapper>
<!-- Learn More Section -->
<mj-wrapper padding="5px 20px 10px 20px">
<mj-bw-learn-more-footer />
</mj-wrapper>
<!-- Footer -->
<mj-include path="../../../components/footer.mjml" />
</mj-body>
</mjml>

View File

@@ -0,0 +1,59 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml" />
</mj-head>
<mj-body css-class="border-fix">
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
title="Welcome to Bitwarden!"
sub-title="Let's get set up to autofill."
/>
</mj-wrapper>
<!-- Main Content -->
<mj-wrapper padding="5px 20px 10px 20px">
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
<mj-column>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
Follow these simple steps to get up and running with Bitwarden
Password Manager:
</mj-text>
</mj-column>
</mj-section>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png"
icon-alt="Browser Extension Icon"
head-url-text="Get the browser extension"
head-url="https://bitwarden.com/download/"
text="With the Bitwarden extension, you can fill passwords with one click."
/>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-install.png"
icon-alt="Install Icon"
head-url-text="Add passwords to your vault"
head-url="https://bitwarden.com/help/import-data/"
text="Quickly transfer existing passwords to Bitwarden using the importer."
/>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-devices.png"
icon-alt="Devices Icon"
head-url-text="Download Bitwarden on all devices"
head-url="https://bitwarden.com/download/"
text="Take your passwords with you anywhere."
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>
</mj-wrapper>
<!-- Learn More Section -->
<mj-wrapper padding="5px 20px 10px 20px">
<mj-bw-learn-more-footer />
</mj-wrapper>
<!-- Footer -->
<mj-include path="../../../components/footer.mjml" />
</mj-body>
</mjml>

View File

@@ -0,0 +1,60 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml" />
</mj-head>
<mj-body css-class="border-fix">
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
title="Welcome to Bitwarden!"
sub-title="Let's get set up to autofill."
/>
</mj-wrapper>
<!-- Main Content -->
<mj-wrapper padding="5px 20px 10px 20px">
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
<mj-column>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
An administrator from <b>{{OrganizationName}}</b> will need to confirm
you before you can share passwords. Get started with Bitwarden
Password Manager:
</mj-text>
</mj-column>
</mj-section>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png"
icon-alt="Browser Extension Icon"
head-url-text="Get the browser extension"
head-url="https://bitwarden.com/download/"
text="With the Bitwarden extension, you can fill passwords with one click."
/>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-install.png"
icon-alt="Install Icon"
head-url-text="Add passwords to your vault"
head-url="https://bitwarden.com/help/import-data/"
text="Quickly transfer existing passwords to Bitwarden using the importer."
/>
<mj-bw-icon-row
icon-src="https://assets.bitwarden.com/email/v1/icon-autofill.png"
icon-alt="Autofill Icon"
head-url-text="Try Bitwarden autofill"
head-url="https://bitwarden.com/help/auto-fill-browser/"
text="Fill your passwords securely with one click."
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>
</mj-wrapper>
<!-- Learn More Section -->
<mj-wrapper padding="5px 20px 10px 20px">
<mj-bw-learn-more-footer />
</mj-wrapper>
<!-- Footer -->
<mj-include path="../../../components/footer.mjml" />
</mj-body>
</mjml>

View File

@@ -1,7 +1,13 @@
<mjml> <mjml>
<mj-head> <mj-head>
<mj-include path="../../components/head.mjml" /> <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-head>
<mj-body css-class="border-fix"> <mj-body css-class="border-fix">
@@ -18,18 +24,17 @@
<mj-section background-color="#fff"> <mj-section background-color="#fff">
<mj-column padding="0px"> <mj-column padding="0px">
<mj-text> Your verification code is: </mj-text> <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-spacer height="20px" />
<mj-text> <mj-text>
This code expires in {{Expiry}} minutes. After that, you'll need to This code expires in {{Expiry}} minutes. After that, you'll need
verify your email again. to verify your email again.
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section <mj-section background-color="#fff" padding="0px 0px 20px 0px">
background-color="#fff"
padding="0px 0px 20px 0px"
>
<mj-column <mj-column
css-class="send-bubble" css-class="send-bubble"
width="90%" width="90%"
@@ -55,7 +60,7 @@
<!-- Learn More Section --> <!-- Learn More Section -->
<mj-wrapper padding="5px 20px 10px 20px"> <mj-wrapper padding="5px 20px 10px 20px">
<mj-include path="../../components/learn-more-footer.mjml" /> <mj-bw-learn-more-footer />
</mj-wrapper> </mj-wrapper>
<!-- Footer --> <!-- Footer -->

View File

@@ -24,6 +24,8 @@
</mj-section> </mj-section>
</mj-wrapper> </mj-wrapper>
<mj-include path="../../components/footer.mjml" /> <mj-wrapper>
<mj-bw-learn-more-footer />
</mj-wrapper>
</mj-body> </mj-body>
</mjml> </mjml>

View File

@@ -22,7 +22,7 @@
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-include path="../components/learn-more-footer.mjml" /> <mj-bw-learn-more-footer />
</mj-wrapper> </mj-wrapper>
<mj-include path="../components/footer.mjml" /> <mj-include path="../components/footer.mjml" />

View File

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

View File

@@ -1,58 +1,118 @@
// FIXME: Update this file to be null safe and then delete the line below using Bit.Core.Billing.Extensions;
#nullable disable
using Bit.Core.Billing.Extensions;
using Stripe; using Stripe;
#nullable enable
namespace Bit.Core.Models.Business; namespace Bit.Core.Models.Business;
public class SubscriptionInfo public class SubscriptionInfo
{ {
public BillingCustomerDiscount CustomerDiscount { get; set; } /// <summary>
public BillingSubscription Subscription { get; set; } /// Converts Stripe's minor currency units (cents) to major currency units (dollars).
public BillingUpcomingInvoice UpcomingInvoice { get; set; } /// 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 class BillingCustomerDiscount
{ {
public 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) public BillingCustomerDiscount(Discount discount)
{ {
Id = discount.Coupon?.Id; 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; Active = discount.End == null;
PercentOff = discount.Coupon?.PercentOff; 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; } 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 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 class BillingSubscription
{ {
public BillingSubscription(Subscription sub) public BillingSubscription(Subscription sub)
{ {
Status = sub.Status; Status = sub?.Status;
TrialStartDate = sub.TrialStart; TrialStartDate = sub?.TrialStart;
TrialEndDate = sub.TrialEnd; TrialEndDate = sub?.TrialEnd;
var currentPeriod = sub.GetCurrentPeriod(); var currentPeriod = sub?.GetCurrentPeriod();
if (currentPeriod != null) if (currentPeriod != null)
{ {
var (start, end) = currentPeriod.Value; var (start, end) = currentPeriod.Value;
PeriodStartDate = start; PeriodStartDate = start;
PeriodEndDate = end; PeriodEndDate = end;
} }
CancelledDate = sub.CanceledAt; CancelledDate = sub?.CanceledAt;
CancelAtEndDate = sub.CancelAtPeriodEnd; CancelAtEndDate = sub?.CancelAtPeriodEnd ?? false;
Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired"; var status = sub?.Status;
if (sub.Items?.Data != null) Cancelled = status == "canceled" || status == "unpaid" || status == "incomplete_expired";
if (sub?.Items?.Data != null)
{ {
Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i));
} }
CollectionMethod = sub.CollectionMethod; CollectionMethod = sub?.CollectionMethod;
GracePeriod = sub.CollectionMethod == "charge_automatically" GracePeriod = sub?.CollectionMethod == "charge_automatically"
? 14 ? 14
: 30; : 30;
} }
@@ -64,10 +124,10 @@ public class SubscriptionInfo
public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate; public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate;
public DateTime? CancelledDate { get; set; } public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; } public bool CancelAtEndDate { get; set; }
public string Status { get; set; } public string? Status { get; set; }
public bool Cancelled { get; set; } public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>(); 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? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; }
public int GracePeriod { get; set; } public int GracePeriod { get; set; }
@@ -80,7 +140,7 @@ public class SubscriptionInfo
{ {
ProductId = item.Plan.ProductId; ProductId = item.Plan.ProductId;
Name = item.Plan.Nickname; Name = item.Plan.Nickname;
Amount = item.Plan.Amount.GetValueOrDefault() / 100M; Amount = ConvertFromStripeMinorUnits(item.Plan.Amount) ?? 0;
Interval = item.Plan.Interval; Interval = item.Plan.Interval;
if (item.Metadata != null) if (item.Metadata != null)
@@ -90,15 +150,15 @@ public class SubscriptionInfo
} }
Quantity = (int)item.Quantity; 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 bool AddonSubscriptionItem { get; set; }
public string ProductId { get; set; } public string? ProductId { get; set; }
public string Name { get; set; } public string? Name { get; set; }
public decimal Amount { get; set; } public decimal Amount { get; set; }
public int Quantity { get; set; } public int Quantity { get; set; }
public string Interval { get; set; } public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; } public bool SponsoredSubscriptionItem { get; set; }
} }
} }
@@ -109,7 +169,7 @@ public class SubscriptionInfo
public BillingUpcomingInvoice(Invoice inv) public BillingUpcomingInvoice(Invoice inv)
{ {
Amount = inv.AmountDue / 100M; Amount = ConvertFromStripeMinorUnits(inv.AmountDue) ?? 0;
Date = inv.Created; Date = inv.Created;
} }

View File

@@ -50,6 +50,7 @@ public abstract class SubscriptionUpdate
protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan) protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
=> plan.Type is => plan.Type is
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019 >= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
or PlanType.FamiliesAnnually2025
or PlanType.FamiliesAnnually or PlanType.FamiliesAnnually
or PlanType.TeamsStarter2023 or PlanType.TeamsStarter2023
or PlanType.TeamsStarter; or PlanType.TeamsStarter;

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.UseApi = newPlan.HasApi;
organization.SelfHost = newPlan.HasSelfHost; organization.SelfHost = newPlan.HasSelfHost;
organization.UsePolicies = newPlan.HasPolicies; organization.UsePolicies = newPlan.HasPolicies;
organization.MaxStorageGb = !newPlan.PasswordManager.BaseStorageGb.HasValue organization.MaxStorageGb = (short)(newPlan.PasswordManager.BaseStorageGb + upgrade.AdditionalStorageGb);
? (short?)null
: (short)(newPlan.PasswordManager.BaseStorageGb.Value + upgrade.AdditionalStorageGb);
organization.UseGroups = newPlan.HasGroups; organization.UseGroups = newPlan.HasGroups;
organization.UseDirectory = newPlan.HasDirectory; organization.UseDirectory = newPlan.HasDirectory;
organization.UseEvents = newPlan.HasEvents; organization.UseEvents = newPlan.HasEvents;

View File

@@ -424,6 +424,8 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
// TODO: DO NOT move to IMailer implementation: PM-27852
[Obsolete("Use SendIndividualUserWelcomeEmailAsync instead")]
public async Task SendWelcomeEmailAsync(User user) public async Task SendWelcomeEmailAsync(User user)
{ {
var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email); var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email);
@@ -437,6 +439,50 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); 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) public async Task SendTrialInitiationEmailAsync(string userEmail)
{ {
var message = CreateDefaultMessage("Welcome to Bitwarden; 3 steps to get started!", 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.")] [Obsolete("The IMailService has been deprecated in favor of the IMailer. All new emails should be sent with an IMailer implementation.")]
public interface IMailService public interface IMailService
{ {
[Obsolete("Use SendIndividualUserWelcomeEmailAsync instead")]
Task SendWelcomeEmailAsync(User user); 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 SendVerifyEmailEmailAsync(string email, Guid userId, string token);
Task SendRegistrationVerificationEmailAsync(string email, string token); Task SendRegistrationVerificationEmailAsync(string email, string token);
Task SendTrialInitiationSignupEmailAsync( Task SendTrialInitiationSignupEmailAsync(

View File

@@ -1,7 +1,9 @@
#nullable enable #nullable enable
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Reflection; using System.Reflection;
using Bit.Core.Settings;
using HandlebarsDotNet; using HandlebarsDotNet;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Mail.Mailer; namespace Bit.Core.Platform.Mail.Mailer;
public class HandlebarMailRenderer : IMailRenderer public class HandlebarMailRenderer : IMailRenderer
@@ -9,7 +11,7 @@ public class HandlebarMailRenderer : IMailRenderer
/// <summary> /// <summary>
/// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once. /// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once.
/// </summary> /// </summary>
private readonly Lazy<Task<IHandlebars>> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication); private readonly Lazy<Task<IHandlebars>> _handlebarsTask;
/// <summary> /// <summary>
/// Helper function that returns the handlebar instance. /// Helper function that returns the handlebar instance.
@@ -21,6 +23,17 @@ public class HandlebarMailRenderer : IMailRenderer
/// </summary> /// </summary>
private readonly ConcurrentDictionary<string, Lazy<Task<HandlebarsTemplate<object, object>>>> _templateCache = new(); private readonly ConcurrentDictionary<string, Lazy<Task<HandlebarsTemplate<object, object>>>> _templateCache = new();
private readonly ILogger<HandlebarMailRenderer> _logger;
private readonly GlobalSettings _globalSettings;
public HandlebarMailRenderer(ILogger<HandlebarMailRenderer> logger, GlobalSettings globalSettings)
{
_logger = logger;
_globalSettings = globalSettings;
_handlebarsTask = new Lazy<Task<IHandlebars>>(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
}
public async Task<(string html, string txt)> RenderAsync(BaseMailView model) public async Task<(string html, string txt)> RenderAsync(BaseMailView model)
{ {
var html = await CompileTemplateAsync(model, "html"); var html = await CompileTemplateAsync(model, "html");
@@ -53,19 +66,59 @@ public class HandlebarMailRenderer : IMailRenderer
return handlebars.Compile(source); return handlebars.Compile(source);
} }
private static async Task<string> ReadSourceAsync(Assembly assembly, string template) private async Task<string> ReadSourceAsync(Assembly assembly, string template)
{ {
if (assembly.GetManifestResourceNames().All(f => f != template)) if (assembly.GetManifestResourceNames().All(f => f != template))
{ {
throw new FileNotFoundException("Template not found: " + template); throw new FileNotFoundException("Template not found: " + template);
} }
var diskSource = await ReadSourceFromDiskAsync(template);
if (!string.IsNullOrWhiteSpace(diskSource))
{
return diskSource;
}
await using var s = assembly.GetManifestResourceStream(template)!; await using var s = assembly.GetManifestResourceStream(template)!;
using var sr = new StreamReader(s); using var sr = new StreamReader(s);
return await sr.ReadToEndAsync(); return await sr.ReadToEndAsync();
} }
private static async Task<IHandlebars> InitializeHandlebarsAsync() private async Task<string?> ReadSourceFromDiskAsync(string template)
{
if (!_globalSettings.SelfHosted)
{
return null;
}
try
{
var diskPath = Path.GetFullPath(Path.Combine(_globalSettings.MailTemplateDirectory, template));
var baseDirectory = Path.GetFullPath(_globalSettings.MailTemplateDirectory);
// Ensure the resolved path is within the configured directory
if (!diskPath.StartsWith(baseDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
!diskPath.Equals(baseDirectory, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Template path traversal attempt detected: {Template}", template);
return null;
}
if (File.Exists(diskPath))
{
var fileContents = await File.ReadAllTextAsync(diskPath);
return fileContents;
}
}
catch (Exception e)
{
_logger.LogError(e, "Failed to read mail template from disk: {TemplateName}", template);
}
return null;
}
private async Task<IHandlebars> InitializeHandlebarsAsync()
{ {
var handlebars = Handlebars.Create(); var handlebars = Handlebars.Create();

View File

@@ -114,6 +114,20 @@ public class NoopMailService : IMailService
return Task.FromResult(0); 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) public Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token)
{ {
return Task.FromResult(0); return Task.FromResult(0);

View File

@@ -641,11 +641,23 @@ public class StripePaymentService : IPaymentService
} }
var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer", "discounts", "test_clock"] }); 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); 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) if (discount != null)
{ {

View File

@@ -904,7 +904,6 @@ public class UserService : UserManager<User>, IUserService
} }
else else
{ {
user.MaxStorageGb = (short)(1 + additionalStorageGb);
user.LicenseKey = CoreHelpers.SecureRandomString(20); user.LicenseKey = CoreHelpers.SecureRandomString(20);
} }
@@ -977,7 +976,8 @@ public class UserService : UserManager<User>, IUserService
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); 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); await SaveUserAsync(user);
return secret; return secret;
} }

View File

@@ -783,6 +783,19 @@ public class GlobalSettings : IGlobalSettings
{ {
public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings(); public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
public virtual IConnectionStringSettings Cosmos { 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 public class WebPushSettings : IWebPushSettings

View File

@@ -150,10 +150,28 @@ public class ImportCiphersCommand : IImportCiphersCommand
foreach (var collection in collections) foreach (var collection in collections)
{ {
if (!organizationCollectionsIds.Contains(collection.Id)) // If the collection already exists, skip it
if (organizationCollectionsIds.Contains(collection.Id))
{ {
continue;
}
// Create new collections if not already present
collection.SetNewId(); collection.SetNewId();
newCollections.Add(collection); 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 newCollectionUsers.Add(new CollectionUser
{ {
CollectionId = collection.Id, CollectionId = collection.Id,
@@ -161,7 +179,6 @@ public class ImportCiphersCommand : IImportCiphersCommand
Manage = true Manage = true
}); });
} }
}
// Create associations based on the newly assigned ids // Create associations based on the newly assigned ids
var collectionCiphers = new List<CollectionCipher>(); var collectionCiphers = new List<CollectionCipher>();

View File

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

@@ -137,6 +137,7 @@ public static class StaticStore
new Teams2019Plan(true), new Teams2019Plan(true),
new Teams2019Plan(false), new Teams2019Plan(false),
new Families2019Plan(), new Families2019Plan(),
new Families2025Plan()
}.ToImmutableList(); }.ToImmutableList();
} }

View File

@@ -681,7 +681,8 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
{ {
organizationUser.Id, organizationUser.Id,
organizationUser.UserId, organizationUser.UserId,
RevisionDate = DateTime.UtcNow.Date RevisionDate = DateTime.UtcNow.Date,
Key = organizationUser.Key
}); });
return rowCount > 0; return rowCount > 0;

View File

@@ -129,6 +129,7 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
PlanType.Free, PlanType.Free,
PlanType.Custom, PlanType.Custom,
PlanType.FamiliesAnnually2019, PlanType.FamiliesAnnually2019,
PlanType.FamiliesAnnually2025,
PlanType.FamiliesAnnually PlanType.FamiliesAnnually
}; };

View File

@@ -950,8 +950,9 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
var result = await dbContext.OrganizationUsers var result = await dbContext.OrganizationUsers
.Where(ou => ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted) .Where(ou => ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted)
.ExecuteUpdateAsync(x => .ExecuteUpdateAsync(x => x
x.SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed)); .SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed)
.SetProperty(y => y.Key, organizationUser.Key));
if (result <= 0) if (result <= 0)
{ {

View File

@@ -524,42 +524,33 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings) public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings)
{ {
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) if (IsAzureServiceBusEnabled(globalSettings))
{
services.TryAddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName))
{ {
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>(); services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
services.TryAddKeyedSingleton<IEventWriteService, EventIntegrationEventWriteService>("broadcast"); services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
return services;
} }
else
{
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
}
else if (globalSettings.SelfHosted)
{
services.TryAddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
if (IsRabbitMqEnabled(globalSettings)) if (IsRabbitMqEnabled(globalSettings))
{ {
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>(); services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
services.TryAddKeyedSingleton<IEventWriteService, EventIntegrationEventWriteService>("broadcast"); services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
} return services;
else
{
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
}
else
{
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("storage");
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
} }
services.TryAddScoped<IEventWriteService, EventRouteService>(); if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
{
services.TryAddSingleton<IEventWriteService, AzureQueueEventWriteService>();
return services;
}
if (globalSettings.SelfHosted)
{
services.TryAddSingleton<IEventWriteService, RepositoryEventWriteService>();
return services;
}
services.TryAddSingleton<IEventWriteService, NoopEventWriteService>();
return services; return services;
} }

View File

@@ -1,7 +1,8 @@
CREATE PROCEDURE [dbo].[OrganizationUser_ConfirmById] CREATE PROCEDURE [dbo].[OrganizationUser_ConfirmById]
@Id UNIQUEIDENTIFIER, @Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER,
@RevisionDate DATETIME2(7) @RevisionDate DATETIME2(7),
@Key NVARCHAR(MAX) = NULL
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@@ -12,7 +13,8 @@ BEGIN
[dbo].[OrganizationUser] [dbo].[OrganizationUser]
SET SET
[Status] = 2, -- Set to Confirmed [Status] = 2, -- Set to Confirmed
[RevisionDate] = @RevisionDate [RevisionDate] = @RevisionDate,
[Key] = @Key
WHERE WHERE
[Id] = @Id [Id] = @Id
AND [Status] = 1 -- Only update if status is Accepted AND [Status] = 1 -- Only update if status is Accepted

View File

@@ -23,4 +23,10 @@ BEGIN
DECLARE @UpdateCollectionsSuccess INT DECLARE @UpdateCollectionsSuccess INT
EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds 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 END

View File

@@ -67,7 +67,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{ {
Policy = new PolicyRequestModel Policy = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
}, },
Metadata = new Dictionary<string, object> Metadata = new Dictionary<string, object>
@@ -148,7 +147,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{ {
Policy = new PolicyRequestModel Policy = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = new Dictionary<string, object> Data = new Dictionary<string, object>
{ {
@@ -218,7 +216,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
var policyType = PolicyType.MasterPassword; var policyType = PolicyType.MasterPassword;
var request = new PolicyRequestModel var request = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = new Dictionary<string, object> Data = new Dictionary<string, object>
{ {
@@ -244,7 +241,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
var policyType = PolicyType.SendOptions; var policyType = PolicyType.SendOptions;
var request = new PolicyRequestModel var request = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = new Dictionary<string, object> Data = new Dictionary<string, object>
{ {
@@ -267,7 +263,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
var policyType = PolicyType.ResetPassword; var policyType = PolicyType.ResetPassword;
var request = new PolicyRequestModel var request = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = new Dictionary<string, object> Data = new Dictionary<string, object>
{ {
@@ -292,7 +287,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{ {
Policy = new PolicyRequestModel Policy = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = new Dictionary<string, object> Data = new Dictionary<string, object>
{ {
@@ -321,7 +315,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{ {
Policy = new PolicyRequestModel Policy = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = new Dictionary<string, object> Data = new Dictionary<string, object>
{ {
@@ -347,7 +340,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{ {
Policy = new PolicyRequestModel Policy = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = new Dictionary<string, object> Data = new Dictionary<string, object>
{ {
@@ -371,7 +363,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
var policyType = PolicyType.SingleOrg; var policyType = PolicyType.SingleOrg;
var request = new PolicyRequestModel var request = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = null Data = null
}; };
@@ -393,7 +384,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{ {
Policy = new PolicyRequestModel Policy = new PolicyRequestModel
{ {
Type = policyType,
Enabled = true, Enabled = true,
Data = null Data = null
}, },

View File

@@ -133,6 +133,29 @@ public class OrganizationIntegrationControllerTests
.DeleteAsync(organizationIntegration); .DeleteAsync(organizationIntegration);
} }
[Theory, BitAutoData]
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
}
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider, SutProvider<OrganizationIntegrationController> sutProvider,

Some files were not shown because too many files have changed in this diff Show More