1
0
mirror of https://github.com/bitwarden/server synced 2026-01-10 04:23:31 +00:00

Merge remote-tracking branch 'origin/main' into arch/seeder-api

This commit is contained in:
Matt Gibson
2025-11-04 21:43:51 -08:00
258 changed files with 23154 additions and 2234 deletions

View File

@@ -152,13 +152,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseCustomPermissions"></label>
</div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
}
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
<div class="form-check">

View File

@@ -18,9 +18,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0",
"sass": "1.93.2",
"sass-loader": "16.0.5",
"webpack": "5.101.3",
"webpack": "5.102.1",
"webpack-cli": "5.1.4"
}
},
@@ -679,6 +679,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -705,6 +706,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -747,6 +749,16 @@
"ajv": "^8.8.2"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bootstrap": {
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
@@ -781,9 +793,9 @@
}
},
"node_modules/browserslist": {
"version": "4.25.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
"integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==",
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true,
"funding": [
{
@@ -800,10 +812,12 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
"node-releases": "^2.0.19",
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
},
"bin": {
@@ -821,9 +835,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001741",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
"integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==",
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"dev": true,
"funding": [
{
@@ -975,9 +989,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.215",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz",
"integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==",
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"dev": true,
"license": "ISC"
},
@@ -1528,9 +1542,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz",
"integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==",
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"dev": true,
"license": "MIT"
},
@@ -1654,6 +1668,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -1860,11 +1875,12 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.91.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz",
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==",
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -1922,9 +1938,9 @@
}
},
"node_modules/schema-utils": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2061,9 +2077,9 @@
}
},
"node_modules/tapable": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz",
"integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2210,11 +2226,12 @@
}
},
"node_modules/webpack": {
"version": "5.101.3",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz",
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==",
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -2224,7 +2241,7 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.24.0",
"browserslist": "^4.26.3",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
@@ -2236,10 +2253,10 @@
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.2",
"tapable": "^2.1.1",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},
"bin": {
@@ -2264,6 +2281,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1",

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0",
"sass": "1.93.2",
"sass-loader": "16.0.5",
"webpack": "5.101.3",
"webpack": "5.102.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -12,10 +12,11 @@ public static class AuthorizationHandlerCollectionExtensions
services.TryAddScoped<IOrganizationContext, OrganizationContext>();
services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, RecoverAccountAuthorizationHandler>(),
]);
}
}

View File

@@ -0,0 +1,110 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// An authorization requirement for recovering an organization member's account.
/// </summary>
/// <remarks>
/// Note: this is different to simply being able to manage account recovery. The user must be recovering
/// a member who has equal or lesser permissions than them.
/// </remarks>
public class RecoverAccountAuthorizationRequirement : IAuthorizationRequirement;
/// <summary>
/// Authorizes members and providers to recover a target OrganizationUser's account.
/// </summary>
/// <remarks>
/// This prevents privilege escalation by ensuring that a user cannot recover the account of
/// another user with a higher role or with provider membership.
/// </remarks>
public class RecoverAccountAuthorizationHandler(
IOrganizationContext organizationContext,
ICurrentContext currentContext,
IProviderUserRepository providerUserRepository)
: AuthorizationHandler<RecoverAccountAuthorizationRequirement, OrganizationUser>
{
public const string FailureReason = "You are not permitted to recover this user's account.";
public const string ProviderFailureReason = "You are not permitted to recover a Provider member's account.";
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
RecoverAccountAuthorizationRequirement requirement,
OrganizationUser targetOrganizationUser)
{
// Step 1: check that the User has permissions with respect to the organization.
// This may come from their role in the organization or their provider relationship.
var canRecoverOrganizationMember =
AuthorizeMember(context.User, targetOrganizationUser) ||
await AuthorizeProviderAsync(context.User, targetOrganizationUser);
if (!canRecoverOrganizationMember)
{
context.Fail(new AuthorizationFailureReason(this, FailureReason));
return;
}
// Step 2: check that the User has permissions with respect to any provider the target user is a member of.
// This prevents an organization admin performing privilege escalation into an unrelated provider.
var canRecoverProviderMember = await CanRecoverProviderAsync(targetOrganizationUser);
if (!canRecoverProviderMember)
{
context.Fail(new AuthorizationFailureReason(this, ProviderFailureReason));
return;
}
context.Succeed(requirement);
}
private async Task<bool> AuthorizeProviderAsync(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
return await organizationContext.IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId);
}
private bool AuthorizeMember(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
var currentContextOrganization = organizationContext.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId);
if (currentContextOrganization == null)
{
return false;
}
// Current user must have equal or greater permissions than the user account being recovered
var authorized = targetOrganizationUser.Type switch
{
OrganizationUserType.Owner => currentContextOrganization.Type is OrganizationUserType.Owner,
OrganizationUserType.Admin => currentContextOrganization.Type is OrganizationUserType.Owner or OrganizationUserType.Admin,
_ => currentContextOrganization is
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin }
or { Type: OrganizationUserType.Custom, Permissions.ManageResetPassword: true }
};
return authorized;
}
private async Task<bool> CanRecoverProviderAsync(OrganizationUser targetOrganizationUser)
{
if (!targetOrganizationUser.UserId.HasValue)
{
// If an OrganizationUser is not linked to a User then it can't be linked to a Provider either.
// This is invalid but does not pose a privilege escalation risk. Return early and let the command
// handle the invalid input.
return true;
}
var targetUserProviderUsers =
await providerUserRepository.GetManyByUserAsync(targetOrganizationUser.UserId.Value);
// If the target user belongs to any provider that the current user is not a member of,
// deny the action to prevent privilege escalation from organization to provider.
// Note: we do not expect that a user is a member of more than 1 provider, but there is also no guarantee
// against it; this returns a sequence, so we handle the possibility.
var authorized = targetUserProviderUsers.All(providerUser => currentContext.ProviderUser(providerUser.ProviderId));
return authorized;
}
}

View File

@@ -3,6 +3,7 @@
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Enums;
@@ -31,10 +32,11 @@ public class EventsController : Controller
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
public EventsController(
IUserService userService,
public EventsController(IUserService userService,
ICipherRepository cipherRepository,
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
@@ -42,7 +44,9 @@ public class EventsController : Controller
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository,
IServiceAccountRepository serviceAccountRepository)
IServiceAccountRepository serviceAccountRepository,
ILogger<EventsController> logger,
IFeatureService featureService)
{
_userService = userService;
_cipherRepository = cipherRepository;
@@ -53,6 +57,8 @@ public class EventsController : Controller
_secretRepository = secretRepository;
_projectRepository = projectRepository;
_serviceAccountRepository = serviceAccountRepository;
_logger = logger;
_featureService = featureService;
}
[HttpGet("")]
@@ -114,6 +120,9 @@ public class EventsController : Controller
var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
_logger.LogAggregateData(_featureService, orgId, responses, continuationToken, start, end);
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}

View File

@@ -1,4 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below
// NOTE: This file is partially migrated to nullable reference types. Remove inline #nullable directives when addressing the FIXME.
#nullable disable
using Bit.Api.AdminConsole.Authorization;
@@ -11,6 +12,7 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@@ -70,6 +72,7 @@ public class OrganizationUsersController : Controller
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -97,7 +100,8 @@ public class OrganizationUsersController : Controller
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -126,6 +130,7 @@ public class OrganizationUsersController : Controller
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
_adminRecoverAccountCommand = adminRecoverAccountCommand;
}
[HttpGet("{id}")]
@@ -474,21 +479,27 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>]
public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}
// Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null)
{
throw new NotFoundException();
return TypedResults.NotFound();
}
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return;
return TypedResults.Ok();
}
foreach (var error in result.Errors)
@@ -497,9 +508,45 @@ public class OrganizationUsersController : Controller
}
await Task.Delay(2000);
throw new BadRequestException(ModelState);
return TypedResults.BadRequest(ModelState);
}
#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
{
return TypedResults.NotFound();
}
var authorizationResult = await _authorizationService.AuthorizeAsync(User, targetOrganizationUser, new RecoverAccountAuthorizationRequirement());
if (!authorizationResult.Succeeded)
{
// Return an informative error to show in the UI.
// The Authorize attribute already prevents enumeration by users outside the organization, so this can be specific.
var failureReason = authorizationResult.Failure?.FailureReasons.FirstOrDefault()?.Message ?? RecoverAccountAuthorizationHandler.FailureReason;
// This should be a 403 Forbidden, but that causes a logout on our client apps so we're using 400 Bad Request instead
return TypedResults.BadRequest(new ErrorResponseModel(failureReason));
}
var result = await _adminRecoverAccountCommand.RecoverAccountAsync(orgId, targetOrganizationUser, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}
#nullable disable
[HttpDelete("{id}")]
[Authorize<ManageUsersRequirement>]
public async Task Remove(Guid orgId, Guid id)

View File

@@ -1,11 +1,8 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Context;
namespace Bit.Api.AdminConsole.Models.Request;
@@ -16,14 +13,20 @@ public class PolicyRequestModel
public PolicyType? Type { get; set; }
[Required]
public bool? Enabled { get; set; }
public Dictionary<string, object> Data { get; set; }
public Dictionary<string, object>? Data { get; set; }
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext) => new()
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext)
{
Type = Type!.Value,
OrganizationId = organizationId,
Data = Data != null ? JsonSerializer.Serialize(Data) : null,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId))
};
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
return new()
{
Type = Type!.Value,
OrganizationId = organizationId,
Data = serializedData,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = performedBy
};
}
}

View File

@@ -1,10 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Context;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request;
@@ -17,45 +15,10 @@ public class SavePolicyRequest
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext)
{
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext);
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
var updatedPolicy = new PolicyUpdate()
{
Type = Policy.Type!.Value,
OrganizationId = organizationId,
Data = Policy.Data != null ? JsonSerializer.Serialize(Policy.Data) : null,
Enabled = Policy.Enabled.GetValueOrDefault(),
};
var metadata = MapToPolicyMetadata();
return new SavePolicyModel(updatedPolicy, performedBy, metadata);
}
private IPolicyMetadataModel MapToPolicyMetadata()
{
if (Metadata == null)
{
return new EmptyMetadataModel();
}
return Policy?.Type switch
{
PolicyType.OrganizationDataOwnership => MapToPolicyMetadata<OrganizationModelOwnershipPolicyModel>(),
_ => new EmptyMetadataModel()
};
}
private IPolicyMetadataModel MapToPolicyMetadata<T>() where T : IPolicyMetadataModel, new()
{
try
{
var json = JsonSerializer.Serialize(Metadata);
return CoreHelpers.LoadClassFromJsonData<T>(json);
}
catch
{
return new EmptyMetadataModel();
}
return new SavePolicyModel(policyUpdate, performedBy, metadata);
}
}

View File

