1
0
mirror of https://github.com/bitwarden/server.git synced 2025-01-30 23:11:22 +01:00

Merge branch 'main' into ac/ac-1682/ef-migrations

This commit is contained in:
Rui Tome 2024-03-22 11:53:01 +00:00
commit 6c21d4e96a
No known key found for this signature in database
GPG Key ID: 526239D96A8EC066
142 changed files with 17858 additions and 918 deletions

View File

@ -49,6 +49,7 @@
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
"Microsoft.Azure.Cosmos",

View File

@ -540,36 +540,11 @@ jobs:
steps:
- name: Check if any job failed
if: |
github.ref == 'refs/heads/main'
(github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc'
env:
LINT_STATUS: ${{ needs.lint.result }}
TESTING_STATUS: ${{ needs.testing.result }}
BUILD_ARTIFACTS_STATUS: ${{ needs.build-artifacts.result }}
BUILD_DOCKER_STATUS: ${{ needs.build-docker.result }}
UPLOAD_STATUS: ${{ needs.upload.result }}
BUILD_MSSQLMIGRATORUTILITY_STATUS: ${{ needs.build-mssqlmigratorutility.result }}
TRIGGER_SELF_HOST_BUILD_STATUS: ${{ needs.self-host-build.result }}
TRIGGER_K8S_DEPLOY_STATUS: ${{ needs.trigger-k8s-deploy.result }}
run: |
if [ "$LINT_STATUS" = "failure" ]; then
exit 1
elif [ "$TESTING_STATUS" = "failure" ]; then
exit 1
elif [ "$BUILD_ARTIFACTS_STATUS" = "failure" ]; then
exit 1
elif [ "$BUILD_DOCKER_STATUS" = "failure" ]; then
exit 1
elif [ "$UPLOAD_STATUS" = "failure" ]; then
exit 1
elif [ "$BUILD_MSSQLMIGRATORUTILITY_STATUS" = "failure" ]; then
exit 1
elif [ "$TRIGGER_SELF_HOST_BUILD_STATUS" = "failure" ]; then
exit 1
elif [ "$TRIGGER_K8S_DEPLOY_STATUS" = "failure" ]; then
exit 1
fi
|| github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7

53
.github/workflows/cleanup-rc-branch.yml vendored Normal file
View File

@ -0,0 +1,53 @@
---
name: Cleanup RC Branch
on:
push:
tags:
- v**
jobs:
delete-rc:
name: Delete RC Branch
runs-on: ubuntu-22.04
steps:
- name: Login to Azure - CI Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve bot secrets
id: retrieve-bot-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: bitwarden-ci
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
- name: Check if a RC branch exists
id: branch-check
run: |
hotfix_rc_branch_check=$(git ls-remote --heads origin hotfix-rc | wc -l)
rc_branch_check=$(git ls-remote --heads origin rc | wc -l)
if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then
echo "hotfix-rc branch exists." | tee -a $GITHUB_STEP_SUMMARY
echo "name=hotfix-rc" >> $GITHUB_OUTPUT
elif [[ "${rc_branch_check}" -gt 0 ]]; then
echo "rc branch exists." | tee -a $GITHUB_STEP_SUMMARY
echo "name=rc" >> $GITHUB_OUTPUT
fi
- name: Delete RC branch
env:
BRANCH_NAME: ${{ steps.branch-check.outputs.name }}
run: |
if ! [[ -z "$BRANCH_NAME" ]]; then
git push --quiet origin --delete $BRANCH_NAME
echo "Deleted $BRANCH_NAME branch." | tee -a $GITHUB_STEP_SUMMARY
fi

View File

@ -69,20 +69,15 @@ jobs:
name: Check for failures
if: always()
runs-on: ubuntu-22.04
needs:
- purge
needs: [purge]
steps:
- name: Check if any job failed
if: |
github.ref == 'refs/heads/main'
(github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc'
env:
PURGE_STATUS: ${{ needs.purge.result }}
run: |
if [ "$PURGE_STATUS" = "failure" ]; then
exit 1
fi
|| github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7

View File

@ -7,25 +7,33 @@ on:
- "main"
- "rc"
- "hotfix-rc"
pull_request:
pull_request_target:
types: [opened, synchronize]
permissions: read-all
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
sast:
name: SAST scan
runs-on: ubuntu-22.04
needs: check-run
permissions:
security-events: write
steps:
- name: Check out repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@749fec53e0db0f6404a97e2e0807c3e80e3583a7 #2.0.23
env:
INCREMENTAL: "${{ github.event_name == 'pull_request' && '--sast-incremental' || '' }}"
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with:
project_name: ${{ github.repository }}
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
@ -35,17 +43,21 @@ jobs:
additional_params: --report-format sarif --output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2
uses: github/codeql-action/upload-sarif@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
with:
sarif_file: cx_result.sarif
quality:
name: Quality scan
runs-on: ubuntu-22.04
needs: check-run
steps:
- name: Check out repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with SonarCloud
uses: sonarsource/sonarcloud-github-action@49e6cd3b187936a73b8280d59ffd9da69df63ec9 # v2.1.1
@ -56,5 +68,4 @@ jobs:
args: >
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
-Dsonar.test.exclusions=test/**
-Dsonar.tests=test/

View File

@ -57,9 +57,9 @@ jobs:
run: sleep 15s
- name: Migrate SQL Server
working-directory: "dev"
run: "./migrate.ps1"
shell: pwsh
run: 'dotnet run --project util/MsSqlMigratorUtility/ "$CONN_STR"'
env:
CONN_STR: "Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;"
- name: Migrate MySQL
working-directory: "util/MySqlMigrations"
@ -147,9 +147,9 @@ jobs:
shell: pwsh
- name: Migrate
working-directory: "dev"
run: "./migrate.ps1"
shell: pwsh
run: 'dotnet run --project util/MsSqlMigratorUtility/ "$CONN_STR"'
env:
CONN_STR: "Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;"
- name: Diff .sqlproj to migrations
run: /usr/local/sqlpackage/sqlpackage /action:DeployReport /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"report.xml" /p:IgnoreColumnOrder=True /p:IgnoreComments=True

View File

@ -1,13 +1,12 @@
---
name: Bump version
run-name: Bump version to ${{ inputs.version_number }}
name: Version Bump
on:
workflow_dispatch:
inputs:
version_number:
description: "New version (example: '2024.1.0')"
required: true
version_number_override:
description: "New version override (leave blank for automatic calculation, example: '2024.1.0')"
required: false
type: string
cut_rc_branch:
description: "Cut RC branch?"
@ -16,22 +15,16 @@ on:
jobs:
bump_version:
name: Bump
name: Bump Version
runs-on: ubuntu-22.04
outputs:
version: ${{ steps.set-final-version-output.outputs.version }}
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
- name: Validate version input
if: ${{ inputs.version_number_override != '' }}
uses: bitwarden/gh-actions/version-check@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-gpg-private-key,
github-gpg-private-key-passphrase,
github-pat-bitwarden-devops-bot-repo-scope"
version: ${{ inputs.version_number_override }}
- name: Check out branch
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
@ -48,6 +41,20 @@ jobs:
exit 1
fi
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-gpg-private-key,
github-gpg-private-key-passphrase,
github-pat-bitwarden-devops-bot-repo-scope"
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0
with:
@ -56,22 +63,35 @@ jobs:
git_user_signingkey: true
git_commit_gpgsign: true
- name: Set up Git
run: |
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
git config --local user.name "bitwarden-devops-bot"
- name: Create version branch
id: create-branch
run: |
NAME=version_bump_${{ github.ref_name }}_${{ inputs.version_number }}
NAME=version_bump_${{ github.ref_name }}_$(date +"%Y-%m-%d")
git switch -c $NAME
echo "name=$NAME" >> $GITHUB_OUTPUT
- name: Install xmllint
run: sudo apt install -y libxml2-utils
run: |
sudo apt-get update
sudo apt-get install -y libxml2-utils
- name: Verify input version
env:
NEW_VERSION: ${{ inputs.version_number }}
- name: Get current version
id: current-version
run: |
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
- name: Verify input version
if: ${{ inputs.version_number_override != '' }}
env:
CURRENT_VERSION: ${{ steps.current-version.outputs.version }}
NEW_VERSION: ${{ inputs.version_number_override }}
run: |
# Error if version has not changed.
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Version has not changed."
@ -87,16 +107,37 @@ jobs:
exit 1
fi
- name: Bump version props
- name: Calculate next release version
if: ${{ inputs.version_number_override == '' }}
id: calculate-next-version
uses: bitwarden/gh-actions/version-next@main
with:
version: ${{ steps.current-version.outputs.version }}
- name: Bump version props - Version Override
if: ${{ inputs.version_number_override != '' }}
id: bump-version-override
uses: bitwarden/gh-actions/version-bump@main
with:
version: ${{ inputs.version_number }}
file_path: "Directory.Build.props"
version: ${{ inputs.version_number_override }}
- name: Set up Git
- name: Bump version props - Automatic Calculation
if: ${{ inputs.version_number_override == '' }}
id: bump-version-automatic
uses: bitwarden/gh-actions/version-bump@main
with:
file_path: "Directory.Build.props"
version: ${{ steps.calculate-next-version.outputs.version }}
- name: Set final version output
id: set-final-version-output
run: |
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
git config --local user.name "bitwarden-devops-bot"
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Check if version changed
id: version-changed
@ -110,7 +151,7 @@ jobs:
- name: Commit files
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
run: git commit -m "Bumped version to ${{ inputs.version_number }}" -a
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
@ -124,7 +165,7 @@ jobs:
env:
GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
TITLE: "Bump version to ${{ inputs.version_number }}"
TITLE: "Bump version to ${{ steps.set-final-version-output.outputs.version }}"
run: |
PR_URL=$(gh pr create --title "$TITLE" \
--base "main" \
@ -140,38 +181,43 @@ jobs:
- [X] Other
## Objective
Automated version bump to ${{ inputs.version_number }}")
Automated version bump to ${{ steps.set-final-version-output.outputs.version }}")
echo "pr_number=${PR_URL##*/}" >> $GITHUB_OUTPUT
- name: Approve PR
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr review $PR_NUMBER --approve
- name: Merge PR
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
env:
GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
cut_rc:
name: Cut RC branch
needs: bump_version
if: ${{ inputs.cut_rc_branch == true }}
needs: bump_version
runs-on: ubuntu-22.04
steps:
- name: Check out branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: main
- name: Install xmllint
run: sudo apt install -y libxml2-utils
run: |
sudo apt-get update
sudo apt-get install -y libxml2-utils
- name: Verify version has been updated
env:
NEW_VERSION: ${{ inputs.version_number }}
NEW_VERSION: ${{ needs.bump_version.outputs.version }}
run: |
# Wait for version to change.
while : ; do

View File

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

View File

@ -17,6 +17,7 @@ using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Services;
@ -257,7 +258,7 @@ public class ProviderService : IProviderService
await _providerUserRepository.ReplaceAsync(providerUser);
events.Add((providerUser, EventType.ProviderUser_Confirmed, null));
await _mailService.SendProviderConfirmedEmailAsync(provider.Name, user.Email);
await _mailService.SendProviderConfirmedEmailAsync(provider.DisplayName(), user.Email);
result.Add(Tuple.Create(providerUser, ""));
}
catch (BadRequestException e)
@ -331,7 +332,7 @@ public class ProviderService : IProviderService
var email = user == null ? providerUser.Email : user.Email;
if (!string.IsNullOrWhiteSpace(email))
{
await _mailService.SendProviderUserRemoved(provider.Name, email);
await _mailService.SendProviderUserRemoved(provider.DisplayName(), email);
}
result.Add(Tuple.Create(providerUser, ""));
@ -374,8 +375,18 @@ public class ProviderService : IProviderService
Key = key,
};
await ApplyProviderPriceRateAsync(organizationId, providerId);
var provider = await _providerRepository.GetByIdAsync(providerId);
await ApplyProviderPriceRateAsync(organization, provider);
await _providerOrganizationRepository.CreateAsync(providerOrganization);
organization.BillingEmail = provider.BillingEmail;
await _organizationRepository.ReplaceAsync(organization);
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Email = provider.BillingEmail
});
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added);
}
@ -400,16 +411,14 @@ public class ProviderService : IProviderService
await _eventService.LogProviderOrganizationEventsAsync(insertedProviderOrganizations.Select(ipo => (ipo, EventType.ProviderOrganization_Added, (DateTime?)null)));
}
private async Task ApplyProviderPriceRateAsync(Guid organizationId, Guid providerId)
private async Task ApplyProviderPriceRateAsync(Organization organization, Provider provider)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
// if a provider was created before Nov 6, 2023.If true, the organization plan assigned to that provider is updated to a 2020 plan.
if (provider.CreationDate >= Constants.ProviderCreatedPriorNov62023)
{
return;
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, GetStripeSeatPlanId(organization.PlanType));
var extractedPlanType = PlanTypeMappings(organization);
if (subscriptionItem != null)
@ -586,7 +595,7 @@ public class ProviderService : IProviderService
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
var token = _dataProtector.Protect(
$"ProviderUserInvite {providerUser.Id} {providerUser.Email} {nowMillis}");
await _mailService.SendProviderInviteEmailAsync(provider.Name, providerUser, token, providerUser.Email);
await _mailService.SendProviderInviteEmailAsync(provider.DisplayName(), providerUser, token, providerUser.Email);
}
private async Task<bool> HasConfirmedProviderAdminExceptAsync(Guid providerId, IEnumerable<Guid> providerUserIds)

View File

@ -26,6 +26,7 @@ using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using AuthenticationSchemes = Bit.Core.AuthenticationSchemes;
using DIM = Duende.IdentityServer.Models;
namespace Bit.Sso.Controllers;
@ -483,7 +484,7 @@ public class AccountController : Controller
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
// Org User is invited - they must manually accept the invite via email and authenticate with MP
throw new Exception(_i18nService.T("UserAlreadyInvited", email, organization.Name));
throw new Exception(_i18nService.T("UserAlreadyInvited", email, organization.DisplayName()));
}
// Accepted or Confirmed - create SSO link and return;
@ -516,7 +517,7 @@ public class AccountController : Controller
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value, prorationDate);
}
_logger.LogInformation(e, "SSO auto provisioning failed");
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.Name));
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
}
}
}

View File

