mirror of
https://github.com/bitwarden/server
synced 2025-12-22 03:03:33 +00:00
Merge branch 'main' into auth/pm-22975/client-version-validator
This commit is contained in:
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -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
|
||||||
|
|||||||
1
.github/workflows/review-code.yml
vendored
1
.github/workflows/review-code.yml
vendored
@@ -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
|
||||||
|
|||||||
4
.github/workflows/test-database.yml
vendored
4
.github/workflows/test-database.yml
vendored
@@ -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: |
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -651,7 +651,23 @@ public class AccountController : Controller
|
|||||||
EmailVerified = emailVerified,
|
EmailVerified = emailVerified,
|
||||||
ApiKey = CoreHelpers.SecureRandomString(30)
|
ApiKey = CoreHelpers.SecureRandomString(30)
|
||||||
};
|
};
|
||||||
await _registerUserCommand.RegisterUser(newUser);
|
|
||||||
|
/*
|
||||||
|
The feature flag is checked here so that we can send the new MJML welcome email templates.
|
||||||
|
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
|
||||||
|
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
|
||||||
|
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
|
||||||
|
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
|
||||||
|
TODO: Remove Feature flag: PM-28221
|
||||||
|
*/
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
|
||||||
|
{
|
||||||
|
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _registerUserCommand.RegisterUser(newUser);
|
||||||
|
}
|
||||||
|
|
||||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||||
var twoFactorPolicy =
|
var twoFactorPolicy =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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,16 +86,24 @@ 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)
|
||||||
{
|
{
|
||||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
if (user.Gateway != null)
|
||||||
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
{
|
||||||
return new SubscriptionResponseModel(user, subscriptionInfo, license);
|
// 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.
|
||||||
else if (!globalSettings.SelfHosted)
|
// The feature flag controls the broader Milestone 2 feature set, not just this specific task.
|
||||||
{
|
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||||
var license = await userService.GenerateLicenseAsync(user);
|
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
||||||
return new SubscriptionResponseModel(user, license);
|
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
||||||
|
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var license = await userService.GenerateLicenseAsync(user);
|
||||||
|
return new SubscriptionResponseModel(user, license);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,203 +56,88 @@ public class UpcomingInvoiceHandler(
|
|||||||
|
|
||||||
if (organizationId.HasValue)
|
if (organizationId.HasValue)
|
||||||
{
|
{
|
||||||
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
|
await HandleOrganizationUpcomingInvoiceAsync(
|
||||||
|
organizationId.Value,
|
||||||
if (organization == null)
|
parsedEvent,
|
||||||
{
|
invoice,
|
||||||
return;
|
customer,
|
||||||
}
|
subscription);
|
||||||
|
|
||||||
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
|
|
||||||
|
|
||||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
|
||||||
|
|
||||||
if (!plan.IsAnnual)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
|
||||||
{
|
|
||||||
var sponsorshipIsValid =
|
|
||||||
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
|
|
||||||
|
|
||||||
if (!sponsorshipIsValid)
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
|
|
||||||
* price. Given that this is the case, we need the new invoice amount
|
|
||||||
*/
|
|
||||||
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
|
|
||||||
* Disabling this as part of a hot fix. It needs to check whether the organization
|
|
||||||
* belongs to a Reseller provider and only send an email to the organization owners if it does.
|
|
||||||
* It also requires a new email template as the current one contains too much billing information.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);
|
|
||||||
|
|
||||||
// await SendEmails(ownerEmails);
|
|
||||||
}
|
}
|
||||||
else if (userId.HasValue)
|
else if (userId.HasValue)
|
||||||
{
|
{
|
||||||
var user = await userRepository.GetByIdAsync(userId.Value);
|
await HandlePremiumUsersUpcomingInvoiceAsync(
|
||||||
|
userId.Value,
|
||||||
if (user == null)
|
parsedEvent,
|
||||||
{
|
invoice,
|
||||||
return;
|
customer,
|
||||||
}
|
subscription);
|
||||||
|
|
||||||
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)
|
else if (providerId.HasValue)
|
||||||
{
|
{
|
||||||
var provider = await providerRepository.GetByIdAsync(providerId.Value);
|
await HandleProviderUpcomingInvoiceAsync(
|
||||||
|
providerId.Value,
|
||||||
if (provider == null)
|
parsedEvent,
|
||||||
{
|
invoice,
|
||||||
return;
|
customer,
|
||||||
}
|
subscription);
|
||||||
|
|
||||||
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)
|
#region Organizations
|
||||||
|
|
||||||
|
private async Task HandleOrganizationUpcomingInvoiceAsync(
|
||||||
|
Guid organizationId,
|
||||||
|
Event @event,
|
||||||
|
Invoice invoice,
|
||||||
|
Customer customer,
|
||||||
|
Subscription subscription)
|
||||||
{
|
{
|
||||||
var pricingItem =
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
|
|
||||||
if (pricingItem != null)
|
if (organization == null)
|
||||||
{
|
{
|
||||||
try
|
logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})",
|
||||||
|
organizationId, @event.Type, @event.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id);
|
||||||
|
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
|
||||||
|
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
|
||||||
|
|
||||||
|
await AlignOrganizationSubscriptionConcernsAsync(
|
||||||
|
organization,
|
||||||
|
@event,
|
||||||
|
subscription,
|
||||||
|
plan,
|
||||||
|
milestone3);
|
||||||
|
|
||||||
|
// Don't send the upcoming invoice email unless the organization's on an annual plan.
|
||||||
|
if (!plan.IsAnnual)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
||||||
|
{
|
||||||
|
var sponsorshipIsValid =
|
||||||
|
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
|
||||||
|
|
||||||
|
if (!sponsorshipIsValid)
|
||||||
{
|
{
|
||||||
var plan = await pricingClient.GetAvailablePremiumPlan();
|
/*
|
||||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
|
||||||
new SubscriptionUpdateOptions
|
* price. Given that this is the case, we need the new invoice amount
|
||||||
{
|
*/
|
||||||
Items =
|
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
|
||||||
[
|
|
||||||
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)
|
await (milestone3
|
||||||
{
|
? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail])
|
||||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
: SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice));
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
return new IntegrationHandlerResult(success: true, message: message);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error };
|
||||||
|
|
||||||
|
if (_retryableErrors.Contains(slackResponse.Error))
|
||||||
|
{
|
||||||
|
result.Retryable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,24 +37,27 @@ 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,
|
||||||
IPolicyRepository policyRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IDataProtectionProvider dataProtectionProvider,
|
IPolicyRepository policyRepository,
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||||
IUserService userService,
|
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
|
||||||
IMailService mailService,
|
IUserService userService,
|
||||||
IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
|
IMailService mailService,
|
||||||
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory
|
IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
|
||||||
)
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,11 +397,11 @@
|
|||||||
<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>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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}}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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}}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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}}
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
`);
|
`,
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
100
src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js
Normal file
100
src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Core.Models.Mail.Auth;
|
||||||
|
|
||||||
|
public class OrganizationWelcomeEmailViewModel : BaseMailModel
|
||||||
|
{
|
||||||
|
public required string OrganizationName { get; set; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -150,17 +150,34 @@ 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))
|
||||||
{
|
{
|
||||||
collection.SetNewId();
|
continue;
|
||||||
newCollections.Add(collection);
|
|
||||||
newCollectionUsers.Add(new CollectionUser
|
|
||||||
{
|
|
||||||
CollectionId = collection.Id,
|
|
||||||
OrganizationUserId = importingOrgUser.Id,
|
|
||||||
Manage = true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new collections if not already present
|
||||||
|
collection.SetNewId();
|
||||||
|
newCollections.Add(collection);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the organization was created by a Provider, the organization may have zero members (users)
|
||||||
|
* In this situation importingOrgUser will be null, and accessing importingOrgUser.Id will
|
||||||
|
* result in a null reference exception.
|
||||||
|
*
|
||||||
|
* Avoid user assignment, but proceed with adding the collection.
|
||||||
|
*/
|
||||||
|
if (importingOrgUser == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
newCollectionUsers.Add(new CollectionUser
|
||||||
|
{
|
||||||
|
CollectionId = collection.Id,
|
||||||
|
OrganizationUserId = importingOrgUser.Id,
|
||||||
|
Manage = true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create associations based on the newly assigned ids
|
// Create associations based on the newly assigned ids
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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");
|
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
|
||||||
|
services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
|
||||||
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
|
return services;
|
||||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName))
|
|
||||||
{
|
|
||||||
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
|
|
||||||
services.TryAddKeyedSingleton<IEventWriteService, EventIntegrationEventWriteService>("broadcast");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (globalSettings.SelfHosted)
|
|
||||||
{
|
|
||||||
services.TryAddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
|
|
||||||
|
|
||||||
if (IsRabbitMqEnabled(globalSettings))
|
|
||||||
{
|
|
||||||
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
|
|
||||||
services.TryAddKeyedSingleton<IEventWriteService, EventIntegrationEventWriteService>("broadcast");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("storage");
|
|
||||||
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
services.TryAddScoped<IEventWriteService, EventRouteService>();
|
if (IsRabbitMqEnabled(globalSettings))
|
||||||
|
{
|
||||||
|
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
|
||||||
|
services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user