@@ -0,0 +1,127 @@
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response;
/// <summary>
/// Contains organization properties for both OrganizationUsers and ProviderUsers.
/// Any organization properties in sync data should be added to this class so they are populated for both
/// members and providers.
/// </summary>
public abstract class BaseProfileOrganizationResponseModel : ResponseModel
{
protected BaseProfileOrganizationResponseModel(
string type, IProfileOrganizationDetails organizationDetails) : base(type)
{
Id = organizationDetails.OrganizationId;
UserId = organizationDetails.UserId;
Name = organizationDetails.Name;
Enabled = organizationDetails.Enabled;
Identifier = organizationDetails.Identifier;
ProductTierType = organizationDetails.PlanType.GetProductTier();
UsePolicies = organizationDetails.UsePolicies;
UseSso = organizationDetails.UseSso;
UseKeyConnector = organizationDetails.UseKeyConnector;
UseScim = organizationDetails.UseScim;
UseGroups = organizationDetails.UseGroups;
UseDirectory = organizationDetails.UseDirectory;
UseEvents = organizationDetails.UseEvents;
UseTotp = organizationDetails.UseTotp;
Use2fa = organizationDetails.Use2fa;
UseApi = organizationDetails.UseApi;
UseResetPassword = organizationDetails.UseResetPassword;
UsersGetPremium = organizationDetails.UsersGetPremium;
UseCustomPermissions = organizationDetails.UseCustomPermissions;
UseActivateAutofillPolicy = organizationDetails.PlanType.GetProductTier() == ProductTierType.Enterprise;
UseRiskInsights = organizationDetails.UseRiskInsights;
UseOrganizationDomains = organizationDetails.UseOrganizationDomains;
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
UseSecretsManager = organizationDetails.UseSecretsManager;
UsePasswordManager = organizationDetails.UsePasswordManager;
SelfHost = organizationDetails.SelfHost;
Seats = organizationDetails.Seats;
MaxCollections = organizationDetails.MaxCollections;
MaxStorageGb = organizationDetails.MaxStorageGb;
Key = organizationDetails.Key;
HasPublicAndPrivateKeys = organizationDetails.PublicKey != null && organizationDetails.PrivateKey != null;
SsoBound = !string.IsNullOrWhiteSpace(organizationDetails.SsoExternalId);
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organizationDetails.ResetPasswordKey);
ProviderId = organizationDetails.ProviderId;
ProviderName = organizationDetails.ProviderName;
ProviderType = organizationDetails.ProviderType;
LimitCollectionCreation = organizationDetails.LimitCollectionCreation;
LimitCollectionDeletion = organizationDetails.LimitCollectionDeletion;
LimitItemDeletion = organizationDetails.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organizationDetails.AllowAdminAccessToAllCollectionItems;
SsoEnabled = organizationDetails.SsoEnabled ?? false;
if (organizationDetails.SsoConfig != null)
{
var ssoConfigData = SsoConfigurationData.Deserialize(organizationDetails.SsoConfig);
KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl);
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType;
}
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; } = null!;
public bool Enabled { get; set; }
public string? Identifier { get; set; }
public ProductTierType ProductTierType { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }
public bool UseTotp { get; set; }
public bool Use2fa { get; set; }
public bool UseApi { get; set; }
public bool UseResetPassword { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseActivateAutofillPolicy { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string? Key { get; set; }
public bool HasPublicAndPrivateKeys { get; set; }
public bool SsoBound { get; set; }
public bool ResetPasswordEnrolled { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string? ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public bool SsoEnabled { get; set; }
public bool KeyConnectorEnabled { get; set; }
public string? KeyConnectorUrl { get; set; }
public MemberDecryptionType? SsoMemberDecryptionType { get; set; }
public bool AccessSecretsManager { get; set; }
public Guid? UserId { get; set; }
public OrganizationUserStatusType Status { get; set; }
public OrganizationUserType Type { get; set; }
public Permissions? Permissions { get; set; }
}

View File

@@ -1,150 +1,47 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response;
public class ProfileOrganizationResponseModel : ResponseModel
/// <summary>
/// Sync data for organization members and their organization.
/// Note: see <see cref="ProfileProviderOrganizationResponseModel"/> for organization sync data received by provider users.
/// </summary>
public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseModel
{
public ProfileOrganizationResponseModel(string str) : base(str) { }
public ProfileOrganizationResponseModel(
OrganizationUserOrganizationDetails organization,
OrganizationUserOrganizationDetails organizationDetails,
IEnumerable<Guid> organizationIdsClaimingUser)
: this("profileOrganization")
: base("profileOrganization", organizationDetails)
{
Id = organization.OrganizationId;
Name = organization.Name;
UsePolicies = organization.UsePolicies;
UseSso = organization.UseSso;
UseKeyConnector = organization.UseKeyConnector;
UseScim = organization.UseScim;
UseGroups = organization.UseGroups;
UseDirectory = organization.UseDirectory;
UseEvents = organization.UseEvents;
UseTotp = organization.UseTotp;
Use2fa = organization.Use2fa;
UseApi = organization.UseApi;
UseResetPassword = organization.UseResetPassword;
UseSecretsManager = organization.UseSecretsManager;
UsePasswordManager = organization.UsePasswordManager;
UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise;
SelfHost = organization.SelfHost;
Seats = organization.Seats;
MaxCollections = organization.MaxCollections;
MaxStorageGb = organization.MaxStorageGb;
Key = organization.Key;
HasPublicAndPrivateKeys = organization.PublicKey != null && organization.PrivateKey != null;
Status = organization.Status;
Type = organization.Type;
Enabled = organization.Enabled;
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
Identifier = organization.Identifier;
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey);
UserId = organization.UserId;
OrganizationUserId = organization.OrganizationUserId;
ProviderId = organization.ProviderId;
ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType;
FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName;
IsAdminInitiated = organization.IsAdminInitiated ?? false;
FamilySponsorshipAvailable = (FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
Status = organizationDetails.Status;
Type = organizationDetails.Type;
OrganizationUserId = organizationDetails.OrganizationUserId;
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organizationDetails.OrganizationId);
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organizationDetails.Permissions);
IsAdminInitiated = organizationDetails.IsAdminInitiated ?? false;
FamilySponsorshipFriendlyName = organizationDetails.FamilySponsorshipFriendlyName;
FamilySponsorshipLastSyncDate = organizationDetails.FamilySponsorshipLastSyncDate;
FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organization);
ProductTierType = organization.PlanType.GetProductTier();
FamilySponsorshipLastSyncDate = organization.FamilySponsorshipLastSyncDate;
FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil;
AccessSecretsManager = organization.AccessSecretsManager;
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
SsoEnabled = organization.SsoEnabled ?? false;
if (organization.SsoConfig != null)
{
var ssoConfigData = SsoConfigurationData.Deserialize(organization.SsoConfig);
KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl);
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType;
}
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
.UsersCanSponsor(organizationDetails);
AccessSecretsManager = organizationDetails.AccessSecretsManager;
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }
public bool UseTotp { get; set; }
public bool Use2fa { get; set; }
public bool UseApi { get; set; }
public bool UseResetPassword { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseActivateAutofillPolicy { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string Key { get; set; }
public OrganizationUserStatusType Status { get; set; }
public OrganizationUserType Type { get; set; }
public bool Enabled { get; set; }
public bool SsoBound { get; set; }
public string Identifier { get; set; }
public Permissions Permissions { get; set; }
public bool ResetPasswordEnrolled { get; set; }
public Guid? UserId { get; set; }
public Guid OrganizationUserId { get; set; }
public bool HasPublicAndPrivateKeys { get; set; }
public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public bool UserIsClaimedByOrganization { get; set; }
public string? FamilySponsorshipFriendlyName { get; set; }
public bool FamilySponsorshipAvailable { get; set; }
public ProductTierType ProductTierType { get; set; }
public bool KeyConnectorEnabled { get; set; }
public string KeyConnectorUrl { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; }
public DateTime? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; }
public bool AccessSecretsManager { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool IsAdminInitiated { get; set; }
/// <summary>
/// Obsolete.
/// See <see cref="UserIsClaimedByOrganization"/>
/// Obsolete property for backward compatibility
/// </summary>
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
public bool UserIsManagedByOrganization
@@ -152,19 +49,4 @@ public class ProfileOrganizationResponseModel : ResponseModel
get => UserIsClaimedByOrganization;
set => UserIsClaimedByOrganization = value;
}
/// <summary>
/// Indicates if the user is claimed by the organization.
/// </summary>
/// <remarks>
/// A user is claimed by an organization if the user's email domain is verified by the organization and the user is a member.
/// The organization must be enabled and able to have verified domains.
/// </remarks>
public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool IsAdminInitiated { get; set; }
public bool SsoEnabled { get; set; }
public MemberDecryptionType? SsoMemberDecryptionType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
}

View File

@@ -1,57 +1,24 @@
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Models.Response;
public class ProfileProviderOrganizationResponseModel : ProfileOrganizationResponseModel
/// <summary>
/// Sync data for provider users and their managed organizations.
/// Note: see <see cref="ProfileOrganizationResponseModel"/> for organization sync data received by organization members.
/// </summary>
public class ProfileProviderOrganizationResponseModel : BaseProfileOrganizationResponseModel
{
public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organization)
: base("profileProviderOrganization")
public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organizationDetails)
: base("profileProviderOrganization", organizationDetails)
{
Id = organization.OrganizationId;
Name = organization.Name;
UsePolicies = organization.UsePolicies;
UseSso = organization.UseSso;
UseKeyConnector = organization.UseKeyConnector;
UseScim = organization.UseScim;
UseGroups = organization.UseGroups;
UseDirectory = organization.UseDirectory;
UseEvents = organization.UseEvents;
UseTotp = organization.UseTotp;
Use2fa = organization.Use2fa;
UseApi = organization.UseApi;
UseResetPassword = organization.UseResetPassword;
UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise;
SelfHost = organization.SelfHost;
Seats = organization.Seats;
MaxCollections = organization.MaxCollections;
MaxStorageGb = organization.MaxStorageGb;
Key = organization.Key;
HasPublicAndPrivateKeys = organization.PublicKey != null && organization.PrivateKey != null;
Status = OrganizationUserStatusType.Confirmed; // Provider users are always confirmed
Type = OrganizationUserType.Owner; // Provider users behave like Owners
Enabled = organization.Enabled;
SsoBound = false;
Identifier = organization.Identifier;
ProviderId = organizationDetails.ProviderId;
ProviderName = organizationDetails.ProviderName;
ProviderType = organizationDetails.ProviderType;
Permissions = new Permissions();
ResetPasswordEnrolled = false;
UserId = organization.UserId;
ProviderId = organization.ProviderId;
ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType;
ProductTierType = organization.PlanType.GetProductTier();
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
AccessSecretsManager = false; // Provider users cannot access Secrets Manager
}
}

View File

@@ -4,9 +4,11 @@
using System.Net;
using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.Context;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -20,15 +22,21 @@ public class EventsController : Controller
private readonly IEventRepository _eventRepository;
private readonly ICipherRepository _cipherRepository;
private readonly ICurrentContext _currentContext;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
public EventsController(
IEventRepository eventRepository,
ICipherRepository cipherRepository,
ICurrentContext currentContext)
ICurrentContext currentContext,
ILogger<EventsController> logger,
IFeatureService featureService)
{
_eventRepository = eventRepository;
_cipherRepository = cipherRepository;
_currentContext = currentContext;
_logger = logger;
_featureService = featureService;
}
/// <summary>
@@ -69,6 +77,8 @@ public class EventsController : Controller
var eventResponses = result.Data.Select(e => new EventResponseModel(e));
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
_logger.LogAggregateData(_featureService, _currentContext.OrganizationId!.Value, response, request);
return new JsonResult(response);
}
}

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Net;
using System.Net;
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
@@ -24,11 +21,9 @@ public class MembersController : Controller
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IGroupRepository _groupRepository;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IPaymentService _paymentService;
private readonly IOrganizationRepository _organizationRepository;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
@@ -39,11 +34,9 @@ public class MembersController : Controller
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
IOrganizationService organizationService,
IUserService userService,
ICurrentContext currentContext,
IUpdateOrganizationUserCommand updateOrganizationUserCommand,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IApplicationCacheService applicationCacheService,
IPaymentService paymentService,
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
@@ -53,11 +46,9 @@ public class MembersController : Controller
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
_organizationService = organizationService;
_userService = userService;
_currentContext = currentContext;
_updateOrganizationUserCommand = updateOrganizationUserCommand;
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService;
_organizationRepository = organizationRepository;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
@@ -115,19 +106,18 @@ public class MembersController : Controller
/// </summary>
/// <remarks>
/// Returns a list of your organization's members.
/// Member objects listed in this call do not include information about their associated collections.
/// Member objects listed in this call include information about their associated collections.
/// </remarks>
[HttpGet]
[ProducesResponseType(typeof(ListResponseModel<MemberResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List()
{
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId.Value);
// TODO: Get all CollectionUser associations for the organization and marry them up here for the response.
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId!.Value, includeCollections: true);
var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails);
var memberResponses = organizationUserUserDetails.Select(u =>
{
return new MemberResponseModel(u, orgUsersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.user == u).twoFactorIsEnabled, null);
return new MemberResponseModel(u, orgUsersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.user == u).twoFactorIsEnabled, u.Collections);
});
var response = new ListResponseModel<MemberResponseModel>(memberResponses);
return new JsonResult(response);
@@ -158,7 +148,7 @@ public class MembersController : Controller
invite.AccessSecretsManager = hasStandaloneSecretsManager;
var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null,
var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId!.Value, null,
systemUser: null, invite, model.ExternalId);
var response = new MemberResponseModel(user, invite.Collections);
return new JsonResult(response);
@@ -188,12 +178,12 @@ public class MembersController : Controller
var updatedUser = model.ToOrganizationUser(existingUser);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups);
MemberResponseModel response = null;
MemberResponseModel response;
if (existingUser.UserId.HasValue)
{
var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
response = new MemberResponseModel(existingUserDetails,
await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails), associations);
response = new MemberResponseModel(existingUserDetails!,
await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails!), associations);
}
else
{
@@ -242,7 +232,7 @@ public class MembersController : Controller
{
return new NotFoundResult();
}
await _removeOrganizationUserCommand.RemoveUserAsync(_currentContext.OrganizationId.Value, id, null);
await _removeOrganizationUserCommand.RemoveUserAsync(_currentContext.OrganizationId!.Value, id, null);
return new OkResult();
}
@@ -264,7 +254,7 @@ public class MembersController : Controller
{
return new NotFoundResult();
}
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id);
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
return new OkResult();
}
}

View File

@@ -1,19 +1,24 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
namespace Bit.Api.AdminConsole.Public.Models.Request;
public class PolicyUpdateRequestModel : PolicyBaseModel
{
public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type) => new()
public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type)
{
Type = type,
OrganizationId = organizationId,
Data = Data != null ? JsonSerializer.Serialize(Data) : null,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = new SystemUser(EventSystemUser.PublicApi)
};
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
return new()
{
Type = type,
OrganizationId = organizationId,
Data = serializedData,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = new SystemUser(EventSystemUser.PublicApi)
};
}
}

View File

@@ -1,9 +1,15 @@
using Bit.Core.Models.Data;
using System.Text.Json.Serialization;
using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Public.Models.Response;
public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel
{
[JsonConstructor]
public AssociationWithPermissionsResponseModel() : base()
{
}
public AssociationWithPermissionsResponseModel(CollectionAccessSelection selection)
{
if (selection == null)

View File

@@ -0,0 +1,13 @@
using Bit.Api.Utilities;
namespace Bit.Api.Billing.Attributes;
public class NonTokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
{
private static readonly string[] _acceptedValues = ["accountCredit"];
public NonTokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues)
{
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
}
}

View File

@@ -2,11 +2,11 @@
namespace Bit.Api.Billing.Attributes;
public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute
public class TokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
{
private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"];
public PaymentMethodTypeValidationAttribute() : base(_acceptedValues)
public TokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues)
{
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
}

View File

@@ -89,19 +89,6 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
if (model.IsAdminInitiated.GetValueOrDefault())
{
throw new BadRequestException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
{
model.Notes = null;
}
}
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
sponsoringOrg,
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),

View File

@@ -3,10 +3,10 @@ using Bit.Core.Billing.Pricing;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
namespace Bit.Api.Billing.Controllers;
[Route("plans")]
[Authorize("Web")]
[Authorize("Application")]
public class PlansController(
IPricingClient pricingClient) : Controller
{
@@ -18,4 +18,11 @@ public class PlansController(
var responses = plans.Select(plan => new PlanResponseModel(plan));
return new ListResponseModel<PlanResponseModel>(responses);
}
[HttpGet("premium")]
public async Task<IResult> GetPremiumPlanAsync()
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
return TypedResults.Ok(premiumPlan);
}
}