@ -458,17 +458,112 @@ public class ProviderServiceTests
{
organization.PlanType = PlanType.EnterpriseAnnually;
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
providerOrganizationRepository.GetByOrganizationId(organization.Id).ReturnsNull();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
await providerOrganizationRepository.Received(1)
.CreateAsync(Arg.Is<ProviderOrganization>(providerOrganization =>
providerOrganization.ProviderId == provider.Id &&
providerOrganization.OrganizationId == organization.Id &&
providerOrganization.Key == key));
await organizationRepository.Received(1)
.ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == provider.BillingEmail));
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerUpdateAsync(
organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.Email == provider.BillingEmail));
await sutProvider.GetDependency<IEventService>()
.Received().LogProviderOrganizationEventAsync(Arg.Is<ProviderOrganization>(providerOrganization =>
providerOrganization.ProviderId == provider.Id &&
providerOrganization.OrganizationId == organization.Id &&
providerOrganization.Key == key),
EventType.ProviderOrganization_Added);
}
[Theory, BitAutoData]
public async Task AddOrganization_CreateAfterNov62023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
provider.Type = ProviderType.Msp;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var expectedPlanType = PlanType.EnterpriseAnnually;
organization.PlanType = PlanType.EnterpriseAnnually;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
await providerOrganizationRepository.Received(1)
.CreateAsync(Arg.Is<ProviderOrganization>(providerOrganization =>
providerOrganization.ProviderId == provider.Id &&
providerOrganization.OrganizationId == organization.Id &&
providerOrganization.Key == key));
await sutProvider.GetDependency<IEventService>()
.Received().LogProviderOrganizationEventAsync(Arg.Is<ProviderOrganization>(providerOrganization =>
providerOrganization.ProviderId == provider.Id &&
providerOrganization.OrganizationId == organization.Id &&
providerOrganization.Key == key),
EventType.ProviderOrganization_Added);
Assert.Equal(organization.PlanType, expectedPlanType);
}
[Theory, BitAutoData]
public async Task AddOrganization_CreateBeforeNov62023_PlanTypeUpdated(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
var newCreationDate = new DateTime(2023, 11, 5);
BackdateProviderCreationDate(provider, newCreationDate);
provider.Type = ProviderType.Msp;
organization.PlanType = PlanType.EnterpriseAnnually;
organization.Plan = "Enterprise (Annually)";
var expectedPlanType = PlanType.EnterpriseAnnually2020;
var expectedPlanId = "2020-enterprise-org-seat-annually";
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
providerOrganizationRepository.GetByOrganizationId(organization.Id).ReturnsNull();
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var subscriptionItem = GetSubscription(organization.GatewaySubscriptionId);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId));
await sutProvider.GetDependency<IStripeAdapter>().SubscriptionUpdateAsync(
organization.GatewaySubscriptionId, SubscriptionUpdateRequest(expectedPlanId, subscriptionItem));
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default);
await providerOrganizationRepository.Received(1)
.CreateAsync(Arg.Is<ProviderOrganization>(providerOrganization =>
providerOrganization.ProviderId == provider.Id &&
providerOrganization.OrganizationId == organization.Id &&
providerOrganization.Key == key));
await sutProvider.GetDependency<IEventService>()
.Received().LogProviderOrganizationEventAsync(Arg.Any<ProviderOrganization>(),
.Received().LogProviderOrganizationEventAsync(Arg.Is<ProviderOrganization>(providerOrganization =>
providerOrganization.ProviderId == provider.Id &&
providerOrganization.OrganizationId == organization.Id &&
providerOrganization.Key == key),
EventType.ProviderOrganization_Added);
Assert.Equal(organization.PlanType, expectedPlanType);
}
[Theory, BitAutoData]
@ -576,65 +671,6 @@ public class ProviderServiceTests
t.First().Item2 == null));
}
[Theory, BitAutoData]
public async Task AddOrganization_CreateAfterNov62023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
provider.Type = ProviderType.Msp;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var expectedPlanType = PlanType.EnterpriseAnnually;
organization.PlanType = PlanType.EnterpriseAnnually;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IEventService>()
.Received().LogProviderOrganizationEventAsync(Arg.Any<ProviderOrganization>(),
EventType.ProviderOrganization_Added);
Assert.Equal(organization.PlanType, expectedPlanType);
}
[Theory, BitAutoData]
public async Task AddOrganization_CreateBeforeNov62023_PlanTypeUpdated(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
var newCreationDate = new DateTime(2023, 11, 5);
BackdateProviderCreationDate(provider, newCreationDate);
provider.Type = ProviderType.Msp;
organization.PlanType = PlanType.EnterpriseAnnually;
organization.Plan = "Enterprise (Annually)";
var expectedPlanType = PlanType.EnterpriseAnnually2020;
var expectedPlanId = "2020-enterprise-org-seat-annually";
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
providerOrganizationRepository.GetByOrganizationId(organization.Id).ReturnsNull();
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var subscriptionItem = GetSubscription(organization.GatewaySubscriptionId);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId));
await sutProvider.GetDependency<IStripeAdapter>().SubscriptionUpdateAsync(
organization.GatewaySubscriptionId, SubscriptionUpdateRequest(expectedPlanId, subscriptionItem));
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IEventService>()
.Received().LogProviderOrganizationEventAsync(Arg.Any<ProviderOrganization>(),
EventType.ProviderOrganization_Added);
Assert.Equal(organization.PlanType, expectedPlanType);
}
private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) =>
new()
{

View File

@ -1,12 +1,12 @@
#!/bin/bash
# There seems to be [a bug with docker-compose](https://github.com/docker/compose/issues/4076#issuecomment-324932294)
#
# !!! UPDATED 2024 for MsSqlMigratorUtility !!!
#
# There seems to be [a bug with docker-compose](https://github.com/docker/compose/issues/4076#issuecomment-324932294)
# where it takes ~40ms to connect to the terminal output of the container, so stuff logged to the terminal in this time is lost.
# The best workaround seems to be adding tiny delay like so:
sleep 0.1;
MIGRATE_DIRECTORY="/mnt/migrator/DbScripts"
LAST_MIGRATION_FILE="/mnt/data/last_migration"
SERVER='mssql'
DATABASE="vault_dev"
USER="SA"
@ -16,58 +16,33 @@ while getopts "s" arg; do
case $arg in
s)
echo "Running for self-host environment"
LAST_MIGRATION_FILE="/mnt/data/last_self_host_migration"
DATABASE="vault_dev_self_host"
;;
esac
done
if [ ! -f "$LAST_MIGRATION_FILE" ]; then
echo "No migration file, nothing to migrate to a database store"
exit 1
else
LAST_MIGRATION=$(cat $LAST_MIGRATION_FILE)
rm $LAST_MIGRATION_FILE
fi
[ -z "$LAST_MIGRATION" ]
PERFORM_MIGRATION=$?
# Create database if it does not already exist
QUERY="IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'migrations_$DATABASE')
QUERY="IF OBJECT_ID('[$DATABASE].[dbo].[Migration]') IS NULL AND OBJECT_ID('[migrations_$DATABASE].[dbo].[migrations]') IS NOT NULL
BEGIN
CREATE DATABASE migrations_$DATABASE;
END;
-- Create [database].dbo.Migration with the schema expected by MsSqlMigratorUtility
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
CREATE TABLE [$DATABASE].[dbo].[Migration](
[Id] [int] IDENTITY(1,1) NOT NULL,
[ScriptName] [nvarchar](255) NOT NULL,
[Applied] [datetime] NOT NULL
) ON [PRIMARY];
ALTER TABLE [$DATABASE].[dbo].[Migration] ADD CONSTRAINT [PK_Migration_Id] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY];
-- Copy across old data
INSERT INTO [$DATABASE].[dbo].[Migration] (ScriptName, Applied)
SELECT CONCAT('Bit.Migrator.DbScripts.', [Filename]), CreationDate
FROM [migrations_$DATABASE].[dbo].[migrations];
END
"
/opt/mssql-tools/bin/sqlcmd -S $SERVER -d master -U $USER -P $PASSWD -I -Q "$QUERY"
QUERY="IF OBJECT_ID('[dbo].[migrations_$DATABASE]') IS NULL
BEGIN
CREATE TABLE [migrations_$DATABASE].[dbo].[migrations] (
[Id] INT IDENTITY(1,1) PRIMARY KEY,
[Filename] NVARCHAR(MAX) NOT NULL,
[CreationDate] DATETIME2 (7) NULL,
);
END;"
/opt/mssql-tools/bin/sqlcmd -S $SERVER -d master -U $USER -P $PASSWD -I -Q "$QUERY"
record_migration () {
echo "recording $1"
local file=$(basename $1)
echo $file
local query="INSERT INTO [migrations] ([Filename], [CreationDate]) VALUES ('$file', GETUTCDATE())"
/opt/mssql-tools/bin/sqlcmd -S $SERVER -d migrations_$DATABASE -U $USER -P $PASSWD -I -Q "$query"
}
for f in `ls -v $MIGRATE_DIRECTORY/*.sql`; do
if (( PERFORM_MIGRATION == 0 )); then
echo "Still need to migrate $f"
else
record_migration $f
if [ "$LAST_MIGRATION" == "$f" ]; then
PERFORM_MIGRATION=0
fi
fi
done;

View File

@ -1,94 +0,0 @@
#!/bin/bash
# There seems to be [a bug with docker-compose](https://github.com/docker/compose/issues/4076#issuecomment-324932294)
# where it takes ~40ms to connect to the terminal output of the container, so stuff logged to the terminal in this time is lost.
# The best workaround seems to be adding tiny delay like so:
sleep 0.1;
MIGRATE_DIRECTORY="/mnt/migrator/DbScripts"
SERVER='mssql'
DATABASE="vault_dev"
USER="SA"
PASSWD=$MSSQL_PASSWORD
while getopts "sp" arg; do
case $arg in
s)
echo "Running for self-host environment"
DATABASE="vault_dev_self_host"
;;
p)
echo "Running for pipeline"
MIGRATE_DIRECTORY=$MSSQL_MIGRATIONS_DIRECTORY
SERVER=$MSSQL_HOST
DATABASE=$MSSQL_DATABASE
USER=$MSSQL_USER
PASSWD=$MSSQL_PASS
esac
done
# Create databases if they do not already exist
QUERY="IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = '$DATABASE')
BEGIN
CREATE DATABASE $DATABASE;
END;
GO
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'migrations_$DATABASE')
BEGIN
CREATE DATABASE migrations_$DATABASE;
END;
GO
"
/opt/mssql-tools/bin/sqlcmd -S $SERVER -d master -U $USER -P $PASSWD -I -Q "$QUERY"
echo "Return code: $?"
# Create migrations table if it does not already exist
QUERY="IF OBJECT_ID('[migrations_$DATABASE].[dbo].[migrations]') IS NULL
BEGIN
CREATE TABLE [migrations_$DATABASE].[dbo].[migrations] (
[Id] INT IDENTITY(1,1) PRIMARY KEY,
[Filename] NVARCHAR(MAX) NOT NULL,
[CreationDate] DATETIME2 (7) NULL,
);
END;
GO
"
/opt/mssql-tools/bin/sqlcmd -S $SERVER -d migrations_$DATABASE -U $USER -P $PASSWD -I -Q "$QUERY"
echo "Return code: $?"
should_migrate () {
local file=$(basename $1)
local query="SELECT * FROM [migrations] WHERE [Filename] = '$file'"
local result=$(/opt/mssql-tools/bin/sqlcmd -S $SERVER -d migrations_$DATABASE -U $USER -P $PASSWD -I -Q "$query")
if [[ "$result" =~ .*"$file".* ]]; then
return 1;
else
return 0;
fi
}
record_migration () {
echo "recording $1"
local file=$(basename $1)
echo $file
local query="INSERT INTO [migrations] ([Filename], [CreationDate]) VALUES ('$file', GETUTCDATE())"
/opt/mssql-tools/bin/sqlcmd -S $SERVER -d migrations_$DATABASE -U $USER -P $PASSWD -I -Q "$query"
}
migrate () {
local file=$1
echo "Performing $file"
/opt/mssql-tools/bin/sqlcmd -S $SERVER -d $DATABASE -U $USER -P $PASSWD -I -i $file
}
for f in `ls -v $MIGRATE_DIRECTORY/*.sql`; do
BASENAME=$(basename $f)
if should_migrate $f == 1 ; then
migrate $f
record_migration $f
else
echo "Skipping $f, $BASENAME"
fi
done;

View File

