mirror of
https://github.com/bitwarden/server.git
synced 2026-02-04 02:05:30 -06:00
Merge branch 'main' into billing/PM-30444/handle-missing-braintree-customer
This commit is contained in:
commit
8cc906ec5f
@ -11,3 +11,7 @@ checkmarx:
|
||||
filter: "!test"
|
||||
kics:
|
||||
filter: "!dev,!.devcontainer"
|
||||
sca:
|
||||
filter: "!dev,!.devcontainer"
|
||||
containers:
|
||||
filter: "!dev,!.devcontainer"
|
||||
|
||||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@ -11,6 +11,9 @@
|
||||
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
|
||||
# Scanning tools
|
||||
.checkmarx/ @bitwarden/team-appsec
|
||||
|
||||
## BRE team owns these workflows ##
|
||||
.github/workflows/publish.yml @bitwarden/dept-bre
|
||||
|
||||
@ -94,9 +97,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
|
||||
.github/workflows/test-database.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/test.yml @bitwarden/team-platform-dev
|
||||
**/*Platform* @bitwarden/team-platform-dev
|
||||
**/.dockerignore @bitwarden/team-platform-dev
|
||||
**/Dockerfile @bitwarden/team-platform-dev
|
||||
**/entrypoint.sh @bitwarden/team-platform-dev
|
||||
|
||||
# The PushType enum is expected to be editted by anyone without need for Platform review
|
||||
src/Core/Platform/Push/PushType.cs
|
||||
|
||||
|
||||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@ -245,7 +245,7 @@ jobs:
|
||||
|
||||
- name: Install Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Sign image with Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
@ -263,14 +263,14 @@ jobs:
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
|
||||
uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
@ -13,6 +13,10 @@
|
||||
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<BitIncludeAuthentication>false</BitIncludeAuthentication>
|
||||
<BitIncludeFeatures>false</BitIncludeFeatures>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Scim</UserSecretsId>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
@ -45,7 +45,7 @@ public class AccountController : Controller
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ISsoUserRepository _ssoUserRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IUserService _userService;
|
||||
private readonly II18nService _i18nService;
|
||||
private readonly UserManager<User> _userManager;
|
||||
@ -67,7 +67,7 @@ public class AccountController : Controller
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ISsoUserRepository ssoUserRepository,
|
||||
IUserRepository userRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IUserService userService,
|
||||
II18nService i18nService,
|
||||
UserManager<User> userManager,
|
||||
@ -88,7 +88,7 @@ public class AccountController : Controller
|
||||
_userRepository = userRepository;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_ssoUserRepository = ssoUserRepository;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_userService = userService;
|
||||
_i18nService = i18nService;
|
||||
_userManager = userManager;
|
||||
@ -687,9 +687,8 @@ public class AccountController : Controller
|
||||
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
|
||||
|
||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||
var twoFactorPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||
var twoFactorPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy.Enabled)
|
||||
{
|
||||
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||
{
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Sso</UserSecretsId>
|
||||
|
||||
@ -8,7 +8,6 @@ using Bit.Core.Utilities;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Sso;
|
||||
@ -91,20 +90,15 @@ public class Startup
|
||||
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IWebHostEnvironment environment,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<Startup> logger)
|
||||
{
|
||||
if (env.IsDevelopment() || globalSettings.SelfHosted)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
}
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
if (!environment.IsDevelopment())
|
||||
{
|
||||
var uri = new Uri(globalSettings.BaseServiceUri.Sso);
|
||||
app.Use(async (ctx, next) =>
|
||||
@ -120,7 +114,7 @@ public class Startup
|
||||
app.UseForwardedHeaders(globalSettings);
|
||||
}
|
||||
|
||||
if (env.IsDevelopment())
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseCookiePolicy();
|
||||
|
||||
@ -28,6 +28,7 @@ $projects = @{
|
||||
Scim = "../bitwarden_license/src/Scim"
|
||||
IntegrationTests = "../test/Infrastructure.IntegrationTest"
|
||||
SeederApi = "../util/SeederApi"
|
||||
SeederUtility = "../util/DbSeederUtility"
|
||||
}
|
||||
|
||||
foreach ($key in $projects.keys) {
|
||||
|
||||
@ -6,6 +6,6 @@
|
||||
"msbuild-sdks": {
|
||||
"Microsoft.Build.Traversal": "4.1.0",
|
||||
"Microsoft.Build.Sql": "1.0.0",
|
||||
"Bitwarden.Server.Sdk": "1.2.0"
|
||||
"Bitwarden.Server.Sdk": "1.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Admin</UserSecretsId>
|
||||
|
||||
@ -86,7 +86,7 @@ public class UsersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, withOrganizations: false);
|
||||
|
||||
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id);
|
||||
|
||||
@ -8,7 +8,7 @@ public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.ConfigureKestrel(o =>
|
||||
|
||||
@ -57,7 +57,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
@ -90,7 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
ICollectionRepository collectionRepository,
|
||||
IGroupRepository groupRepository,
|
||||
IUserService userService,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
ICurrentContext currentContext,
|
||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
@ -123,7 +123,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
_collectionRepository = collectionRepository;
|
||||
_groupRepository = groupRepository;
|
||||
_userService = userService;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_currentContext = currentContext;
|
||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
@ -287,14 +287,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
|
||||
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud))
|
||||
{
|
||||
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
}
|
||||
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
|
||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(
|
||||
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
|
||||
@ -357,10 +350,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
return false;
|
||||
}
|
||||
|
||||
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
|
||||
var useMasterPasswordPolicy = masterPasswordPolicy != null &&
|
||||
masterPasswordPolicy.Enabled &&
|
||||
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
|
||||
var masterPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
|
||||
var useMasterPasswordPolicy = masterPasswordPolicy.Enabled &&
|
||||
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
|
||||
|
||||
return useMasterPasswordPolicy;
|
||||
}
|
||||
@ -697,7 +689,16 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task RestoreAsync(Guid orgId, Guid id)
|
||||
{
|
||||
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId));
|
||||
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, null));
|
||||
}
|
||||
|
||||
|
||||
[HttpPut("{id}/restore/vnext")]
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
[RequireFeature(FeatureFlagKeys.DefaultUserCollectionRestore)]
|
||||
public async Task RestoreAsync_vNext(Guid orgId, Guid id, [FromBody] OrganizationUserRestoreRequest request)
|
||||
{
|
||||
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, request.DefaultUserCollectionName));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/restore")]
|
||||
@ -712,7 +713,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model,
|
||||
(orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds,
|
||||
restoringUserId, _userService, model.DefaultUserCollectionName));
|
||||
}
|
||||
|
||||
[HttpPatch("restore")]
|
||||
|
||||
@ -48,7 +48,7 @@ public class OrganizationsController : Controller
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
@ -74,7 +74,7 @@ public class OrganizationsController : Controller
|
||||
public OrganizationsController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IOrganizationService organizationService,
|
||||
IUserService userService,
|
||||
ICurrentContext currentContext,
|
||||
@ -99,7 +99,7 @@ public class OrganizationsController : Controller
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_organizationService = organizationService;
|
||||
_userService = userService;
|
||||
_currentContext = currentContext;
|
||||
@ -183,15 +183,14 @@ public class OrganizationsController : Controller
|
||||
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id));
|
||||
}
|
||||
|
||||
var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
|
||||
var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
|
||||
if (!resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
|
||||
{
|
||||
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
|
||||
}
|
||||
|
||||
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
|
||||
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
|
||||
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
|
||||
@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
@ -43,6 +42,7 @@ public class PoliciesController : Controller
|
||||
private readonly IUserService _userService;
|
||||
private readonly ISavePolicyCommand _savePolicyCommand;
|
||||
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
|
||||
public PoliciesController(IPolicyRepository policyRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@ -54,7 +54,8 @@ public class PoliciesController : Controller
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISavePolicyCommand savePolicyCommand,
|
||||
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
||||
IVNextSavePolicyCommand vNextSavePolicyCommand,
|
||||
IPolicyQuery policyQuery)
|
||||
{
|
||||
_policyRepository = policyRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -68,27 +69,24 @@ public class PoliciesController : Controller
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
_savePolicyCommand = savePolicyCommand;
|
||||
_vNextSavePolicyCommand = vNextSavePolicyCommand;
|
||||
_policyQuery = policyQuery;
|
||||
}
|
||||
|
||||
[HttpGet("{type}")]
|
||||
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
|
||||
public async Task<PolicyStatusResponseModel> Get(Guid orgId, PolicyType type)
|
||||
{
|
||||
if (!await _currentContext.ManagePolicies(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
||||
if (policy == null)
|
||||
{
|
||||
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
|
||||
}
|
||||
|
||||
var policy = await _policyQuery.RunAsync(orgId, type);
|
||||
if (policy.Type is PolicyType.SingleOrg)
|
||||
{
|
||||
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
|
||||
return await policy.GetSingleOrgPolicyStatusResponseAsync(_organizationHasVerifiedDomainsQuery);
|
||||
}
|
||||
|
||||
return new PolicyDetailResponseModel(policy);
|
||||
return new PolicyStatusResponseModel(policy);
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
|
||||
@ -116,12 +116,17 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
|
||||
public string ResetPasswordKey { get; set; }
|
||||
public string MasterPasswordHash { get; set; }
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
public class OrganizationUserBulkRequestModel
|
||||
{
|
||||
[Required, MinLength(1)]
|
||||
public IEnumerable<Guid> Ids { get; set; }
|
||||
public IEnumerable<Guid> Ids { get; set; } = new List<Guid>();
|
||||
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string? DefaultUserCollectionName { get; set; }
|
||||
}
|
||||
#nullable disable
|
||||
|
||||
public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel
|
||||
{
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
|
||||
public class OrganizationUserRestoreRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// This is the encrypted default collection name to be used for restored users if required
|
||||
/// </summary>
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string? DefaultUserCollectionName { get; set; }
|
||||
}
|
||||
@ -1,19 +1,21 @@
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||
|
||||
public static class PolicyDetailResponses
|
||||
public static class PolicyStatusResponses
|
||||
{
|
||||
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
|
||||
public static async Task<PolicyStatusResponseModel> GetSingleOrgPolicyStatusResponseAsync(
|
||||
this PolicyStatus policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
|
||||
{
|
||||
if (policy.Type is not PolicyType.SingleOrg)
|
||||
{
|
||||
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
|
||||
}
|
||||
return new PolicyDetailResponseModel(policy, await CanToggleState());
|
||||
|
||||
return new PolicyStatusResponseModel(policy, await CanToggleState());
|
||||
|
||||
async Task<bool> CanToggleState()
|
||||
{
|
||||
@ -25,5 +27,4 @@ public static class PolicyDetailResponses
|
||||
return !policy.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public class PolicyDetailResponseModel : PolicyResponseModel
|
||||
{
|
||||
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
|
||||
{
|
||||
}
|
||||
|
||||
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
|
||||
{
|
||||
CanToggleState = canToggleState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the Policy can be enabled/disabled
|
||||
/// </summary>
|
||||
public bool CanToggleState { get; set; } = true;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public class PolicyStatusResponseModel : ResponseModel
|
||||
{
|
||||
public PolicyStatusResponseModel(PolicyStatus policy, bool canToggleState = true) : base("policy")
|
||||
{
|
||||
OrganizationId = policy.OrganizationId;
|
||||
Type = policy.Type;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policy.Data))
|
||||
{
|
||||
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data) ?? new();
|
||||
}
|
||||
|
||||
Enabled = policy.Enabled;
|
||||
CanToggleState = canToggleState;
|
||||
}
|
||||
|
||||
public Guid OrganizationId { get; init; }
|
||||
public PolicyType Type { get; init; }
|
||||
public Dictionary<string, object> Data { get; init; } = new();
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the Policy can be enabled/disabled
|
||||
/// </summary>
|
||||
public bool CanToggleState { get; init; }
|
||||
}
|
||||
@ -2,12 +2,16 @@
|
||||
using Bit.Api.AdminConsole.Public.Models.Request;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -30,6 +34,8 @@ public class MembersController : Controller
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
|
||||
public MembersController(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@ -42,7 +48,9 @@ public class MembersController : Controller
|
||||
IOrganizationRepository organizationRepository,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_groupRepository = groupRepository;
|
||||
@ -55,6 +63,8 @@ public class MembersController : Controller
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
||||
_revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -258,4 +268,59 @@ public class MembersController : Controller
|
||||
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a member's access to an organization.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the member to be revoked.</param>
|
||||
[HttpPost("{id}/revoke")]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Revoke(Guid id)
|
||||
{
|
||||
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
var request = new RevokeOrganizationUsersRequest(
|
||||
_currentContext.OrganizationId!.Value,
|
||||
[id],
|
||||
new SystemUser(EventSystemUser.PublicApi)
|
||||
);
|
||||
|
||||
var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request);
|
||||
var result = results.Single();
|
||||
|
||||
return result.Result.Match<IActionResult>(
|
||||
error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)),
|
||||
_ => new OkResult()
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore a member.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Restores a previously revoked member of the organization.
|
||||
/// </remarks>
|
||||
/// <param name="id">The identifier of the member to be restored.</param>
|
||||
[HttpPost("{id}/restore")]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Restore(Guid id)
|
||||
{
|
||||
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi);
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
|
||||
@ -6,7 +6,7 @@ using Bit.Api.Models.Response;
|
||||
using Bit.Api.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -38,7 +38,7 @@ public class OrganizationSponsorshipsController : Controller
|
||||
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationSponsorshipsController(
|
||||
@ -55,7 +55,7 @@ public class OrganizationSponsorshipsController : Controller
|
||||
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
|
||||
IUserService userService,
|
||||
ICurrentContext currentContext,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_organizationSponsorshipRepository = organizationSponsorshipRepository;
|
||||
@ -71,7 +71,7 @@ public class OrganizationSponsorshipsController : Controller
|
||||
_syncSponsorshipsCommand = syncSponsorshipsCommand;
|
||||
_userService = userService;
|
||||
_currentContext = currentContext;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
@ -81,10 +81,10 @@ public class OrganizationSponsorshipsController : Controller
|
||||
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
|
||||
{
|
||||
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
|
||||
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
if (freeFamiliesSponsorshipPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
@ -108,10 +108,10 @@ public class OrganizationSponsorshipsController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName)
|
||||
{
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
|
||||
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
if (freeFamiliesSponsorshipPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
@ -138,9 +138,9 @@ public class OrganizationSponsorshipsController : Controller
|
||||
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
|
||||
if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
|
||||
{
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
|
||||
var policy = await _policyQuery.RunAsync(sponsorship.SponsoringOrganizationId.Value,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
|
||||
isFreeFamilyPolicyEnabled = policy.Enabled;
|
||||
}
|
||||
|
||||
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
|
||||
@ -165,10 +165,10 @@ public class OrganizationSponsorshipsController : Controller
|
||||
throw new BadRequestException("Can only redeem sponsorship for an organization you own.");
|
||||
}
|
||||
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(
|
||||
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(
|
||||
model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
if (freeFamiliesSponsorshipPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Tax;
|
||||
using Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Organizations.Commands;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
@ -10,10 +11,11 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Authorize("Application")]
|
||||
[Route("billing/tax")]
|
||||
public class TaxController(
|
||||
[Route("billing/preview-invoice")]
|
||||
public class PreviewInvoiceController(
|
||||
IPreviewOrganizationTaxCommand previewOrganizationTaxCommand,
|
||||
IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController
|
||||
IPreviewPremiumTaxCommand previewPremiumTaxCommand,
|
||||
IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController
|
||||
{
|
||||
[HttpPost("organizations/subscriptions/purchase")]
|
||||
public async Task<IResult> PreviewOrganizationSubscriptionPurchaseTaxAsync(
|
||||
@ -21,11 +23,7 @@ public class TaxController(
|
||||
{
|
||||
var (purchase, billingAddress) = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("organizations/{organizationId:guid}/subscription/plan-change")]
|
||||
@ -36,11 +34,7 @@ public class TaxController(
|
||||
{
|
||||
var (planChange, billingAddress) = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPut("organizations/{organizationId:guid}/subscription/update")]
|
||||
@ -51,11 +45,7 @@ public class TaxController(
|
||||
{
|
||||
var update = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(organization, update);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("premium/subscriptions/purchase")]
|
||||
@ -64,10 +54,29 @@ public class TaxController(
|
||||
{
|
||||
var (purchase, billingAddress) = request.ToDomain();
|
||||
var result = await previewPremiumTaxCommand.Run(purchase, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("premium/subscriptions/upgrade")]
|
||||
[InjectUser]
|
||||
public async Task<IResult> PreviewPremiumUpgradeProrationAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] PreviewPremiumUpgradeProrationRequest request)
|
||||
{
|
||||
var (planType, billingAddress) = request.ToDomain();
|
||||
|
||||
var result = await previewPremiumUpgradeProrationCommand.Run(
|
||||
user,
|
||||
planType,
|
||||
billingAddress);
|
||||
|
||||
return Handle(result.Map(proration => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
proration.NewPlanProratedAmount,
|
||||
proration.Credit,
|
||||
proration.Tax,
|
||||
proration.Total,
|
||||
proration.NewPlanProratedMonths
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -132,8 +132,8 @@ public class AccountBillingVNextController(
|
||||
[BindNever] User user,
|
||||
[FromBody] UpgradePremiumToOrganizationRequest request)
|
||||
{
|
||||
var (organizationName, key, planType) = request.ToDomain();
|
||||
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
|
||||
var (organizationName, key, planType, billingAddress) = request.ToDomain();
|
||||
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType, billingAddress);
|
||||
return Handle(result);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Premium;
|
||||
@ -14,24 +15,30 @@ public class UpgradePremiumToOrganizationRequest
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public ProductTierType Tier { get; set; }
|
||||
public required ProductTierType TargetProductTierType { get; set; }
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public PlanCadenceType Cadence { get; set; }
|
||||
public required MinimalBillingAddressRequest BillingAddress { get; set; }
|
||||
|
||||
private PlanType PlanType =>
|
||||
Tier switch
|
||||
private PlanType PlanType
|
||||
{
|
||||
get
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.TeamsMonthly
|
||||
: PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.EnterpriseMonthly
|
||||
: PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
|
||||
};
|
||||
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
|
||||
}
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
|
||||
return TargetProductTierType switch
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType, Core.Billing.Payment.Models.BillingAddress BillingAddress) ToDomain() =>
|
||||
(OrganizationName, Key, PlanType, BillingAddress.ToDomain());
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewOrganizationSubscriptionPlanChangeTaxRequest
|
||||
{
|
||||
@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewOrganizationSubscriptionPurchaseTaxRequest
|
||||
{
|
||||
@ -1,7 +1,7 @@
|
||||
using Bit.Api.Billing.Models.Requests.Organizations;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public class PreviewOrganizationSubscriptionUpdateTaxRequest
|
||||
{
|
||||
@ -2,7 +2,7 @@
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewPremiumSubscriptionPurchaseTaxRequest
|
||||
{
|
||||
@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewPremiumUpgradeProrationRequest
|
||||
{
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public required ProductTierType TargetProductTierType { get; set; }
|
||||
|
||||
[Required]
|
||||
public required MinimalBillingAddressRequest BillingAddress { get; set; }
|
||||
|
||||
private PlanType PlanType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
|
||||
}
|
||||
|
||||
return TargetProductTierType switch
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public (PlanType, BillingAddress) ToDomain() =>
|
||||
(PlanType, BillingAddress.ToDomain());
|
||||
}
|
||||
@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator<IEnumerable<
|
||||
}
|
||||
|
||||
// Exclude any account recovery that do not have a key.
|
||||
existing = existing.Where(o => o.ResetPasswordKey != null).ToList();
|
||||
|
||||
existing = existing.Where(o => !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList();
|
||||
|
||||
foreach (var ou in existing)
|
||||
{
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Bit.Api.Models.Public.Response;
|
||||
@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel
|
||||
{ }
|
||||
|
||||
public ErrorResponseModel(string errorKey, string errorValue)
|
||||
: this(errorKey, new string[] { errorValue })
|
||||
: this(errorKey, [errorValue])
|
||||
{ }
|
||||
|
||||
public ErrorResponseModel(string errorKey, IEnumerable<string> errorValues)
|
||||
: this(new Dictionary<string, IEnumerable<string>> { { errorKey, errorValues } })
|
||||
{ }
|
||||
|
||||
[JsonConstructor]
|
||||
public ErrorResponseModel(string message, Dictionary<string, IEnumerable<string>> errors)
|
||||
{
|
||||
Message = message;
|
||||
@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel
|
||||
/// </summary>
|
||||
/// <example>The request model is invalid.</example>
|
||||
[Required]
|
||||
public string Message { get; set; }
|
||||
public string Message { get; init; }
|
||||
/// <summary>
|
||||
/// If multiple errors occurred, they are listed in dictionary. Errors related to a specific
|
||||
/// request parameter will include a dictionary key describing that parameter.
|
||||
/// </summary>
|
||||
public Dictionary<string, IEnumerable<string>> Errors { get; set; }
|
||||
public Dictionary<string, IEnumerable<string>>? Errors { get; }
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
|
||||
@ -14,7 +14,6 @@ using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.SharedWeb.Health;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
@ -238,8 +237,6 @@ public class Startup
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<Startup> logger)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
|
||||
@ -74,11 +74,6 @@ public class ImportCiphersController : Controller
|
||||
throw new BadRequestException("You cannot import this much data at once.");
|
||||
}
|
||||
|
||||
if (model.Ciphers.Any(c => c.ArchivedDate.HasValue))
|
||||
{
|
||||
throw new BadRequestException("You cannot import archived items into an organization.");
|
||||
}
|
||||
|
||||
var orgId = new Guid(organizationId);
|
||||
var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();
|
||||
|
||||
|
||||
@ -239,12 +239,6 @@ public class SendsController : Controller
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var sendResponse = new SendAccessResponseModel(send);
|
||||
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
|
||||
@ -272,12 +266,6 @@ public class SendsController : Controller
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
|
||||
|
||||
|
||||
@ -102,9 +102,17 @@ public class SendRequestModel
|
||||
/// Comma-separated list of emails that may access the send using OTP
|
||||
/// authentication. Mutually exclusive with <see cref="Password"/>.
|
||||
/// </summary>
|
||||
[StringLength(4000)]
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(4000)]
|
||||
public string Emails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of email **hashes** that may access the send using OTP
|
||||
/// authentication. Mutually exclusive with <see cref="Password"/>.
|
||||
/// </summary>
|
||||
[StringLength(4000)]
|
||||
public string EmailHashes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, send access is disabled.
|
||||
/// Defaults to <see langword="false"/>.
|
||||
@ -253,6 +261,7 @@ public class SendRequestModel
|
||||
// normalize encoding
|
||||
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
|
||||
existingSend.Emails = string.Join(",", emails);
|
||||
existingSend.EmailHashes = EmailHashes;
|
||||
existingSend.Password = null;
|
||||
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
public class PolicyStatus
|
||||
{
|
||||
public PolicyStatus(Guid organizationId, PolicyType policyType, Policy? policy = null)
|
||||
{
|
||||
OrganizationId = policy?.OrganizationId ?? organizationId;
|
||||
Data = policy?.Data;
|
||||
Type = policy?.Type ?? policyType;
|
||||
Enabled = policy?.Enabled ?? false;
|
||||
}
|
||||
|
||||
public Guid OrganizationId { get; set; }
|
||||
public PolicyType Type { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public string? Data { get; set; }
|
||||
|
||||
public T GetDataModel<T>() where T : IPolicyDataModel, new()
|
||||
{
|
||||
return CoreHelpers.LoadClassFromJsonData<T>(Data);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
|
||||
|
||||
public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IUserRepository userRepository,
|
||||
IMailService mailService,
|
||||
IEventService eventService,
|
||||
@ -30,9 +30,8 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo
|
||||
}
|
||||
|
||||
// Enterprise policy must be enabled
|
||||
var resetPasswordPolicy =
|
||||
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
|
||||
var resetPasswordPolicy = await policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
|
||||
if (!resetPasswordPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Organization does not have the password reset policy enabled.");
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Utilities.v2;
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
@ -20,7 +19,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
|
||||
IUserService userService,
|
||||
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
|
||||
IPolicyQuery policyQuery) : IAutomaticallyConfirmOrganizationUsersValidator
|
||||
{
|
||||
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
|
||||
AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||
@ -74,7 +73,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||
}
|
||||
|
||||
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
|
||||
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true }
|
||||
(await policyQuery.RunAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation)).Enabled
|
||||
&& request.Organization is { UseAutomaticUserConfirmation: true };
|
||||
|
||||
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Auth.Models.Business;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse
|
||||
public class SendOrganizationInvitesCommand(
|
||||
IUserRepository userRepository,
|
||||
ISsoConfigRepository ssoConfigurationRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
|
||||
IMailService mailService) : ISendOrganizationInvitesCommand
|
||||
@ -58,7 +58,7 @@ public class SendOrganizationInvitesCommand(
|
||||
// need to check the policy if the org has SSO enabled.
|
||||
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
||||
organization.UsePolicies &&
|
||||
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
||||
(await policyQuery.RunAsync(organization.Id, PolicyType.RequireSso)).Enabled;
|
||||
|
||||
// Generate the list of org users and expiring tokens
|
||||
// create helper function to create expiring tokens
|
||||
|
||||
@ -20,7 +20,7 @@ public interface IRestoreOrganizationUserCommand
|
||||
/// </summary>
|
||||
/// <param name="organizationUser">Revoked user to be restored.</param>
|
||||
/// <param name="restoringUserId">UserId of the user performing the action.</param>
|
||||
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
|
||||
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string? defaultCollectionName);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
|
||||
@ -50,5 +50,5 @@ public interface IRestoreOrganizationUserCommand
|
||||
/// <param name="userService">Passed in from caller to avoid circular dependency</param>
|
||||
/// <returns>List of organization user Ids and strings. A successful restoration will have an empty string.
|
||||
/// If an error occurs, the error message will be provided.</returns>
|
||||
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService, string? defaultCollectionName);
|
||||
}
|
||||
|
||||
@ -31,9 +31,10 @@ public class RestoreOrganizationUserCommand(
|
||||
IOrganizationService organizationService,
|
||||
IFeatureService featureService,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
ICollectionRepository collectionRepository,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand
|
||||
{
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName)
|
||||
{
|
||||
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
|
||||
{
|
||||
@ -46,7 +47,7 @@ public class RestoreOrganizationUserCommand(
|
||||
throw new BadRequestException("Only owners can restore other owners.");
|
||||
}
|
||||
|
||||
await RepositoryRestoreUserAsync(organizationUser);
|
||||
await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName);
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
|
||||
if (organizationUser.UserId.HasValue)
|
||||
@ -57,7 +58,7 @@ public class RestoreOrganizationUserCommand(
|
||||
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
|
||||
{
|
||||
await RepositoryRestoreUserAsync(organizationUser);
|
||||
await RepositoryRestoreUserAsync(organizationUser, null); // users stored by a system user will not get a default collection at this point.
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
|
||||
systemUser);
|
||||
|
||||
@ -67,7 +68,7 @@ public class RestoreOrganizationUserCommand(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
|
||||
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, string defaultCollectionName)
|
||||
{
|
||||
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
@ -104,7 +105,17 @@ public class RestoreOrganizationUserCommand(
|
||||
|
||||
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||
|
||||
organizationUser.Status = status;
|
||||
if (organizationUser.UserId.HasValue
|
||||
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(organizationUser.UserId
|
||||
.Value)).State == OrganizationDataOwnershipState.Enabled
|
||||
&& status == OrganizationUserStatusType.Confirmed
|
||||
&& featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore)
|
||||
&& !string.IsNullOrWhiteSpace(defaultCollectionName))
|
||||
{
|
||||
await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId,
|
||||
[organizationUser.Id],
|
||||
defaultCollectionName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
|
||||
@ -156,7 +167,8 @@ public class RestoreOrganizationUserCommand(
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
|
||||
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService,
|
||||
string defaultCollectionName)
|
||||
{
|
||||
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
|
||||
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
|
||||
@ -187,6 +199,9 @@ public class RestoreOrganizationUserCommand(
|
||||
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers);
|
||||
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
var organizationUsersDataOwnershipEnabled = (await policyRequirementQuery
|
||||
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId))
|
||||
.ToList();
|
||||
|
||||
foreach (var organizationUser in filteredUsers)
|
||||
{
|
||||
@ -223,13 +238,24 @@ public class RestoreOrganizationUserCommand(
|
||||
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
|
||||
|
||||
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||
organizationUser.Status = status;
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
if (organizationUsersDataOwnershipEnabled.Contains(organizationUser.Id)
|
||||
&& status == OrganizationUserStatusType.Confirmed
|
||||
&& !string.IsNullOrWhiteSpace(defaultCollectionName)
|
||||
&& featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore))
|
||||
{
|
||||
await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId,
|
||||
[organizationUser.Id],
|
||||
defaultCollectionName);
|
||||
}
|
||||
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
|
||||
result.Add(Tuple.Create(organizationUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
|
||||
public interface IPolicyQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a summary view of an organization's usage of a policy specified by the <paramref name="policyType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This query is the entrypoint for consumers interested in understanding how a particular <see cref="PolicyType"/>
|
||||
/// has been applied to an organization; the resultant <see cref="PolicyStatus"/> is not indicative of explicit
|
||||
/// policy configuration.
|
||||
/// </remarks>
|
||||
Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType);
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||
|
||||
public class PolicyQuery(IPolicyRepository policyRepository) : IPolicyQuery
|
||||
{
|
||||
public async Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType)
|
||||
{
|
||||
var dbPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, policyType);
|
||||
return new PolicyStatus(organizationId, policyType, dbPolicy);
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
|
||||
/// <param name="policyDetails">Collection of policy details that apply to this user id</param>
|
||||
public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement
|
||||
{
|
||||
public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any();
|
||||
public bool CannotHaveEmergencyAccess() => policyDetails.Any();
|
||||
|
||||
public bool CannotJoinProvider() => policyDetails.Any();
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
|
||||
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
|
||||
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
|
||||
services.AddScoped<IPolicyQuery, PolicyQuery>();
|
||||
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
|
||||
|
||||
services.AddPolicyValidators();
|
||||
|
||||
@ -27,7 +27,6 @@ public interface IOrganizationService
|
||||
OrganizationUserInvite invite, string externalId);
|
||||
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
|
||||
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
|
||||
Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId);
|
||||
Task DeleteSsoUserAsync(Guid userId, Guid? organizationId);
|
||||
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
||||
|
||||
@ -14,7 +14,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
@ -49,7 +48,7 @@ public class OrganizationService : IOrganizationService
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IStripePaymentService _paymentService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ISsoUserRepository _ssoUserRepository;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
@ -76,7 +75,7 @@ public class OrganizationService : IOrganizationService
|
||||
IEventService eventService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IStripePaymentService paymentService,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IPolicyService policyService,
|
||||
ISsoUserRepository ssoUserRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
@ -103,7 +102,7 @@ public class OrganizationService : IOrganizationService
|
||||
_eventService = eventService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_paymentService = paymentService;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_policyService = policyService;
|
||||
_ssoUserRepository = ssoUserRepository;
|
||||
_globalSettings = globalSettings;
|
||||
@ -718,32 +717,6 @@ public class OrganizationService : IOrganizationService
|
||||
return (allOrgUsers, events);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId,
|
||||
Guid? invitingUserId,
|
||||
IEnumerable<Guid> organizationUsersId)
|
||||
{
|
||||
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId);
|
||||
_logger.LogUserInviteStateDiagnostics(orgUsers);
|
||||
|
||||
var org = await GetOrgById(organizationId);
|
||||
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
foreach (var orgUser in orgUsers)
|
||||
{
|
||||
if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
result.Add(Tuple.Create(orgUser, "User invalid."));
|
||||
continue;
|
||||
}
|
||||
|
||||
await SendInviteAsync(orgUser, org, false);
|
||||
result.Add(Tuple.Create(orgUser, ""));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
|
||||
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
|
||||
|
||||
@ -862,9 +835,8 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
|
||||
// Make sure the organization has the policy enabled
|
||||
var resetPasswordPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
|
||||
var resetPasswordPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.ResetPassword);
|
||||
if (!resetPasswordPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Organization does not have the password reset policy enabled.");
|
||||
}
|
||||
|
||||
@ -147,16 +147,12 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Support up to 5 keys
|
||||
for (var i = 1; i <= 5; i++)
|
||||
// Load all WebAuthn credentials stored in metadata. The number of allowed credentials
|
||||
// is controlled by credential registration.
|
||||
foreach (var kvp in provider.MetaData.Where(k => k.Key.StartsWith("Key")))
|
||||
{
|
||||
var keyName = $"Key{i}";
|
||||
if (provider.MetaData.TryGetValue(keyName, out var value))
|
||||
{
|
||||
var key = new TwoFactorProvider.WebAuthnData((dynamic)value);
|
||||
|
||||
keys.Add(new Tuple<string, TwoFactorProvider.WebAuthnData>(keyName, key));
|
||||
}
|
||||
var key = new TwoFactorProvider.WebAuthnData((dynamic)kvp.Value);
|
||||
keys.Add(new Tuple<string, TwoFactorProvider.WebAuthnData>(kvp.Key, key));
|
||||
}
|
||||
|
||||
return keys;
|
||||
|
||||
@ -5,9 +5,9 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
@ -21,7 +21,7 @@ namespace Bit.Core.Auth.Services;
|
||||
public class SsoConfigService : ISsoConfigService
|
||||
{
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IEventService _eventService;
|
||||
@ -29,14 +29,14 @@ public class SsoConfigService : ISsoConfigService
|
||||
|
||||
public SsoConfigService(
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IEventService eventService,
|
||||
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
||||
{
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_eventService = eventService;
|
||||
@ -114,14 +114,14 @@ public class SsoConfigService : ISsoConfigService
|
||||
throw new BadRequestException("Organization cannot use Key Connector.");
|
||||
}
|
||||
|
||||
var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg);
|
||||
if (singleOrgPolicy is not { Enabled: true })
|
||||
var singleOrgPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.SingleOrg);
|
||||
if (!singleOrgPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Key Connector requires the Single Organization policy to be enabled.");
|
||||
}
|
||||
|
||||
var ssoPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso);
|
||||
if (ssoPolicy is not { Enabled: true })
|
||||
var ssoPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.RequireSso);
|
||||
if (!ssoPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Key Connector requires the Single Sign-On Authentication policy to be enabled.");
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
@ -27,7 +27,7 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
@ -50,7 +50,7 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
IGlobalSettings globalSettings,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IOrganizationDomainRepository organizationDomainRepository,
|
||||
IFeatureService featureService,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
@ -65,7 +65,7 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
_globalSettings = globalSettings;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
_featureService = featureService;
|
||||
|
||||
@ -246,9 +246,9 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
|
||||
if (orgUser != null)
|
||||
{
|
||||
var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId,
|
||||
var twoFactorPolicy = await _policyQuery.RunAsync(orgUser.OrganizationId,
|
||||
PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||
if (twoFactorPolicy.Enabled)
|
||||
{
|
||||
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||
{
|
||||
|
||||
@ -70,10 +70,6 @@ public static class StripeConstants
|
||||
public const string InvoiceApproved = "invoice_approved";
|
||||
public const string OrganizationId = "organizationId";
|
||||
public const string PayPalTransactionId = "btPayPalTransactionId";
|
||||
public const string PreviousAdditionalStorage = "previous_additional_storage";
|
||||
public const string PreviousPeriodEndDate = "previous_period_end_date";
|
||||
public const string PreviousPremiumPriceId = "previous_premium_price_id";
|
||||
public const string PreviousPremiumUserId = "previous_premium_user_id";
|
||||
public const string ProviderId = "providerId";
|
||||
public const string Region = "region";
|
||||
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
|
||||
|
||||
@ -59,6 +59,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
|
||||
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
|
||||
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
|
||||
services.AddScoped<IPreviewPremiumUpgradeProrationCommand, PreviewPremiumUpgradeProrationCommand>();
|
||||
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
|
||||
services.AddScoped<IUpgradePremiumToOrganizationCommand, UpgradePremiumToOrganizationCommand>();
|
||||
}
|
||||
|
||||
@ -0,0 +1,166 @@
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Premium.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Premium.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Previews the proration details for upgrading a Premium user subscription to an Organization
|
||||
/// plan by using the Stripe API to create an invoice preview, prorated, for the upgrade.
|
||||
/// </summary>
|
||||
public interface IPreviewPremiumUpgradeProrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the tax, total cost, and proration credit for upgrading a Premium subscription to an Organization plan.
|
||||
/// </summary>
|
||||
/// <param name="user">The user with an active Premium subscription.</param>
|
||||
/// <param name="targetPlanType">The target organization plan type.</param>
|
||||
/// <param name="billingAddress">The billing address for tax calculation.</param>
|
||||
/// <returns>The proration details for the upgrade including costs, credits, tax, and time remaining.</returns>
|
||||
Task<BillingCommandResult<PremiumUpgradeProration>> Run(
|
||||
User user,
|
||||
PlanType targetPlanType,
|
||||
BillingAddress billingAddress);
|
||||
}
|
||||
|
||||
public class PreviewPremiumUpgradeProrationCommand(
|
||||
ILogger<PreviewPremiumUpgradeProrationCommand> logger,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter)
|
||||
: BaseBillingCommand<PreviewPremiumUpgradeProrationCommand>(logger),
|
||||
IPreviewPremiumUpgradeProrationCommand
|
||||
{
|
||||
public Task<BillingCommandResult<PremiumUpgradeProration>> Run(
|
||||
User user,
|
||||
PlanType targetPlanType,
|
||||
BillingAddress billingAddress) => HandleAsync<PremiumUpgradeProration>(async () =>
|
||||
{
|
||||
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
||||
{
|
||||
return new BadRequest("User does not have an active Premium subscription.");
|
||||
}
|
||||
|
||||
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(
|
||||
user.GatewaySubscriptionId,
|
||||
new SubscriptionGetOptions { Expand = ["customer"] });
|
||||
var premiumPlans = await pricingClient.ListPremiumPlans();
|
||||
var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||
premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id));
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
return new BadRequest("Premium subscription password manager item not found.");
|
||||
}
|
||||
|
||||
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
||||
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
|
||||
var subscriptionItems = new List<InvoiceSubscriptionDetailsItemOptions>();
|
||||
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
|
||||
|
||||
// Delete the storage item if it exists for this user's plan
|
||||
if (storageItem != null)
|
||||
{
|
||||
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Id = storageItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
}
|
||||
|
||||
// Hardcode seats to 1 for upgrade flow
|
||||
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
|
||||
{
|
||||
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Price = targetPlan.PasswordManager.StripePlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Price = targetPlan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
}
|
||||
|
||||
var options = new InvoiceCreatePreviewOptions
|
||||
{
|
||||
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true },
|
||||
Customer = user.GatewayCustomerId,
|
||||
Subscription = user.GatewaySubscriptionId,
|
||||
CustomerDetails = new InvoiceCustomerDetailsOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Country = billingAddress.Country,
|
||||
PostalCode = billingAddress.PostalCode
|
||||
}
|
||||
},
|
||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||
{
|
||||
Items = subscriptionItems,
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice
|
||||
}
|
||||
};
|
||||
|
||||
var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options);
|
||||
var proration = GetProration(invoicePreview, passwordManagerItem);
|
||||
|
||||
return proration;
|
||||
});
|
||||
|
||||
private static PremiumUpgradeProration GetProration(Invoice invoicePreview, SubscriptionItem passwordManagerItem) => new()
|
||||
{
|
||||
NewPlanProratedAmount = GetNewPlanProratedAmountFromInvoice(invoicePreview),
|
||||
Credit = GetProrationCreditFromInvoice(invoicePreview),
|
||||
Tax = Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100,
|
||||
Total = Convert.ToDecimal(invoicePreview.Total) / 100,
|
||||
// Use invoice periodEnd here instead of UtcNow so that testing with Stripe time clocks works correctly. And if there is no test clock,
|
||||
// (like in production), the previewInvoice's periodEnd is the same as UtcNow anyway because of the proration behavior (always_invoice)
|
||||
NewPlanProratedMonths = CalculateNewPlanProratedMonths(invoicePreview.PeriodEnd, passwordManagerItem.CurrentPeriodEnd)
|
||||
};
|
||||
|
||||
private static decimal GetProrationCreditFromInvoice(Invoice invoicePreview)
|
||||
{
|
||||
// Extract proration credit from negative line items (credits are negative in Stripe)
|
||||
var prorationCredit = invoicePreview.Lines?.Data?
|
||||
.Where(line => line.Amount < 0)
|
||||
.Sum(line => Math.Abs(line.Amount)) ?? 0; // Return the credit as positive number
|
||||
|
||||
return Convert.ToDecimal(prorationCredit) / 100;
|
||||
}
|
||||
|
||||
private static decimal GetNewPlanProratedAmountFromInvoice(Invoice invoicePreview)
|
||||
{
|
||||
// The target plan's prorated upgrade amount should be the only positive-valued line item
|
||||
var proratedTotal = invoicePreview.Lines?.Data?
|
||||
.Where(line => line.Amount > 0)
|
||||
.Sum(line => line.Amount) ?? 0;
|
||||
|
||||
return Convert.ToDecimal(proratedTotal) / 100;
|
||||
}
|
||||
|
||||
private static int CalculateNewPlanProratedMonths(DateTime invoicePeriodEnd, DateTime currentPeriodEnd)
|
||||
{
|
||||
var daysInProratedPeriod = (currentPeriodEnd - invoicePeriodEnd).TotalDays;
|
||||
|
||||
// Round to nearest month (30-day periods)
|
||||
// 1-14 days = 1 month, 15-44 days = 1 month, 45-74 days = 2 months, etc.
|
||||
// Minimum is always 1 month (never returns 0)
|
||||
// Use MidpointRounding.AwayFromZero to round 0.5 up to 1
|
||||
var months = (int)Math.Round(daysInProratedPeriod / 30, MidpointRounding.AwayFromZero);
|
||||
return Math.Max(1, months);
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,6 @@
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
@ -28,12 +27,14 @@ public interface IUpgradePremiumToOrganizationCommand
|
||||
/// <param name="organizationName">The name for the new organization.</param>
|
||||
/// <param name="key">The encrypted organization key for the owner.</param>
|
||||
/// <param name="targetPlanType">The target organization plan type to upgrade to.</param>
|
||||
/// <param name="billingAddress">The billing address for tax calculation.</param>
|
||||
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
|
||||
Task<BillingCommandResult<None>> Run(
|
||||
User user,
|
||||
string organizationName,
|
||||
string key,
|
||||
PlanType targetPlanType);
|
||||
PlanType targetPlanType,
|
||||
Payment.Models.BillingAddress billingAddress);
|
||||
}
|
||||
|
||||
public class UpgradePremiumToOrganizationCommand(
|
||||
@ -51,7 +52,8 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
User user,
|
||||
string organizationName,
|
||||
string key,
|
||||
PlanType targetPlanType) => HandleAsync<None>(async () =>
|
||||
PlanType targetPlanType,
|
||||
Payment.Models.BillingAddress billingAddress) => HandleAsync<None>(async () =>
|
||||
{
|
||||
// Validate that the user has an active Premium subscription
|
||||
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
||||
@ -74,7 +76,7 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
return new BadRequest("Premium subscription item not found.");
|
||||
return new BadRequest("Premium subscription password manager item not found.");
|
||||
}
|
||||
|
||||
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
||||
@ -85,20 +87,10 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
// Build the list of subscription item updates
|
||||
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
|
||||
|
||||
// Delete the user's specific password manager item
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
|
||||
// Delete the storage item if it exists for this user's plan
|
||||
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
|
||||
|
||||
// Capture the previous additional storage quantity for potential revert
|
||||
var previousAdditionalStorage = storageItem?.Quantity ?? 0;
|
||||
|
||||
if (storageItem != null)
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
@ -113,6 +105,7 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Price = targetPlan.PasswordManager.StripePlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
@ -121,6 +114,7 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Price = targetPlan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = seats
|
||||
});
|
||||
@ -133,14 +127,12 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.None,
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice,
|
||||
BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged,
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId,
|
||||
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty,
|
||||
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(),
|
||||
[StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User
|
||||
}
|
||||
};
|
||||
@ -152,7 +144,7 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
Name = organizationName,
|
||||
BillingEmail = user.Email,
|
||||
PlanType = targetPlan.Type,
|
||||
Seats = (short)seats,
|
||||
Seats = seats,
|
||||
MaxCollections = targetPlan.PasswordManager.MaxCollections,
|
||||
MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb,
|
||||
UsePolicies = targetPlan.HasPolicies,
|
||||
@ -182,6 +174,16 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
GatewaySubscriptionId = currentSubscription.Id
|
||||
};
|
||||
|
||||
// Update customer billing address for tax calculation
|
||||
await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Country = billingAddress.Country,
|
||||
PostalCode = billingAddress.PostalCode
|
||||
}
|
||||
});
|
||||
|
||||
// Update the subscription in Stripe
|
||||
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);
|
||||
|
||||
|
||||
36
src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs
Normal file
36
src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs
Normal file
@ -0,0 +1,36 @@
|
||||
namespace Bit.Core.Billing.Premium.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the proration details for upgrading a Premium user subscription to an Organization plan.
|
||||
/// </summary>
|
||||
public class PremiumUpgradeProration
|
||||
{
|
||||
/// <summary>
|
||||
/// The prorated cost for the new organization plan, calculated from now until the end of the current billing period.
|
||||
/// This represents what the user will pay for the upgraded plan for the remainder of the period.
|
||||
/// </summary>
|
||||
public decimal NewPlanProratedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The credit amount for the unused portion of the current Premium subscription.
|
||||
/// This credit is applied against the cost of the new organization plan.
|
||||
/// </summary>
|
||||
public decimal Credit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tax amount calculated for the upgrade transaction.
|
||||
/// </summary>
|
||||
public decimal Tax { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The total amount due for the upgrade after applying the credit and adding tax.
|
||||
/// </summary>
|
||||
public decimal Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of months the user will be charged for the new organization plan in the prorated billing period.
|
||||
/// Calculated by rounding the days remaining in the current billing cycle to the nearest month.
|
||||
/// Minimum value is 1 month (never returns 0).
|
||||
/// </summary>
|
||||
public int NewPlanProratedMonths { get; set; }
|
||||
}
|
||||
@ -141,7 +141,7 @@ public static class FeatureFlagKeys
|
||||
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
|
||||
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
|
||||
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
|
||||
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
|
||||
public const string DefaultUserCollectionRestore = "pm-30883-my-items-restored-users";
|
||||
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
|
||||
public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance";
|
||||
|
||||
@ -160,12 +160,12 @@ public static class FeatureFlagKeys
|
||||
public const string Otp6Digits = "pm-18612-otp-6-digits";
|
||||
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
|
||||
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
|
||||
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
|
||||
public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock";
|
||||
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
|
||||
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
|
||||
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
|
||||
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
|
||||
public const string SafariAccountSwitching = "pm-5594-safari-account-switching";
|
||||
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
|
||||
|
||||
/* Autofill Team */
|
||||
@ -224,9 +224,10 @@ public static class FeatureFlagKeys
|
||||
|
||||
/* Platform Team */
|
||||
public const string WebPush = "web-push";
|
||||
public const string IpcChannelFramework = "ipc-channel-framework";
|
||||
public const string ContentScriptIpcFramework = "content-script-ipc-channel-framework";
|
||||
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
|
||||
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
|
||||
public const string WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins";
|
||||
|
||||
/* Tools Team */
|
||||
/// <summary>
|
||||
@ -259,6 +260,7 @@ public static class FeatureFlagKeys
|
||||
public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike";
|
||||
public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging";
|
||||
public const string EventManagementForHuntress = "event-management-for-huntress";
|
||||
public const string Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements";
|
||||
|
||||
/* UIF Team */
|
||||
public const string RouterFocusManagement = "router-focus-management";
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.10.4" />
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.11.0" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||
|
||||
@ -2,17 +2,17 @@
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="display: table; width:100%; padding: 30px; text-align: left;" align="center">
|
||||
<tr>
|
||||
<td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;">
|
||||
<b>Here's what that means:</b>
|
||||
<b>What this means for you</b>
|
||||
<ul>
|
||||
<li>Your Bitwarden account is owned by {{OrganizationName}}</li>
|
||||
<li>Your administrators can delete your account at any time</li>
|
||||
<li>You cannot leave the organization</li>
|
||||
<li>Your day-to-day use of Bitwarden remains the same.</li>
|
||||
<li>Only store work-related items in your {{OrganizationName}} vault.</li>
|
||||
<li>{{OrganizationName}} admins now manage your account, meaning they can revoke or delete your account.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;">
|
||||
For more information, please refer to the following help article: <a href="https://bitwarden.com/help/claimed-accounts">Claimed Accounts</a>
|
||||
For more information, please refer to the following help article: <a href="https://bitwarden.com/help/claimed-accounts">Claimed accounts</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
As a member of {{OrganizationName}}, your Bitwarden account is claimed and owned by your organization.
|
||||
What this means for you:
|
||||
- Your day-to-day use of Bitwarden remains the same.
|
||||
- Only store work-related items in your {{OrganizationName}} vault.
|
||||
- {{OrganizationName}} admins now manage your account, meaning they can revoke or delete your account.
|
||||
|
||||
Here's what that means:
|
||||
- Your administrators can delete your account at any time
|
||||
- You cannot leave the organization
|
||||
|
||||
For more information, please refer to the following help article: Claimed Accounts (https://bitwarden.com/help/claimed-accounts)
|
||||
For more information, please refer to the following help article: Claimed accounts (https://bitwarden.com/help/claimed-accounts)
|
||||
|
||||
@ -1,28 +1,691 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
Verify your email to access this Bitwarden Send.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
Your verification code is: <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Token}}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<hr />
|
||||
{{TheDate}} at {{TheTime}} {{TimeZone}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
||||
<!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-90 { width:90% !important; max-width: 90%; }
|
||||
}
|
||||
</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-90 { width:90% !important; max-width: 90%; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
.send-bubble {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
width: 90% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- Blue Header Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
Verify your email to access this Bitwarden Send
|
||||
</h1></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:155px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Main Content -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0px;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Your verification code is:</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="font-size:0px;word-break:break-word;">
|
||||
|
||||
<div style="height:20px;line-height:20px;"> </div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need
|
||||
to verify your email again.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 0px 20px 0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="send-bubble-outlook" style="vertical-align:top;width:558px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-90 mj-outlook-group-fix send-bubble" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0px;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#DBE5F6;border-radius:16px;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><p>
|
||||
Bitwarden Send transmits sensitive, temporary information to
|
||||
others easily and securely. Learn more about
|
||||
<a href="https://bitwarden.com/help/send" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Bitwarden Send</a>
|
||||
or
|
||||
<a href="https://bitwarden.com/signup" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">sign up</a>
|
||||
to try it today.
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Learn More Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#F3F6F9;background-color:#F3F6F9;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:#F3F6F9;background-color:#F3F6F9;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="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:94px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" 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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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; margin-top: 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/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">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>
|
||||
|
||||
@ -3,7 +3,7 @@ Verify your email to access this Bitwarden Send.
|
||||
|
||||
Your verification code is: {{Token}}
|
||||
|
||||
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
|
||||
This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again.
|
||||
|
||||
Date : {{TheDate}} at {{TheTime}} {{TimeZone}}
|
||||
{{/BasicTextLayout}}
|
||||
Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today.
|
||||
{{/BasicTextLayout}}
|
||||
|
||||
@ -1,691 +0,0 @@
|
||||
<!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-90 { width:90% !important; max-width: 90%; }
|
||||
}
|
||||
</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-90 { width:90% !important; max-width: 90%; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
.send-bubble {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
width: 90% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- Blue Header Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
Verify your email to access this Bitwarden Send
|
||||
</h1></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:155px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Main Content -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0px;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Your verification code is:</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="font-size:0px;word-break:break-word;">
|
||||
|
||||
<div style="height:20px;line-height:20px;"> </div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need
|
||||
to verify your email again.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 0px 20px 0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="send-bubble-outlook" style="vertical-align:top;width:558px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-90 mj-outlook-group-fix send-bubble" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0px;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#DBE5F6;border-radius:16px;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><p>
|
||||
Bitwarden Send transmits sensitive, temporary information to
|
||||
others easily and securely. Learn more about
|
||||
<a href="https://bitwarden.com/help/send" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Bitwarden Send</a>
|
||||
or
|
||||
<a href="https://bitwarden.com/signup" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">sign up</a>
|
||||
to try it today.
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Learn More Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#F3F6F9;background-color:#F3F6F9;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:#F3F6F9;background-color:#F3F6F9;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="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:94px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" 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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
|
||||
</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; margin-top: 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/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">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>
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
{{#>BasicTextLayout}}
|
||||
Verify your email to access this Bitwarden Send.
|
||||
|
||||
Your verification code is: {{Token}}
|
||||
|
||||
This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again.
|
||||
|
||||
Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today.
|
||||
{{/BasicTextLayout}}
|
||||
@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
@ -30,6 +31,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly IStripePaymentService _paymentService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
|
||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||
@ -45,6 +47,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
|
||||
IGroupRepository groupRepository,
|
||||
IStripePaymentService paymentService,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IOrganizationConnectionRepository organizationConnectionRepository,
|
||||
IServiceAccountRepository serviceAccountRepository,
|
||||
@ -59,6 +62,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
|
||||
_groupRepository = groupRepository;
|
||||
_paymentService = paymentService;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_organizationConnectionRepository = organizationConnectionRepository;
|
||||
_serviceAccountRepository = serviceAccountRepository;
|
||||
@ -184,9 +188,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
|
||||
|
||||
if (!newPlan.HasResetPassword && organization.UseResetPassword)
|
||||
{
|
||||
var resetPasswordPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled)
|
||||
var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Your new plan does not allow the Password Reset feature. " +
|
||||
"Disable your Password Reset policy.");
|
||||
|
||||
@ -209,26 +209,6 @@ public class HandlebarsMailService : IMailService
|
||||
}
|
||||
|
||||
public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
|
||||
{
|
||||
var message = CreateDefaultMessage(subject, email);
|
||||
var requestDateTime = DateTime.UtcNow;
|
||||
var model = new DefaultEmailOtpViewModel
|
||||
{
|
||||
Token = token,
|
||||
TheDate = requestDateTime.ToLongDateString(),
|
||||
TheTime = requestDateTime.ToShortTimeString(),
|
||||
TimeZone = _utcTimeZoneDisplay,
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||
SiteName = _globalSettings.SiteName,
|
||||
};
|
||||
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
|
||||
message.MetaData.Add("SendGridBypassListManagement", true);
|
||||
// TODO - PM-25380 change to string constant
|
||||
message.Category = "SendEmailOtp";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendSendEmailOtpEmailv2Async(string email, string token, string subject)
|
||||
{
|
||||
var message = CreateDefaultMessage(subject, email);
|
||||
var requestDateTime = DateTime.UtcNow;
|
||||
@ -242,7 +222,7 @@ public class HandlebarsMailService : IMailService
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||
SiteName = _globalSettings.SiteName,
|
||||
};
|
||||
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmailv2", model);
|
||||
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
|
||||
message.MetaData.Add("SendGridBypassListManagement", true);
|
||||
// TODO - PM-25380 change to string constant
|
||||
message.Category = "SendEmailOtp";
|
||||
@ -657,11 +637,11 @@ public class HandlebarsMailService : IMailService
|
||||
return;
|
||||
|
||||
MailQueueMessage CreateMessage(string emailAddress, Organization org) =>
|
||||
new(CreateDefaultMessage($"Your Bitwarden account is claimed by {org.DisplayName()}", emailAddress),
|
||||
new(CreateDefaultMessage($"Important update to your Bitwarden account", emailAddress),
|
||||
"AdminConsole.DomainClaimedByOrganization",
|
||||
new ClaimedDomainUserNotificationViewModel
|
||||
{
|
||||
TitleFirst = $"Your Bitwarden account is claimed by {org.DisplayName()}",
|
||||
TitleFirst = $"Important update to your Bitwarden account",
|
||||
OrganizationName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false)
|
||||
});
|
||||
}
|
||||
|
||||
@ -51,17 +51,15 @@ public interface IMailService
|
||||
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
|
||||
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
|
||||
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
|
||||
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
|
||||
/// <summary>
|
||||
/// <see cref="DefaultOtpTokenProviderOptions"/> has a default expiry of 5 minutes so we set the expiry to that value int he view model.
|
||||
/// <see cref="DefaultOtpTokenProviderOptions"/> has a default expiry of 5 minutes so we set the expiry to that value in the view model.
|
||||
/// Sends OTP code token to the specified email address.
|
||||
/// will replace <see cref="SendSendEmailOtpEmailAsync"/> when MJML templates are fully accepted.
|
||||
/// </summary>
|
||||
/// <param name="email">Email address to send the OTP to</param>
|
||||
/// <param name="token">Otp code token</param>
|
||||
/// <param name="subject">subject line of the email</param>
|
||||
/// <param name="subject">Subject line of the email</param>
|
||||
/// <returns>Task</returns>
|
||||
Task SendSendEmailOtpEmailv2Async(string email, string token, string subject);
|
||||
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
|
||||
Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
|
||||
Task SendNoMasterPasswordHintEmailAsync(string email);
|
||||
Task SendMasterPasswordHintEmailAsync(string email, string hint);
|
||||
|
||||
@ -99,11 +99,6 @@ public class NoopMailService : IMailService
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendSendEmailOtpEmailv2Async(string email, string token, string subject)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
|
||||
@ -61,7 +61,7 @@ public class UserService : UserManager<User>, IUserService
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IStripePaymentService _paymentService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly IFido2 _fido2;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
@ -98,7 +98,7 @@ public class UserService : UserManager<User>, IUserService
|
||||
IEventService eventService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IStripePaymentService paymentService,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IPolicyService policyService,
|
||||
IFido2 fido2,
|
||||
ICurrentContext currentContext,
|
||||
@ -139,7 +139,7 @@ public class UserService : UserManager<User>, IUserService
|
||||
_eventService = eventService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_paymentService = paymentService;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_policyService = policyService;
|
||||
_fido2 = fido2;
|
||||
_currentContext = currentContext;
|
||||
@ -722,9 +722,8 @@ public class UserService : UserManager<User>, IUserService
|
||||
}
|
||||
|
||||
// Enterprise policy must be enabled
|
||||
var resetPasswordPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
|
||||
var resetPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
|
||||
if (!resetPasswordPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Organization does not have the password reset policy enabled.");
|
||||
}
|
||||
|
||||
@ -81,6 +81,15 @@ public class Send : ITableObject<Guid>
|
||||
[MaxLength(4000)]
|
||||
public string? Emails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of email **hashes** for OTP authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field is mutually exclusive with <see cref="Password" />
|
||||
/// </remarks>
|
||||
[MaxLength(4000)]
|
||||
public string? EmailHashes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The send becomes unavailable to API callers when
|
||||
/// <see cref="AccessCount"/> >= <see cref="MaxAccessCount"/>.
|
||||
|
||||
@ -76,6 +76,12 @@ public class ImportCiphersCommand : IImportCiphersCommand
|
||||
{
|
||||
cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":true}}";
|
||||
}
|
||||
|
||||
if (cipher.UserId.HasValue && cipher.ArchivedDate.HasValue)
|
||||
{
|
||||
cipher.Archives = $"{{\"{cipher.UserId.Value.ToString().ToUpperInvariant()}\":\"" +
|
||||
$"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}";
|
||||
}
|
||||
}
|
||||
|
||||
var userfoldersIds = (await _folderRepository.GetManyByUserIdAsync(importingUserId)).Select(f => f.Id).ToList();
|
||||
@ -135,10 +141,16 @@ public class ImportCiphersCommand : IImportCiphersCommand
|
||||
}
|
||||
}
|
||||
|
||||
// Init. ids for ciphers
|
||||
foreach (var cipher in ciphers)
|
||||
{
|
||||
// Init. ids for ciphers
|
||||
cipher.SetNewId();
|
||||
|
||||
if (cipher.ArchivedDate.HasValue)
|
||||
{
|
||||
cipher.Archives = $"{{\"{importingUserId.ToString().ToUpperInvariant()}\":\"" +
|
||||
$"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}";
|
||||
}
|
||||
}
|
||||
|
||||
var organizationCollectionsIds = (await _collectionRepository.GetManyByOrganizationIdAsync(org.Id)).Select(c => c.Id).ToList();
|
||||
|
||||
@ -44,7 +44,7 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod;
|
||||
/// <summary>
|
||||
/// Create a send claim by requesting a one time password (OTP) confirmation code.
|
||||
/// </summary>
|
||||
/// <param name="Emails">
|
||||
/// The list of email addresses permitted access to the send.
|
||||
/// <param name="EmailHashes">
|
||||
/// The list of email address **hashes** permitted access to the send.
|
||||
/// </param>
|
||||
public record EmailOtp(string[] Emails) : SendAuthenticationMethod;
|
||||
public record EmailOtp(string[] EmailHashes) : SendAuthenticationMethod;
|
||||
|
||||
@ -37,8 +37,11 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
|
||||
SendAuthenticationMethod method = send switch
|
||||
{
|
||||
null => NEVER_AUTHENTICATE,
|
||||
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
|
||||
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
|
||||
var s when s.Disabled => NEVER_AUTHENTICATE,
|
||||
var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => NEVER_AUTHENTICATE,
|
||||
var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => NEVER_AUTHENTICATE,
|
||||
var s when s.DeletionDate <= DateTime.UtcNow => NEVER_AUTHENTICATE,
|
||||
var s when s.AuthType == AuthType.Email && s.EmailHashes is not null => EmailOtp(s.EmailHashes),
|
||||
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
|
||||
_ => NOT_AUTHENTICATED
|
||||
};
|
||||
@ -46,9 +49,13 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
|
||||
return method;
|
||||
}
|
||||
|
||||
private EmailOtp emailOtp(string emails)
|
||||
private static EmailOtp EmailOtp(string? emailHashes)
|
||||
{
|
||||
var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (string.IsNullOrWhiteSpace(emailHashes))
|
||||
{
|
||||
return new EmailOtp([]);
|
||||
}
|
||||
var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return new EmailOtp(list);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Events</UserSecretsId>
|
||||
|
||||
@ -8,7 +8,7 @@ public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-EventsProcessor</UserSecretsId>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
|
||||
namespace Bit.EventsProcessor;
|
||||
|
||||
@ -40,7 +39,6 @@ public class Startup
|
||||
|
||||
public void Configure(IApplicationBuilder app)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
app.UseRouting();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Icons</UserSecretsId>
|
||||
|
||||
@ -8,6 +8,7 @@ public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Identity</UserSecretsId>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Services;
|
||||
@ -11,7 +12,6 @@ using Duende.IdentityServer.Validation;
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
|
||||
public class SendEmailOtpRequestValidator(
|
||||
IFeatureService featureService,
|
||||
IOtpTokenProvider<DefaultOtpTokenProviderOptions> otpTokenProvider,
|
||||
IMailService mailService) : ISendAuthenticationMethodValidator<EmailOtp>
|
||||
{
|
||||
@ -40,8 +40,10 @@ public class SendEmailOtpRequestValidator(
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired);
|
||||
}
|
||||
|
||||
// email must be in the list of emails in the EmailOtp array
|
||||
if (!authMethod.Emails.Contains(email))
|
||||
// email hash must be in the list of email hashes in the EmailOtp array
|
||||
byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(email));
|
||||
string hashEmailHex = Convert.ToHexString(hashBytes).ToUpperInvariant();
|
||||
if (!authMethod.EmailHashes.Contains(hashEmailHex))
|
||||
{
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid);
|
||||
}
|
||||
@ -62,20 +64,12 @@ public class SendEmailOtpRequestValidator(
|
||||
{
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed);
|
||||
}
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.MJMLBasedEmailTemplates))
|
||||
{
|
||||
await mailService.SendSendEmailOtpEmailv2Async(
|
||||
email,
|
||||
token,
|
||||
string.Format(SendAccessConstants.OtpEmail.Subject, token));
|
||||
}
|
||||
else
|
||||
{
|
||||
await mailService.SendSendEmailOtpEmailAsync(
|
||||
email,
|
||||
token,
|
||||
string.Format(SendAccessConstants.OtpEmail.Subject, token));
|
||||
}
|
||||
|
||||
await mailService.SendSendEmailOtpEmailAsync(
|
||||
email,
|
||||
token,
|
||||
string.Format(SendAccessConstants.OtpEmail.Subject, token));
|
||||
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent);
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ public class Program
|
||||
{
|
||||
return Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
|
||||
@ -14,7 +14,6 @@ using Bit.SharedWeb.Swagger;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
||||
namespace Bit.Identity;
|
||||
@ -170,16 +169,14 @@ public class Startup
|
||||
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IWebHostEnvironment environment,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<Startup> logger)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
if (!environment.IsDevelopment())
|
||||
{
|
||||
var uri = new Uri(globalSettings.BaseServiceUri.Identity);
|
||||
app.Use(async (ctx, next) =>
|
||||
@ -196,7 +193,7 @@ public class Startup
|
||||
}
|
||||
|
||||
// Default Middleware
|
||||
app.UseDefaultMiddleware(env, globalSettings);
|
||||
app.UseDefaultMiddleware(environment, globalSettings);
|
||||
|
||||
if (!globalSettings.SelfHosted)
|
||||
{
|
||||
@ -204,7 +201,7 @@ public class Startup
|
||||
app.UseMiddleware<CustomIpRateLimitMiddleware>();
|
||||
}
|
||||
|
||||
if (env.IsDevelopment())
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseDeveloperExceptionPage();
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using Bit.Core;
|
||||
using Bit.Core.KeyManagement.UserKey;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Entities;
|
||||
@ -8,6 +9,7 @@ using Bit.Core.Tools.Repositories;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
using Bit.Infrastructure.Dapper.Tools.Helpers;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Bit.Infrastructure.Dapper.Tools.Repositories;
|
||||
@ -15,13 +17,24 @@ namespace Bit.Infrastructure.Dapper.Tools.Repositories;
|
||||
/// <inheritdoc cref="ISendRepository" />
|
||||
public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
{
|
||||
public SendRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
private readonly IDataProtector _dataProtector;
|
||||
|
||||
public SendRepository(GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString, dataProtectionProvider)
|
||||
{ }
|
||||
|
||||
public SendRepository(string connectionString, string readOnlyConnectionString)
|
||||
public SendRepository(string connectionString, string readOnlyConnectionString, IDataProtectionProvider dataProtectionProvider)
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
{
|
||||
_dataProtector = dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose);
|
||||
}
|
||||
|
||||
public override async Task<Send?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var send = await base.GetByIdAsync(id);
|
||||
UnprotectData(send);
|
||||
return send;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ICollection<Send>> GetManyByUserIdAsync(Guid userId)
|
||||
@ -33,7 +46,9 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
new { UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
var sends = results.ToList();
|
||||
UnprotectData(sends);
|
||||
return sends;
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,15 +62,35 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
new { DeletionDate = deletionDateBefore },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
var sends = results.ToList();
|
||||
UnprotectData(sends);
|
||||
return sends;
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<Send> CreateAsync(Send send)
|
||||
{
|
||||
await ProtectDataAndSaveAsync(send, async () => await base.CreateAsync(send));
|
||||
return send;
|
||||
}
|
||||
|
||||
public override async Task ReplaceAsync(Send send)
|
||||
{
|
||||
await ProtectDataAndSaveAsync(send, async () => await base.ReplaceAsync(send));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable<Send> sends)
|
||||
{
|
||||
return async (connection, transaction) =>
|
||||
{
|
||||
// Protect all sends before bulk update
|
||||
var sendsList = sends.ToList();
|
||||
foreach (var send in sendsList)
|
||||
{
|
||||
ProtectData(send);
|
||||
}
|
||||
|
||||
// Create temp table
|
||||
var sqlCreateTemp = @"
|
||||
SELECT TOP 0 *
|
||||
@ -71,7 +106,7 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
|
||||
{
|
||||
bulkCopy.DestinationTableName = "#TempSend";
|
||||
var sendsTable = sends.ToDataTable();
|
||||
var sendsTable = sendsList.ToDataTable();
|
||||
foreach (DataColumn col in sendsTable.Columns)
|
||||
{
|
||||
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
|
||||
@ -101,6 +136,69 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
|
||||
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Unprotect after save
|
||||
foreach (var send in sendsList)
|
||||
{
|
||||
UnprotectData(send);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ProtectDataAndSaveAsync(Send send, Func<Task> saveTask)
|
||||
{
|
||||
if (send == null)
|
||||
{
|
||||
await saveTask();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture original value
|
||||
var originalEmailHashes = send.EmailHashes;
|
||||
|
||||
// Protect value
|
||||
ProtectData(send);
|
||||
|
||||
// Save
|
||||
await saveTask();
|
||||
|
||||
// Restore original value
|
||||
send.EmailHashes = originalEmailHashes;
|
||||
}
|
||||
|
||||
private void ProtectData(Send send)
|
||||
{
|
||||
if (!send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
|
||||
{
|
||||
send.EmailHashes = string.Concat(Constants.DatabaseFieldProtectedPrefix,
|
||||
_dataProtector.Protect(send.EmailHashes!));
|
||||
}
|
||||
}
|
||||
|
||||
private void UnprotectData(Send? send)
|
||||
{
|
||||
if (send == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
|
||||
{
|
||||
send.EmailHashes = _dataProtector.Unprotect(
|
||||
send.EmailHashes.Substring(Constants.DatabaseFieldProtectedPrefix.Length));
|
||||
}
|
||||
}
|
||||
|
||||
private void UnprotectData(IEnumerable<Send> sends)
|
||||
{
|
||||
if (sends == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var send in sends)
|
||||
{
|
||||
UnprotectData(send);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -248,7 +248,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results;
|
||||
return DateTime.SpecifyKind(results, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
@ -595,7 +595,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results;
|
||||
return DateTime.SpecifyKind(results, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
@ -608,7 +608,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results;
|
||||
return DateTime.SpecifyKind(results, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
@ -621,7 +621,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
new { Ids = ids.ToGuidIdArrayTVP(), OrganizationId = organizationId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results;
|
||||
return DateTime.SpecifyKind(results, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -119,6 +119,7 @@ public class DatabaseContext : DbContext
|
||||
var eOrganizationDomain = builder.Entity<OrganizationDomain>();
|
||||
var aWebAuthnCredential = builder.Entity<WebAuthnCredential>();
|
||||
var eOrganizationMemberBaseDetail = builder.Entity<OrganizationMemberBaseDetail>();
|
||||
var eSend = builder.Entity<Send>();
|
||||
|
||||
// Shadow property configurations go here
|
||||
|
||||
@ -148,6 +149,7 @@ public class DatabaseContext : DbContext
|
||||
var dataProtectionConverter = new DataProtectionConverter(dataProtector);
|
||||
eUser.Property(c => c.Key).HasConversion(dataProtectionConverter);
|
||||
eUser.Property(c => c.MasterPassword).HasConversion(dataProtectionConverter);
|
||||
eSend.Property(c => c.EmailHashes).HasConversion(dataProtectionConverter);
|
||||
|
||||
if (Database.IsNpgsql())
|
||||
{
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Notifications</UserSecretsId>
|
||||
|
||||
@ -8,7 +8,7 @@ public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
|
||||
@ -5,7 +5,6 @@ using Bit.Core.Utilities;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Duende.IdentityModel;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
|
||||
namespace Bit.Notifications;
|
||||
|
||||
@ -84,8 +83,6 @@ public class Startup
|
||||
IWebHostEnvironment env,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
|
||||
@ -472,11 +472,6 @@ public static class ServiceCollectionExtensions
|
||||
addAuthorization.Invoke(config);
|
||||
});
|
||||
}
|
||||
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void AddCustomDataProtectionServices(
|
||||
@ -666,7 +661,6 @@ public static class ServiceCollectionExtensions
|
||||
Constants.BrowserExtensions.OperaId
|
||||
};
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,8 @@
|
||||
-- FIXME: remove null default value once this argument has been
|
||||
-- in 2 server releases
|
||||
@Emails NVARCHAR(4000) = NULL,
|
||||
@AuthType TINYINT = NULL
|
||||
@AuthType TINYINT = NULL,
|
||||
@EmailHashes NVARCHAR(4000) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@ -42,7 +43,8 @@ BEGIN
|
||||
[HideEmail],
|
||||
[CipherId],
|
||||
[Emails],
|
||||
[AuthType]
|
||||
[AuthType],
|
||||
[EmailHashes]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@ -63,7 +65,8 @@ BEGIN
|
||||
@HideEmail,
|
||||
@CipherId,
|
||||
@Emails,
|
||||
@AuthType
|
||||
@AuthType,
|
||||
@EmailHashes
|
||||
)
|
||||
|
||||
IF @UserId IS NOT NULL
|
||||
|
||||
@ -16,7 +16,8 @@
|
||||
@HideEmail BIT,
|
||||
@CipherId UNIQUEIDENTIFIER = NULL,
|
||||
@Emails NVARCHAR(4000) = NULL,
|
||||
@AuthType TINYINT = NULL
|
||||
@AuthType TINYINT = NULL,
|
||||
@EmailHashes NVARCHAR(4000) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@ -40,7 +41,8 @@ BEGIN
|
||||
[HideEmail] = @HideEmail,
|
||||
[CipherId] = @CipherId,
|
||||
[Emails] = @Emails,
|
||||
[AuthType] = @AuthType
|
||||
[AuthType] = @AuthType,
|
||||
[EmailHashes] = @EmailHashes
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
|
||||
|
||||
@ -1,22 +1,24 @@
|
||||
CREATE TABLE [dbo].[Send] (
|
||||
CREATE TABLE [dbo].[Send]
|
||||
(
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NULL,
|
||||
[Type] TINYINT NOT NULL,
|
||||
[Data] VARCHAR(MAX) NOT NULL,
|
||||
[Key] VARCHAR (MAX) NOT NULL,
|
||||
[Password] NVARCHAR (300) NULL,
|
||||
[Emails] NVARCHAR (4000) NULL,
|
||||
[Key] VARCHAR(MAX) NOT NULL,
|
||||
[Password] NVARCHAR(300) NULL,
|
||||
[Emails] NVARCHAR(4000) NULL,
|
||||
[MaxAccessCount] INT NULL,
|
||||
[AccessCount] INT NOT NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
[RevisionDate] DATETIME2 (7) NOT NULL,
|
||||
[ExpirationDate] DATETIME2 (7) NULL,
|
||||
[DeletionDate] DATETIME2 (7) NOT NULL,
|
||||
[CreationDate] DATETIME2(7) NOT NULL,
|
||||
[RevisionDate] DATETIME2(7) NOT NULL,
|
||||
[ExpirationDate] DATETIME2(7) NULL,
|
||||
[DeletionDate] DATETIME2(7) NOT NULL,
|
||||
[Disabled] BIT NOT NULL,
|
||||
[HideEmail] BIT NULL,
|
||||
[CipherId] UNIQUEIDENTIFIER NULL,
|
||||
[AuthType] TINYINT NULL,
|
||||
[EmailHashes] NVARCHAR(4000) NULL,
|
||||
CONSTRAINT [PK_Send] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_Send_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
|
||||
CONSTRAINT [FK_Send_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),
|
||||
@ -26,9 +28,9 @@
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_Send_UserId_OrganizationId]
|
||||
ON [dbo].[Send]([UserId] ASC, [OrganizationId] ASC);
|
||||
ON [dbo].[Send] ([UserId] ASC, [OrganizationId] ASC);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_Send_DeletionDate]
|
||||
ON [dbo].[Send]([DeletionDate] ASC);
|
||||
ON [dbo].[Send] ([DeletionDate] ASC);
|
||||
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
@ -26,7 +29,9 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@ -34,8 +39,8 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
|
||||
var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0);
|
||||
var collectionIds = collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
|
||||
var groupIds = groupsSeeder.Seed(orgId, 1, orgUserIds, 0);
|
||||
|
||||
var groupId = groupIds.First();
|
||||
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
@ -28,7 +31,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@ -37,8 +42,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds);
|
||||
groupsSeeder.AddToOrganization(orgId, 5, orgUserIds);
|
||||
collectionsSeeder.Seed(orgId, 10, orgUserIds);
|
||||
groupsSeeder.Seed(orgId, 5, orgUserIds);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
@ -64,7 +69,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@ -72,8 +79,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds);
|
||||
groupsSeeder.AddToOrganization(orgId, 5, orgUserIds);
|
||||
collectionsSeeder.Seed(orgId, 10, orgUserIds);
|
||||
groupsSeeder.Seed(orgId, 5, orgUserIds);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
@ -98,14 +105,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
|
||||
var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault();
|
||||
groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]);
|
||||
groupsSeeder.Seed(orgId, 2, [orgUserId]);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
@ -130,7 +139,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
@ -163,7 +174,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
@ -211,7 +224,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
@ -251,7 +266,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
@ -295,7 +312,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
@ -339,7 +358,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var domainSeeder = new OrganizationDomainRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
@ -350,7 +371,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
users: userCount,
|
||||
usersStatus: OrganizationUserStatusType.Confirmed);
|
||||
|
||||
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
|
||||
domainSeeder.Seed(orgId, domain);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
@ -384,7 +405,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@ -392,8 +415,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0);
|
||||
var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
|
||||
var collectionIds = collectionsSeeder.Seed(orgId, 3, orgUserIds, 0);
|
||||
var groupIds = groupsSeeder.Seed(orgId, 2, orgUserIds, 0);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
@ -434,7 +457,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
@ -471,7 +496,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var domainSeeder = new OrganizationDomainRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
@ -481,7 +508,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
users: 2,
|
||||
usersStatus: OrganizationUserStatusType.Confirmed);
|
||||
|
||||
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
|
||||
domainSeeder.Seed(orgId, domain);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
@ -512,14 +539,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
|
||||
var collectionIds = collectionsSeeder.Seed(orgId, 2, orgUserIds, 0);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
@ -560,7 +589,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
@ -29,7 +32,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@ -37,8 +42,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
|
||||
groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0);
|
||||
collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
|
||||
groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
@ -77,7 +82,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@ -85,8 +92,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
|
||||
groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0);
|
||||
collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
|
||||
groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
|
||||
@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
|
||||
new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true },
|
||||
orgUser.GetPermissions());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_Member_Success()
|
||||
{
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var updatedUser = await _factory.GetService<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(orgUser.Id);
|
||||
Assert.NotNull(updatedUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Revoked, updatedUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_AlreadyRevoked_ReturnsBadRequest()
|
||||
{
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var revokeResponse = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
|
||||
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
|
||||
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var error = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
|
||||
Assert.Equal("Already revoked.", error?.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_NotFound_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/revoke", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_DifferentOrganization_ReturnsNotFound()
|
||||
{
|
||||
// Create a different organization
|
||||
var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ownerEmail);
|
||||
var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
||||
ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
|
||||
|
||||
// Create a user in the other organization
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, otherOrganization.Id, OrganizationUserType.User);
|
||||
|
||||
// Re-authenticate with the original organization
|
||||
await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
|
||||
|
||||
// Try to revoke the user from the other organization
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_Member_Success()
|
||||
{
|
||||
// Invite a user to revoke
|
||||
var email = $"integration-test{Guid.NewGuid()}@example.com";
|
||||
var inviteRequest = new MemberCreateRequestModel
|
||||
{
|
||||
Email = email,
|
||||
Type = OrganizationUserType.User,
|
||||
};
|
||||
|
||||
var inviteResponse = await _client.PostAsync("/public/members", JsonContent.Create(inviteRequest));
|
||||
Assert.Equal(HttpStatusCode.OK, inviteResponse.StatusCode);
|
||||
var invitedMember = await inviteResponse.Content.ReadFromJsonAsync<MemberResponseModel>();
|
||||
Assert.NotNull(invitedMember);
|
||||
|
||||
// Revoke the invited user
|
||||
var revokeResponse = await _client.PostAsync($"/public/members/{invitedMember.Id}/revoke", null);
|
||||
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
|
||||
|
||||
// Restore the user
|
||||
var response = await _client.PostAsync($"/public/members/{invitedMember.Id}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// Verify user is restored to Invited state
|
||||
var updatedUser = await _factory.GetService<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(invitedMember.Id);
|
||||
Assert.NotNull(updatedUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Invited, updatedUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_AlreadyActive_ReturnsBadRequest()
|
||||
{
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var error = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
|
||||
Assert.Equal("Already active.", error?.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_NotFound_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_DifferentOrganization_ReturnsNotFound()
|
||||
{
|
||||
// Create a different organization
|
||||
var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ownerEmail);
|
||||
var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
||||
ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
|
||||
|
||||
// Create a user in the other organization
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory, otherOrganization.Id, OrganizationUserType.User);
|
||||
|
||||
// Re-authenticate with the original organization
|
||||
await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
|
||||
|
||||
// Try to restore the user from the other organization
|
||||
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user