View File

@@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment;
public class MinimalTokenizedPaymentMethodRequest
{
[Required]
[PaymentMethodTypeValidation]
[TokenizedPaymentMethodTypeValidation]
public required string Type { get; set; }
[Required]

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Billing.Attributes;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Payment;
public class NonTokenizedPaymentMethodRequest
{
[Required]
[NonTokenizedPaymentMethodTypeValidation]
public required string Type { get; set; }
public NonTokenizedPaymentMethod ToDomain()
{
return Type switch
{
"accountCredit" => new NonTokenizedPaymentMethod { Type = NonTokenizablePaymentMethodType.AccountCredit },
_ => throw new InvalidOperationException($"Invalid value for {nameof(NonTokenizedPaymentMethod)}.{nameof(NonTokenizedPaymentMethod.Type)}")
};
}
}

View File

@@ -4,10 +4,10 @@ using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Premium;
public class PremiumCloudHostedSubscriptionRequest
public class PremiumCloudHostedSubscriptionRequest : IValidatableObject
{
[Required]
public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; }
public MinimalTokenizedPaymentMethodRequest? TokenizedPaymentMethod { get; set; }
public NonTokenizedPaymentMethodRequest? NonTokenizedPaymentMethod { get; set; }
[Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; }
@@ -15,11 +15,38 @@ public class PremiumCloudHostedSubscriptionRequest
[Range(0, 99)]
public short AdditionalStorageGb { get; set; } = 0;
public (TokenizedPaymentMethod, BillingAddress, short) ToDomain()
public (PaymentMethod, BillingAddress, short) ToDomain()
{
var paymentMethod = TokenizedPaymentMethod.ToDomain();
// Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided.
var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain();
var nonTokenizedPaymentMethod = NonTokenizedPaymentMethod?.ToDomain();
PaymentMethod paymentMethod = tokenizedPaymentMethod != null
? tokenizedPaymentMethod
: nonTokenizedPaymentMethod!;
var billingAddress = BillingAddress.ToDomain();
return (paymentMethod, billingAddress, AdditionalStorageGb);
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (TokenizedPaymentMethod == null && NonTokenizedPaymentMethod == null)
{
yield return new ValidationResult(
"Either TokenizedPaymentMethod or NonTokenizedPaymentMethod must be provided.",
new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) }
);
}
if (TokenizedPaymentMethod != null && NonTokenizedPaymentMethod != null)
{
yield return new ValidationResult(
"Only one of TokenizedPaymentMethod or NonTokenizedPaymentMethod can be provided.",
new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) }
);
}
}
}

View File

@@ -1,45 +0,0 @@
using Bit.Api.Models.Request;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace Bit.Api.Controllers;
public class MiscController : Controller
{
private readonly BitPayClient _bitPayClient;
private readonly GlobalSettings _globalSettings;
public MiscController(
BitPayClient bitPayClient,
GlobalSettings globalSettings)
{
_bitPayClient = bitPayClient;
_globalSettings = globalSettings;
}
[Authorize("Application")]
[HttpPost("~/bitpay-invoice")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<string> PostBitPayInvoice([FromBody] BitPayInvoiceRequestModel model)
{
var invoice = await _bitPayClient.CreateInvoiceAsync(model.ToBitpayInvoice(_globalSettings));
return invoice.Url;
}
[Authorize("Application")]
[HttpPost("~/setup-payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<string> PostSetupPayment()
{
var options = new SetupIntentCreateOptions
{
Usage = "off_session"
};
var service = new SetupIntentService();
var setupIntent = await service.CreateAsync(options);
return setupIntent.ClientSecret;
}
}

View File

@@ -55,19 +55,6 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
[HttpPost("{sponsoringOrgId}/families-for-enterprise")]
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
if (model.IsAdminInitiated.GetValueOrDefault())
{
throw new BadRequestException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
{
model.Notes = null;
}
}
await _offerSponsorshipCommand.CreateSponsorshipAsync(
await _organizationRepository.GetByIdAsync(sponsoringOrgId),
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),

View File

@@ -1,4 +1,5 @@
using Bit.Core.Context;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
using Bit.Core.Exceptions;
@@ -61,8 +62,9 @@ public class OrganizationReportsController : Controller
}
var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId);
var response = latestReport == null ? null : new OrganizationReportResponseModel(latestReport);
return Ok(latestReport);
return Ok(response);
}
[HttpGet("{organizationId}/{reportId}")]
@@ -102,7 +104,8 @@ public class OrganizationReportsController : Controller
}
var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request);
return Ok(report);
var response = report == null ? null : new OrganizationReportResponseModel(report);
return Ok(response);
}
[HttpPatch("{organizationId}/{reportId}")]
@@ -119,7 +122,8 @@ public class OrganizationReportsController : Controller
}
var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request);
return Ok(updatedReport);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(response);
}
#endregion
@@ -182,10 +186,10 @@ public class OrganizationReportsController : Controller
{
throw new BadRequestException("Report ID in the request body must match the route parameter");
}
var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(updatedReport);
return Ok(response);
}
#endregion
@@ -228,7 +232,9 @@ public class OrganizationReportsController : Controller
}
var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request);
return Ok(updatedReport);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(response);
}
#endregion
@@ -265,7 +271,6 @@ public class OrganizationReportsController : Controller
{
try
{
if (!await _currentContext.AccessReports(organizationId))
{
throw new NotFoundException();
@@ -282,10 +287,9 @@ public class OrganizationReportsController : Controller
}
var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(updatedReport);
return Ok(response);
}
catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException))
{

View File

@@ -0,0 +1,38 @@
using Bit.Core.Dirt.Entities;
namespace Bit.Api.Dirt.Models.Response;
public class OrganizationReportResponseModel
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public string? ReportData { get; set; }
public string? ContentEncryptionKey { get; set; }
public string? SummaryData { get; set; }
public string? ApplicationData { get; set; }
public int? PasswordCount { get; set; }
public int? PasswordAtRiskCount { get; set; }
public int? MemberCount { get; set; }
public DateTime? CreationDate { get; set; } = null;
public DateTime? RevisionDate { get; set; } = null;
public OrganizationReportResponseModel(OrganizationReport organizationReport)
{
if (organizationReport == null)
{
return;
}
Id = organizationReport.Id;
OrganizationId = organizationReport.OrganizationId;
ReportData = organizationReport.ReportData;
ContentEncryptionKey = organizationReport.ContentEncryptionKey;
SummaryData = organizationReport.SummaryData;
ApplicationData = organizationReport.ApplicationData;
PasswordCount = organizationReport.PasswordCount;
PasswordAtRiskCount = organizationReport.PasswordAtRiskCount;
MemberCount = organizationReport.MemberCount;
CreationDate = organizationReport.CreationDate;
RevisionDate = organizationReport.RevisionDate;
}
}

View File

@@ -1,73 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Settings;
namespace Bit.Api.Models.Request;
public class BitPayInvoiceRequestModel : IValidatableObject
{
public Guid? UserId { get; set; }
public Guid? OrganizationId { get; set; }
public Guid? ProviderId { get; set; }
public bool Credit { get; set; }
[Required]
public decimal? Amount { get; set; }
public string ReturnUrl { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public BitPayLight.Models.Invoice.Invoice ToBitpayInvoice(GlobalSettings globalSettings)
{
var inv = new BitPayLight.Models.Invoice.Invoice
{
Price = Convert.ToDouble(Amount.Value),
Currency = "USD",
RedirectUrl = ReturnUrl,
Buyer = new BitPayLight.Models.Invoice.Buyer
{
Email = Email,
Name = Name
},
NotificationUrl = globalSettings.BitPay.NotificationUrl,
FullNotifications = true,
ExtendedNotifications = true
};
var posData = string.Empty;
if (UserId.HasValue)
{
posData = "userId:" + UserId.Value;
}
else if (OrganizationId.HasValue)
{
posData = "organizationId:" + OrganizationId.Value;
}
else if (ProviderId.HasValue)
{
posData = "providerId:" + ProviderId.Value;
}
if (Credit)
{
posData += ",accountCredit:1";
inv.ItemDesc = "Bitwarden Account Credit";
}
else
{
inv.ItemDesc = "Bitwarden";
}
inv.PosData = posData;
return inv;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!UserId.HasValue && !OrganizationId.HasValue && !ProviderId.HasValue)
{
yield return new ValidationResult("User, Organization or Provider is required.");
}
}
}

View File

@@ -94,9 +94,6 @@ public class Startup
services.AddMemoryCache();
services.AddDistributedCache(globalSettings);
// BitPay
services.AddSingleton<BitPayClient>();
if (!globalSettings.SelfHosted)
{
services.AddIpRateLimiting(globalSettings);

View File

@@ -74,10 +74,14 @@ 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();
//An User is allowed to import if CanCreate Collections or has AccessToImportExport
var authorized = await CheckOrgImportPermission(collections, orgId);
if (!authorized)
@@ -156,7 +160,7 @@ public class ImportCiphersController : Controller
if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
{
return true;
};
}
return false;
}

View File

@@ -0,0 +1,87 @@
using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.Services;
namespace Bit.Api.Utilities.DiagnosticTools;
public static class EventDiagnosticLogger
{
public static void LogAggregateData(
this ILogger logger,
IFeatureService featureService,
Guid organizationId,
PagedListResponseModel<EventResponseModel> data, EventFilterRequestModel request)
{
try
{
if (!featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging))
{
return;
}
var orderedRecords = data.Data.OrderBy(e => e.Date).ToList();
var recordCount = orderedRecords.Count;
var newestRecordDate = orderedRecords.LastOrDefault()?.Date.ToString("o");
var oldestRecordDate = orderedRecords.FirstOrDefault()?.Date.ToString("o"); ;
var hasMore = !string.IsNullOrEmpty(data.ContinuationToken);
logger.LogInformation(
"Events query for Organization:{OrgId}. Event count:{Count} newest record:{newestRecord} oldest record:{oldestRecord} HasMore:{HasMore} " +
"Request Filters Start:{QueryStart} End:{QueryEnd} ActingUserId:{ActingUserId} ItemId:{ItemId},",
organizationId,
recordCount,
newestRecordDate,
oldestRecordDate,
hasMore,
request.Start?.ToString("o"),
request.End?.ToString("o"),
request.ActingUserId,
request.ItemId);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Unexpected exception from EventDiagnosticLogger.LogAggregateData");
}
}
public static void LogAggregateData(
this ILogger logger,
IFeatureService featureService,
Guid organizationId,
IEnumerable<Bit.Api.Models.Response.EventResponseModel> data,
string? continuationToken,
DateTime? queryStart = null,
DateTime? queryEnd = null)
{
try
{
if (!featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging))
{
return;
}
var orderedRecords = data.OrderBy(e => e.Date).ToList();
var recordCount = orderedRecords.Count;
var newestRecordDate = orderedRecords.LastOrDefault()?.Date.ToString("o");
var oldestRecordDate = orderedRecords.FirstOrDefault()?.Date.ToString("o"); ;
var hasMore = !string.IsNullOrEmpty(continuationToken);
logger.LogInformation(
"Events query for Organization:{OrgId}. Event count:{Count} newest record:{newestRecord} oldest record:{oldestRecord} HasMore:{HasMore} " +
"Request Filters Start:{QueryStart} End:{QueryEnd}",
organizationId,
recordCount,
newestRecordDate,
oldestRecordDate,
hasMore,
queryStart?.ToString("o"),
queryEnd?.ToString("o"));
}
catch (Exception exception)
{
logger.LogWarning(exception, "Unexpected exception from EventDiagnosticLogger.LogAggregateData");
}
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Globalization;
using System.Text.Json;
using Azure.Messaging.EventGrid;
using Bit.Api.Auth.Models.Request.Accounts;
@@ -1366,7 +1367,7 @@ public class CiphersController : Controller
}
var (attachmentId, uploadUrl) = await _cipherService.CreateAttachmentForDelayedUploadAsync(cipher,
request.Key, request.FileName, request.FileSize, request.AdminRequest, user.Id);
request.Key, request.FileName, request.FileSize, request.AdminRequest, user.Id, request.LastKnownRevisionDate);
return new AttachmentUploadDataResponseModel
{
AttachmentId = attachmentId,
@@ -1419,9 +1420,11 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream) =>
{
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData);
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData, lastKnownRevisionDate);
});
}
@@ -1440,10 +1443,12 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream, fileName, key) =>
{
await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key,
Request.ContentLength.GetValueOrDefault(0), user.Id);
Request.ContentLength.GetValueOrDefault(0), user.Id, false, lastKnownRevisionDate);
});
return new CipherResponseModel(
@@ -1469,10 +1474,13 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream, fileName, key) =>
{
await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key,
Request.ContentLength.GetValueOrDefault(0), userId, true);
Request.ContentLength.GetValueOrDefault(0), userId, true, lastKnownRevisionDate);
});
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp);
@@ -1515,10 +1523,13 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream, fileName, key) =>
{
await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, key,
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId);
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId, lastKnownRevisionDate);
});
}
@@ -1630,4 +1641,19 @@ public class CiphersController : Controller
{
return await _cipherRepository.GetByIdAsync(cipherId, userId);
}
private DateTime? GetLastKnownRevisionDateFromForm()
{
DateTime? lastKnownRevisionDate = null;
if (Request.Form.TryGetValue("lastKnownRevisionDate", out var dateValue))
{
if (!DateTime.TryParse(dateValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedDate))
{
throw new BadRequestException("Invalid lastKnownRevisionDate format.");
}
lastKnownRevisionDate = parsedDate;
}
return lastKnownRevisionDate;
}
}