@ -2,20 +2,20 @@
# Creates the vault_dev database, and runs all the migrations.
# Due to azure-edge-sql not containing the mssql-tools on ARM, we manually use
# the mssql-tools container which runs under x86_64. We should monitor this
# in the future and investigate if we can migrate back.
# docker-compose --profile mssql exec mssql bash /mnt/helpers/run_migrations.sh @args
# the mssql-tools container which runs under x86_64.
param(
[switch]$all = $false,
[switch]$postgres = $false,
[switch]$mysql = $false,
[switch]$mssql = $false,
[switch]$sqlite = $false,
[switch]$selfhost = $false,
[switch]$pipeline = $false
[switch]$all,
[switch]$postgres,
[switch]$mysql,
[switch]$mssql,
[switch]$sqlite,
[switch]$selfhost
)
# Abort on any error
$ErrorActionPreference = "Stop"
if (!$all -and !$postgres -and !$mysql -and !$sqlite) {
$mssql = $true;
}
@ -29,22 +29,27 @@ if ($all -or $postgres -or $mysql -or $sqlite) {
}
if ($all -or $mssql) {
if ($selfhost) {
$migrationArgs = "-s"
} elseif ($pipeline) {
$migrationArgs = "-p"
function Get-UserSecrets {
return dotnet user-secrets list --json --project ../src/Api | ConvertFrom-Json
}
Write-Host "Starting Microsoft SQL Server Migrations"
docker run `
-v "$(pwd)/helpers/mssql:/mnt/helpers" `
-v "$(pwd)/../util/Migrator:/mnt/migrator/" `
-v "$(pwd)/.data/mssql:/mnt/data" `
--env-file .env `
--network=bitwardenserver_default `
--rm `
mcr.microsoft.com/mssql-tools `
/mnt/helpers/run_migrations.sh $migrationArgs
if ($selfhost) {
$msSqlConnectionString = $(Get-UserSecrets).'dev:selfHostOverride:globalSettings:sqlServer:connectionString'
$envName = "self-host"
Write-Output "Migrating your migrations to use MsSqlMigratorUtility (if needed)"
./migrate_migration_record.ps1 -s
} else {
$msSqlConnectionString = $(Get-UserSecrets).'globalSettings:sqlServer:connectionString'
$envName = "cloud"
Write-Output "Migrating your migrations to use MsSqlMigratorUtility (if needed)"
./migrate_migration_record.ps1
}
Write-Host "Starting Microsoft SQL Server Migrations for $envName"
dotnet run --project ../util/MsSqlMigratorUtility/ "$msSqlConnectionString"
}
$currentDir = Get-Location

View File

@ -1,15 +1,13 @@
#!/usr/bin/env pwsh
# This script need only be run once
# !!! UPDATED 2024 for MsSqlMigratorUtility !!!
#
# This is a migration script for updating recording the last migration run
# in a file to recording migrations in a database table. It will create a
# migrations_vault table and store all of the previously run migrations as
# indicated by a last_migrations file. It will then delete this file.
# This is a migration script to move data from [migrations_vault_dev].[dbo].[migrations] (used by our custom
# migrator script) to [vault_dev].[dbo].[Migration] (used by MsSqlMigratorUtility). It is safe to run multiple
# times because it will not perform any migration if it detects that the new table is already present.
# This will be deleted after a few months after everyone has (presumably) migrated to the new schema.
# Due to azure-edge-sql not containing the mssql-tools on ARM, we manually use
# the mssql-tools container which runs under x86_64. We should monitor this
# in the future and investigate if we can migrate back.
# docker-compose --profile mssql exec mssql bash /mnt/helpers/run_migrations.sh @args
# the mssql-tools container which runs under x86_64.
docker run `
-v "$(pwd)/helpers/mssql:/mnt/helpers" `

View File

@ -1,4 +1,5 @@
using Bit.Admin.AdminConsole.Models;
using System.Net;
using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums;
using Bit.Admin.Services;
using Bit.Admin.Utilities;
@ -119,8 +120,9 @@ public class OrganizationsController : Controller
count = 1;
}
var encodedName = WebUtility.HtmlEncode(name);
var skip = (page - 1) * count;
var organizations = await _organizationRepository.SearchAsync(name, userEmail, paid, skip, count);
var organizations = await _organizationRepository.SearchAsync(encodedName, userEmail, paid, skip, count);
return View(new OrganizationsModel
{
Items = organizations as List<Organization>,

View File

@ -1,4 +1,5 @@
using Bit.Admin.AdminConsole.Models;
using System.Net;
using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core;
@ -188,8 +189,9 @@ public class ProvidersController : Controller
count = 1;
}
var encodedName = WebUtility.HtmlEncode(name);
var skip = (page - 1) * count;
var unassignedOrganizations = await _organizationRepository.SearchUnassignedToProviderAsync(name, ownerEmail, skip, count);
var unassignedOrganizations = await _organizationRepository.SearchUnassignedToProviderAsync(encodedName, ownerEmail, skip, count);
var viewModel = new OrganizationUnassignedToProviderSearchViewModel
{
OrganizationName = string.IsNullOrWhiteSpace(name) ? null : name,
@ -199,7 +201,7 @@ public class ProvidersController : Controller
Items = unassignedOrganizations.Select(uo => new OrganizationSelectableViewModel
{
Id = uo.Id,
Name = uo.Name,
Name = uo.DisplayName(),
PlanType = uo.PlanType
}).ToList()
};

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@ -36,8 +37,8 @@ public class OrganizationEditModel : OrganizationViewModel
BillingInfo = billingInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
Name = org.Name;
BusinessName = org.BusinessName;
Name = org.DisplayName();
BusinessName = org.DisplayBusinessName();
BillingEmail = provider?.Type == ProviderType.Reseller ? provider.BillingEmail : org.BillingEmail;
PlanType = org.PlanType;
Plan = org.Plan;
@ -184,8 +185,8 @@ public class OrganizationEditModel : OrganizationViewModel
public Organization ToOrganization(Organization existingOrganization)
{
existingOrganization.Name = Name;
existingOrganization.BusinessName = BusinessName;
existingOrganization.Name = WebUtility.HtmlEncode(Name.Trim());
existingOrganization.BusinessName = WebUtility.HtmlEncode(BusinessName.Trim());
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
existingOrganization.PlanType = PlanType.Value;
existingOrganization.Plan = Plan;

View File

@ -11,8 +11,8 @@ public class ProviderEditModel : ProviderViewModel
public ProviderEditModel(Provider provider, IEnumerable<ProviderUserUserDetails> providerUsers, IEnumerable<ProviderOrganizationOrganizationDetails> organizations)
: base(provider, providerUsers, organizations)
{
Name = provider.Name;
BusinessName = provider.BusinessName;
Name = provider.DisplayName();
BusinessName = provider.DisplayBusinessName();
BillingEmail = provider.BillingEmail;
BillingPhone = provider.BillingPhone;
}

View File

@ -4,7 +4,7 @@
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@model OrganizationEditModel
@{
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Organization.Name;
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name;
var canViewOrganizationInformation = AccessControlService.UserHasPermission(Permission.Org_OrgInformation_View);
var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.Org_BillingInformation_View);
@ -58,7 +58,7 @@
</script>
}
<h1>@(Model.Provider != null ? "Client " : string.Empty)Organization <small>@Model.Organization.Name</small></h1>
<h1>@(Model.Provider != null ? "Client " : string.Empty)Organization <small>@Model.Name</small></h1>
@if (Model.Provider != null)
{

View File

@ -46,7 +46,7 @@
{
<tr>
<td>
<a asp-action="@Model.Action" asp-route-id="@org.Id">@org.Name</a>
<a asp-action="@Model.Action" asp-route-id="@org.Id">@org.DisplayName()</a>
</td>
<td>
@org.Plan

View File

@ -1,10 +1,10 @@
@inject Bit.Core.Settings.GlobalSettings GlobalSettings
@model OrganizationViewModel
@{
ViewData["Title"] = "Organization: " + Model.Organization.Name;
ViewData["Title"] = "Organization: " + Model.Organization.DisplayName();
}
<h1>Organization <small>@Model.Organization.Name</small></h1>
<h1>Organization <small>@Model.Organization.DisplayName()</small></h1>
@if (Model.Provider != null)
{

View File

@ -2,8 +2,8 @@
@model Bit.Core.AdminConsole.Entities.Provider.Provider
<dl class="row">
<dt class="col-sm-4 col-lg-3">Provider Name</dt>
<dd class="col-sm-8 col-lg-9">@Model.Name</dd>
<dd class="col-sm-8 col-lg-9">@Model.DisplayName()</dd>
<dt class="col-sm-4 col-lg-3">Provider Type</dt>
<dd class="col-sm-8 col-lg-9">@(Model.Type.GetDisplayAttribute()?.GetName())</dd>
</dl>
</dl>

View File

@ -45,7 +45,7 @@
@Html.HiddenFor(m => Model.Items[i].Id, new { @readonly = "readonly", autocomplete = "off" })
@Html.CheckBoxFor(m => Model.Items[i].Selected)
</td>
<td>@Html.ActionLink(Model.Items[i].Name, "Edit", "Organizations", new { id = Model.Items[i].Id }, new { target = "_blank" })</td>
<td>@Html.ActionLink(Model.Items[i].DisplayName(), "Edit", "Organizations", new { id = Model.Items[i].Id }, new { target = "_blank" })</td>
<td>@(Model.Items[i].PlanType.GetDisplayAttribute()?.Name ?? Model.Items[i].PlanType.ToString())</td>
</tr>
}

View File

@ -3,12 +3,12 @@
@model ProviderEditModel
@{
ViewData["Title"] = "Provider: " + Model.Provider.Name;
ViewData["Title"] = "Provider: " + Model.Provider.DisplayName();
var canEdit = AccessControlService.UserHasPermission(Permission.Provider_Edit);
}
<h1>Provider <small>@Model.Provider.Name</small></h1>
<h1>Provider <small>@Model.Provider.DisplayName()</small></h1>
<h2>Provider Information</h2>
@await Html.PartialAsync("_ViewInformation", Model)
@ -17,12 +17,12 @@
<h2>General</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Name</dt>
<dd class="col-sm-8 col-lg-9">@Model.Provider.Name</dd>
<dd class="col-sm-8 col-lg-9">@Model.Provider.DisplayName()</dd>
</dl>
<h2>Business Information</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Business Name</dt>
<dd class="col-sm-8 col-lg-9">@Model.Provider.BusinessName</dd>
<dd class="col-sm-8 col-lg-9">@Model.Provider.DisplayBusinessName()</dd>
</dl>
<h2>Billing</h2>
<div class="row">

View File

@ -52,7 +52,7 @@
{
<tr>
<td>
<a asp-action="@Model.Action" asp-route-id="@provider.Id">@(provider.Name ?? "Pending")</a>
<a asp-action="@Model.Action" asp-route-id="@provider.Id">@(!string.IsNullOrEmpty(provider.DisplayName()) ? provider.DisplayName() : "Pending")</a>
</td>
<td>@provider.Type.GetDisplayAttribute()?.GetShortName()</td>
<td>@provider.Status</td>

View File

@ -45,7 +45,7 @@
{
<tr>
<td class="align-middle">
<a asp-controller="Organizations" asp-action="Edit" asp-route-id="@providerOrganization.OrganizationId">@providerOrganization.OrganizationName</a>
<a asp-controller="Organizations" asp-action="Edit" asp-route-id="@providerOrganization.OrganizationId">@providerOrganization.DisplayName()</a>
</td>
<td>
@providerOrganization.Status

View File

@ -1,9 +1,9 @@
@model ProviderViewModel
@{
ViewData["Title"] = "Provider: " + Model.Provider.Name;
ViewData["Title"] = "Provider: " + Model.Provider.DisplayName();
}
<h1>Provider <small>@Model.Provider.Name</small></h1>
<h1>Provider <small>@Model.Provider.DisplayName()</small></h1>
<h2>Information</h2>
@await Html.PartialAsync("_ViewInformation", Model)

View File

@ -28,7 +28,7 @@
<div class="col-sm">
<div class="form-group">
<label asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name" required>
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required>
</div>
</div>
</div>
@ -68,7 +68,7 @@
<div class="col-sm">
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
<input type="text" class="form-control" asp-for="BusinessName" value="@Model.BusinessName">
</div>
</div>
</div>

View File

@ -87,6 +87,7 @@ public static class RolePermissionMapping
Permission.Provider_List_View,
Permission.Provider_Create,
Permission.Provider_View,
Permission.Provider_Edit,
Permission.Provider_ResendEmailInvite,
Permission.Tools_ChargeBrainTreeCustomer,
Permission.Tools_PromoteAdmin,

View File

@ -4,6 +4,7 @@ using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
@ -41,6 +42,7 @@ public class OrganizationUsersController : Controller
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IAuthorizationService _authorizationService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IFeatureService _featureService;
public OrganizationUsersController(
IOrganizationRepository organizationRepository,
@ -56,7 +58,8 @@ public class OrganizationUsersController : Controller
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IAcceptOrgUserCommand acceptOrgUserCommand,
IAuthorizationService authorizationService,
IApplicationCacheService applicationCacheService)
IApplicationCacheService applicationCacheService,
IFeatureService featureService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -72,6 +75,7 @@ public class OrganizationUsersController : Controller
_acceptOrgUserCommand = acceptOrgUserCommand;
_authorizationService = authorizationService;
_applicationCacheService = applicationCacheService;
_featureService = featureService;
}
[HttpGet("{id}")]
@ -305,43 +309,34 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}")]
[HttpPost("{id}")]
public async Task Put(string orgId, string id, [FromBody] OrganizationUserUpdateRequestModel model)
public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
{
var orgGuidId = new Guid(orgId);
if (!await _currentContext.ManageUsers(orgGuidId))
if (!await _currentContext.ManageUsers(orgId))
{
throw new NotFoundException();
}
var organizationUser = await _organizationUserRepository.GetByIdAsync(new Guid(id));
if (organizationUser == null || organizationUser.OrganizationId != orgGuidId)
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (organizationUser == null || organizationUser.OrganizationId != orgId)
{
throw new NotFoundException();
}
var userId = _userService.GetProperUserId(User);
await _organizationService.SaveUserAsync(model.ToOrganizationUser(organizationUser), userId.Value,
model.Collections?.Select(c => c.ToSelectionReadOnly()).ToList(), model.Groups);
}
// If admins are not allowed access to all collections, you cannot add yourself to a group
// In this case we just don't update groups
var userId = _userService.GetProperUserId(User).Value;
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
var restrictEditingGroups = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) &&
organizationAbility.FlexibleCollections &&
userId == organizationUser.UserId &&
!organizationAbility.AllowAdminAccessToAllCollectionItems;
[HttpPut("{id}/groups")]
[HttpPost("{id}/groups")]
public async Task PutGroups(string orgId, string id, [FromBody] OrganizationUserUpdateGroupsRequestModel model)
{
var orgGuidId = new Guid(orgId);
if (!await _currentContext.ManageUsers(orgGuidId))
{
throw new NotFoundException();
}
var groups = restrictEditingGroups
? null
: model.Groups;
var organizationUser = await _organizationUserRepository.GetByIdAsync(new Guid(id));
if (organizationUser == null || organizationUser.OrganizationId != orgGuidId)
{
throw new NotFoundException();
}
var loggedInUserId = _userService.GetProperUserId(User);
await _updateOrganizationUserGroupsCommand.UpdateUserGroupsAsync(organizationUser, model.GroupIds.Select(g => new Guid(g)), loggedInUserId);
await _organizationService.SaveUserAsync(model.ToOrganizationUser(organizationUser), userId,
model.Collections?.Select(c => c.ToSelectionReadOnly()).ToList(), groups);
}
[HttpPut("{userId}/reset-password-enrollment")]

View File

@ -261,19 +261,19 @@ public class OrganizationsController : Controller
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
}
[HttpGet("{id}/risks-subscription-failure")]
public async Task<OrganizationRisksSubscriptionFailureResponseModel> RisksSubscriptionFailure(Guid id)
[HttpGet("{id}/billing-status")]
public async Task<OrganizationBillingStatusResponseModel> GetBillingStatus(Guid id)
{
if (!await _currentContext.EditPaymentMethods(id))
{
return new OrganizationRisksSubscriptionFailureResponseModel(id, false);
throw new NotFoundException();
}
var organization = await _organizationRepository.GetByIdAsync(id);
var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization);
return new OrganizationRisksSubscriptionFailureResponseModel(id, risksSubscriptionFailure);
return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure);
}
[HttpPost("")]
@ -303,7 +303,7 @@ public class OrganizationsController : Controller
throw new NotFoundException();
}
var updateBilling = !_globalSettings.SelfHosted && (model.BusinessName != organization.BusinessName ||
var updateBilling = !_globalSettings.SelfHosted && (model.BusinessName != organization.DisplayBusinessName() ||
model.BillingEmail != organization.BillingEmail);
var hasRequiredPermissions = updateBilling
@ -464,8 +464,8 @@ public class OrganizationsController : Controller
await _organizationService.VerifyBankAsync(orgIdGuid, model.Amount1.Value, model.Amount2.Value);
}
[HttpPost("{id}/churn")]
public async Task PostChurn(Guid id, [FromBody] SubscriptionCancellationRequestModel request)
[HttpPost("{id}/cancel")]
public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request)
{
if (!await _currentContext.EditSubscription(id))
{
@ -499,19 +499,6 @@ public class OrganizationsController : Controller
});
}
[HttpPost("{id}/cancel")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostCancel(string id)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.EditSubscription(orgIdGuid))
{
throw new NotFoundException();
}
await _organizationService.CancelSubscriptionAsync(orgIdGuid);
}
[HttpPost("{id}/reinstate")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstate(string id)

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
@ -9,9 +10,11 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationCreateRequestModel : IValidatableObject
{
[Required]
[StringLength(50)]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50)]
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[Required]
[StringLength(256)]

View File

@ -1,16 +1,20 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationUpdateRequestModel
{
[Required]
[StringLength(50)]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50)]
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[EmailAddress]
[Required]

View File

@ -102,12 +102,6 @@ public class OrganizationUserUpdateRequestModel
}
}
public class OrganizationUserUpdateGroupsRequestModel
{
[Required]
public IEnumerable<string> GroupIds { get; set; }
}
public class OrganizationUserResetPasswordEnrollmentRequestModel
{
public string ResetPasswordKey { get; set; }

View File

@ -1,14 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Providers;
public class ProviderSetupRequestModel
{
[Required]
[StringLength(50)]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50)]
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[Required]
[StringLength(256)]

View File

@ -1,15 +1,19 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Providers;
public class ProviderUpdateRequestModel
{
[Required]
[StringLength(50)]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50)]
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[EmailAddress]
[Required]

View File

@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationBillingStatusResponseModel(
Organization organization,
bool risksSubscriptionFailure) : ResponseModel("organizationBillingStatus")
{
public Guid OrganizationId { get; } = organization.Id;
public string OrganizationName { get; } = organization.Name;
public bool RisksSubscriptionFailure { get; } = risksSubscriptionFailure;
}

View File

@ -1,4 +1,5 @@
using Bit.Api.Models.Response;
using System.Text.Json.Serialization;
using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
@ -60,7 +61,9 @@ public class OrganizationResponseModel : ResponseModel
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
public string BusinessAddress1 { get; set; }
public string BusinessAddress2 { get; set; }

View File

@ -1,17 +0,0 @@
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationRisksSubscriptionFailureResponseModel : ResponseModel
{
public Guid OrganizationId { get; }
public bool RisksSubscriptionFailure { get; }
public OrganizationRisksSubscriptionFailureResponseModel(
Guid organizationId,
bool risksSubscriptionFailure) : base("organizationRisksSubscriptionFailure")
{
OrganizationId = organizationId;
RisksSubscriptionFailure = risksSubscriptionFailure;
}
}

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Enums.Provider;
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.Enums;
@ -103,6 +104,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
@ -135,6 +137,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
public Guid? UserId { 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; }

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Enums.Provider;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
@ -23,6 +24,7 @@ public class ProfileProviderResponseModel : ResponseModel
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Key { get; set; }
public ProviderUserStatusType Status { get; set; }

View File

@ -1,6 +1,8 @@
using Bit.Core.AdminConsole.Entities.Provider;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response.Providers;
@ -68,5 +70,6 @@ public class ProviderOrganizationOrganizationDetailsResponseModel : ProviderOrga
OrganizationName = providerOrganization.OrganizationName;
}
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string OrganizationName { get; set; }
}