View File

@@ -9,4 +9,9 @@ public class AttachmentRequestModel
public string FileName { get; set; }
public long FileSize { get; set; }
public bool AdminRequest { get; set; } = false;
/// <summary>
/// The last known revision date of the Cipher that this attachment belongs to.
/// </summary>
public DateTime? LastKnownRevisionDate { get; set; }
}

View File

@@ -64,7 +64,8 @@
"bitPay": {
"production": false,
"token": "SECRET",
"notificationUrl": "https://bitwarden.com/SECRET"
"notificationUrl": "https://bitwarden.com/SECRET",
"webhookKey": "SECRET"
},
"amazon": {
"accessKeyId": "SECRET",

View File

@@ -8,7 +8,6 @@ public class BillingSettings
public virtual string JobsKey { get; set; }
public virtual string StripeWebhookKey { get; set; }
public virtual string StripeWebhookSecret20250827Basil { get; set; }
public virtual string BitPayWebhookKey { get; set; }
public virtual string AppleWebhookKey { get; set; }
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
public virtual string FreshsalesApiKey { get; set; }

View File

@@ -1,7 +0,0 @@
namespace Bit.Billing.Constants;
public static class BitPayInvoiceStatus
{
public const string Confirmed = "confirmed";
public const string Complete = "complete";
}

View File

@@ -1,125 +1,79 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Globalization;
using Bit.Billing.Constants;
using System.Globalization;
using Bit.Billing.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using BitPayLight.Models.Invoice;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers;
using static BitPayConstants;
using static StripeConstants;
[Route("bitpay")]
[ApiExplorerSettings(IgnoreApi = true)]
public class BitPayController : Controller
public class BitPayController(
GlobalSettings globalSettings,
IBitPayClient bitPayClient,
ITransactionRepository transactionRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IProviderRepository providerRepository,
IMailService mailService,
IPaymentService paymentService,
ILogger<BitPayController> logger,
IPremiumUserBillingService premiumUserBillingService)
: Controller
{
private readonly BillingSettings _billingSettings;
private readonly BitPayClient _bitPayClient;
private readonly ITransactionRepository _transactionRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserRepository _userRepository;
private readonly IProviderRepository _providerRepository;
private readonly IMailService _mailService;
private readonly IPaymentService _paymentService;
private readonly ILogger<BitPayController> _logger;
private readonly IPremiumUserBillingService _premiumUserBillingService;
public BitPayController(
IOptions<BillingSettings> billingSettings,
BitPayClient bitPayClient,
ITransactionRepository transactionRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IProviderRepository providerRepository,
IMailService mailService,
IPaymentService paymentService,
ILogger<BitPayController> logger,
IPremiumUserBillingService premiumUserBillingService)
{
_billingSettings = billingSettings?.Value;
_bitPayClient = bitPayClient;
_transactionRepository = transactionRepository;
_organizationRepository = organizationRepository;
_userRepository = userRepository;
_providerRepository = providerRepository;
_mailService = mailService;
_paymentService = paymentService;
_logger = logger;
_premiumUserBillingService = premiumUserBillingService;
}
[HttpPost("ipn")]
public async Task<IActionResult> PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key)
{
if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.BitPayWebhookKey))
if (!CoreHelpers.FixedTimeEquals(key, globalSettings.BitPay.WebhookKey))
{
return new BadRequestResult();
}
if (model == null || string.IsNullOrWhiteSpace(model.Data?.Id) ||
string.IsNullOrWhiteSpace(model.Event?.Name))
{
return new BadRequestResult();
return new BadRequestObjectResult("Invalid key");
}
if (model.Event.Name != BitPayNotificationCode.InvoiceConfirmed)
{
// Only processing confirmed invoice events for now.
return new OkResult();
}
var invoice = await _bitPayClient.GetInvoiceAsync(model.Data.Id);
if (invoice == null)
{
// Request forged...?
_logger.LogWarning("Invoice not found. #{InvoiceId}", model.Data.Id);
return new BadRequestResult();
}
if (invoice.Status != BitPayInvoiceStatus.Confirmed && invoice.Status != BitPayInvoiceStatus.Complete)
{
_logger.LogWarning("Invoice status of '{InvoiceStatus}' is not acceptable. #{InvoiceId}", invoice.Status, invoice.Id);
return new BadRequestResult();
}
var invoice = await bitPayClient.GetInvoice(model.Data.Id);
if (invoice.Currency != "USD")
{
// Only process USD payments
_logger.LogWarning("Non USD payment received. #{InvoiceId}", invoice.Id);
return new OkResult();
logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) with non-USD currency: {Currency}", invoice.Id, invoice.Currency);
return new BadRequestObjectResult("Cannot process non-USD payments");
}
var (organizationId, userId, providerId) = GetIdsFromPosData(invoice);
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
if ((!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) || !invoice.PosData.Contains(PosDataKeys.AccountCredit))
{
return new OkResult();
logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) that had invalid POS data: {PosData}", invoice.Id, invoice.PosData);
return new BadRequestObjectResult("Invalid POS data");
}
var isAccountCredit = IsAccountCredit(invoice);
if (!isAccountCredit)
if (invoice.Status != InvoiceStatuses.Complete)
{
// Only processing credits
_logger.LogWarning("Non-credit payment received. #{InvoiceId}", invoice.Id);
return new OkResult();
logger.LogInformation("Received valid BitPay invoice webhook for invoice ({InvoiceID}) that is not yet complete: {Status}",
invoice.Id, invoice.Status);
return new OkObjectResult("Waiting for invoice to be completed");
}
var transaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id);
if (transaction != null)
var existingTransaction = await transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id);
if (existingTransaction != null)
{
_logger.LogWarning("Already processed this invoice. #{InvoiceId}", invoice.Id);
return new OkResult();
logger.LogWarning("Already processed BitPay invoice webhook for invoice ({InvoiceID})", invoice.Id);
return new OkObjectResult("Invoice already processed");
}
try
{
var tx = new Transaction
var transaction = new Transaction
{
Amount = Convert.ToDecimal(invoice.Price),
CreationDate = GetTransactionDate(invoice),
@@ -132,50 +86,47 @@ public class BitPayController : Controller
PaymentMethodType = PaymentMethodType.BitPay,
Details = $"{invoice.Currency}, BitPay {invoice.Id}"
};
await _transactionRepository.CreateAsync(tx);
string billingEmail = null;
if (tx.OrganizationId.HasValue)
await transactionRepository.CreateAsync(transaction);
var billingEmail = "";
if (transaction.OrganizationId.HasValue)
{
var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value);
if (org != null)
var organization = await organizationRepository.GetByIdAsync(transaction.OrganizationId.Value);
if (organization != null)
{
billingEmail = org.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(org, tx.Amount))
billingEmail = organization.BillingEmailAddress();
if (await paymentService.CreditAccountAsync(organization, transaction.Amount))
{
await _organizationRepository.ReplaceAsync(org);
await organizationRepository.ReplaceAsync(organization);
}
}
}
else if (tx.UserId.HasValue)
else if (transaction.UserId.HasValue)
{
var user = await _userRepository.GetByIdAsync(tx.UserId.Value);
var user = await userRepository.GetByIdAsync(transaction.UserId.Value);
if (user != null)
{
billingEmail = user.BillingEmailAddress();
await _premiumUserBillingService.Credit(user, tx.Amount);
await premiumUserBillingService.Credit(user, transaction.Amount);
}
}
else if (tx.ProviderId.HasValue)
else if (transaction.ProviderId.HasValue)
{
var provider = await _providerRepository.GetByIdAsync(tx.ProviderId.Value);
var provider = await providerRepository.GetByIdAsync(transaction.ProviderId.Value);
if (provider != null)
{
billingEmail = provider.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(provider, tx.Amount))
if (await paymentService.CreditAccountAsync(provider, transaction.Amount))
{
await _providerRepository.ReplaceAsync(provider);
await providerRepository.ReplaceAsync(provider);
}
}
}
else
{
_logger.LogError("Received BitPay account credit transaction that didn't have a user, org, or provider. Invoice#{InvoiceId}", invoice.Id);
}
if (!string.IsNullOrWhiteSpace(billingEmail))
{
await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount);
await mailService.SendAddedCreditAsync(billingEmail, transaction.Amount);
}
}
// Catch foreign key violations because user/org could have been deleted.
@@ -186,58 +137,34 @@ public class BitPayController : Controller
return new OkResult();
}
private bool IsAccountCredit(BitPayLight.Models.Invoice.Invoice invoice)
private static DateTime GetTransactionDate(Invoice invoice)
{
return invoice != null && invoice.PosData != null && invoice.PosData.Contains("accountCredit:1");
var transactions = invoice.Transactions?.Where(transaction =>
transaction.Type == null && !string.IsNullOrWhiteSpace(transaction.Confirmations) &&
transaction.Confirmations != "0").ToList();
return transactions?.Count == 1
? DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)
: CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
}
private DateTime GetTransactionDate(BitPayLight.Models.Invoice.Invoice invoice)
public (Guid? OrganizationId, Guid? UserId, Guid? ProviderId) GetIdsFromPosData(Invoice invoice)
{
var transactions = invoice.Transactions?.Where(t => t.Type == null &&
!string.IsNullOrWhiteSpace(t.Confirmations) && t.Confirmations != "0");
if (transactions != null && transactions.Count() == 1)
if (invoice.PosData is null or { Length: 0 } || !invoice.PosData.Contains(':'))
{
return DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind);
}
return CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
}
public Tuple<Guid?, Guid?, Guid?> GetIdsFromPosData(BitPayLight.Models.Invoice.Invoice invoice)
{
Guid? orgId = null;
Guid? userId = null;
Guid? providerId = null;
if (invoice == null || string.IsNullOrWhiteSpace(invoice.PosData) || !invoice.PosData.Contains(':'))
{
return new Tuple<Guid?, Guid?, Guid?>(null, null, null);
return new ValueTuple<Guid?, Guid?, Guid?>(null, null, null);
}
var mainParts = invoice.PosData.Split(',');
foreach (var mainPart in mainParts)
{
var parts = mainPart.Split(':');
var ids = invoice.PosData
.Split(',')
.Select(part => part.Split(':'))
.Where(parts => parts.Length == 2 && Guid.TryParse(parts[1], out _))
.ToDictionary(parts => parts[0], parts => Guid.Parse(parts[1]));
if (parts.Length <= 1 || !Guid.TryParse(parts[1], out var id))
{
continue;
}
switch (parts[0])
{
case "userId":
userId = id;
break;
case "organizationId":
orgId = id;
break;
case "providerId":
providerId = id;
break;
}
}
return new Tuple<Guid?, Guid?, Guid?>(orgId, userId, providerId);
return new ValueTuple<Guid?, Guid?, Guid?>(
ids.TryGetValue(MetadataKeys.OrganizationId, out var id) ? id : null,
ids.TryGetValue(MetadataKeys.UserId, out id) ? id : null,
ids.TryGetValue(MetadataKeys.ProviderId, out id) ? id : null
);
}
}

View File

@@ -0,0 +1,88 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Quartz;
namespace Bit.Billing.Jobs;
public class ProviderOrganizationDisableJob(
IProviderOrganizationRepository providerOrganizationRepository,
IOrganizationDisableCommand organizationDisableCommand,
ILogger<ProviderOrganizationDisableJob> logger)
: IJob
{
private const int MaxConcurrency = 5;
private const int MaxTimeoutMinutes = 10;
public async Task Execute(IJobExecutionContext context)
{
var providerId = new Guid(context.MergedJobDataMap.GetString("providerId") ?? string.Empty);
var expirationDateString = context.MergedJobDataMap.GetString("expirationDate");
DateTime? expirationDate = string.IsNullOrEmpty(expirationDateString)
? null
: DateTime.Parse(expirationDateString);
logger.LogInformation("Starting to disable organizations for provider {ProviderId}", providerId);
var startTime = DateTime.UtcNow;
var totalProcessed = 0;
var totalErrors = 0;
try
{
var providerOrganizations = await providerOrganizationRepository
.GetManyDetailsByProviderAsync(providerId);
if (providerOrganizations == null || !providerOrganizations.Any())
{
logger.LogInformation("No organizations found for provider {ProviderId}", providerId);
return;
}
logger.LogInformation("Disabling {OrganizationCount} organizations for provider {ProviderId}",
providerOrganizations.Count, providerId);
var semaphore = new SemaphoreSlim(MaxConcurrency, MaxConcurrency);
var tasks = providerOrganizations.Select(async po =>
{
if (DateTime.UtcNow.Subtract(startTime).TotalMinutes > MaxTimeoutMinutes)
{
logger.LogWarning("Timeout reached while disabling organizations for provider {ProviderId}", providerId);
return false;
}
await semaphore.WaitAsync();
try
{
await organizationDisableCommand.DisableAsync(po.OrganizationId, expirationDate);
Interlocked.Increment(ref totalProcessed);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to disable organization {OrganizationId} for provider {ProviderId}",
po.OrganizationId, providerId);
Interlocked.Increment(ref totalErrors);
return false;
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
logger.LogInformation("Completed disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}",
providerId, totalProcessed, totalErrors);
}
catch (Exception ex)
{
logger.LogError(ex, "Error disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}",
providerId, totalProcessed, totalErrors);
throw;
}
}
}

View File

@@ -1,7 +1,11 @@
using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Quartz;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
@@ -11,17 +15,26 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
private readonly IUserService _userService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ISchedulerFactory _schedulerFactory;
public SubscriptionDeletedHandler(
IStripeEventService stripeEventService,
IUserService userService,
IStripeEventUtilityService stripeEventUtilityService,
IOrganizationDisableCommand organizationDisableCommand)
IOrganizationDisableCommand organizationDisableCommand,
IProviderRepository providerRepository,
IProviderService providerService,
ISchedulerFactory schedulerFactory)
{
_stripeEventService = stripeEventService;
_userService = userService;
_stripeEventUtilityService = stripeEventUtilityService;
_organizationDisableCommand = organizationDisableCommand;
_providerRepository = providerRepository;
_providerService = providerService;
_schedulerFactory = schedulerFactory;
}
/// <summary>
@@ -53,9 +66,38 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
}
else if (providerId.HasValue)
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider != null)
{
provider.Enabled = false;
await _providerService.UpdateAsync(provider);
await QueueProviderOrganizationDisableJobAsync(providerId.Value, subscription.GetCurrentPeriodEnd());
}
}
else if (userId.HasValue)
{
await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
}
}
private async Task QueueProviderOrganizationDisableJobAsync(Guid providerId, DateTime? expirationDate)
{
var scheduler = await _schedulerFactory.GetScheduler();
var job = JobBuilder.Create<ProviderOrganizationDisableJob>()
.WithIdentity($"disable-provider-orgs-{providerId}", "provider-management")
.UsingJobData("providerId", providerId.ToString())
.UsingJobData("expirationDate", expirationDate?.ToString("O"))
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"disable-trigger-{providerId}", "provider-management")
.StartNow()
.Build();
await scheduler.ScheduleJob(job, trigger);
}
}

View File

@@ -51,9 +51,6 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
// BitPay Client
services.AddSingleton<BitPayClient>();
// PayPal IPN Client
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();

View File

@@ -333,5 +333,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
UseRiskInsights = license.UseRiskInsights;
UseOrganizationDomains = license.UseOrganizationDomains;
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
}
}

View File

@@ -45,7 +45,7 @@ public static class PolicyTypeExtensions
PolicyType.MaximumVaultTimeout => "Vault timeout",
PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
PolicyType.ActivateAutofill => "Active auto-fill",
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications",
PolicyType.AutomaticAppLogIn => "Automatic login with SSO",
PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship",
PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN",
PolicyType.RestrictedItemTypesPolicy => "Restricted item types",

View File

@@ -6,6 +6,7 @@ public interface IIntegrationMessage
{
IntegrationType IntegrationType { get; }
string MessageId { get; set; }
string? OrganizationId { get; set; }
int RetryCount { get; }
DateTime? DelayUntilDate { get; }
void ApplyRetry(DateTime? handlerDelayUntilDate);

View File

@@ -7,6 +7,7 @@ public class IntegrationMessage : IIntegrationMessage
{
public IntegrationType IntegrationType { get; set; }
public required string MessageId { get; set; }
public string? OrganizationId { get; set; }
public required string RenderedTemplate { get; set; }
public int RetryCount { get; set; } = 0;
public DateTime? DelayUntilDate { get; set; }

View File

@@ -23,7 +23,17 @@ public class IntegrationTemplateContext(EventMessage eventMessage)
public Guid? CollectionId => Event.CollectionId;
public Guid? GroupId => Event.GroupId;
public Guid? PolicyId => Event.PolicyId;
public Guid? IdempotencyId => Event.IdempotencyId;
public Guid? ProviderId => Event.ProviderId;
public Guid? ProviderUserId => Event.ProviderUserId;
public Guid? ProviderOrganizationId => Event.ProviderOrganizationId;
public Guid? InstallationId => Event.InstallationId;
public Guid? SecretId => Event.SecretId;
public Guid? ProjectId => Event.ProjectId;
public Guid? ServiceAccountId => Event.ServiceAccountId;
public Guid? GrantedServiceAccountId => Event.GrantedServiceAccountId;
public string DateIso8601 => Date.ToString("o");
public string EventMessage => JsonSerializer.Serialize(Event);
public User? User { get; set; }

View File

@@ -0,0 +1,56 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
namespace Bit.Core.AdminConsole.Models.Data;
/// <summary>
/// Interface defining common organization details properties shared between
/// regular organization users and provider organization users for profile endpoints.
/// </summary>
public interface IProfileOrganizationDetails
{
Guid? UserId { get; set; }
Guid OrganizationId { get; set; }
string Name { get; set; }
bool Enabled { get; set; }
PlanType PlanType { get; set; }
bool UsePolicies { get; set; }
bool UseSso { get; set; }
bool UseKeyConnector { get; set; }
bool UseScim { get; set; }
bool UseGroups { get; set; }
bool UseDirectory { get; set; }
bool UseEvents { get; set; }
bool UseTotp { get; set; }
bool Use2fa { get; set; }
bool UseApi { get; set; }
bool UseResetPassword { get; set; }
bool SelfHost { get; set; }
bool UsersGetPremium { get; set; }
bool UseCustomPermissions { get; set; }
bool UseSecretsManager { get; set; }
int? Seats { get; set; }
short? MaxCollections { get; set; }
short? MaxStorageGb { get; set; }
string? Identifier { get; set; }
string? Key { get; set; }
string? ResetPasswordKey { get; set; }
string? PublicKey { get; set; }
string? PrivateKey { get; set; }
string? SsoExternalId { get; set; }
string? Permissions { get; set; }
Guid? ProviderId { get; set; }
string? ProviderName { get; set; }
ProviderType? ProviderType { get; set; }
bool? SsoEnabled { get; set; }
string? SsoConfig { get; set; }
bool UsePasswordManager { get; set; }
bool LimitCollectionCreation { get; set; }
bool LimitCollectionDeletion { get; set; }
bool AllowAdminAccessToAllCollectionItems { get; set; }
bool UseRiskInsights { get; set; }
bool LimitItemDeletion { get; set; }
bool UseAdminSponsoredFamilies { get; set; }
bool UseOrganizationDomains { get; set; }
bool UseAutomaticUserConfirmation { get; set; }
}

View File

@@ -1,20 +1,18 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
public class OrganizationUserOrganizationDetails
public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
{
public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; }
public Guid OrganizationUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Name { get; set; } = null!;
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
@@ -33,24 +31,24 @@ public class OrganizationUserOrganizationDetails
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string Key { get; set; }
public string? Key { get; set; }
public Enums.OrganizationUserStatusType Status { get; set; }
public Enums.OrganizationUserType Type { get; set; }
public bool Enabled { get; set; }
public PlanType PlanType { get; set; }
public string SsoExternalId { get; set; }
public string Identifier { get; set; }
public string Permissions { get; set; }
public string ResetPasswordKey { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
public string? SsoExternalId { get; set; }
public string? Identifier { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
public string? PublicKey { get; set; }
public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public string? ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public string? FamilySponsorshipFriendlyName { get; set; }
public bool? SsoEnabled { get; set; }
public string SsoConfig { get; set; }
public string? SsoConfig { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; }
public DateTime? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; }

View File

@@ -1,19 +1,16 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider;
public class ProviderUserOrganizationDetails
public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
{
public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Name { get; set; } = null!;
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
@@ -28,20 +25,22 @@ public class ProviderUserOrganizationDetails
public bool SelfHost { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string Key { get; set; }
public string? Key { get; set; }
public ProviderUserStatusType Status { get; set; }
public ProviderUserType Type { get; set; }
public bool Enabled { get; set; }
public string Identifier { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
public string? Identifier { get; set; }
public string? PublicKey { get; set; }
public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; }
public Guid? ProviderUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public string? ProviderName { get; set; }
public PlanType PlanType { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
@@ -50,6 +49,11 @@ public class ProviderUserOrganizationDetails
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public ProviderType ProviderType { get; set; }
public ProviderType? ProviderType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool? SsoEnabled { get; set; }
public string? SsoConfig { get; set; }
public string? SsoExternalId { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
}

View File

@@ -0,0 +1,79 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IUserRepository userRepository,
IMailService mailService,
IEventService eventService,
IPushNotificationService pushNotificationService,
IUserService userService,
TimeProvider timeProvider) : IAdminRecoverAccountCommand
{
public async Task<IdentityResult> RecoverAccountAsync(Guid orgId,
OrganizationUser organizationUser, string newMasterPassword, string key)
{
// Org must be able to use reset password
var org = await organizationRepository.GetByIdAsync(orgId);
if (org == null || !org.UseResetPassword)
{
throw new BadRequestException("Organization does not allow password reset.");
}
// Enterprise policy must be enabled
var resetPasswordPolicy =
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}
// Org User must be confirmed and have a ResetPasswordKey
if (organizationUser == null ||
organizationUser.Status != OrganizationUserStatusType.Confirmed ||
organizationUser.OrganizationId != orgId ||
string.IsNullOrEmpty(organizationUser.ResetPasswordKey) ||
!organizationUser.UserId.HasValue)
{
throw new BadRequestException("Organization User not valid");
}
var user = await userService.GetUserByIdAsync(organizationUser.UserId.Value);
if (user == null)
{
throw new NotFoundException();
}
if (user.UsesKeyConnector)
{
throw new BadRequestException("Cannot reset password of a user with Key Connector.");
}
var result = await userService.UpdatePasswordHash(user, newMasterPassword);
if (!result.Succeeded)
{
return result;
}
user.RevisionDate = user.AccountRevisionDate = timeProvider.GetUtcNow().UtcDateTime;
user.LastPasswordChangeDate = user.RevisionDate;
user.ForcePasswordReset = true;
user.Key = key;
await userRepository.ReplaceAsync(user);
await mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName());
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_AdminResetPassword);
await pushNotificationService.PushLogOutAsync(user.Id);
return IdentityResult.Success;
}
}

View File

@@ -0,0 +1,24 @@
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
/// <summary>
/// A command used to recover an organization user's account by an organization admin.
/// </summary>
public interface IAdminRecoverAccountCommand
{
/// <summary>
/// Recovers an organization user's account by resetting their master password.
/// </summary>
/// <param name="orgId">The organization the user belongs to.</param>
/// <param name="organizationUser">The organization user being recovered.</param>
/// <param name="newMasterPassword">The user's new master password hash.</param>
/// <param name="key">The user's new master-password-sealed user key.</param>
/// <returns>An IdentityResult indicating success or failure.</returns>
/// <exception cref="BadRequestException">When organization settings, policy, or user state is invalid.</exception>
/// <exception cref="NotFoundException">When the user does not exist.</exception>
Task<IdentityResult> RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser,
string newMasterPassword, string key);
}

View File

@@ -1,6 +1,4 @@
#nullable enable
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
@@ -20,7 +18,7 @@ public class PolicyRequirementQuery(
throw new NotImplementedException("No Requirement Factory found for " + typeof(T));
}
var policyDetails = await GetPolicyDetails(userId);
var policyDetails = await GetPolicyDetails(userId, factory.PolicyType);
var filteredPolicies = policyDetails
.Where(p => p.PolicyType == factory.PolicyType)
.Where(factory.Enforce);
@@ -48,8 +46,8 @@ public class PolicyRequirementQuery(
return eligibleOrganizationUserIds;
}
private Task<IEnumerable<PolicyDetails>> GetPolicyDetails(Guid userId)
=> policyRepository.GetPolicyDetailsByUserId(userId);
private async Task<IEnumerable<OrganizationPolicyDetails>> GetPolicyDetails(Guid userId, PolicyType policyType)
=> await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType([userId], policyType);
private async Task<IEnumerable<OrganizationPolicyDetails>> GetOrganizationPolicyDetails(Guid organizationId, PolicyType policyType)
=> await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType);

View File

@@ -33,6 +33,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyValidator, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
}
@@ -51,6 +52,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyUpdateEvent, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
}
private static void AddPolicyRequirements(this IServiceCollection services)

View File

@@ -0,0 +1,14 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class UriMatchDefaultPolicyValidator : IPolicyValidator, IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.UriMatchDefaults;
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.CompletedTask;
}

View File

@@ -20,17 +20,6 @@ public interface IPolicyRepository : IRepository<Policy, Guid>
Task<Policy?> GetByOrganizationIdTypeAsync(Guid organizationId, PolicyType type);
Task<ICollection<Policy>> GetManyByOrganizationIdAsync(Guid organizationId);
Task<ICollection<Policy>> GetManyByUserIdAsync(Guid userId);
/// <summary>
/// Gets all PolicyDetails for a user for all policy types.
/// </summary>
/// <remarks>
/// Each PolicyDetail represents an OrganizationUser and a Policy which *may* be enforced
/// against them. It only returns PolicyDetails for policies that are enabled and where the organization's plan
/// supports policies. It also excludes "revoked invited" users who are not subject to policy enforcement.
/// This is consumed by <see cref="IPolicyRequirementQuery"/> to create requirements for specific policy types.
/// You probably do not want to call it directly.
/// </remarks>
Task<IEnumerable<PolicyDetails>> GetPolicyDetailsByUserId(Guid userId);
/// <summary>
/// Retrieves <see cref="OrganizationPolicyDetails"/> of the specified <paramref name="policyType"/>

View File

@@ -5,5 +5,5 @@ namespace Bit.Core.Services;
public interface IEventIntegrationPublisher : IAsyncDisposable
{
Task PublishAsync(IIntegrationMessage message);
Task PublishEventAsync(string body);
Task PublishEventAsync(string body, string? organizationId);
}

View File