View File

@ -1,5 +1,7 @@
using Bit.Core.AdminConsole.Entities.Provider;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response.Providers;
@ -25,6 +27,7 @@ public class ProviderResponseModel : ResponseModel
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string BusinessName { get; set; }
public string BusinessAddress1 { get; set; }

View File

@ -32,15 +32,10 @@
</Choose>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.AzureServiceBus" Version="6.1.0" />
<PackageReference Include="AspNetCore.HealthChecks.AzureStorage" Version="6.1.2" />
<PackageReference Include="AspNetCore.HealthChecks.Network" Version="6.0.4" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="6.0.4" />
<PackageReference Include="AspNetCore.HealthChecks.SendGrid" Version="6.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="6.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="6.0.3" />
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.0" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.10.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

@ -821,8 +821,8 @@ public class AccountsController : Controller
await _userService.UpdateLicenseAsync(user, license);
}
[HttpPost("churn-premium")]
public async Task PostChurn([FromBody] SubscriptionCancellationRequestModel request)
[HttpPost("cancel")]
public async Task PostCancel([FromBody] SubscriptionCancellationRequestModel request)
{
var user = await _userService.GetUserByPrincipalAsync(User);
@ -851,19 +851,6 @@ public class AccountsController : Controller
});
}
[HttpPost("cancel-premium")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostCancel()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
await _userService.CancelPremiumAsync(user);
}
[HttpPost("reinstate-premium")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstate()

View File

@ -132,7 +132,7 @@ public class OrganizationSponsorshipsController : Controller
}
var (syncResponseData, offersToSend) = await _syncSponsorshipsCommand.SyncOrganization(sponsoringOrg, model.ToOrganizationSponsorshipSync().SponsorshipsBatch);
await _sendSponsorshipOfferCommand.BulkSendSponsorshipOfferAsync(sponsoringOrg.Name, offersToSend);
await _sendSponsorshipOfferCommand.BulkSendSponsorshipOfferAsync(sponsoringOrg.DisplayName(), offersToSend);
return new OrganizationSponsorshipSyncResponseModel(syncResponseData);
}

View File

@ -58,7 +58,7 @@ public class SelfHostedSponsorshipSyncJob : BaseJob
}
catch (Exception ex)
{
_logger.LogError(ex, $"Sponsorship sync for organization {org.Name} Failed");
_logger.LogError(ex, "Sponsorship sync for organization {OrganizationName} Failed", org.DisplayName());
}
}
}

View File

@ -92,34 +92,6 @@ public static class ServiceCollectionExtensions
{
builder.AddSqlServer(globalSettings.SqlServer.ConnectionString);
}
if (CoreHelpers.SettingHasValue(globalSettings.DistributedCache?.Redis?.ConnectionString))
{
builder.AddRedis(globalSettings.DistributedCache.Redis.ConnectionString);
}
if (CoreHelpers.SettingHasValue(globalSettings.Storage.ConnectionString))
{
builder.AddAzureQueueStorage(globalSettings.Storage.ConnectionString, name: "storage_queue")
.AddAzureQueueStorage(globalSettings.Events.ConnectionString, name: "events_queue");
}
if (CoreHelpers.SettingHasValue(globalSettings.Notifications.ConnectionString))
{
builder.AddAzureQueueStorage(globalSettings.Notifications.ConnectionString,
name: "notifications_queue");
}
if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString))
{
builder.AddAzureServiceBusTopic(_ => globalSettings.ServiceBus.ConnectionString,
_ => globalSettings.ServiceBus.ApplicationCacheTopicName, name: "service_bus");
}
if (CoreHelpers.SettingHasValue(globalSettings.Mail.SendGridApiKey))
{
builder.AddSendGrid(globalSettings.Mail.SendGridApiKey);
}
});
}

View File

@ -95,7 +95,7 @@ public class FreshsalesController : Controller
foreach (var org in orgs)
{
noteItems.Add($"Org, {org.Name}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}");
noteItems.Add($"Org, {org.DisplayName()}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}");
if (TryGetPlanName(org.PlanType, out var planName))
{
newTags.Add($"Org: {planName}");

View File

@ -1,6 +1,5 @@
using System.Text;
using Bit.Billing.Models;
using Bit.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
@ -20,7 +19,6 @@ public class PayPalController : Controller
private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPaymentService _paymentService;
private readonly IPayPalIPNClient _payPalIPNClient;
private readonly ITransactionRepository _transactionRepository;
private readonly IUserRepository _userRepository;
@ -30,7 +28,6 @@ public class PayPalController : Controller
IMailService mailService,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IPayPalIPNClient payPalIPNClient,
ITransactionRepository transactionRepository,
IUserRepository userRepository)
{
@ -39,7 +36,6 @@ public class PayPalController : Controller
_mailService = mailService;
_organizationRepository = organizationRepository;
_paymentService = paymentService;
_payPalIPNClient = payPalIPNClient;
_transactionRepository = transactionRepository;
_userRepository = userRepository;
}
@ -91,14 +87,6 @@ public class PayPalController : Controller
return BadRequest();
}
var verified = await _payPalIPNClient.VerifyIPN(transactionModel.TransactionId, requestContent);
if (!verified)
{
_logger.LogError("PayPal IPN ({Id}): Verification failed", transactionModel.TransactionId);
return BadRequest();
}
if (transactionModel.TransactionType != "web_accept" &&
transactionModel.TransactionType != "merch_pmt" &&
transactionModel.PaymentStatus != "Refunded")
@ -204,8 +192,8 @@ public class PayPalController : Controller
if (parentTransaction == null)
{
_logger.LogError("PayPal IPN ({Id}): Could not find parent transaction", transactionModel.TransactionId);
return BadRequest();
_logger.LogWarning("PayPal IPN ({Id}): Could not find parent transaction", transactionModel.TransactionId);
return Ok();
}
var refundAmount = Math.Abs(transactionModel.MerchantGross);

View File

@ -3,6 +3,7 @@ using Bit.Billing.Models;
using Bit.Billing.Services;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@ -188,7 +189,7 @@ public class StripeController : Controller
}
var user = await _userService.GetUserByIdAsync(userId);
if (user.Premium)
if (user?.Premium == true)
{
await _userService.DisablePremiumAsync(userId, subscription.CurrentPeriodEnd);
}
@ -250,21 +251,21 @@ public class StripeController : Controller
var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax);
if (pm5766AutomaticTaxIsEnabled)
{
var customer = await _stripeFacade.GetCustomer(subscription.CustomerId);
var customerGetOptions = new CustomerGetOptions();
customerGetOptions.AddExpand("tax");
var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions);
if (!subscription.AutomaticTax.Enabled &&
!string.IsNullOrEmpty(customer.Address?.PostalCode) &&
!string.IsNullOrEmpty(customer.Address?.Country))
customer.Tax?.AutomaticTax == StripeCustomerAutomaticTaxStatus.Supported)
{
subscription = await _stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
DefaultTaxRates = new List<string>(),
DefaultTaxRates = [],
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
}
var updatedSubscription = pm5766AutomaticTaxIsEnabled
? subscription
: await VerifyCorrectTaxRateForCharge(invoice, subscription);
@ -319,7 +320,7 @@ public class StripeController : Controller
{
var user = await _userService.GetUserByIdAsync(userId.Value);
if (user.Premium)
if (user?.Premium == true)
{
await SendEmails(new List<string> { user.Email });
}
@ -571,7 +572,7 @@ public class StripeController : Controller
else if (parsedEvent.Type.Equals(HandledStripeWebhook.CustomerUpdated))
{
var customer =
await _stripeEventService.GetCustomer(parsedEvent, true, new List<string> { "subscriptions" });
await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
if (customer.Subscriptions == null || !customer.Subscriptions.Any())
{
@ -614,7 +615,7 @@ public class StripeController : Controller
{
Customer = paymentMethod.CustomerId,
Status = StripeSubscriptionStatus.Unpaid,
Expand = new List<string> { "data.latest_invoice" }
Expand = ["data.latest_invoice"]
};
StripeList<Subscription> unpaidSubscriptions;
@ -672,9 +673,9 @@ public class StripeController : Controller
}
}
private Tuple<Guid?, Guid?> GetIdsFromMetaData(IDictionary<string, string> metaData)
private static Tuple<Guid?, Guid?> GetIdsFromMetaData(Dictionary<string, string> metaData)
{
if (metaData == null || !metaData.Any())
if (metaData == null || metaData.Count == 0)
{
return new Tuple<Guid?, Guid?>(null, null);
}
@ -682,29 +683,35 @@ public class StripeController : Controller
Guid? orgId = null;
Guid? userId = null;
if (metaData.ContainsKey("organizationId"))
if (metaData.TryGetValue("organizationId", out var orgIdString))
{
orgId = new Guid(metaData["organizationId"]);
orgId = new Guid(orgIdString);
}
else if (metaData.ContainsKey("userId"))
else if (metaData.TryGetValue("userId", out var userIdString))
{
userId = new Guid(metaData["userId"]);
userId = new Guid(userIdString);
}
if (userId == null && orgId == null)
if (userId != null && userId != Guid.Empty || orgId != null && orgId != Guid.Empty)
{
var orgIdKey = metaData.Keys.FirstOrDefault(k => k.ToLowerInvariant() == "organizationid");
if (!string.IsNullOrWhiteSpace(orgIdKey))
return new Tuple<Guid?, Guid?>(orgId, userId);
}
var orgIdKey = metaData.Keys
.FirstOrDefault(k => k.Equals("organizationid", StringComparison.InvariantCultureIgnoreCase));
if (!string.IsNullOrWhiteSpace(orgIdKey))
{
orgId = new Guid(metaData[orgIdKey]);
}
else
{
var userIdKey = metaData.Keys
.FirstOrDefault(k => k.Equals("userid", StringComparison.InvariantCultureIgnoreCase));
if (!string.IsNullOrWhiteSpace(userIdKey))
{
orgId = new Guid(metaData[orgIdKey]);
}
else
{
var userIdKey = metaData.Keys.FirstOrDefault(k => k.ToLowerInvariant() == "userid");
if (!string.IsNullOrWhiteSpace(userIdKey))
{
userId = new Guid(metaData[userIdKey]);
}
userId = new Guid(metaData[userIdKey]);
}
}
@ -891,9 +898,9 @@ public class StripeController : Controller
return subscription;
}
subscription.DefaultTaxRates = new List<Stripe.TaxRate> { stripeTaxRate };
subscription.DefaultTaxRates = [stripeTaxRate];
var subscriptionOptions = new SubscriptionUpdateOptions { DefaultTaxRates = new List<string> { stripeTaxRate.Id } };
var subscriptionOptions = new SubscriptionUpdateOptions { DefaultTaxRates = [stripeTaxRate.Id] };
subscription = await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionOptions);
return subscription;

View File

@ -25,7 +25,10 @@ public class PayPalIPNTransactionModel
var data = queryString
.AllKeys
.ToDictionary(key => key, key => queryString[key]);
.Where(key => !string.IsNullOrWhiteSpace(key))
.ToDictionary(key =>
key.Trim('\r'),
key => queryString[key]?.Trim('\r'));
TransactionId = Extract(data, "txn_id");
TransactionType = Extract(data, "txn_type");

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Text.Json;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
@ -17,8 +18,14 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
public Guid Id { get; set; }
[MaxLength(50)]
public string Identifier { get; set; }
/// <summary>
/// This value is HTML encoded. For display purposes use the method DisplayName() instead.
/// </summary>
[MaxLength(50)]
public string Name { get; set; }
/// <summary>
/// This value is HTML encoded. For display purposes use the method DisplayBusinessName() instead.
/// </summary>
[MaxLength(50)]
public string BusinessName { get; set; }
[MaxLength(50)]
@ -104,6 +111,22 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
}
}
/// <summary>
/// Returns the name of the organization, HTML decoded ready for display.
/// </summary>
public string DisplayName()
{
return WebUtility.HtmlDecode(Name);
}
/// <summary>
/// Returns the business name of the organization, HTML decoded ready for display.
/// </summary>
public string DisplayBusinessName()
{
return WebUtility.HtmlDecode(BusinessName);
}
public string BillingEmailAddress()
{
return BillingEmail?.ToLowerInvariant()?.Trim();
@ -111,12 +134,12 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
public string BillingName()
{
return BusinessName;
return DisplayBusinessName();
}
public string SubscriberName()
{
return Name;
return DisplayName();
}
public string BraintreeCustomerIdPrefix()

View File

@ -1,5 +1,7 @@
using Bit.Core.AdminConsole.Enums.Provider;
using System.Net;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Entities.Provider;
@ -7,7 +9,13 @@ namespace Bit.Core.AdminConsole.Entities.Provider;
public class Provider : ITableObject<Guid>
{
public Guid Id { get; set; }
/// <summary>
/// This value is HTML encoded. For display purposes use the method DisplayName() instead.
/// </summary>
public string Name { get; set; }
/// <summary>
/// This value is HTML encoded. For display purposes use the method DisplayBusinessName() instead.
/// </summary>
public string BusinessName { get; set; }
public string BusinessAddress1 { get; set; }
public string BusinessAddress2 { get; set; }
@ -22,6 +30,9 @@ public class Provider : ITableObject<Guid>
public bool Enabled { get; set; } = true;
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
public GatewayType? Gateway { get; set; }
public string GatewayCustomerId { get; set; }
public string GatewaySubscriptionId { get; set; }
public void SetNewId()
{
@ -30,4 +41,20 @@ public class Provider : ITableObject<Guid>
Id = CoreHelpers.GenerateComb();
}
}
/// <summary>
/// Returns the name of the provider, HTML decoded ready for display.
/// </summary>
public string DisplayName()
{
return WebUtility.HtmlDecode(Name);
}
/// <summary>
/// Returns the business name of the provider, HTML decoded ready for display.
/// </summary>
public string DisplayBusinessName()
{
return WebUtility.HtmlDecode(BusinessName);
}
}

View File

@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Enums.Provider;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
@ -6,6 +8,7 @@ public class OrganizationUserOrganizationDetails
{
public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
@ -37,6 +40,7 @@ public class OrganizationUserOrganizationDetails
public string PublicKey { get; set; }
public string PrivateKey { 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; }

View File

@ -1,4 +1,7 @@
using Bit.Core.Enums;
using System.Net;
using System.Text.Json.Serialization;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider;
@ -7,6 +10,10 @@ public class ProviderOrganizationOrganizationDetails
public Guid Id { get; set; }
public Guid ProviderId { get; set; }
public Guid OrganizationId { get; set; }
/// <summary>
/// This value is HTML encoded. For display purposes use the method DisplayName() instead.
/// </summary>
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string OrganizationName { get; set; }
public string Key { get; set; }
public string Settings { get; set; }
@ -16,4 +23,12 @@ public class ProviderOrganizationOrganizationDetails
public int? Seats { get; set; }
public string Plan { get; set; }
public OrganizationStatusType Status { get; set; }
/// <summary>
/// Returns the name of the organization, HTML decoded ready for display.
/// </summary>
public string DisplayName()
{
return WebUtility.HtmlDecode(OrganizationName);
}
}

View File

@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Enums.Provider;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider;
@ -7,6 +9,7 @@ public class ProviderOrganizationProviderDetails
public Guid Id { get; set; }
public Guid ProviderId { get; set; }
public Guid OrganizationId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public ProviderType ProviderType { get; set; }
}

View File