@@ -30,7 +30,8 @@ public class AzureServiceBusService : IAzureServiceBusService
var serviceBusMessage = new ServiceBusMessage(json)
{
Subject = message.IntegrationType.ToRoutingKey(),
MessageId = message.MessageId
MessageId = message.MessageId,
PartitionKey = message.OrganizationId
};
await _integrationSender.SendMessageAsync(serviceBusMessage);
@@ -44,18 +45,20 @@ public class AzureServiceBusService : IAzureServiceBusService
{
Subject = message.IntegrationType.ToRoutingKey(),
ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow,
MessageId = message.MessageId
MessageId = message.MessageId,
PartitionKey = message.OrganizationId
};
await _integrationSender.SendMessageAsync(serviceBusMessage);
}
public async Task PublishEventAsync(string body)
public async Task PublishEventAsync(string body, string? organizationId)
{
var message = new ServiceBusMessage(body)
{
ContentType = "application/json",
MessageId = Guid.NewGuid().ToString()
MessageId = Guid.NewGuid().ToString(),
PartitionKey = organizationId
};
await _eventSender.SendMessageAsync(message);

View File

@@ -14,15 +14,21 @@ public class EventIntegrationEventWriteService : IEventWriteService, IAsyncDispo
public async Task CreateAsync(IEvent e)
{
var body = JsonSerializer.Serialize(e);
await _eventIntegrationPublisher.PublishEventAsync(body: body);
await _eventIntegrationPublisher.PublishEventAsync(body: body, organizationId: e.OrganizationId?.ToString());
}
public async Task CreateManyAsync(IEnumerable<IEvent> events)
{
var body = JsonSerializer.Serialize(events);
await _eventIntegrationPublisher.PublishEventAsync(body: body);
}
var eventList = events as IList<IEvent> ?? events.ToList();
if (eventList.Count == 0)
{
return;
}
var organizationId = eventList[0].OrganizationId?.ToString();
var body = JsonSerializer.Serialize(eventList);
await _eventIntegrationPublisher.PublishEventAsync(body: body, organizationId: organizationId);
}
public async ValueTask DisposeAsync()
{
await _eventIntegrationPublisher.DisposeAsync();

View File

@@ -57,6 +57,7 @@ public class EventIntegrationHandler<T>(
{
IntegrationType = integrationType,
MessageId = messageId.ToString(),
OrganizationId = organizationId.ToString(),
Configuration = config,
RenderedTemplate = renderedTemplate,
RetryCount = 0,

View File

@@ -122,7 +122,7 @@ public class RabbitMqService : IRabbitMqService
body: body);
}
public async Task PublishEventAsync(string body)
public async Task PublishEventAsync(string body, string? organizationId)
{
await using var channel = await CreateChannelAsync();
var properties = new BasicProperties

View File

@@ -111,5 +111,6 @@ public static class OrganizationFactory
UseRiskInsights = license.UseRiskInsights,
UseOrganizationDomains = license.UseOrganizationDomains,
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation
};
}

View File

@@ -0,0 +1,81 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.Exceptions;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Utilities;
public static class PolicyDataValidator
{
/// <summary>
/// Validates and serializes policy data based on the policy type.
/// </summary>
/// <param name="data">The policy data to validate</param>
/// <param name="policyType">The type of policy</param>
/// <returns>Serialized JSON string if data is valid, null if data is null or empty</returns>
/// <exception cref="BadRequestException">Thrown when data validation fails</exception>
public static string? ValidateAndSerialize(Dictionary<string, object>? data, PolicyType policyType)
{
if (data == null || data.Count == 0)
{
return null;
}
try
{
var json = JsonSerializer.Serialize(data);
switch (policyType)
{
case PolicyType.MasterPassword:
CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
break;
case PolicyType.SendOptions:
CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(json);
break;
case PolicyType.ResetPassword:
CoreHelpers.LoadClassFromJsonData<ResetPasswordDataModel>(json);
break;
}
return json;
}
catch (JsonException ex)
{
var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : "";
throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}.");
}
}
/// <summary>
/// Validates and deserializes policy metadata based on the policy type.
/// </summary>
/// <param name="metadata">The policy metadata to validate</param>
/// <param name="policyType">The type of policy</param>
/// <returns>Deserialized metadata model, or EmptyMetadataModel if metadata is null, empty, or validation fails</returns>
public static IPolicyMetadataModel ValidateAndDeserializeMetadata(Dictionary<string, object>? metadata, PolicyType policyType)
{
if (metadata == null || metadata.Count == 0)
{
return new EmptyMetadataModel();
}
try
{
var json = JsonSerializer.Serialize(metadata);
return policyType switch
{
PolicyType.OrganizationDataOwnership =>
CoreHelpers.LoadClassFromJsonData<OrganizationModelOwnershipPolicyModel>(json),
_ => new EmptyMetadataModel()
};
}
catch (JsonException)
{
return new EmptyMetadataModel();
}
}
}

View File

@@ -65,7 +65,7 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
}
var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code);
var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid)
{
await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -64,7 +64,7 @@ public class OtpTokenProvider<TOptions>(
}
var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code);
var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid)
{
await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -0,0 +1,14 @@
namespace Bit.Core.Billing.Constants;
public static class BitPayConstants
{
public static class InvoiceStatuses
{
public const string Complete = "complete";
}
public static class PosDataKeys
{
public const string AccountCredit = "accountCredit:1";
}
}

View File

@@ -1,5 +1,7 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Exceptions;
@@ -52,6 +54,12 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
throw new BadRequestException(exception);
}
var useAutomaticUserConfirmation = claimsPrincipal?
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
await WriteLicenseFileAsync(selfHostedOrganization, license);
await UpdateOrganizationAsync(selfHostedOrganization, license);
}

View File

@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Entities;
using Bit.Core.Settings;
@@ -9,6 +10,8 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Payment.Commands;
using static BitPayConstants;
public interface ICreateBitPayInvoiceForCreditCommand
{
Task<BillingCommandResult<string>> Run(
@@ -31,6 +34,8 @@ public class CreateBitPayInvoiceForCreditCommand(
{
var (name, email, posData) = GetSubscriberInformation(subscriber);
var notificationUrl = $"{globalSettings.BitPay.NotificationUrl}?key={globalSettings.BitPay.WebhookKey}";
var invoice = new Invoice
{
Buyer = new Buyer { Email = email, Name = name },
@@ -38,7 +43,7 @@ public class CreateBitPayInvoiceForCreditCommand(
ExtendedNotifications = true,
FullNotifications = true,
ItemDesc = "Bitwarden",
NotificationUrl = globalSettings.BitPay.NotificationUrl,
NotificationUrl = notificationUrl,
PosData = posData,
Price = Convert.ToDouble(amount),
RedirectUrl = redirectUrl
@@ -51,10 +56,10 @@ public class CreateBitPayInvoiceForCreditCommand(
private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
ISubscriber subscriber) => subscriber switch
{
User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"),
User user => (user.Email, user.Email, $"userId:{user.Id},{PosDataKeys.AccountCredit}"),
Organization organization => (organization.Name, organization.BillingEmail,
$"organizationId:{organization.Id},accountCredit:1"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"),
$"organizationId:{organization.Id},{PosDataKeys.AccountCredit}"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},{PosDataKeys.AccountCredit}"),
_ => throw new ArgumentOutOfRangeException(nameof(subscriber))
};
}

View File

@@ -0,0 +1,11 @@
namespace Bit.Core.Billing.Payment.Models;
public record NonTokenizedPaymentMethod
{
public NonTokenizablePaymentMethodType Type { get; set; }
}
public enum NonTokenizablePaymentMethodType
{
AccountCredit,
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using OneOf;
namespace Bit.Core.Billing.Payment.Models;
[JsonConverter(typeof(PaymentMethodJsonConverter))]
public class PaymentMethod(OneOf<TokenizedPaymentMethod, NonTokenizedPaymentMethod> input)
: OneOfBase<TokenizedPaymentMethod, NonTokenizedPaymentMethod>(input)
{
public static implicit operator PaymentMethod(TokenizedPaymentMethod tokenized) => new(tokenized);
public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized);
public bool IsTokenized => IsT0;
public TokenizedPaymentMethod AsTokenized => AsT0;
public bool IsNonTokenized => IsT1;
public NonTokenizedPaymentMethod AsNonTokenized => AsT1;
}
internal class PaymentMethodJsonConverter : JsonConverter<PaymentMethod>
{
public override PaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var element = JsonElement.ParseValue(ref reader);
if (!element.TryGetProperty("type", out var typeProperty))
{
throw new JsonException("PaymentMethod requires a 'type' property");
}
var type = typeProperty.GetString();
if (Enum.TryParse<TokenizablePaymentMethodType>(type, true, out var tokenizedType) &&
Enum.IsDefined(typeof(TokenizablePaymentMethodType), tokenizedType))
{
var token = element.TryGetProperty("token", out var tokenProperty) ? tokenProperty.GetString() : null;
if (string.IsNullOrEmpty(token))
{
throw new JsonException("TokenizedPaymentMethod requires a 'token' property");
}
return new TokenizedPaymentMethod { Type = tokenizedType, Token = token };
}
if (Enum.TryParse<NonTokenizablePaymentMethodType>(type, true, out var nonTokenizedType) &&
Enum.IsDefined(typeof(NonTokenizablePaymentMethodType), nonTokenizedType))
{
return new NonTokenizedPaymentMethod { Type = nonTokenizedType };
}
throw new JsonException($"Unknown payment method type: {type}");
}
public override void Write(Utf8JsonWriter writer, PaymentMethod value, JsonSerializerOptions options)
{
writer.WriteStartObject();
value.Switch(
tokenized =>
{
writer.WriteString("type",
tokenized.Type.ToString().ToLowerInvariant()
);
writer.WriteString("token", tokenized.Token);
},
nonTokenized => { writer.WriteString("type", nonTokenized.Type.ToString().ToLowerInvariant()); }
);
writer.WriteEndObject();
}
}

View File

@@ -2,7 +2,10 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -15,10 +18,12 @@ using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
using Customer = Stripe.Customer;
using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
using static Utilities;
/// <summary>
@@ -30,14 +35,14 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
/// <summary>
/// Creates a premium cloud-hosted subscription for the specified user.
/// </summary>
/// <param name="user">The user to create the premium subscription for. Must not already be a premium user.</param>
/// <param name="user">The user to create the premium subscription for. Must not yet be a premium user.</param>
/// <param name="paymentMethod">The tokenized payment method containing the payment type and token for billing.</param>
/// <param name="billingAddress">The billing address information required for tax calculation and customer creation.</param>
/// <param name="additionalStorageGb">Additional storage in GB beyond the base 1GB included with premium (must be >= 0).</param>
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run(
User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
BillingAddress billingAddress,
short additionalStorageGb);
}
@@ -50,7 +55,10 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
ISubscriberService subscriberService,
IUserService userService,
IPushNotificationService pushNotificationService,
ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger)
ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger,
IPricingClient pricingClient,
IHasPaymentMethodQuery hasPaymentMethodQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand)
: BaseBillingCommand<CreatePremiumCloudHostedSubscriptionCommand>(logger), ICreatePremiumCloudHostedSubscriptionCommand
{
private static readonly List<string> _expand = ["tax"];
@@ -58,7 +66,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
public Task<BillingCommandResult<None>> Run(
User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
BillingAddress billingAddress,
short additionalStorageGb) => HandleAsync<None>(async () =>
{
@@ -72,26 +80,62 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
return new BadRequest("Additional storage must be greater than 0.");
}
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
? await CreateCustomerAsync(user, paymentMethod, billingAddress)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
Customer? customer;
/*
* For a new customer purchasing a new subscription, we attach the payment method while creating the customer.
*/
if (string.IsNullOrEmpty(user.GatewayCustomerId))
{
customer = await CreateCustomerAsync(user, paymentMethod, billingAddress);
}
/*
* An existing customer without a payment method starting a new subscription indicates a user who previously
* purchased account credit but chose to use a tokenizable payment method to pay for the subscription. In this case,
* we need to add the payment method to their customer first. If the incoming payment method is account credit,
* we can just go straight to fetching the customer since there's no payment method to apply.
*/
else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user))
{
await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress);
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
}
else
{
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
}
customer = await ReconcileBillingLocationAsync(customer, billingAddress);
var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null);
switch (paymentMethod)
{
case { Type: TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
case { Type: not TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
paymentMethod.Switch(
tokenized =>
{
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (tokenized)
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
case { Type: TokenizablePaymentMethodType.PayPal }
when subscription.Status == SubscriptionStatus.Incomplete:
case { Type: not TokenizablePaymentMethodType.PayPal }
when subscription.Status == SubscriptionStatus.Active:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
}
}
}
},
_ =>
{
if (subscription.Status != SubscriptionStatus.Active)
{
return;
}
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
});
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
@@ -107,9 +151,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
});
private async Task<Customer> CreateCustomerAsync(User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
BillingAddress billingAddress)
{
if (paymentMethod.IsNonTokenized)
{
_logger.LogError("Cannot create customer for user ({UserID}) using non-tokenized payment method. The customer should already exist", user.Id);
throw new BillingException();
}
var subscriberName = user.SubscriberName();
var customerCreateOptions = new CustomerCreateOptions
{
@@ -140,24 +190,25 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion,
[StripeConstants.MetadataKeys.UserId] = user.Id.ToString()
[MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion,
[MetadataKeys.UserId] = user.Id.ToString()
},
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
ValidateLocation = ValidateTaxLocationTiming.Immediately
}
};
var braintreeCustomerId = "";
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (paymentMethod.Type)
// We have checked that the payment method is tokenized, so we can safely cast it.
var tokenizedPaymentMethod = paymentMethod.AsTokenized;
switch (tokenizedPaymentMethod.Type)
{
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token }))
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token }))
.FirstOrDefault();
if (setupIntent == null)
@@ -171,19 +222,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
}
case TokenizablePaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = paymentMethod.Token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
customerCreateOptions.PaymentMethod = tokenizedPaymentMethod.Token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = tokenizedPaymentMethod.Token;
break;
}
case TokenizablePaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token);
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, tokenizedPaymentMethod.Token);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
default:
{
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString());
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, tokenizedPaymentMethod.Type.ToString());
throw new BillingException();
}
}
@@ -201,7 +252,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
async Task Revert()
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
switch (tokenizedPaymentMethod.Type)
{
case TokenizablePaymentMethodType.BankAccount:
{
@@ -244,7 +295,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
Expand = _expand,
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
ValidateLocation = ValidateTaxLocationTiming.Immediately
}
};
return await stripeAdapter.CustomerUpdateAsync(customer.Id, options);
@@ -255,11 +306,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
Customer customer,
int? storage)
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{
new ()
{
Price = StripeConstants.Prices.PremiumAnnually,
Price = premiumPlan.Seat.StripePriceId,
Quantity = 1
}
};
@@ -268,7 +321,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = StripeConstants.Prices.StoragePlanPersonal,
Price = premiumPlan.Storage.StripePriceId,
Quantity = storage
});
}
@@ -281,15 +334,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
CollectionMethod = CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.UserId] = userId.ToString()
[MetadataKeys.UserId] = userId.ToString()
},
PaymentBehavior = usingPayPal
? StripeConstants.PaymentBehavior.DefaultIncomplete
? PaymentBehavior.DefaultIncomplete
: null,
OffSession = true
};

View File

@@ -1,14 +1,12 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
public interface IPreviewPremiumTaxCommand
{
Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
@@ -18,6 +16,7 @@ public interface IPreviewPremiumTaxCommand
public class PreviewPremiumTaxCommand(
ILogger<PreviewPremiumTaxCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter) : BaseBillingCommand<PreviewPremiumTaxCommand>(logger), IPreviewPremiumTaxCommand
{
public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
@@ -25,6 +24,8 @@ public class PreviewPremiumTaxCommand(
BillingAddress billingAddress)
=> HandleAsync<(decimal, decimal)>(async () =>
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var options = new InvoiceCreatePreviewOptions
{
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true },
@@ -41,7 +42,7 @@ public class PreviewPremiumTaxCommand(
{
Items =
[
new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 }
new InvoiceSubscriptionDetailsItemOptions { Price = premiumPlan.Seat.StripePriceId, Quantity = 1 }
]
}
};
@@ -50,7 +51,7 @@ public class PreviewPremiumTaxCommand(
{
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = Prices.StoragePlanPersonal,
Price = premiumPlan.Storage.StripePriceId,
Quantity = additionalStorage
});
}

View File

@@ -3,12 +3,14 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.StaticStore;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.Billing.Pricing;
using OrganizationPlan = Plan;
using PremiumPlan = Premium.Plan;
public interface IPricingClient
{
// TODO: Rename with Organization focus.
/// <summary>
/// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
@@ -16,8 +18,9 @@ public interface IPricingClient
/// <param name="planType">The type of plan to retrieve.</param>
/// <returns>A Bitwarden <see cref="Plan"/> record or null in the case the plan could not be found or the method was executed from a self-hosted instance.</returns>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<Plan?> GetPlan(PlanType planType);
Task<OrganizationPlan?> GetPlan(PlanType planType);
// TODO: Rename with Organization focus.
/// <summary>
/// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
@@ -26,13 +29,17 @@ public interface IPricingClient
/// <returns>A Bitwarden <see cref="Plan"/> record.</returns>
/// <exception cref="NotFoundException">Thrown when the <see cref="Plan"/> for the provided <paramref name="planType"/> could not be found or the method was executed from a self-hosted instance.</exception>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<Plan> GetPlanOrThrow(PlanType planType);
Task<OrganizationPlan> GetPlanOrThrow(PlanType planType);
// TODO: Rename with Organization focus.
/// <summary>
/// Retrieve all the Bitwarden plans. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
/// </summary>
/// <returns>A list of Bitwarden <see cref="Plan"/> records or an empty list in the case the method is executed from a self-hosted instance.</returns>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<List<Plan>> ListPlans();
Task<List<OrganizationPlan>> ListPlans();
Task<PremiumPlan> GetAvailablePremiumPlan();
Task<List<PremiumPlan>> ListPremiumPlans();
}

View File

@@ -1,4 +1,4 @@
namespace Bit.Core.Billing.Pricing.Models;
namespace Bit.Core.Billing.Pricing.Organizations;
public class Feature
{

View File

@@ -1,4 +1,4 @@
namespace Bit.Core.Billing.Pricing.Models;
namespace Bit.Core.Billing.Pricing.Organizations;
public class Plan
{

View File

@@ -1,8 +1,6 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing.Models;
using Plan = Bit.Core.Billing.Pricing.Models.Plan;
namespace Bit.Core.Billing.Pricing;
namespace Bit.Core.Billing.Pricing.Organizations;
public record PlanAdapter : Core.Models.StaticStore.Plan
{

View File

@@ -2,7 +2,7 @@
using System.Text.Json.Serialization;
using OneOf;
namespace Bit.Core.Billing.Pricing.Models;
namespace Bit.Core.Billing.Pricing.Organizations;
[JsonConverter(typeof(PurchasableJsonConverter))]
public class Purchasable(OneOf<Free, Packaged, Scalable> input) : OneOfBase<Free, Packaged, Scalable>(input)

View File

@@ -0,0 +1,10 @@
namespace Bit.Core.Billing.Pricing.Premium;
public class Plan
{
public string Name { get; init; } = null!;
public int? LegacyYear { get; init; }
public bool Available { get; init; }
public Purchasable Seat { get; init; } = null!;
public Purchasable Storage { get; init; } = null!;
}

View File

@@ -0,0 +1,7 @@
namespace Bit.Core.Billing.Pricing.Premium;
public class Purchasable
{
public string StripePriceId { get; init; } = null!;
public decimal Price { get; init; }
}

View File

@@ -1,24 +1,27 @@
using System.Net;
using System.Net.Http.Json;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing.Organizations;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Plan = Bit.Core.Models.StaticStore.Plan;
#nullable enable
namespace Bit.Core.Billing.Pricing;
using OrganizationPlan = Bit.Core.Models.StaticStore.Plan;
using PremiumPlan = Premium.Plan;
using Purchasable = Premium.Purchasable;
public class PricingClient(
IFeatureService featureService,
GlobalSettings globalSettings,
HttpClient httpClient,
ILogger<PricingClient> logger) : IPricingClient
{
public async Task<Plan?> GetPlan(PlanType planType)
public async Task<OrganizationPlan?> GetPlan(PlanType planType)
{
if (globalSettings.SelfHosted)
{
@@ -40,16 +43,14 @@ public class PricingClient(
return null;
}
var response = await httpClient.GetAsync($"plans/lookup/{lookupKey}");
var response = await httpClient.GetAsync($"plans/organization/{lookupKey}");
if (response.IsSuccessStatusCode)
{
var plan = await response.Content.ReadFromJsonAsync<Models.Plan>();
if (plan == null)
{
throw new BillingException(message: "Deserialization of Pricing Service response resulted in null");
}
return new PlanAdapter(plan);
var plan = await response.Content.ReadFromJsonAsync<Plan>();
return plan == null
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
: new PlanAdapter(plan);
}
if (response.StatusCode == HttpStatusCode.NotFound)
@@ -62,19 +63,14 @@ public class PricingClient(
message: $"Request to the Pricing Service failed with status code {response.StatusCode}");
}
public async Task<Plan> GetPlanOrThrow(PlanType planType)
public async Task<OrganizationPlan> GetPlanOrThrow(PlanType planType)
{
var plan = await GetPlan(planType);
if (plan == null)
{
throw new NotFoundException();
}
return plan;
return plan ?? throw new NotFoundException($"Could not find plan for type {planType}");
}
public async Task<List<Plan>> ListPlans()
public async Task<List<OrganizationPlan>> ListPlans()
{
if (globalSettings.SelfHosted)
{
@@ -88,16 +84,53 @@ public class PricingClient(
return StaticStore.Plans.ToList();
}
var response = await httpClient.GetAsync("plans");
var response = await httpClient.GetAsync("plans/organization");
if (response.IsSuccessStatusCode)
{
var plans = await response.Content.ReadFromJsonAsync<List<Models.Plan>>();
if (plans == null)
{
throw new BillingException(message: "Deserialization of Pricing Service response resulted in null");
}
return plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList();
var plans = await response.Content.ReadFromJsonAsync<List<Plan>>();
return plans == null
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
: plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList();
}
throw new BillingException(
message: $"Request to the Pricing Service failed with status {response.StatusCode}");
}
public async Task<PremiumPlan> GetAvailablePremiumPlan()
{
var premiumPlans = await ListPremiumPlans();
var availablePlan = premiumPlans.FirstOrDefault(premiumPlan => premiumPlan.Available);
return availablePlan ?? throw new NotFoundException("Could not find available premium plan");
}
public async Task<List<PremiumPlan>> ListPremiumPlans()
{
if (globalSettings.SelfHosted)
{
return [];
}
var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService);
var fetchPremiumPriceFromPricingService =
featureService.IsEnabled(FeatureFlagKeys.PM26793_FetchPremiumPriceFromPricingService);
if (!usePricingService || !fetchPremiumPriceFromPricingService)
{
return [CurrentPremiumPlan];
}
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
var response = await httpClient.GetAsync($"plans/premium?milestone2={milestone2Feature}");
if (response.IsSuccessStatusCode)
{
var plans = await response.Content.ReadFromJsonAsync<List<PremiumPlan>>();
return plans ?? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null");
}
throw new BillingException(
@@ -130,4 +163,13 @@ public class PricingClient(
PlanType.TeamsStarter2023 => "teams-starter-2023",
_ => null
};
private static PremiumPlan CurrentPremiumPlan => new()
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal }
};
}

View File

@@ -6,6 +6,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -30,7 +31,8 @@ public class PremiumUserBillingService(
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
IUserRepository userRepository) : IPremiumUserBillingService
IUserRepository userRepository,
IPricingClient pricingClient) : IPremiumUserBillingService
{
public async Task Credit(User user, decimal amount)
{
@@ -301,11 +303,13 @@ public class PremiumUserBillingService(
Customer customer,
int? storage)
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{
new ()
{
Price = StripeConstants.Prices.PremiumAnnually,
Price = premiumPlan.Seat.StripePriceId,
Quantity = 1
}
};
@@ -314,7 +318,7 @@ public class PremiumUserBillingService(
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = StripeConstants.Prices.StoragePlanPersonal,
Price = premiumPlan.Storage.StripePriceId,
Quantity = storage
});
}

View File

@@ -142,6 +142,7 @@ public static class FeatureFlagKeys
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery";
/* Auth Team */
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
@@ -151,9 +152,12 @@ public static class FeatureFlagKeys
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
public const string Otp6Digits = "pm-18612-otp-6-digits";
public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email";
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 PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
/* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
@@ -161,6 +165,7 @@ public static class FeatureFlagKeys
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
public const string SSHAgent = "ssh-agent";
public const string SSHAgentV2 = "ssh-agent-v2";
public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override";
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2";
@@ -174,7 +179,6 @@ public static class FeatureFlagKeys
/* Billing Team */
public const string TrialPayment = "PM-8163-trial-payment";
public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships";
public const string UsePricingService = "use-pricing-service";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
@@ -184,6 +188,8 @@ public static class FeatureFlagKeys
public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button";
public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog";
public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page";
public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service";
public const string PM23341_Milestone_2 = "pm-23341-milestone-2";
/* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
@@ -225,6 +231,7 @@ public static class FeatureFlagKeys
/* Tools Team */
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
public const string UseChromiumImporter = "pm-23982-chromium-importer";
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
/* Vault Team */
@@ -238,12 +245,16 @@ public static class FeatureFlagKeys
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp";
public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption";
public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium";
public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search";
/* Innovation Team */
public const string ArchiveVaultItems = "pm-19148-innovation-archive";
/* DIRT Team */
public const string PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab";
public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike";
public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging";
public static List<string> GetAllKeys()
{

View File

@@ -1,6 +1,4 @@
#nullable enable
using System.Security.Claims;
using System.Security.Claims;
using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Identity;
@@ -12,6 +10,14 @@ using Microsoft.AspNetCore.Http;
namespace Bit.Core.Context;
/// <summary>
/// Provides information about the current HTTP request and the currently authenticated user (if any).
/// This is often (but not exclusively) parsed from the JWT in the current request.
/// </summary>
/// <remarks>
/// This interface suffers from having too much responsibility; consider whether any new code can go in a more
/// specific class rather than adding it here.
/// </remarks>
public interface ICurrentContext
{
HttpContext HttpContext { get; set; }
@@ -59,8 +65,20 @@ public interface ICurrentContext
Task<bool> EditSubscription(Guid orgId);
Task<bool> EditPaymentMethods(Guid orgId);
Task<bool> ViewBillingHistory(Guid orgId);
/// <summary>
/// Returns true if the current user is a member of a provider that manages the specified organization.
/// This generally gives the user administrative privileges for the organization.
/// </summary>
/// <param name="orgId"></param>
/// <returns></returns>
Task<bool> ProviderUserForOrgAsync(Guid orgId);
/// <summary>
/// Returns true if the current user is a Provider Admin of the specified provider.
/// </summary>
bool ProviderProviderAdmin(Guid providerId);
/// <summary>
/// Returns true if the current user is a member of the specified provider (with any role).
/// </summary>
bool ProviderUser(Guid providerId);
bool ProviderManageUsers(Guid providerId);
bool ProviderAccessEventLogs(Guid providerId);

View File

@@ -16,7 +16,9 @@
<ItemGroup>
<EmbeddedResource Include="licensing.cer" />
<EmbeddedResource Include="licensing_dev.cer" />
<EmbeddedResource Include="MailTemplates\Handlebars\**\*.hbs" />
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
</ItemGroup>
<ItemGroup>
@@ -72,7 +74,7 @@
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Resources\" />
<Folder Include="Properties\" />

View File

@@ -11,12 +11,24 @@ public class OrganizationReport : ITableObject<Guid>
public Guid OrganizationId { get; set; }
public string ReportData { get; set; } = string.Empty;
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public string ContentEncryptionKey { get; set; } = string.Empty;
public string? SummaryData { get; set; } = null;
public string? ApplicationData { get; set; } = null;
public string? SummaryData { get; set; }
public string? ApplicationData { get; set; }
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public int? ApplicationCount { get; set; }
public int? ApplicationAtRiskCount { get; set; }
public int? CriticalApplicationCount { get; set; }
public int? CriticalApplicationAtRiskCount { get; set; }
public int? MemberCount { get; set; }
public int? MemberAtRiskCount { get; set; }
public int? CriticalMemberCount { get; set; }
public int? CriticalMemberAtRiskCount { get; set; }
public int? PasswordCount { get; set; }
public int? PasswordAtRiskCount { get; set; }
public int? CriticalPasswordCount { get; set; }
public int? CriticalPasswordAtRiskCount { get; set; }
public void SetNewId()
{

View File

@@ -0,0 +1,48 @@
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
namespace Bit.Core.Dirt.Reports.Models.Data;
public class OrganizationReportMetricsData
{
public Guid OrganizationId { get; set; }
public int? ApplicationCount { get; set; }
public int? ApplicationAtRiskCount { get; set; }
public int? CriticalApplicationCount { get; set; }
public int? CriticalApplicationAtRiskCount { get; set; }
public int? MemberCount { get; set; }
public int? MemberAtRiskCount { get; set; }
public int? CriticalMemberCount { get; set; }
public int? CriticalMemberAtRiskCount { get; set; }
public int? PasswordCount { get; set; }
public int? PasswordAtRiskCount { get; set; }
public int? CriticalPasswordCount { get; set; }
public int? CriticalPasswordAtRiskCount { get; set; }
public static OrganizationReportMetricsData From(Guid organizationId, OrganizationReportMetricsRequest? request)
{
if (request == null)
{
return new OrganizationReportMetricsData
{
OrganizationId = organizationId
};
}
return new OrganizationReportMetricsData
{
OrganizationId = organizationId,
ApplicationCount = request.ApplicationCount,
ApplicationAtRiskCount = request.ApplicationAtRiskCount,
CriticalApplicationCount = request.CriticalApplicationCount,
CriticalApplicationAtRiskCount = request.CriticalApplicationAtRiskCount,
MemberCount = request.MemberCount,
MemberAtRiskCount = request.MemberAtRiskCount,
CriticalMemberCount = request.CriticalMemberCount,
CriticalMemberAtRiskCount = request.CriticalMemberAtRiskCount,
PasswordCount = request.PasswordCount,
PasswordAtRiskCount = request.PasswordAtRiskCount,
CriticalPasswordCount = request.CriticalPasswordCount,
CriticalPasswordAtRiskCount = request.CriticalPasswordAtRiskCount
};
}
}

View File

@@ -35,14 +35,28 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand
throw new BadRequestException(errorMessage);
}
var requestMetrics = request.Metrics ?? new OrganizationReportMetricsRequest();
var organizationReport = new OrganizationReport
{
OrganizationId = request.OrganizationId,
ReportData = request.ReportData,
ReportData = request.ReportData ?? string.Empty,
CreationDate = DateTime.UtcNow,
ContentEncryptionKey = request.ContentEncryptionKey,
ContentEncryptionKey = request.ContentEncryptionKey ?? string.Empty,
SummaryData = request.SummaryData,
ApplicationData = request.ApplicationData,
ApplicationCount = requestMetrics.ApplicationCount,
ApplicationAtRiskCount = requestMetrics.ApplicationAtRiskCount,
CriticalApplicationCount = requestMetrics.CriticalApplicationCount,
CriticalApplicationAtRiskCount = requestMetrics.CriticalApplicationAtRiskCount,
MemberCount = requestMetrics.MemberCount,
MemberAtRiskCount = requestMetrics.MemberAtRiskCount,
CriticalMemberCount = requestMetrics.CriticalMemberCount,
CriticalMemberAtRiskCount = requestMetrics.CriticalMemberAtRiskCount,
PasswordCount = requestMetrics.PasswordCount,
PasswordAtRiskCount = requestMetrics.PasswordAtRiskCount,
CriticalPasswordCount = requestMetrics.CriticalPasswordCount,
CriticalPasswordAtRiskCount = requestMetrics.CriticalPasswordAtRiskCount,
RevisionDate = DateTime.UtcNow
};

View File

@@ -1,16 +1,15 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
public class AddOrganizationReportRequest
{
public Guid OrganizationId { get; set; }
public string ReportData { get; set; }
public string? ReportData { get; set; }
public string ContentEncryptionKey { get; set; }
public string? ContentEncryptionKey { get; set; }
public string SummaryData { get; set; }
public string? SummaryData { get; set; }
public string ApplicationData { get; set; }
public string? ApplicationData { get; set; }
public OrganizationReportMetricsRequest? Metrics { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
public class OrganizationReportMetricsRequest
{
[JsonPropertyName("totalApplicationCount")]
public int? ApplicationCount { get; set; } = null;
[JsonPropertyName("totalAtRiskApplicationCount")]
public int? ApplicationAtRiskCount { get; set; } = null;
[JsonPropertyName("totalCriticalApplicationCount")]
public int? CriticalApplicationCount { get; set; } = null;
[JsonPropertyName("totalCriticalAtRiskApplicationCount")]
public int? CriticalApplicationAtRiskCount { get; set; } = null;
[JsonPropertyName("totalMemberCount")]
public int? MemberCount { get; set; } = null;
[JsonPropertyName("totalAtRiskMemberCount")]
public int? MemberAtRiskCount { get; set; } = null;
[JsonPropertyName("totalCriticalMemberCount")]
public int? CriticalMemberCount { get; set; } = null;
[JsonPropertyName("totalCriticalAtRiskMemberCount")]
public int? CriticalMemberAtRiskCount { get; set; } = null;
[JsonPropertyName("totalPasswordCount")]
public int? PasswordCount { get; set; } = null;
[JsonPropertyName("totalAtRiskPasswordCount")]
public int? PasswordAtRiskCount { get; set; } = null;
[JsonPropertyName("totalCriticalPasswordCount")]
public int? CriticalPasswordCount { get; set; } = null;
[JsonPropertyName("totalCriticalAtRiskPasswordCount")]
public int? CriticalPasswordAtRiskCount { get; set; } = null;
}

View File

@@ -1,11 +1,8 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
public class UpdateOrganizationReportApplicationDataRequest
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public string ApplicationData { get; set; }
public string? ApplicationData { get; set; }
}

View File

@@ -1,11 +1,9 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
public class UpdateOrganizationReportSummaryRequest
{
public Guid OrganizationId { get; set; }
public Guid ReportId { get; set; }
public string SummaryData { get; set; }
public string? SummaryData { get; set; }
public OrganizationReportMetricsRequest? Metrics { get; set; }
}

View File

@@ -53,7 +53,7 @@ public class UpdateOrganizationReportApplicationDataCommand : IUpdateOrganizatio
throw new BadRequestException("Organization report does not belong to the specified organization");
}
var updatedReport = await _organizationReportRepo.UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData);
var updatedReport = await _organizationReportRepo.UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData ?? string.Empty);
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report application data {reportId} for organization {organizationId}",
request.Id, request.OrganizationId);

View File

@@ -1,4 +1,5 @@
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
using Bit.Core.Dirt.Repositories;
@@ -53,7 +54,8 @@ public class UpdateOrganizationReportSummaryCommand : IUpdateOrganizationReportS
throw new BadRequestException("Organization report does not belong to the specified organization");
}
var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData);
await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.Metrics));
var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData ?? string.Empty);
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}",
request.ReportId, request.OrganizationId);