@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Enums.Provider;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider;
@ -6,6 +8,7 @@ public class ProviderUserOrganizationDetails
{
public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
@ -33,6 +36,7 @@ public class ProviderUserOrganizationDetails
public string PrivateKey { get; set; }
public Guid? ProviderId { get; set; }
public Guid? ProviderUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public Core.Enums.PlanType PlanType { get; set; }
public bool LimitCollectionCreationDeletion { get; set; }

View File

@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Enums.Provider;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider;
@ -6,6 +8,7 @@ public class ProviderUserProviderDetails
{
public Guid ProviderId { get; set; }
public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Key { get; set; }
public ProviderUserStatusType Status { get; set; }

View File

@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Enums.Provider;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider;
@ -7,6 +9,7 @@ public class ProviderUserUserDetails
public Guid Id { get; set; }
public Guid ProviderId { get; set; }
public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Email { get; set; }
public ProviderUserStatusType Status { get; set; }

View File

@ -537,6 +537,7 @@ public class OrganizationService : IOrganizationService
Storage = returnValue.Item1.MaxStorageGb,
// TODO: add reference events for SmSeats and Service Accounts - see AC-1481
});
return returnValue;
}
@ -819,7 +820,7 @@ public class OrganizationService : IOrganizationService
await customerService.UpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Email = organization.BillingEmail,
Description = organization.BusinessName
Description = organization.DisplayBusinessName()
});
}
}
@ -1276,7 +1277,7 @@ public class OrganizationService : IOrganizationService
orgUser.Email = null;
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
await _mailService.SendOrganizationConfirmedEmailAsync(organization.Name, user.Email);
await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email);
await DeleteAndPushUserRegistrationAsync(organizationId, user.Id);
succeededUsers.Add(orgUser);
result.Add(Tuple.Create(orgUser, ""));
@ -1412,18 +1413,18 @@ public class OrganizationService : IOrganizationService
}
// If the organization is using Flexible Collections, prevent use of any deprecated permissions
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(user.OrganizationId);
if (organizationAbility?.FlexibleCollections == true && user.Type == OrganizationUserType.Manager)
var organization = await _organizationRepository.GetByIdAsync(user.OrganizationId);
if (organization.FlexibleCollections && user.Type == OrganizationUserType.Manager)
{
throw new BadRequestException("The Manager role has been deprecated by collection enhancements. Use the collection Can Manage permission instead.");
}
if (organizationAbility?.FlexibleCollections == true && user.AccessAll)
if (organization.FlexibleCollections && user.AccessAll)
{
throw new BadRequestException("The AccessAll property has been deprecated by collection enhancements. Assign the user to collections instead.");
}
if (organizationAbility?.FlexibleCollections == true && collections?.Any() == true)
if (organization.FlexibleCollections && collections?.Any() == true)
{
var invalidAssociations = collections.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords));
if (invalidAssociations.Any())
@ -1440,7 +1441,6 @@ public class OrganizationService : IOrganizationService
var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(user.OrganizationId, 1);
if (additionalSmSeatsRequired > 0)
{
var organization = await _organizationRepository.GetByIdAsync(user.OrganizationId);
var update = new SecretsManagerSubscriptionUpdate(organization, true)
.AdjustSeats(additionalSmSeatsRequired);
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update);

View File

@ -124,14 +124,21 @@ public class PolicyService : IPolicyService
switch (policy.Type)
{
case PolicyType.TwoFactorAuthentication:
foreach (var orgUser in removableOrgUsers)
// Reorder by HasMasterPassword to prioritize checking users without a master if they have 2FA enabled
foreach (var orgUser in removableOrgUsers.OrderBy(ou => ou.HasMasterPassword))
{
if (!await userService.TwoFactorIsEnabledAsync(orgUser))
{
if (!orgUser.HasMasterPassword)
{
throw new BadRequestException(
"Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.");
}
await organizationService.DeleteUserAsync(policy.OrganizationId, orgUser.Id,
savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(
org.Name, orgUser.Email);
org.DisplayName(), orgUser.Email);
}
}
break;
@ -147,7 +154,7 @@ public class PolicyService : IPolicyService
await organizationService.DeleteUserAsync(policy.OrganizationId, orgUser.Id,
savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(
org.Name, orgUser.Email);
org.DisplayName(), orgUser.Email);
}
}
break;

View File

@ -4,6 +4,7 @@ using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Microsoft.Extensions.Logging;
using Duo = DuoUniversal;
namespace Bit.Core.Auth.Identity;
@ -25,6 +26,7 @@ public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService
private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory;
private readonly ILogger<TemporaryDuoWebV4SDKService> _logger;
/// <summary>
/// Constructor for the DuoUniversalPromptService. Used to supplement v2 implementation of Duo with v4 SDK
@ -34,11 +36,13 @@ public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService
public TemporaryDuoWebV4SDKService(
ICurrentContext currentContext,
GlobalSettings globalSettings,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory)
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
ILogger<TemporaryDuoWebV4SDKService> logger)
{
_currentContext = currentContext;
_globalSettings = globalSettings;
_tokenDataFactory = tokenDataFactory;
_logger = logger;
}
/// <summary>
@ -129,8 +133,9 @@ public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService
(string)provider.MetaData["Host"],
redirectUri).Build();
if (!await client.DoHealthCheck())
if (!await client.DoHealthCheck(true))
{
_logger.LogError("Unable to connect to Duo. Health check failed.");
return null;
}
return client;

View File

@ -103,19 +103,27 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider<User>
// established ownership in this context.
IsUserHandleOwnerOfCredentialIdAsync callback = (args, cancellationToken) => Task.FromResult(true);
var res = await _fido2.MakeAssertionAsync(clientResponse, options, webAuthCred.Item2.PublicKey, webAuthCred.Item2.SignatureCounter, callback);
try
{
var res = await _fido2.MakeAssertionAsync(clientResponse, options, webAuthCred.Item2.PublicKey, webAuthCred.Item2.SignatureCounter, callback);
provider.MetaData.Remove("login");
provider.MetaData.Remove("login");
// Update SignatureCounter
webAuthCred.Item2.SignatureCounter = res.Counter;
// Update SignatureCounter
webAuthCred.Item2.SignatureCounter = res.Counter;
var providers = user.GetTwoFactorProviders();
providers[TwoFactorProviderType.WebAuthn].MetaData[webAuthCred.Item1] = webAuthCred.Item2;
user.SetTwoFactorProviders(providers);
await userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, logEvent: false);
var providers = user.GetTwoFactorProviders();
providers[TwoFactorProviderType.WebAuthn].MetaData[webAuthCred.Item1] = webAuthCred.Item2;
user.SetTwoFactorProviders(providers);
await userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, logEvent: false);
return res.Status == "ok";
}
catch (Fido2VerificationException)
{
return false;
}
return res.Status == "ok";
}
private bool HasProperMetaData(TwoFactorProvider provider)

View File

@ -0,0 +1,9 @@
namespace Bit.Core.Billing.Constants;
public static class StripeCustomerAutomaticTaxStatus
{
public const string Failed = "failed";
public const string NotCollecting = "not_collecting";
public const string Supported = "supported";
public const string UnrecognizedLocation = "unrecognized_location";
}

View File

@ -0,0 +1,23 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Billing.Entities;
public class ProviderPlan : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid ProviderId { get; set; }
public PlanType PlanType { get; set; }
public int? SeatMinimum { get; set; }
public int? PurchasedSeats { get; set; }
public int? AllocatedSeats { get; set; }
public void SetNewId()
{
if (Id == default)
{
Id = CoreHelpers.GenerateComb();
}
}
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Billing.Entities;
using Bit.Core.Repositories;
namespace Bit.Core.Billing.Repositories;
public interface IProviderPlanRepository : IRepository<ProviderPlan, Guid>
{
Task<ProviderPlan> GetByProviderId(Guid providerId);
}

View File

@ -108,7 +108,6 @@ public static class FeatureFlagKeys
public const string TrustedDeviceEncryption = "trusted-device-encryption";
public const string Fido2VaultCredentials = "fido2-vault-credentials";
public const string VaultOnboarding = "vault-onboarding";
public const string AutofillV2 = "autofill-v2";
public const string BrowserFilelessImport = "browser-fileless-import";
/// <summary>
/// Deprecated - never used, do not use. Will always default to false. Will be deleted as part of Flexible Collections cleanup
@ -116,7 +115,6 @@ public static class FeatureFlagKeys
public const string FlexibleCollections = "flexible-collections-disabled-do-not-use";
public const string FlexibleCollectionsV1 = "flexible-collections-v-1"; // v-1 is intentional
public const string BulkCollectionAccess = "bulk-collection-access";
public const string AutofillOverlay = "autofill-overlay";
public const string ItemShare = "item-share";
public const string KeyRotationImprovements = "key-rotation-improvements";
public const string DuoRedirect = "duo-redirect";
@ -129,9 +127,10 @@ public static class FeatureFlagKeys
/// flexible collections
/// </summary>
public const string FlexibleCollectionsMigration = "flexible-collections-migration";
public const string AC1607_PresentUsersWithOffboardingSurvey = "AC-1607_present-user-offboarding-survey";
public const string PM5766AutomaticTax = "PM-5766-automatic-tax";
public const string PM5864DollarThreshold = "PM-5864-dollar-threshold";
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners";
public static List<string> GetAllKeys()
{

View File

@ -21,8 +21,8 @@
<ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.52" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.300.52" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.59" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.300.59" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.2" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.15.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.14.1" />
@ -32,22 +32,22 @@
<PackageReference Include="DnsClient" Version="1.7.0" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.4" />
<PackageReference Include="MailKit" Version="4.3.0" />
<PackageReference Include="MailKit" Version="4.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.25" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.38.0" />
<PackageReference Include="Microsoft.Azure.Cosmos.Table" Version="1.0.8" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.1.0" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="6.0.25" />
<PackageReference Include="Quartz" Version="3.4.0" />
<PackageReference Include="Quartz" Version="3.8.1" />
<PackageReference Include="SendGrid" Version="9.29.2" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Sentry.Serilog" Version="3.41.3" />
<PackageReference Include="Sentry.Serilog" Version="3.41.4" />
<PackageReference Include="Duende.IdentityServer" Version="6.3.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.1" />
@ -57,7 +57,7 @@
<PackageReference Include="Otp.NET" Version="1.3.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="6.0.25" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.0.0" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.2.0" />
</ItemGroup>
<ItemGroup>

View File

@ -20,6 +20,7 @@ public class Transaction : ITableObject<Guid>
[MaxLength(50)]
public string GatewayId { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public Guid? ProviderId { get; set; }
public void SetNewId()
{

View File

@ -0,0 +1,75 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
Welcome to Bitwarden! You can now get started with secure credential management and extend the benefits of end-to-end encryption across your entire organization.
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 10px; -webkit-text-size-adjust: none; text-align: center; border: 2px solid #175DDC; border-radius: 2px;" valign="top">
Your Master Password is <b>the only way</b> to unlock your account and only <b>you</b> hold the key. Memorize it, or write it down and keep it in a safe place.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="h3" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 18px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; font-weight: bold; padding: 0 0 5px;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
Install Bitwarden
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
Access your Bitwarden account from anywhere and any device at <a href="{{{WebVaultUrl}}}/?utm_source=welcome_email&utm_medium=email" target="_blank" rel="noopener" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: underline;">{{{WebVaultUrlHostname}}}</a>! For added convenience, <a href="https://bitwarden.com/download/" target="_blank" rel="noopener" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: underline;">download and install Bitwarden</a> on any desktop, device, and browser.
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<a href="https://bitwarden.com/download/" target="_blank" rel="noopener" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: auto; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: none;">
<img src="https://bitwarden.com/images/mail-download-options.png" alt="Windows, Mac, Linux, Android, Apple, Chrome, Safari, Firefox, Edge, Opera, Brave, Vivaldi, Tor" width="598" height="65" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; margin: auto; border: none; max-width: 100%; height: auto; display: block;" />
</a>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="h3" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 18px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; font-weight: bold; padding: 0 0 5px;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
Securely Share using Bitwarden Organizations
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
Bitwarden makes it easy for teams and enterprises to securely share passwords, developer secrets, and passkeys via Organizations. Join an Organization if invited, or launch a new one anytime from the <a href="{{{WebVaultUrl}}}/?utm_source=welcome_email&utm_medium=email" target="_blank" rel="noopener" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: underline;">Web App</a> with the <b>+ New Organization</b> button.
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top" align="center">
<a href="{{{WebVaultUrl}}}/?utm_source=welcome_email&utm_medium=email" clicktracking=off target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Login to Bitwarden
</a>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="h3" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 18px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; font-weight: bold; padding: 0 0 5px;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
Bitwarden is Here for You
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
Check out the <a href="http://www.bitwarden.com/help" target="_blank" rel="noopener" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: underline;">Help</a> site for documentation, join the <a href="https://community.bitwarden.com/" target="_blank" rel="noopener" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: underline;">Bitwarden Community</a> forums and connect with other enthusiasts on <a href="https://www.reddit.com/r/Bitwarden/" target="_blank" rel="noopener" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: underline;">Reddit</a>. If you have any questions or issues, <a href="http://www.bitwarden.com/contact" target="_blank" rel="noopener" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: underline;">contact support.</a>
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; font-weight: bold;" valign="top">
Stay safe and secure,<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
The Bitwarden Team
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,39 @@
{{#>FullTextLayout}}
Welcome to Bitwarden! You can now get started with secure credential management and extend the benefits of end-to-end encryption across your entire organization.
Your Master Password is the only way to unlock your account and only you hold the key. Memorize it, or write it down and keep it in a safe place.
Install Bitwarden
============
Access your Bitwarden account from anywhere and any device at the web vault ({{WebVaultUrl}}/?utm_source=welcome_email&utm_medium=email). For added convenience, download and install Bitwarden on any desktop, device and browser (http://www.bitwarden.com/download).
Download Options
============
http://www.bitwarden.com/download
Securely Share using Bitwarden Organizations
============
Bitwarden makes it easy for teams and enterprises to securely share passwords, developer secrets, and passkeys via Organizations. Join an Organization if invited, or launch a new one anytime from the Web App ({{WebVaultUrl}}/?utm_source=welcome_email&utm_medium=email) with the + New Organization button.
Login to Bitwarden
============
{{WebVaultUrl}}/?utm_source=welcome_email&utm_medium=email
Bitwarden is Here for You
============
Check out our Help (http://www.bitwarden.com/help) site for documentation, join the Bitwarden Community forums (https://community.bitwarden.com/) and connect with other enthusiasts on Reddit (https://www.reddit.com/r/Bitwarden/). If you have any questions or issues, contact support (http://www.bitwarden.com/contact).
Stay safe and secure,
The Bitwarden Team
{{/FullTextLayout}}

View File

@ -42,6 +42,7 @@ public class SubscriptionInfo
{
Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i));
}
CollectionMethod = sub.CollectionMethod;
}
public DateTime? TrialStartDate { get; set; }
@ -54,6 +55,7 @@ public class SubscriptionInfo
public string Status { get; set; }
public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public string CollectionMethod { get; set; }
public class BillingSubscriptionItem
{

View File

@ -15,7 +15,7 @@ public class OrganizationInvitesInfo
bool initOrganization = false
)
{
OrganizationName = org.Name;
OrganizationName = org.DisplayName();
OrgSsoIdentifier = org.Identifier;
IsFreeOrg = org.PlanType == PlanType.Free;

View File

@ -65,6 +65,6 @@ public class SendSponsorshipOfferCommand : ISendSponsorshipOfferCommand
throw new BadRequestException("Cannot find an outstanding sponsorship offer for this organization.");
}
await SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
await SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.DisplayName());
}
}

View File

@ -7,5 +7,6 @@ public interface ITransactionRepository : IRepository<Transaction, Guid>
{
Task<ICollection<Transaction>> GetManyByUserIdAsync(Guid userId);
Task<ICollection<Transaction>> GetManyByOrganizationIdAsync(Guid organizationId);
Task<ICollection<Transaction>> GetManyByProviderIdAsync(Guid providerId);
Task<Transaction> GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId);
}