View File

@@ -1,5 +1,6 @@
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Models.Data;
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Core.Repositories;
namespace Bit.Core.Dirt.Repositories;
@@ -21,5 +22,8 @@ public interface IOrganizationReportRepository : IRepository<OrganizationReport,
// ApplicationData methods
Task<OrganizationReportApplicationDataResponse> GetApplicationDataAsync(Guid reportId);
Task<OrganizationReport> UpdateApplicationDataAsync(Guid orgId, Guid reportId, string applicationData);
// Metrics methods
Task UpdateMetricsAsync(Guid reportId, OrganizationReportMetricsData metrics);
}

View File

@@ -7,6 +7,10 @@ namespace Bit.Core.KeyManagement.Models.Api.Response;
public class MasterPasswordUnlockResponseModel
{
public required MasterPasswordUnlockKdfResponseModel Kdf { get; init; }
/// <summary>
/// The user's symmetric key encrypted with their master key.
/// Also known as "MasterKeyWrappedUserKey"
/// </summary>
[EncryptedString] public required string MasterKeyEncryptedUserKey { get; init; }
[StringLength(256)] public required string Salt { get; init; }
}

View File

@@ -13,6 +13,10 @@ public class MasterPasswordUnlockAndAuthenticationData
public required string Email { get; set; }
public required string MasterKeyAuthenticationHash { get; set; }
/// <summary>
/// The user's symmetric key encrypted with their master key.
/// Also known as "MasterKeyWrappedUserKey"
/// </summary>
public required string MasterKeyEncryptedUserKey { get; set; }
public string? MasterPasswordHint { get; set; }

View File

@@ -0,0 +1,675 @@
<!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:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
@media only screen and
(max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } }
</style>
<!-- Responsive icon visibility -->
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
Verify your email to access this Bitwarden Send
</h1></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:140px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:140px;width:100%;font-size:16px;" width="140" height="140">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Your verification code is:</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
</td>
</tr>
<tr>
<td style="font-size:0px;word-break:break-word;">
<div style="height:20px;line-height:20px;">&#8202;</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need to
verify your email again.</div>
</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="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 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:406px;" ><![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;"><h3 style="font-size: 20px; margin: 0; line-height: 28px">
Learn more about Bitwarden
</h3>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" 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:174px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,9 @@
{{#>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}}

View File

@@ -1,5 +1,5 @@
{
"packages": [
"components/hero"
"components/mj-bw-hero"
]
}

View File

@@ -2,38 +2,38 @@
<mj-column>
<mj-social icon-size="30px" inner-padding="10px" padding="0">
<mj-social-element
href="https://twitter.com/bitwarden"
src="https://bitwarden.com/images/mail-twitter.png"
href="https://x.com/bitwarden"
src="https://assets.bitwarden.com/email/v1/mail-x.png"
></mj-social-element>
<mj-social-element
href="https://www.reddit.com/r/Bitwarden/"
src="https://bitwarden.com/images/mail-reddit.png"
src="https://assets.bitwarden.com/email/v1/mail-reddit.png"
></mj-social-element>
<mj-social-element
href="https://community.bitwarden.com/"
src="https://bitwarden.com/images/mail-discourse.png"
src="https://assets.bitwarden.com/email/v1/mail-discourse.png"
></mj-social-element>
<mj-social-element
href="https://github.com/bitwarden"
src="https://bitwarden.com/images/mail-github.png"
src="https://assets.bitwarden.com/email/v1/mail-github.png"
></mj-social-element>
<mj-social-element
href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw"
src="https://bitwarden.com/images/mail-youtube.png"
src="https://assets.bitwarden.com/email/v1/mail-youtube.png"
></mj-social-element>
<mj-social-element
href="https://www.linkedin.com/company/bitwarden1/"
src="https://bitwarden.com/images/mail-linkedin.png"
src="https://assets.bitwarden.com/email/v1/mail-linkedin.png"
></mj-social-element>
<mj-social-element
href="https://www.facebook.com/bitwarden/"
src="https://bitwarden.com/images/mail-facebook.png"
src="https://assets.bitwarden.com/email/v1/mail-facebook.png"
></mj-social-element>
</mj-social>
@@ -45,8 +45,8 @@
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br />
<a href="#">bitwarden.com</a> |
<a href="#">Learn why we include this</a>
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
</p>
</mj-text>
</mj-column>

View File

@@ -4,7 +4,7 @@
font-size="16px"
/>
<mj-button background-color="#175ddc" />
<mj-text color="#333" />
<mj-text color="#1B2029" />
<mj-body background-color="#e6e9ef" width="660px" />
</mj-attributes>
<mj-style inline="inline">
@@ -22,3 +22,9 @@
border-radius: 3px;
}
</mj-style>
<!-- Responsive icon visibility -->
<mj-style>
@media only screen and
(max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } }
</mj-style>

View File

@@ -1,64 +0,0 @@
const { BodyComponent } = require("mjml-core");
class MjBwHero extends BodyComponent {
static dependencies = {
// Tell the validator which tags are allowed as our component's parent
"mj-column": ["mj-bw-hero"],
"mj-wrapper": ["mj-bw-hero"],
// Tell the validator which tags are allowed as our component's children
"mj-bw-hero": [],
};
static allowedAttributes = {
"img-src": "string",
title: "string",
"button-text": "string",
"button-url": "string",
};
static defaultAttributes = {};
render() {
return this.renderMJML(`
<mj-section
full-width="full-width"
background-color="#175ddc"
border-radius="4px 4px 0 0"
>
<mj-column width="70%">
<mj-image
align="left"
src="https://bitwarden.com/images/logo-horizontal-white.png"
width="150px"
height="30px"
></mj-image>
<mj-text color="#fff" padding-top="0" padding-bottom="0">
<h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
${this.getAttribute("title")}
</h1>
</mj-text>
<mj-button
href="${this.getAttribute("button-url")}"
background-color="#fff"
color="#1A41AC"
border-radius="20px"
align="left"
>
${this.getAttribute("button-text")}
</mj-button
>
</mj-column>
<mj-column width="30%" vertical-align="bottom">
<mj-image
src="${this.getAttribute("img-src")}"
alt=""
width="140px"
height="140px"
padding="0"
/>
</mj-column>
</mj-section>
`);
}
}
module.exports = MjBwHero;

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