View File

@ -77,5 +77,6 @@ public interface IMailService
Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier);
Task SendTrialInitiationEmailAsync(string email);
}

View File

@ -145,7 +145,7 @@ public class HandlebarsMailService : IMailService
public async Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails)
{
var message = CreateDefaultMessage($"{organization.Name} Seat Count Has Increased", ownerEmails);
var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Count Has Increased", ownerEmails);
var model = new OrganizationSeatsAutoscaledViewModel
{
OrganizationId = organization.Id,
@ -160,7 +160,7 @@ public class HandlebarsMailService : IMailService
public async Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails)
{
var message = CreateDefaultMessage($"{organization.Name} Seat Limit Reached", ownerEmails);
var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Limit Reached", ownerEmails);
var model = new OrganizationSeatsMaxReachedViewModel
{
OrganizationId = organization.Id,
@ -179,7 +179,7 @@ public class HandlebarsMailService : IMailService
var model = new OrganizationUserAcceptedViewModel
{
OrganizationId = organization.Id,
OrganizationName = CoreHelpers.SanitizeForEmail(organization.Name, false),
OrganizationName = CoreHelpers.SanitizeForEmail(organization.DisplayName(), false),
UserIdentifier = userIdentifier,
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
@ -251,6 +251,19 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendTrialInitiationEmailAsync(string userEmail)
{
var message = CreateDefaultMessage("Welcome to Bitwarden!", userEmail);
var model = new BaseMailModel
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};
await AddMessageContentAsync(message, "TrialInitiation", model);
message.Category = "Welcome";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendPasswordlessSignInAsync(string returnUrl, string token, string email)
{
var message = CreateDefaultMessage("[Admin] Continue Logging In", email);
@ -920,7 +933,7 @@ public class HandlebarsMailService : IMailService
public async Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
var message = CreateDefaultMessage($"{organization.Name} Secrets Manager Seat Limit Reached", ownerEmails);
var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Seat Limit Reached", ownerEmails);
var model = new OrganizationSeatsMaxReachedViewModel
{
OrganizationId = organization.Id,
@ -935,7 +948,7 @@ public class HandlebarsMailService : IMailService
public async Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
var message = CreateDefaultMessage($"{organization.Name} Secrets Manager Service Accounts Limit Reached", ownerEmails);
var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Service Accounts Limit Reached", ownerEmails);
var model = new OrganizationServiceAccountsMaxReachedViewModel
{
OrganizationId = organization.Id,

View File

@ -132,13 +132,13 @@ public class LicensingService : ILicensingService
{
_logger.LogInformation(Constants.BypassFiltersEventId, null,
"Organization {0} ({1}) has an invalid license and is being disabled. Reason: {2}",
org.Id, org.Name, reason);
org.Id, org.DisplayName(), reason);
org.Enabled = false;
org.ExpirationDate = license?.Expires ?? DateTime.UtcNow;
org.RevisionDate = DateTime.UtcNow;
await _organizationRepository.ReplaceAsync(org);
await _mailService.SendLicenseExpiredAsync(new List<string> { org.BillingEmail }, org.Name);
await _mailService.SendLicenseExpiredAsync(new List<string> { org.BillingEmail }, org.DisplayName());
}
public async Task ValidateUsersAsync()

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -125,59 +126,61 @@ public class StripePaymentService : IPaymentService
var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon
, additionalSmSeats, additionalServiceAccount);
Stripe.Customer customer = null;
Stripe.Subscription subscription;
Customer customer = null;
Subscription subscription;
try
{
customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions
var customerCreateOptions = new CustomerCreateOptions
{
Description = org.BusinessName,
Description = org.DisplayBusinessName(),
Email = org.BillingEmail,
Source = stipeCustomerSourceToken,
PaymentMethod = stipeCustomerPaymentMethodId,
Metadata = stripeCustomerMetadata,
InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = stipeCustomerPaymentMethodId,
CustomFields = new List<Stripe.CustomerInvoiceSettingsCustomFieldOptions>
{
new Stripe.CustomerInvoiceSettingsCustomFieldOptions()
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = org.SubscriberType(),
Value = GetFirstThirtyCharacters(org.SubscriberName()),
},
},
}
],
},
Coupon = signupIsFromSecretsManagerTrial
? SecretsManagerStandaloneDiscountId
: provider
? ProviderDiscountId
: null,
Address = new Stripe.AddressOptions
Address = new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
Country = taxInfo?.BillingAddressCountry,
PostalCode = taxInfo?.BillingAddressPostalCode,
// Line1 is required in Stripe's API, suggestion in Docs is to use Business Name instead.
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState,
Line1 = taxInfo?.BillingAddressLine1 ?? string.Empty,
Line2 = taxInfo?.BillingAddressLine2,
City = taxInfo?.BillingAddressCity,
State = taxInfo?.BillingAddressState,
},
TaxIdData = !taxInfo.HasTaxId ? null : new List<Stripe.CustomerTaxIdDataOptions>
{
new Stripe.CustomerTaxIdDataOptions
{
Type = taxInfo.TaxIdType,
Value = taxInfo.TaxIdNumber,
},
},
});
TaxIdData = taxInfo?.HasTaxId != true
? null
:
[
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, }
],
};
customerCreateOptions.AddExpand("tax");
customer = await _stripeAdapter.CustomerCreateAsync(customerCreateOptions);
subCreateOptions.AddExpand("latest_invoice.payment_intent");
subCreateOptions.Customer = customer.Id;
if (pm5766AutomaticTaxIsEnabled)
if (pm5766AutomaticTaxIsEnabled && CustomerHasTaxLocationVerified(customer))
{
subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true };
subCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions);
@ -185,7 +188,7 @@ public class StripePaymentService : IPaymentService
{
if (subscription.LatestInvoice.PaymentIntent.Status == "requires_payment_method")
{
await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new Stripe.SubscriptionCancelOptions());
await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new SubscriptionCancelOptions());
throw new GatewayException("Payment method was declined.");
}
}
@ -252,9 +255,10 @@ public class StripePaymentService : IPaymentService
throw new BadRequestException("Organization already has a subscription.");
}
var customerOptions = new Stripe.CustomerGetOptions();
var customerOptions = new CustomerGetOptions();
customerOptions.AddExpand("default_source");
customerOptions.AddExpand("invoice_settings.default_payment_method");
customerOptions.AddExpand("tax");
var customer = await _stripeAdapter.CustomerGetAsync(org.GatewayCustomerId, customerOptions);
if (customer == null)
{
@ -301,14 +305,15 @@ public class StripePaymentService : IPaymentService
var customerUpdateOptions = new CustomerUpdateOptions { Address = addressOptions };
customerUpdateOptions.AddExpand("default_source");
customerUpdateOptions.AddExpand("invoice_settings.default_payment_method");
customerUpdateOptions.AddExpand("tax");
customer = await _stripeAdapter.CustomerUpdateAsync(org.GatewayCustomerId, customerUpdateOptions);
}
var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, upgrade);
if (pm5766AutomaticTaxIsEnabled)
if (pm5766AutomaticTaxIsEnabled && CustomerHasTaxLocationVerified(customer))
{
subCreateOptions.DefaultTaxRates = new List<string>();
subCreateOptions.DefaultTaxRates = [];
subCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
@ -333,7 +338,7 @@ public class StripePaymentService : IPaymentService
}
private (bool stripePaymentMethod, PaymentMethodType PaymentMethodType) IdentifyPaymentMethod(
Stripe.Customer customer, Stripe.SubscriptionCreateOptions subCreateOptions)
Customer customer, SubscriptionCreateOptions subCreateOptions)
{
var stripePaymentMethod = false;
var paymentMethodType = PaymentMethodType.Credit;
@ -351,12 +356,12 @@ public class StripePaymentService : IPaymentService
}
else if (customer.DefaultSource != null)
{
if (customer.DefaultSource is Stripe.Card || customer.DefaultSource is Stripe.SourceCard)
if (customer.DefaultSource is Card || customer.DefaultSource is SourceCard)
{
paymentMethodType = PaymentMethodType.Card;
stripePaymentMethod = true;
}
else if (customer.DefaultSource is Stripe.BankAccount || customer.DefaultSource is Stripe.SourceAchDebit)
else if (customer.DefaultSource is BankAccount || customer.DefaultSource is SourceAchDebit)
{
paymentMethodType = PaymentMethodType.BankAccount;
stripePaymentMethod = true;
@ -394,7 +399,7 @@ public class StripePaymentService : IPaymentService
}
var createdStripeCustomer = false;
Stripe.Customer customer = null;
Customer customer = null;
Braintree.Customer braintreeCustomer = null;
var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount
or PaymentMethodType.Credit;
@ -422,14 +427,23 @@ public class StripePaymentService : IPaymentService
try
{
customer = await _stripeAdapter.CustomerGetAsync(user.GatewayCustomerId);
var customerGetOptions = new CustomerGetOptions();
customerGetOptions.AddExpand("tax");
customer = await _stripeAdapter.CustomerGetAsync(user.GatewayCustomerId, customerGetOptions);
}
catch
{
_logger.LogWarning(
"Attempted to get existing customer from Stripe, but customer ID was not found. Attempting to recreate customer...");
}
catch { }
}
if (customer == null && !string.IsNullOrWhiteSpace(paymentToken))
{
var stripeCustomerMetadata = new Dictionary<string, string> { { "region", _globalSettings.BaseServiceUri.CloudRegion } };
var stripeCustomerMetadata = new Dictionary<string, string>
{
{ "region", _globalSettings.BaseServiceUri.CloudRegion }
};
if (paymentMethodType == PaymentMethodType.PayPal)
{
var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false);
@ -458,32 +472,35 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("Payment method is not supported at this time.");
}
customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions
var customerCreateOptions = new CustomerCreateOptions
{
Description = user.Name,
Email = user.Email,
Metadata = stripeCustomerMetadata,
PaymentMethod = stipeCustomerPaymentMethodId,
Source = stipeCustomerSourceToken,
InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = stipeCustomerPaymentMethodId,
CustomFields = new List<Stripe.CustomerInvoiceSettingsCustomFieldOptions>
{
new Stripe.CustomerInvoiceSettingsCustomFieldOptions()
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions()
{
Name = user.SubscriberType(),
Value = GetFirstThirtyCharacters(user.SubscriberName()),
},
}
}
]
},
Address = new Stripe.AddressOptions
Address = new AddressOptions
{
Line1 = string.Empty,
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
},
});
};
customerCreateOptions.AddExpand("tax");
customer = await _stripeAdapter.CustomerCreateAsync(customerCreateOptions);
createdStripeCustomer = true;
}
@ -492,17 +509,17 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("Could not set up customer payment profile.");
}
var subCreateOptions = new Stripe.SubscriptionCreateOptions
var subCreateOptions = new SubscriptionCreateOptions
{
Customer = customer.Id,
Items = new List<Stripe.SubscriptionItemOptions>(),
Items = [],
Metadata = new Dictionary<string, string>
{
[user.GatewayIdField()] = user.Id.ToString()
}
};
subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = PremiumPlanId,
Quantity = 1
@ -524,25 +541,22 @@ public class StripePaymentService : IPaymentService
var taxRate = taxRates.FirstOrDefault();
if (taxRate != null)
{
subCreateOptions.DefaultTaxRates = new List<string>(1)
{
taxRate.Id
};
subCreateOptions.DefaultTaxRates = [taxRate.Id];
}
}
if (additionalStorageGb > 0)
{
subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = StoragePlanId,
Quantity = additionalStorageGb
});
}
if (pm5766AutomaticTaxIsEnabled)
if (pm5766AutomaticTaxIsEnabled && CustomerHasTaxLocationVerified(customer))
{
subCreateOptions.DefaultTaxRates = new List<string>();
subCreateOptions.DefaultTaxRates = [];
subCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
@ -558,34 +572,33 @@ public class StripePaymentService : IPaymentService
{
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
}
else
{
user.Premium = true;
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
return null;
}
user.Premium = true;
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
return null;
}
private async Task<Stripe.Subscription> ChargeForNewSubscriptionAsync(ISubscriber subscriber, Stripe.Customer customer,
private async Task<Subscription> ChargeForNewSubscriptionAsync(ISubscriber subscriber, Customer customer,
bool createdStripeCustomer, bool stripePaymentMethod, PaymentMethodType paymentMethodType,
Stripe.SubscriptionCreateOptions subCreateOptions, Braintree.Customer braintreeCustomer)
SubscriptionCreateOptions subCreateOptions, Braintree.Customer braintreeCustomer)
{
var addedCreditToStripeCustomer = false;
Braintree.Transaction braintreeTransaction = null;
var subInvoiceMetadata = new Dictionary<string, string>();
Stripe.Subscription subscription = null;
Subscription subscription = null;
try
{
if (!stripePaymentMethod)
{
var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions
var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(new UpcomingInvoiceOptions
{
Customer = customer.Id,
SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items)
});
if (_featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax))
if (_featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax) &&
CustomerHasTaxLocationVerified(customer))
{
previewInvoice.AutomaticTax = new InvoiceAutomaticTax { Enabled = true };
}
@ -632,7 +645,7 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("No payment was able to be collected.");
}
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = customer.Balance - previewInvoice.AmountDue
});
@ -649,10 +662,10 @@ public class StripePaymentService : IPaymentService
};
var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax);
if (pm5766AutomaticTaxIsEnabled)
if (pm5766AutomaticTaxIsEnabled && CustomerHasTaxLocationVerified(customer))
{
upcomingInvoiceOptions.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
upcomingInvoiceOptions.SubscriptionDefaultTaxRates = new List<string>();
upcomingInvoiceOptions.SubscriptionDefaultTaxRates = [];
}
var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(upcomingInvoiceOptions);
@ -666,17 +679,12 @@ public class StripePaymentService : IPaymentService
subCreateOptions.OffSession = true;
subCreateOptions.AddExpand("latest_invoice.payment_intent");
if (_featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax))
{
subCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions);
if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null)
{
if (subscription.LatestInvoice.PaymentIntent.Status == "requires_payment_method")
{
await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new Stripe.SubscriptionCancelOptions());
await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new SubscriptionCancelOptions());
throw new GatewayException("Payment method was declined.");
}
}
@ -694,7 +702,7 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("Invoice not found.");
}
await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new Stripe.InvoiceUpdateOptions
await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new InvoiceUpdateOptions
{
Metadata = subInvoiceMetadata
});
@ -712,7 +720,7 @@ public class StripePaymentService : IPaymentService
}
else if (addedCreditToStripeCustomer || customer.Balance < 0)
{
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = customer.Balance
});
@ -727,7 +735,7 @@ public class StripePaymentService : IPaymentService
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
}
if (e is Stripe.StripeException strEx &&
if (e is StripeException strEx &&
(strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false))
{
throw new GatewayException("Bank account is not yet verified.");
@ -737,10 +745,10 @@ public class StripePaymentService : IPaymentService
}
}
private List<Stripe.InvoiceSubscriptionItemOptions> ToInvoiceSubscriptionItemOptions(
List<Stripe.SubscriptionItemOptions> subItemOptions)
private List<InvoiceSubscriptionItemOptions> ToInvoiceSubscriptionItemOptions(
List<SubscriptionItemOptions> subItemOptions)
{
return subItemOptions.Select(si => new Stripe.InvoiceSubscriptionItemOptions
return subItemOptions.Select(si => new InvoiceSubscriptionItemOptions
{
Plan = si.Plan,
Price = si.Price,
@ -753,7 +761,10 @@ public class StripePaymentService : IPaymentService
SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate, bool invoiceNow = false)
{
// remember, when in doubt, throw
var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId);
var subGetOptions = new SubscriptionGetOptions();
// subGetOptions.AddExpand("customer");
subGetOptions.AddExpand("customer.tax");
var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId, subGetOptions);
if (sub == null)
{
throw new GatewayException("Subscription not found.");
@ -766,7 +777,7 @@ public class StripePaymentService : IPaymentService
var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub);
var isPm5864DollarThresholdEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5864DollarThreshold);
var subUpdateOptions = new Stripe.SubscriptionUpdateOptions
var subUpdateOptions = new SubscriptionUpdateOptions
{
Items = updatedItemOptions,
ProrationBehavior = !isPm5864DollarThresholdEnabled || invoiceNow
@ -777,7 +788,7 @@ public class StripePaymentService : IPaymentService
ProrationDate = prorationDate,
};
var immediatelyInvoice = false;
if (!invoiceNow && isPm5864DollarThresholdEnabled)
if (!invoiceNow && isPm5864DollarThresholdEnabled && sub.Status.Trim() != "trialing")
{
var upcomingInvoiceWithChanges = await _stripeAdapter.InvoiceUpcomingAsync(new UpcomingInvoiceOptions
{
@ -789,7 +800,8 @@ public class StripePaymentService : IPaymentService
SubscriptionBillingCycleAnchor = SubscriptionBillingCycleAnchor.Now
});
immediatelyInvoice = upcomingInvoiceWithChanges.AmountRemaining >= 50000;
var isAnnualPlan = sub?.Items?.Data.FirstOrDefault()?.Plan?.Interval == "year";
immediatelyInvoice = isAnnualPlan && upcomingInvoiceWithChanges.AmountRemaining >= 50000;
subUpdateOptions.BillingCycleAnchor = immediatelyInvoice
? SubscriptionBillingCycleAnchor.Now
@ -797,9 +809,11 @@ public class StripePaymentService : IPaymentService
}
var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax);
if (pm5766AutomaticTaxIsEnabled)
if (pm5766AutomaticTaxIsEnabled &&
sub.AutomaticTax.Enabled != true &&
CustomerHasTaxLocationVerified(sub.Customer))
{
subUpdateOptions.DefaultTaxRates = new List<string>();
subUpdateOptions.DefaultTaxRates = [];
subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
@ -824,7 +838,7 @@ public class StripePaymentService : IPaymentService
var taxRate = taxRates.FirstOrDefault();
if (taxRate != null && !sub.DefaultTaxRates.Any(x => x.Equals(taxRate.Id)))
{
subUpdateOptions.DefaultTaxRates = new List<string>(1) { taxRate.Id };
subUpdateOptions.DefaultTaxRates = [taxRate.Id];
}
}
}
@ -834,7 +848,7 @@ public class StripePaymentService : IPaymentService
{
var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions);
var invoice = await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new Stripe.InvoiceGetOptions());
var invoice = await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new InvoiceGetOptions());
if (invoice == null)
{
throw new BadRequestException("Unable to locate draft invoice for subscription update.");
@ -852,11 +866,11 @@ public class StripePaymentService : IPaymentService
}
else
{
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new Stripe.InvoiceFinalizeOptions
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new InvoiceFinalizeOptions
{
AutoAdvance = false,
});
await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new Stripe.InvoiceSendOptions());
await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new InvoiceSendOptions());
paymentIntentClientSecret = null;
}
}
@ -864,7 +878,7 @@ public class StripePaymentService : IPaymentService
catch
{
// Need to revert the subscription
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
Items = subscriptionUpdate.RevertItemsOptions(sub),
// This proration behavior prevents a false "credit" from
@ -889,7 +903,7 @@ public class StripePaymentService : IPaymentService
// Change back the subscription collection method and/or days until due
if (collectionMethod != "send_invoice" || daysUntilDue == null)
{
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
CollectionMethod = collectionMethod,
DaysUntilDue = daysUntilDue,
@ -950,7 +964,7 @@ public class StripePaymentService : IPaymentService
if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
{
await _stripeAdapter.SubscriptionCancelAsync(subscriber.GatewaySubscriptionId,
new Stripe.SubscriptionCancelOptions());
new SubscriptionCancelOptions());
}
if (string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
@ -983,7 +997,7 @@ public class StripePaymentService : IPaymentService
}
else
{
var charges = await _stripeAdapter.ChargeListAsync(new Stripe.ChargeListOptions
var charges = await _stripeAdapter.ChargeListAsync(new ChargeListOptions
{
Customer = subscriber.GatewayCustomerId
});
@ -992,7 +1006,7 @@ public class StripePaymentService : IPaymentService
{
foreach (var charge in charges.Data.Where(c => c.Captured && !c.Refunded))
{
await _stripeAdapter.RefundCreateAsync(new Stripe.RefundCreateOptions { Charge = charge.Id });
await _stripeAdapter.RefundCreateAsync(new RefundCreateOptions { Charge = charge.Id });
}
}
}
@ -1000,9 +1014,9 @@ public class StripePaymentService : IPaymentService
await _stripeAdapter.CustomerDeleteAsync(subscriber.GatewayCustomerId);
}
public async Task<string> PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, Stripe.Invoice invoice)
public async Task<string> PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, Invoice invoice)
{
var customerOptions = new Stripe.CustomerGetOptions();
var customerOptions = new CustomerGetOptions();
customerOptions.AddExpand("default_source");
customerOptions.AddExpand("invoice_settings.default_payment_method");
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions);
@ -1016,7 +1030,7 @@ public class StripePaymentService : IPaymentService
{
var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card";
var hasDefaultValidSource = customer.DefaultSource != null &&
(customer.DefaultSource is Stripe.Card || customer.DefaultSource is Stripe.BankAccount);
(customer.DefaultSource is Card || customer.DefaultSource is BankAccount);
if (!hasDefaultCardPaymentMethod && !hasDefaultValidSource)
{
cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id;
@ -1029,7 +1043,7 @@ public class StripePaymentService : IPaymentService
}
catch
{
await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new Stripe.InvoiceFinalizeOptions
await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions
{
AutoAdvance = false
});
@ -1045,11 +1059,11 @@ public class StripePaymentService : IPaymentService
{
// Finalize the invoice (from Draft) w/o auto-advance so we
// can attempt payment manually.
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new Stripe.InvoiceFinalizeOptions
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions
{
AutoAdvance = false,
});
var invoicePayOptions = new Stripe.InvoicePayOptions
var invoicePayOptions = new InvoicePayOptions
{
PaymentMethod = cardPaymentMethodId,
};
@ -1083,7 +1097,7 @@ public class StripePaymentService : IPaymentService
}
braintreeTransaction = transactionResult.Target;
invoice = await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new Stripe.InvoiceUpdateOptions
invoice = await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new InvoiceUpdateOptions
{
Metadata = new Dictionary<string, string>
{
@ -1099,13 +1113,13 @@ public class StripePaymentService : IPaymentService
{
invoice = await _stripeAdapter.InvoicePayAsync(invoice.Id, invoicePayOptions);
}
catch (Stripe.StripeException e)
catch (StripeException e)
{
if (e.HttpStatusCode == System.Net.HttpStatusCode.PaymentRequired &&
e.StripeError?.Code == "invoice_payment_intent_requires_action")
{
// SCA required, get intent client secret
var invoiceGetOptions = new Stripe.InvoiceGetOptions();
var invoiceGetOptions = new InvoiceGetOptions();
invoiceGetOptions.AddExpand("payment_intent");
invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions);
paymentIntentClientSecret = invoice?.PaymentIntent?.ClientSecret;
@ -1130,7 +1144,7 @@ public class StripePaymentService : IPaymentService
return paymentIntentClientSecret;
}
invoice = await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id, new Stripe.InvoiceVoidOptions());
invoice = await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions());
// HACK: Workaround for customer balance credit
if (invoice.StartingBalance < 0)
@ -1143,7 +1157,7 @@ public class StripePaymentService : IPaymentService
// Assumption: Customer balance should now be $0, otherwise payment would not have failed.
if (customer.Balance == 0)
{
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = invoice.StartingBalance
});
@ -1151,7 +1165,7 @@ public class StripePaymentService : IPaymentService
}
}
if (e is Stripe.StripeException strEx &&
if (e is StripeException strEx &&
(strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false))
{
throw new GatewayException("Bank account is not yet verified.");
@ -1192,14 +1206,14 @@ public class StripePaymentService : IPaymentService
{
var canceledSub = endOfPeriod ?
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id,
new Stripe.SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) :
await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new Stripe.SubscriptionCancelOptions());
new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) :
await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new SubscriptionCancelOptions());
if (!canceledSub.CanceledAt.HasValue)
{
throw new GatewayException("Unable to cancel subscription.");
}
}
catch (Stripe.StripeException e)
catch (StripeException e)
{
if (e.Message != $"No such subscription: {subscriber.GatewaySubscriptionId}")
{
@ -1233,7 +1247,7 @@ public class StripePaymentService : IPaymentService
}
var updatedSub = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id,
new Stripe.SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
if (updatedSub.CanceledAt.HasValue)
{
throw new GatewayException("Unable to reinstate subscription.");
@ -1264,12 +1278,11 @@ public class StripePaymentService : IPaymentService
};
var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount;
Stripe.Customer customer = null;
Customer customer = null;
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{
var options = new Stripe.CustomerGetOptions();
options.AddExpand("sources");
var options = new CustomerGetOptions { Expand = ["sources", "tax", "subscriptions"] };
customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, options);
if (customer.Metadata?.Any() ?? false)
{
@ -1369,26 +1382,27 @@ public class StripePaymentService : IPaymentService
{
if (customer == null)
{
customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions
customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions
{
Description = subscriber.BillingName(),
Email = subscriber.BillingEmailAddress(),
Metadata = stripeCustomerMetadata,
Source = stipeCustomerSourceToken,
PaymentMethod = stipeCustomerPaymentMethodId,
InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = stipeCustomerPaymentMethodId,
CustomFields = new List<Stripe.CustomerInvoiceSettingsCustomFieldOptions>
{
new Stripe.CustomerInvoiceSettingsCustomFieldOptions()
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions()
{
Name = subscriber.SubscriberType(),
Value = GetFirstThirtyCharacters(subscriber.SubscriberName()),
},
}
}
]
},
Address = taxInfo == null ? null : new Stripe.AddressOptions
Address = taxInfo == null ? null : new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
@ -1397,7 +1411,7 @@ public class StripePaymentService : IPaymentService
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState,
},
Expand = new List<string> { "sources" },
Expand = ["sources", "tax", "subscriptions"],
});
subscriber.Gateway = GatewayType.Stripe;
@ -1413,7 +1427,7 @@ public class StripePaymentService : IPaymentService
{
if (!string.IsNullOrWhiteSpace(stipeCustomerSourceToken) && paymentToken.StartsWith("btok_"))
{
var bankAccount = await _stripeAdapter.BankAccountCreateAsync(customer.Id, new Stripe.BankAccountCreateOptions
var bankAccount = await _stripeAdapter.BankAccountCreateAsync(customer.Id, new BankAccountCreateOptions
{
Source = paymentToken
});
@ -1422,7 +1436,7 @@ public class StripePaymentService : IPaymentService
else if (!string.IsNullOrWhiteSpace(stipeCustomerPaymentMethodId))
{
await _stripeAdapter.PaymentMethodAttachAsync(stipeCustomerPaymentMethodId,
new Stripe.PaymentMethodAttachOptions { Customer = customer.Id });
new PaymentMethodAttachOptions { Customer = customer.Id });
defaultPaymentMethodId = stipeCustomerPaymentMethodId;
}
}
@ -1431,44 +1445,44 @@ public class StripePaymentService : IPaymentService
{
foreach (var source in customer.Sources.Where(s => s.Id != defaultSourceId))
{
if (source is Stripe.BankAccount)
if (source is BankAccount)
{
await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
}
else if (source is Stripe.Card)
else if (source is Card)
{
await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
}
}
}
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(new Stripe.PaymentMethodListOptions
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(new PaymentMethodListOptions
{
Customer = customer.Id,
Type = "card"
});
foreach (var cardMethod in cardPaymentMethods.Where(m => m.Id != defaultPaymentMethodId))
{
await _stripeAdapter.PaymentMethodDetachAsync(cardMethod.Id, new Stripe.PaymentMethodDetachOptions());
await _stripeAdapter.PaymentMethodDetachAsync(cardMethod.Id, new PaymentMethodDetachOptions());
}
customer = await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
customer = await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = stripeCustomerMetadata,
DefaultSource = defaultSourceId,
InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = defaultPaymentMethodId,
CustomFields = new List<Stripe.CustomerInvoiceSettingsCustomFieldOptions>
{
new Stripe.CustomerInvoiceSettingsCustomFieldOptions()
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions()
{
Name = subscriber.SubscriberType(),
Value = GetFirstThirtyCharacters(subscriber.SubscriberName())
},
}
}
]
},
Address = taxInfo == null ? null : new Stripe.AddressOptions
Address = taxInfo == null ? null : new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
@ -1477,8 +1491,27 @@ public class StripePaymentService : IPaymentService
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState,
},
Expand = ["tax", "subscriptions"]
});
}
if (_featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax) &&
!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId) &&
customer.Subscriptions.Any(sub =>
sub.Id == subscriber.GatewaySubscriptionId &&
!sub.AutomaticTax.Enabled) &&
CustomerHasTaxLocationVerified(customer))
{
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
DefaultTaxRates = []
};
_ = await _stripeAdapter.SubscriptionUpdateAsync(
subscriber.GatewaySubscriptionId,
subscriptionUpdateOptions);
}
}
catch
{
@ -1494,7 +1527,7 @@ public class StripePaymentService : IPaymentService
public async Task<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount)
{
Stripe.Customer customer = null;
Customer customer = null;
var customerExists = subscriber.Gateway == GatewayType.Stripe &&
!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId);
if (customerExists)
@ -1503,7 +1536,7 @@ public class StripePaymentService : IPaymentService
}
else
{
customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions
customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions
{
Email = subscriber.BillingEmailAddress(),
Description = subscriber.BillingName(),
@ -1511,7 +1544,7 @@ public class StripePaymentService : IPaymentService
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = customer.Id;
}
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = customer.Balance - (long)(creditAmount * 100)
});
@ -1614,7 +1647,7 @@ public class StripePaymentService : IPaymentService
}
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId,
new Stripe.CustomerGetOptions { Expand = new List<string> { "tax_ids" } });
new CustomerGetOptions { Expand = ["tax_ids"] });
if (customer == null)
{
@ -1647,9 +1680,9 @@ public class StripePaymentService : IPaymentService
{
if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{
var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new Stripe.CustomerUpdateOptions
var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions
{
Address = new Stripe.AddressOptions
Address = new AddressOptions
{
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
Line2 = taxInfo.BillingAddressLine2,
@ -1658,7 +1691,7 @@ public class StripePaymentService : IPaymentService
PostalCode = taxInfo.BillingAddressPostalCode,
Country = taxInfo.BillingAddressCountry,
},
Expand = new List<string> { "tax_ids" }
Expand = ["tax_ids"]
});
if (!subscriber.IsUser() && customer != null)
@ -1672,7 +1705,7 @@ public class StripePaymentService : IPaymentService
if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) &&
!string.IsNullOrWhiteSpace(taxInfo.TaxIdType))
{
await _stripeAdapter.TaxIdCreateAsync(customer.Id, new Stripe.TaxIdCreateOptions
await _stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions
{
Type = taxInfo.TaxIdType,
Value = taxInfo.TaxIdNumber,
@ -1684,7 +1717,7 @@ public class StripePaymentService : IPaymentService
public async Task<TaxRate> CreateTaxRateAsync(TaxRate taxRate)
{
var stripeTaxRateOptions = new Stripe.TaxRateCreateOptions()
var stripeTaxRateOptions = new TaxRateCreateOptions()
{
DisplayName = $"{taxRate.Country} - {taxRate.PostalCode}",
Inclusive = false,
@ -1717,7 +1750,7 @@ public class StripePaymentService : IPaymentService
var updatedStripeTaxRate = await _stripeAdapter.TaxRateUpdateAsync(
taxRate.Id,
new Stripe.TaxRateUpdateOptions() { Active = false }
new TaxRateUpdateOptions() { Active = false }
);
if (!updatedStripeTaxRate.Active)
{
@ -1738,32 +1771,36 @@ public class StripePaymentService : IPaymentService
{
var subscriptionInfo = await GetSubscriptionAsync(organization);
if (subscriptionInfo.Subscription is not { Status: "active" or "trialing" or "past_due" } ||
subscriptionInfo.UpcomingInvoice == null)
if (subscriptionInfo.Subscription is not
{
Status: "active" or "trialing" or "past_due",
CollectionMethod: "charge_automatically"
}
|| subscriptionInfo.UpcomingInvoice == null)
{
return false;
}
var customer = await GetCustomerAsync(organization.GatewayCustomerId);
var customer = await GetCustomerAsync(organization.GatewayCustomerId, GetCustomerPaymentOptions());
var paymentSource = await GetBillingPaymentSourceAsync(customer);
return paymentSource == null;
}
private Stripe.PaymentMethod GetLatestCardPaymentMethod(string customerId)
private PaymentMethod GetLatestCardPaymentMethod(string customerId)
{
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(
new Stripe.PaymentMethodListOptions { Customer = customerId, Type = "card" });
new PaymentMethodListOptions { Customer = customerId, Type = "card" });
return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault();
}
private decimal GetBillingBalance(Stripe.Customer customer)
private decimal GetBillingBalance(Customer customer)
{
return customer != null ? customer.Balance / 100M : default;
}
private async Task<BillingInfo.BillingSource> GetBillingPaymentSourceAsync(Stripe.Customer customer)
private async Task<BillingInfo.BillingSource> GetBillingPaymentSourceAsync(Customer customer)
{
if (customer == null)
{
@ -1792,7 +1829,7 @@ public class StripePaymentService : IPaymentService
}
if (customer.DefaultSource != null &&
(customer.DefaultSource is Stripe.Card || customer.DefaultSource is Stripe.BankAccount))
(customer.DefaultSource is Card || customer.DefaultSource is BankAccount))
{
return new BillingInfo.BillingSource(customer.DefaultSource);
}
@ -1801,27 +1838,27 @@ public class StripePaymentService : IPaymentService
return paymentMethod != null ? new BillingInfo.BillingSource(paymentMethod) : null;
}
private Stripe.CustomerGetOptions GetCustomerPaymentOptions()
private CustomerGetOptions GetCustomerPaymentOptions()
{
var customerOptions = new Stripe.CustomerGetOptions();
var customerOptions = new CustomerGetOptions();
customerOptions.AddExpand("default_source");
customerOptions.AddExpand("invoice_settings.default_payment_method");
return customerOptions;
}
private async Task<Stripe.Customer> GetCustomerAsync(string gatewayCustomerId, Stripe.CustomerGetOptions options = null)
private async Task<Customer> GetCustomerAsync(string gatewayCustomerId, CustomerGetOptions options = null)
{
if (string.IsNullOrWhiteSpace(gatewayCustomerId))
{
return null;
}
Stripe.Customer customer = null;
Customer customer = null;
try
{
customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId, options);
}
catch (Stripe.StripeException) { }
catch (StripeException) { }
return customer;
}
@ -1843,7 +1880,7 @@ public class StripePaymentService : IPaymentService
}
private async Task<IEnumerable<BillingInfo.BillingInvoice>> GetBillingInvoicesAsync(Stripe.Customer customer)
private async Task<IEnumerable<BillingInfo.BillingInvoice>> GetBillingInvoicesAsync(Customer customer)
{
if (customer == null)
{
@ -1865,28 +1902,32 @@ public class StripePaymentService : IPaymentService
.OrderByDescending(invoice => invoice.Created)
.Select(invoice => new BillingInfo.BillingInvoice(invoice));
}
catch (Stripe.StripeException exception)
catch (StripeException exception)
{
_logger.LogError(exception, "An error occurred while listing Stripe invoices");
throw new GatewayException("Failed to retrieve current invoices", exception);
}
}
/// <summary>
/// Determines if a Stripe customer supports automatic tax
/// </summary>
/// <param name="customer"></param>
/// <returns></returns>
private static bool CustomerHasTaxLocationVerified(Customer customer) =>
customer?.Tax?.AutomaticTax == StripeCustomerAutomaticTaxStatus.Supported;
// We are taking only first 30 characters of the SubscriberName because stripe provide
// for 30 characters for custom_fields,see the link: https://stripe.com/docs/api/invoices/create
public static string GetFirstThirtyCharacters(string subscriberName)
private static string GetFirstThirtyCharacters(string subscriberName)
{
if (string.IsNullOrWhiteSpace(subscriberName))
{
return "";
}
else if (subscriberName.Length <= 30)
{
return subscriberName;
}
else
{
return subscriberName.Substring(0, 30);
return string.Empty;
}
return subscriberName.Length <= 30
? subscriberName
: subscriberName[..30];
}
}

View File

@ -338,14 +338,13 @@ public class UserService : UserManager<User>, IUserService, IDisposable
var result = await base.CreateAsync(user, masterPassword);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
if (!string.IsNullOrEmpty(user.ReferenceData))
{
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData);
if (referenceData.TryGetValue("initiationPath", out var value))
{
var initiationPath = value.ToString();
await SendAppropriateWelcomeEmailAsync(user, initiationPath);
if (!string.IsNullOrEmpty(initiationPath))
{
await _referenceEventService.RaiseEventAsync(
@ -797,7 +796,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
user.ForcePasswordReset = true;
await _userRepository.ReplaceAsync(user);
await _mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.Name);
await _mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName());
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_AdminResetPassword);
await _pushService.PushLogOutAsync(user.Id);
@ -1391,7 +1390,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
await organizationService.DeleteUserAsync(p.OrganizationId, user.Id);
var organization = await _organizationRepository.GetByIdAsync(p.OrganizationId);
await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(
organization.Name, user.Email);
organization.DisplayName(), user.Email);
}).ToArray();
await Task.WhenAll(removeOrgUserTasks);
@ -1453,4 +1452,18 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return isVerified;
}
private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath)
{
var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial");
if (isFromMarketingWebsite)
{
await _mailService.SendTrialInitiationEmailAsync(user.Email);
}
else
{
await _mailService.SendWelcomeEmailAsync(user);
}
}
}

View File

@ -262,5 +262,10 @@ public class NoopMailService : IMailService
{
return Task.FromResult(0);
}
public Task SendTrialInitiationEmailAsync(string email)
{
return Task.FromResult(0);
}
}

View File

@ -117,6 +117,11 @@ public class Send : ITableObject<Guid>
/// </value>
public bool? HideEmail { get; set; }
/// <summary>
/// Identifies the Cipher associated with this send.
/// </summary>
public Guid? CipherId { get; set; }
/// <summary>
/// Generates the send's <see cref="Id" />
/// </summary>

View File

@ -1,4 +1,5 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using NS = Newtonsoft.Json;
@ -192,3 +193,33 @@ public class PermissiveStringEnumerableConverter : JsonConverter<IEnumerable<str
writer.WriteEndArray();
}
}
/// <summary>
/// Encodes incoming strings using HTML encoding
/// and decodes outgoing strings using HTML decoding.
/// </summary>
public class HtmlEncodingStringConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
var originalValue = reader.GetString();
return WebUtility.HtmlEncode(originalValue);
}
return reader.GetString();
}
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
{
if (!string.IsNullOrEmpty(value))
{
var encodedValue = WebUtility.HtmlDecode(value);
writer.WriteStringValue(encodedValue);
}
else
{
writer.WriteNullValue();
}
}
}

View File

@ -90,7 +90,12 @@ public class ClientStore : IClientStore
private async Task<Client> CreateApiKeyClientAsync(string clientId)
{
var apiKey = await _apiKeyRepository.GetDetailsByIdAsync(new Guid(clientId));
if (!Guid.TryParse(clientId, out var guid))
{
return null;
}
var apiKey = await _apiKeyRepository.GetDetailsByIdAsync(guid);
if (apiKey == null || apiKey.ExpireAt <= DateTime.Now)
{

View File

@ -0,0 +1,28 @@
using System.Data;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Repositories;
using Bit.Core.Settings;
using Bit.Infrastructure.Dapper.Repositories;
using Dapper;
using Microsoft.Data.SqlClient;
namespace Bit.Infrastructure.Dapper.Billing.Repositories;
public class ProviderPlanRepository(
GlobalSettings globalSettings)
: Repository<ProviderPlan, Guid>(
globalSettings.SqlServer.ConnectionString,
globalSettings.SqlServer.ReadOnlyConnectionString), IProviderPlanRepository
{
public async Task<ProviderPlan> GetByProviderId(Guid providerId)
{
var sqlConnection = new SqlConnection(ConnectionString);
var results = await sqlConnection.QueryAsync<ProviderPlan>(
"[dbo].[ProviderPlan_ReadByProviderId]",
new { ProviderId = providerId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}

View File

@ -1,11 +1,13 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Repositories;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Tools.Repositories;
using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.Dapper.AdminConsole.Repositories;
using Bit.Infrastructure.Dapper.Auth.Repositories;
using Bit.Infrastructure.Dapper.Billing.Repositories;
using Bit.Infrastructure.Dapper.Repositories;
using Bit.Infrastructure.Dapper.SecretsManager.Repositories;
using Bit.Infrastructure.Dapper.Tools.Repositories;
@ -48,6 +50,7 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton<IUserRepository, UserRepository>();
services.AddSingleton<IOrganizationDomainRepository, OrganizationDomainRepository>();
services.AddSingleton<IWebAuthnCredentialRepository, WebAuthnCredentialRepository>();
services.AddSingleton<IProviderPlanRepository, ProviderPlanRepository>();
if (selfHosted)
{

View File

@ -44,6 +44,16 @@ public class TransactionRepository : Repository<Transaction, Guid>, ITransaction
}
}
public async Task<ICollection<Transaction>> GetManyByProviderIdAsync(Guid providerId)
{
await using var sqlConnection = new SqlConnection(ConnectionString);
var results = await sqlConnection.QueryAsync<Transaction>(
$"[{Schema}].[Transaction_ReadByProviderId]",
new { ProviderId = providerId },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
public async Task<Transaction> GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId)
{
using (var connection = new SqlConnection(ConnectionString))

View File

@ -0,0 +1,21 @@
using Bit.Infrastructure.EntityFramework.Billing.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.Billing.Configurations;
public class ProviderPlanEntityTypeConfiguration : IEntityTypeConfiguration<ProviderPlan>
{
public void Configure(EntityTypeBuilder<ProviderPlan> builder)
{
builder
.Property(t => t.Id)
.ValueGeneratedNever();
builder
.HasIndex(providerPlan => new { providerPlan.Id, providerPlan.PlanType })
.IsUnique();
builder.ToTable(nameof(ProviderPlan));
}
}

View File

@ -0,0 +1,17 @@
using AutoMapper;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
namespace Bit.Infrastructure.EntityFramework.Billing.Models;
public class ProviderPlan : Core.Billing.Entities.ProviderPlan
{
public virtual Provider Provider { get; set; }
}
public class ProviderPlanMapperProfile : Profile
{
public ProviderPlanMapperProfile()
{
CreateMap<Core.Billing.Entities.ProviderPlan, ProviderPlan>().ReverseMap();
}
}

View File

@ -0,0 +1,29 @@
using AutoMapper;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using EFProviderPlan = Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan;
namespace Bit.Infrastructure.EntityFramework.Billing.Repositories;
public class ProviderPlanRepository(
IMapper mapper,
IServiceScopeFactory serviceScopeFactory)
: Repository<ProviderPlan, EFProviderPlan, Guid>(
serviceScopeFactory,
mapper,
context => context.ProviderPlans), IProviderPlanRepository
{
public async Task<ProviderPlan> GetByProviderId(Guid providerId)
{
using var serviceScope = ServiceScopeFactory.CreateScope();
var databaseContext = GetDatabaseContext(serviceScope);
var query =
from providerPlan in databaseContext.ProviderPlans
where providerPlan.ProviderId == providerId
select providerPlan;
return await query.FirstOrDefaultAsync();
}
}

View File

@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
@ -7,6 +8,7 @@ using Bit.Core.Tools.Repositories;
using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using Bit.Infrastructure.EntityFramework.Auth.Repositories;
using Bit.Infrastructure.EntityFramework.Billing.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework.Tools.Repositories;
@ -85,6 +87,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton<IUserRepository, UserRepository>();
services.AddSingleton<IOrganizationDomainRepository, OrganizationDomainRepository>();
services.AddSingleton<IWebAuthnCredentialRepository, WebAuthnCredentialRepository>();
services.AddSingleton<IProviderPlanRepository, ProviderPlanRepository>();
if (selfHosted)
{

View File

@ -1,5 +1,6 @@
using AutoMapper;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
namespace Bit.Infrastructure.EntityFramework.Models;
@ -7,6 +8,7 @@ public class Transaction : Core.Entities.Transaction
{
public virtual Organization Organization { get; set; }
public virtual User User { get; set; }
public virtual Provider Provider { get; set; }
}
public class TransactionMapperProfile : Profile

View File

@ -2,6 +2,7 @@
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Bit.Infrastructure.EntityFramework.Auth.Models;
using Bit.Infrastructure.EntityFramework.Billing.Models;
using Bit.Infrastructure.EntityFramework.Converters;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
@ -65,6 +66,7 @@ public class DatabaseContext : DbContext
public DbSet<AuthRequest> AuthRequests { get; set; }
public DbSet<OrganizationDomain> OrganizationDomains { get; set; }
public DbSet<WebAuthnCredential> WebAuthnCredentials { get; set; }
public DbSet<ProviderPlan> ProviderPlans { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -47,4 +47,14 @@ public class TransactionRepository : Repository<Core.Entities.Transaction, Trans
return Mapper.Map<List<Core.Entities.Transaction>>(results);
}
}
public async Task<ICollection<Core.Entities.Transaction>> GetManyByProviderIdAsync(Guid providerId)
{
using var serviceScope = ServiceScopeFactory.CreateScope();
var databaseContext = GetDatabaseContext(serviceScope);
var results = await databaseContext.Transactions
.Where(transaction => transaction.ProviderId == providerId)
.ToListAsync();
return Mapper.Map<List<Core.Entities.Transaction>>(results);
}
}

View File

@ -7,8 +7,8 @@
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Notifications' " />
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Notifications-SelfHost' " />
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.3" />
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,30 @@
CREATE PROCEDURE [dbo].[ProviderPlan_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@ProviderId UNIQUEIDENTIFIER,
@PlanType TINYINT,
@SeatMinimum INT,
@PurchasedSeats INT,
@AllocatedSeats INT
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[ProviderPlan]
(
[Id],
[ProviderId],
[PlanType],
[SeatMinimum],
[PurchasedSeats],
[AllocatedSeats]
)
VALUES
(
@Id,
@ProviderId,
@PlanType,
@SeatMinimum,
@PurchasedSeats,
@AllocatedSeats
)
END

View File

@ -0,0 +1,12 @@
CREATE PROCEDURE [dbo].[ProviderPlan_DeleteById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
DELETE
FROM
[dbo].[ProviderPlan]
WHERE
[Id] = @Id
END

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[ProviderPlan_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ProviderPlanView]
WHERE
[Id] = @Id
END

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