1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

Merge branch 'main' into vault/pm-11249/attachment-cipher-date

This commit is contained in:
Nick Krantz 2024-11-20 12:48:42 -06:00 committed by GitHub
commit 9743b1f9eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
222 changed files with 23904 additions and 3778 deletions

View File

@ -7,18 +7,27 @@ on:
- "main" - "main"
- "rc" - "rc"
- "hotfix-rc" - "hotfix-rc"
pull_request: pull_request_target:
types: [opened, synchronize]
env: env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs: jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs:
- check-run
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
@ -68,6 +77,8 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
@ -115,24 +126,6 @@ jobs:
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
if-no-files-found: error if-no-files-found: error
check-akv-secrets:
name: Check for AKV secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-akv-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-akv-secrets
run: |
if [ "${{ secrets.AZURE_PROD_KV_CREDENTIALS }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
build-docker: build-docker:
name: Build Docker images name: Build Docker images
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@ -140,8 +133,6 @@ jobs:
security-events: write security-events: write
needs: needs:
- build-artifacts - build-artifacts
- check-akv-secrets
if: ${{ needs.check-akv-secrets.outputs.available == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -194,6 +185,8 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Check branch to publish - name: Check branch to publish
env: env:
@ -233,7 +226,7 @@ jobs:
- name: Generate Docker image tag - name: Generate Docker image tag
id: tag id: tag
run: | run: |
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
else else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
@ -313,6 +306,8 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
@ -326,9 +321,9 @@ jobs:
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Make Docker stubs - name: Make Docker stubs
if: github.ref == 'refs/heads/main' || if: |
github.ref == 'refs/heads/rc' || github.event_name != 'pull_request_target'
github.ref == 'refs/heads/hotfix-rc' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
# Set proper setup image based on branch # Set proper setup image based on branch
case "$GITHUB_REF" in case "$GITHUB_REF" in
@ -368,13 +363,17 @@ jobs:
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
- name: Make Docker stub checksums - name: Make Docker stub checksums
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
- name: Upload Docker stub US artifact - name: Upload Docker stub US artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with: with:
name: docker-stub-US.zip name: docker-stub-US.zip
@ -382,7 +381,9 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Upload Docker stub EU artifact - name: Upload Docker stub EU artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with: with:
name: docker-stub-EU.zip name: docker-stub-EU.zip
@ -390,7 +391,9 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Upload Docker stub US checksum artifact - name: Upload Docker stub US checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with: with:
name: docker-stub-US-sha256.txt name: docker-stub-US-sha256.txt
@ -398,7 +401,9 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Upload Docker stub EU checksum artifact - name: Upload Docker stub EU checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with: with:
name: docker-stub-EU-sha256.txt name: docker-stub-EU-sha256.txt
@ -473,7 +478,8 @@ jobs:
build-mssqlmigratorutility: build-mssqlmigratorutility:
name: Build MSSQL migrator utility name: Build MSSQL migrator utility
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: lint needs:
- lint
defaults: defaults:
run: run:
shell: bash shell: bash
@ -488,6 +494,8 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
@ -522,8 +530,10 @@ jobs:
self-host-build: self-host-build:
name: Trigger self-host build name: Trigger self-host build
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -554,9 +564,10 @@ jobs:
trigger-k8s-deploy: trigger-k8s-deploy:
name: Trigger k8s deploy name: Trigger k8s deploy
if: github.ref == 'refs/heads/main' if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -588,9 +599,12 @@ jobs:
trigger-ee-updates: trigger-ee-updates:
name: Trigger Ephemeral Environment updates name: Trigger Ephemeral Environment updates
if: github.ref != 'refs/heads/main' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') if: |
github.event_name == 'pull_request_target'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -634,9 +648,8 @@ jobs:
steps: steps:
- name: Check if any job failed - name: Check if any job failed
if: | if: |
(github.ref == 'refs/heads/main' github.event_name != 'pull_request_target'
|| github.ref == 'refs/heads/rc' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|| github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure') && contains(needs.*.result, 'failure')
run: exit 1 run: exit 1

View File

@ -28,7 +28,6 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs: outputs:
branch: ${{ steps.set-branch.outputs.branch }} branch: ${{ steps.set-branch.outputs.branch }}
token: ${{ steps.app-token.outputs.token }}
steps: steps:
- name: Set branch - name: Set branch
id: set-branch id: set-branch
@ -45,13 +44,6 @@ jobs:
echo "branch=$BRANCH" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> $GITHUB_OUTPUT
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
cut_branch: cut_branch:
name: Cut branch name: Cut branch
@ -59,11 +51,18 @@ jobs:
needs: setup needs: setup
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out target ref - name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ inputs.target_ref }} ref: ${{ inputs.target_ref }}
token: ${{ needs.setup.outputs.token }} token: ${{ steps.app-token.outputs.token }}
- name: Check if ${{ needs.setup.outputs.branch }} branch exists - name: Check if ${{ needs.setup.outputs.branch }} branch exists
env: env:
@ -98,11 +97,18 @@ jobs:
with: with:
version: ${{ inputs.version_number_override }} version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out branch - name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ needs.setup.outputs.token }} token: ${{ steps.app-token.outputs.token }}
- name: Configure Git - name: Configure Git
run: | run: |
@ -190,11 +196,18 @@ jobs:
- bump_version - bump_version
- setup - setup
steps: steps:
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out main branch - name: Check out main branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ needs.setup.outputs.token }} token: ${{ steps.app-token.outputs.token }}
- name: Configure Git - name: Configure Git
run: | run: |

View File

@ -70,6 +70,11 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh shell: pwsh
- name: Add MariaDB for unified
# Use a different port than MySQL
run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready # I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
- name: Sleep - name: Sleep
run: sleep 15s run: sleep 15s
@ -103,6 +108,12 @@ jobs:
env: env:
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true" CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
- name: Migrate MariaDB
working-directory: "util/MySqlMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
- name: Migrate Postgres - name: Migrate Postgres
working-directory: "util/PostgresMigrations" working-directory: "util/PostgresMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"' run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
@ -130,6 +141,9 @@ jobs:
# Default Sqlite # Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite" BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
# Unified MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql"
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx"
shell: pwsh shell: pwsh
@ -137,6 +151,10 @@ jobs:
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mysql")' run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
- name: Print MariaDB Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")'
- name: Print Postgres Logs - name: Print Postgres Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=postgres")' run: 'docker logs $(docker ps --quiet --filter "name=postgres")'

View File

@ -1,5 +1,4 @@
using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@ -10,7 +9,6 @@ using Bit.Core.Billing.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -21,35 +19,28 @@ public class CreateProviderCommand : ICreateProviderCommand
private readonly IProviderService _providerService; private readonly IProviderService _providerService;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IFeatureService _featureService;
public CreateProviderCommand( public CreateProviderCommand(
IProviderRepository providerRepository, IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IProviderService providerService, IProviderService providerService,
IUserRepository userRepository, IUserRepository userRepository,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository)
IFeatureService featureService)
{ {
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
_providerService = providerService; _providerService = providerService;
_userRepository = userRepository; _userRepository = userRepository;
_providerPlanRepository = providerPlanRepository; _providerPlanRepository = providerPlanRepository;
_featureService = featureService;
} }
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats) public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
{ {
var providerId = await CreateProviderAsync(provider, ownerEmail); var providerId = await CreateProviderAsync(provider, ownerEmail);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); await Task.WhenAll(
CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats),
if (isConsolidatedBillingEnabled) CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats));
{
await CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats);
await CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats);
}
} }
public async Task CreateResellerAsync(Provider provider) public async Task CreateResellerAsync(Provider provider)
@ -61,13 +52,8 @@ public class CreateProviderCommand : ICreateProviderCommand
{ {
var providerId = await CreateProviderAsync(provider, ownerEmail); var providerId = await CreateProviderAsync(provider, ownerEmail);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled)
{
await CreateProviderPlanAsync(providerId, plan, minimumSeats); await CreateProviderPlanAsync(providerId, plan, minimumSeats);
} }
}
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail) private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
{ {
@ -77,12 +63,7 @@ public class CreateProviderCommand : ICreateProviderCommand
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user."); throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
} }
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled)
{
provider.Gateway = GatewayType.Stripe; provider.Gateway = GatewayType.Stripe;
}
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending); await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);

View File

@ -1,7 +1,5 @@
using Bit.Core; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@ -102,11 +100,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Provider provider, Provider provider,
IEnumerable<string> organizationOwnerEmails) IEnumerable<string> organizationOwnerEmails)
{ {
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (provider.IsBillable() &&
organization.IsValidClient() &&
if (isConsolidatedBillingEnabled &&
provider.Status == ProviderStatusType.Billable &&
organization.Status == OrganizationStatusType.Managed &&
!string.IsNullOrEmpty(organization.GatewayCustomerId)) !string.IsNullOrEmpty(organization.GatewayCustomerId))
{ {
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions

View File

@ -8,7 +8,6 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
@ -101,13 +100,6 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner."); throw new BadRequestException("Invalid owner.");
} }
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
provider.Status = ProviderStatusType.Created;
await _providerRepository.UpsertAsync(provider);
}
else
{
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
{ {
throw new BadRequestException("Both address and postal code are required to set up your provider."); throw new BadRequestException("Both address and postal code are required to set up your provider.");
@ -118,7 +110,6 @@ public class ProviderService : IProviderService
provider.GatewaySubscriptionId = subscription.Id; provider.GatewaySubscriptionId = subscription.Id;
provider.Status = ProviderStatusType.Billable; provider.Status = ProviderStatusType.Billable;
await _providerRepository.UpsertAsync(provider); await _providerRepository.UpsertAsync(provider);
}
providerUser.Key = key; providerUser.Key = key;
await _providerUserRepository.ReplaceAsync(providerUser); await _providerUserRepository.ReplaceAsync(providerUser);
@ -545,13 +536,9 @@ public class ProviderService : IProviderService
{ {
var provider = await _providerRepository.GetByIdAsync(providerId); var provider = await _providerRepository.GetByIdAsync(providerId);
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable(); ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan);
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan, consolidatedBillingEnabled); var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup);
var (organization, _, defaultCollection) = consolidatedBillingEnabled
? await _organizationService.SignupClientAsync(organizationSignup)
: await _organizationService.SignUpAsync(organizationSignup);
var providerOrganization = new ProviderOrganization var providerOrganization = new ProviderOrganization
{ {
@ -687,9 +674,7 @@ public class ProviderService : IProviderService
return confirmedOwnersIds.Except(providerUserIds).Any(); return confirmedOwnersIds.Except(providerUserIds).Any();
} }
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType, bool consolidatedBillingEnabled = false) private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType)
{
if (consolidatedBillingEnabled)
{ {
switch (providerType) switch (providerType)
{ {
@ -708,7 +693,6 @@ public class ProviderService : IProviderService
default: default:
throw new BadRequestException($"Unsupported provider type {providerType}."); throw new BadRequestException($"Unsupported provider type {providerType}.");
} }
}
if (ProviderDisallowedOrganizationTypes.Contains(requestedType)) if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
{ {

View File

@ -2,17 +2,14 @@
using Bit.Commercial.Core.Billing.Models; using Bit.Commercial.Core.Billing.Models;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing; using Bit.Core.Billing;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
@ -27,7 +24,6 @@ using Stripe;
namespace Bit.Commercial.Core.Billing; namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService( public class ProviderBillingService(
ICurrentContext currentContext,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger, ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -35,39 +31,77 @@ public class ProviderBillingService(
IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IProviderBillingService ISubscriberService subscriberService) : IProviderBillingService
{ {
public async Task AssignSeatsToClientOrganization( public async Task ChangePlan(ChangeProviderPlanCommand command)
Provider provider,
Organization organization,
int seats)
{ {
ArgumentNullException.ThrowIfNull(organization); var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (seats < 0) if (plan == null)
{ {
throw new BillingException( throw new BadRequestException("Provider plan not found.");
"You cannot assign negative seats to a client.",
"MSP cannot assign negative seats to a client organization");
} }
if (seats == organization.Seats) if (plan.PlanType == command.NewPlan)
{ {
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
return; return;
} }
var seatAdjustment = seats - (organization.Seats ?? 0); var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
await ScaleSeats(provider, organization.PlanType, seatAdjustment); plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
organization.Seats = seats; Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
await organizationRepository.ReplaceAsync(organization); await organizationRepository.ReplaceAsync(organization);
} }
}
public async Task CreateCustomerForClientOrganization( public async Task CreateCustomerForClientOrganization(
Provider provider, Provider provider,
@ -171,65 +205,16 @@ public class ProviderBillingService(
return memoryStream.ToArray(); return memoryStream.ToArray();
} }
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId,
PlanType planType)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogError(
"Could not find provider ({ID}) when retrieving assigned seat total",
providerId);
throw new BillingException();
}
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
throw new BillingException();
}
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
public async Task ScaleSeats( public async Task ScaleSeats(
Provider provider, Provider provider,
PlanType planType, PlanType planType,
int seatAdjustment) int seatAdjustment)
{ {
ArgumentNullException.ThrowIfNull(provider); var providerPlan = await GetProviderPlanAsync(provider, planType);
if (!provider.SupportsConsolidatedBilling()) var seatMinimum = providerPlan.SeatMinimum ?? 0;
{
logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id);
throw new BillingException(); var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
throw new BillingException();
}
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment; var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
@ -256,13 +241,6 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal <= seatMinimum && else if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal > seatMinimum) newlyAssignedSeatTotal > seatMinimum)
{ {
if (!currentContext.ProviderProviderAdmin(provider.Id))
{
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
throw new BillingException();
}
await update( await update(
seatMinimum, seatMinimum,
newlyAssignedSeatTotal); newlyAssignedSeatTotal);
@ -291,6 +269,26 @@ public class ProviderBillingService(
} }
} }
public async Task<bool> SeatAdjustmentResultsInPurchase(
Provider provider,
PlanType planType,
int seatAdjustment)
{
var providerPlan = await GetProviderPlanAsync(provider, planType);
var seatMinimum = providerPlan.SeatMinimum;
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
return
// Below the limit to above the limit
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
// Above the limit to further above the limit
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
}
public async Task<Customer> SetupCustomer( public async Task<Customer> SetupCustomer(
Provider provider, Provider provider,
TaxInfo taxInfo) TaxInfo taxInfo)
@ -431,75 +429,6 @@ public class ProviderBillingService(
} }
} }
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (plan == null)
{
throw new BadRequestException("Provider plan not found.");
}
if (plan.PlanType == command.NewPlan)
{
return;
}
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
await organizationRepository.ReplaceAsync(organization);
}
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
{ {
if (command.Configuration.Any(x => x.SeatsMinimum < 0)) if (command.Configuration.Any(x => x.SeatsMinimum < 0))
@ -610,4 +539,32 @@ public class ProviderBillingService(
await providerPlanRepository.ReplaceAsync(providerPlan); await providerPlanRepository.ReplaceAsync(providerPlan);
}; };
// TODO: Replace with SPROC
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
{
var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
// TODO: Replace with SPROC
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
{
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
throw new BillingException(message: "Provider plan is missing or misconfigured");
}
return providerPlan;
}
} }

View File

@ -23,7 +23,7 @@
@RenderBody() @RenderBody()
</div> </div>
<div class="container footer text-muted"> <div class="container footer text-body-secondary">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
&copy; @DateTime.Now.Year, Bitwarden Inc. &copy; @DateTime.Now.Year, Bitwarden Inc.

View File

@ -9,10 +9,9 @@
"version": "0.0.0", "version": "0.0.0",
"license": "-", "license": "-",
"dependencies": { "dependencies": {
"bootstrap": "4.6.2", "bootstrap": "5.3.3",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"jquery": "3.7.1", "jquery": "3.7.1"
"popper.js": "1.16.1"
}, },
"devDependencies": { "devDependencies": {
"css-loader": "7.1.2", "css-loader": "7.1.2",
@ -384,6 +383,17 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -702,9 +712,9 @@
} }
}, },
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "4.6.2", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==", "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -715,10 +725,8 @@
"url": "https://opencollective.com/bootstrap" "url": "https://opencollective.com/bootstrap"
} }
], ],
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"jquery": "1.9.1 - 3", "@popperjs/core": "^2.11.8"
"popper.js": "^1.16.1"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
@ -1577,17 +1585,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.47", "version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",

View File

@ -8,10 +8,9 @@
"build": "webpack" "build": "webpack"
}, },
"dependencies": { "dependencies": {
"bootstrap": "4.6.2", "bootstrap": "5.3.3",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"jquery": "3.7.1", "jquery": "3.7.1"
"popper.js": "1.16.1"
}, },
"devDependencies": { "devDependencies": {
"css-loader": "7.1.2", "css-loader": "7.1.2",

View File

@ -13,8 +13,6 @@ module.exports = {
entry: { entry: {
site: [ site: [
path.resolve(__dirname, paths.sassDir, "site.scss"), path.resolve(__dirname, paths.sassDir, "site.scss"),
"popper.js",
"bootstrap", "bootstrap",
"jquery", "jquery",
"font-awesome/css/font-awesome.css", "font-awesome/css/font-awesome.css",

View File

@ -1,5 +1,4 @@
using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -155,9 +154,6 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com" "b@example.com"
]); ]);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(false);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId) sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId)); .Returns(GetSubscription(organization.GatewaySubscriptionId));
@ -222,9 +218,6 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com" "b@example.com"
]); ]);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription

View File

@ -1,6 +1,5 @@
using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture; using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -55,8 +54,8 @@ public class ProviderServiceTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser, [ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
providerUser.ProviderId = provider.Id; providerUser.ProviderId = provider.Id;
@ -71,37 +70,6 @@ public class ProviderServiceTests
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector") sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector); .Returns(protector);
sutProvider.Create();
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key);
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(provider);
await sutProvider.GetDependency<IProviderUserRepository>().Received()
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_ConsolidatedBilling_Success(User user, Provider provider, string key, TaxInfo taxInfo,
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>(); var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
@ -489,7 +457,7 @@ public class ProviderServiceTests
public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key, public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
organization.PlanType = PlanType.EnterpriseAnnually; organization.PlanType = PlanType.EnterpriseMonthly;
organization.UseSecretsManager = true; organization.UseSecretsManager = true;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
@ -506,7 +474,7 @@ public class ProviderServiceTests
public async Task AddOrganization_Success(Provider provider, Organization organization, string key, public async Task AddOrganization_Success(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
organization.PlanType = PlanType.EnterpriseAnnually; organization.PlanType = PlanType.EnterpriseMonthly;
var providerRepository = sutProvider.GetDependency<IProviderRepository>(); var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider); providerRepository.GetByIdAsync(provider.Id).Returns(provider);
@ -549,8 +517,8 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var expectedPlanType = PlanType.EnterpriseAnnually; var expectedPlanType = PlanType.EnterpriseMonthly;
organization.PlanType = PlanType.EnterpriseAnnually; organization.PlanType = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key); await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
@ -579,12 +547,12 @@ public class ProviderServiceTests
BackdateProviderCreationDate(provider, newCreationDate); BackdateProviderCreationDate(provider, newCreationDate);
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
organization.PlanType = PlanType.EnterpriseAnnually; organization.PlanType = PlanType.EnterpriseMonthly;
organization.Plan = "Enterprise (Annually)"; organization.Plan = "Enterprise (Monthly)";
var expectedPlanType = PlanType.EnterpriseAnnually2020; var expectedPlanType = PlanType.EnterpriseMonthly2020;
var expectedPlanId = "2020-enterprise-org-seat-annually"; var expectedPlanId = "2020-enterprise-org-seat-monthly";
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
@ -663,11 +631,11 @@ public class ProviderServiceTests
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup, public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider) Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
{ {
organizationSignup.Plan = PlanType.EnterpriseAnnually; organizationSignup.Plan = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup) sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, new Collection())); .Returns((organization, null as OrganizationUser, new Collection()));
var providerOrganization = var providerOrganization =
@ -688,7 +656,7 @@ public class ProviderServiceTests
} }
[Theory, OrganizationCustomize, BitAutoData] [Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException( public async Task CreateOrganizationAsync_InvalidPlanType_ThrowsBadRequestException(
Provider provider, Provider provider,
OrganizationSignup organizationSignup, OrganizationSignup organizationSignup,
Organization organization, Organization organization,
@ -696,8 +664,6 @@ public class ProviderServiceTests
User user, User user,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable; provider.Status = ProviderStatusType.Billable;
@ -717,7 +683,7 @@ public class ProviderServiceTests
} }
[Theory, OrganizationCustomize, BitAutoData] [Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync( public async Task CreateOrganizationAsync_InvokeSignupClientAsync(
Provider provider, Provider provider,
OrganizationSignup organizationSignup, OrganizationSignup organizationSignup,
Organization organization, Organization organization,
@ -725,8 +691,6 @@ public class ProviderServiceTests
User user, User user,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable; provider.Status = ProviderStatusType.Billable;
@ -771,11 +735,11 @@ public class ProviderServiceTests
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail, (Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection) User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
{ {
organizationSignup.Plan = PlanType.EnterpriseAnnually; organizationSignup.Plan = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup) sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, defaultCollection)); .Returns((organization, null as OrganizationUser, defaultCollection));
var providerOrganization = var providerOrganization =

View File

@ -5,8 +5,10 @@ using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
@ -53,8 +55,8 @@ public class OrganizationsController : Controller
private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IFeatureService _featureService;
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IFeatureService _featureService;
public OrganizationsController( public OrganizationsController(
IOrganizationService organizationService, IOrganizationService organizationService,
@ -80,8 +82,8 @@ public class OrganizationsController : Controller
IServiceAccountRepository serviceAccountRepository, IServiceAccountRepository serviceAccountRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IFeatureService featureService, IProviderBillingService providerBillingService,
IProviderBillingService providerBillingService) IFeatureService featureService)
{ {
_organizationService = organizationService; _organizationService = organizationService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -106,8 +108,8 @@ public class OrganizationsController : Controller
_serviceAccountRepository = serviceAccountRepository; _serviceAccountRepository = serviceAccountRepository;
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_featureService = featureService;
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_featureService = featureService;
} }
[RequirePermission(Permission.Org_List_View)] [RequirePermission(Permission.Org_List_View)]
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model) public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
{ {
var organization = await GetOrganization(id, model); var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
TempData["Error"] = "Could not find organization to update.";
return RedirectToAction("Index");
}
var existingOrganizationData = new Organization
{
Id = organization.Id,
Status = organization.Status,
PlanType = organization.PlanType,
Seats = organization.Seats
};
UpdateOrganization(organization, model);
if (organization.UseSecretsManager && if (organization.UseSecretsManager &&
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager) !StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }
await HandlePotentialProviderSeatScalingAsync(
existingOrganizationData,
model);
await _organizationRepository.ReplaceAsync(organization); await _organizationRepository.ReplaceAsync(organization);
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext) await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
{ {
@ -262,9 +285,7 @@ public class OrganizationsController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (organization.IsValidClient())
if (consolidatedBillingEnabled && organization.IsValidClient())
{ {
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id); var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
@ -394,10 +415,9 @@ public class OrganizationsController : Controller
return Json(null); return Json(null);
} }
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
{
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox)) if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
{ {
organization.Enabled = model.Enabled; organization.Enabled = model.Enabled;
@ -449,7 +469,62 @@ public class OrganizationsController : Controller
organization.GatewayCustomerId = model.GatewayCustomerId; organization.GatewayCustomerId = model.GatewayCustomerId;
organization.GatewaySubscriptionId = model.GatewaySubscriptionId; organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
} }
}
return organization; private async Task HandlePotentialProviderSeatScalingAsync(
Organization organization,
OrganizationEditModel update)
{
var scaleMSPOnClientOrganizationUpdate =
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
if (!scaleMSPOnClientOrganizationUpdate)
{
return;
}
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
// No scaling required
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
organization is not { Status: OrganizationStatusType.Managed } ||
!organization.Seats.HasValue ||
update is { Seats: null, PlanType: null } ||
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
(PlanTypesMatch() && SeatsMatch()))
{
return;
}
// Only scale the plan
if (!PlanTypesMatch() && SeatsMatch())
{
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
}
// Only scale the seats
else if (PlanTypesMatch() && !SeatsMatch())
{
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
}
// Scale both
else if (!PlanTypesMatch() && !SeatsMatch())
{
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
var planTypeAdjustment = organization.Seats.Value;
var totalAdjustment = seatAdjustment + planTypeAdjustment;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
}
return;
bool PlanTypesMatch()
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
bool SeatsMatch()
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
} }
} }

View File

@ -282,9 +282,7 @@ public class ProvidersController : Controller
await _providerRepository.ReplaceAsync(provider); await _providerRepository.ReplaceAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (!provider.IsBillable())
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
{ {
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }
@ -340,10 +338,7 @@ public class ProvidersController : Controller
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id); var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id); var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (!provider.IsBillable())
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
{ {
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>()); return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
} }

View File

@ -103,19 +103,19 @@
<div class="d-flex mt-4"> <div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button> <button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex"> <div class="ms-auto d-flex">
@if (canInitiateTrial && Model.Provider is null) @if (canInitiateTrial && Model.Provider is null)
{ {
<button class="btn btn-secondary mr-2" type="button" id="teams-trial"> <button class="btn btn-secondary me-2" type="button" id="teams-trial">
Teams Trial Teams Trial
</button> </button>
<button class="btn btn-secondary mr-2" type="button" id="enterprise-trial"> <button class="btn btn-secondary me-2" type="button" id="enterprise-trial">
Enterprise Trial Enterprise Trial
</button> </button>
} }
@if (canUnlinkFromProvider && Model.Provider is not null) @if (canUnlinkFromProvider && Model.Provider is not null)
{ {
<button class="btn btn-outline-danger mr-2" <button class="btn btn-outline-danger me-2"
onclick="return unlinkProvider('@Model.Organization.Id');"> onclick="return unlinkProvider('@Model.Organization.Id');">
Unlink provider Unlink provider
</button> </button>
@ -124,7 +124,7 @@
{ {
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form"> <form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
<input type="hidden" name="AdminEmail" id="AdminEmail" /> <input type="hidden" name="AdminEmail" id="AdminEmail" />
<button class="btn btn-danger mr-2" type="submit">Request Delete</button> <button class="btn btn-danger me-2" type="submit">Request Delete</button>
</form> </form>
<form asp-action="Delete" asp-route-id="@Model.Organization.Id" <form asp-action="Delete" asp-route-id="@Model.Organization.Id"
onsubmit="return confirm('Are you sure you want to hard delete this organization?')"> onsubmit="return confirm('Are you sure you want to hard delete this organization?')">

View File

@ -5,21 +5,31 @@
<h1>Organizations</h1> <h1>Organizations</h1>
<form class="form-inline mb-2" method="get"> <form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
<label class="sr-only" asp-for="Name">Name</label> <div class="col-12">
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name"> <label class="visually-hidden" asp-for="Name">Name</label>
<label class="sr-only" asp-for="UserEmail">User email</label> <input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail"> </div>
<div class="col-12">
<label class="visually-hidden" asp-for="UserEmail">User email</label>
<input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
</div>
@if(!Model.SelfHosted) @if(!Model.SelfHosted)
{ {
<label class="sr-only" asp-for="Paid">Customer</label> <div class="col-12">
<select class="form-control mb-2 mr-2" asp-for="Paid" name="paid"> <label class="visually-hidden" asp-for="Paid">Customer</label>
<select class="form-select" asp-for="Paid" name="paid">
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option> <option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option> <option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option> <option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
</select> </select>
</div>
} }
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button> <div class="col-12">
<button type="submit" class="btn btn-primary" title="Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</form> </form>
<div class="table-responsive"> <div class="table-responsive">
@ -68,7 +78,7 @@
} }
else else
{ {
<i class="fa fa-smile-o fa-lg fa-fw text-muted" title="Freeloader"></i> <i class="fa fa-smile-o fa-lg fa-fw text-body-secondary" title="Freeloader"></i>
} }
} }
@if(org.MaxStorageGb.HasValue && org.MaxStorageGb > 1) @if(org.MaxStorageGb.HasValue && org.MaxStorageGb > 1)
@ -78,7 +88,7 @@
} }
else else
{ {
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted" <i class="fa fa-plus-square-o fa-lg fa-fw text-body-secondary"
title="No Additional Storage"></i> title="No Additional Storage"></i>
} }
@if(org.Enabled) @if(org.Enabled)
@ -88,7 +98,7 @@
} }
else else
{ {
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Disabled"></i> <i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Disabled"></i>
} }
@if(org.TwoFactorIsEnabled()) @if(org.TwoFactorIsEnabled())
{ {
@ -96,7 +106,7 @@
} }
else else
{ {
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i> <i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
} }
</td> </td>
</tr> </tr>

View File

@ -9,12 +9,18 @@
<h1>Add Existing Organization</h1> <h1>Add Existing Organization</h1>
<div class="row mb-2"> <div class="row mb-2">
<div class="col"> <div class="col">
<form class="form-inline mb-2" method="get" asp-route-id="@providerId"> <form class="row g-3 align-items-center mb-2" method="get" asp-route-id="@providerId">
<label class="sr-only" asp-for="OrganizationName"></label> <div class="col">
<input type="text" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name"> <label class="visually-hidden" asp-for="OrganizationName"></label>
<label class="sr-only" asp-for="OrganizationOwnerEmail"></label> <input type="text" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
<input type="email" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail"> </div>
<button type="submit" class="btn btn-primary mb-2" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button> <div class="col">
<label class="visually-hidden" asp-for="OrganizationOwnerEmail"></label>
<input type="email" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -22,23 +22,23 @@
<h1>Create Provider</h1> <h1>Create Provider</h1>
<form method="post" asp-action="Create"> <form method="post" asp-action="Create">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group"> <div class="mb-3">
<label asp-for="Type" class="h2"></label> <label asp-for="Type" class="form-label h2"></label>
@foreach (var providerType in providerTypes) @foreach (var providerType in providerTypes)
{ {
var providerTypeValue = (int)providerType; var providerTypeValue = (int)providerType;
<div class="form-group"> <div class="mb-3">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="form-check"> <div class="form-check">
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" }) @Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" }) @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label", @for = $"providerType-{providerTypeValue}" })
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted align-top", @for = $"providerType-{providerTypeValue}" }) @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-body-secondary ps-4", @for = $"providerType-{providerTypeValue}" })
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,39 +1,31 @@
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core
@model CreateMspProviderModel @model CreateMspProviderModel
@inject Bit.Core.Services.IFeatureService FeatureService
@{ @{
ViewData["Title"] = "Create Managed Service Provider"; ViewData["Title"] = "Create Managed Service Provider";
} }
<h1>Create Managed Service Provider</h1> <h1>Create Managed Service Provider</h1>
<div> <div>
<form class="form-group" method="post" asp-action="CreateMsp"> <form method="post" asp-action="CreateMsp">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group"> <div class="mb-3">
<label asp-for="OwnerEmail"></label> <label asp-for="OwnerEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="OwnerEmail"> <input type="text" class="form-control" asp-for="OwnerEmail">
</div> </div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="TeamsMonthlySeatMinimum"></label> <label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum"> <input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="EnterpriseMonthlySeatMinimum"></label> <label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum"> <input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div> </div>
</div> </div>
</div> </div>
}
<button type="submit" class="btn btn-primary mb-2">Create Provider</button> <button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form> </form>
</div> </div>

View File

@ -7,17 +7,17 @@
ViewData["Title"] = "Create Multi-organization Enterprise Provider"; ViewData["Title"] = "Create Multi-organization Enterprise Provider";
} }
<h1>Create Multi-organization Enterprise Provider</h1> <h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
<div> <div>
<form class="form-group" method="post" asp-action="CreateMultiOrganizationEnterprise"> <form method="post" asp-action="CreateMultiOrganizationEnterprise">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group"> <div class="mb-3">
<label asp-for="OwnerEmail"></label> <label asp-for="OwnerEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="OwnerEmail"> <input type="text" class="form-control" asp-for="OwnerEmail">
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
@{ @{
var multiOrgPlans = new List<PlanType> var multiOrgPlans = new List<PlanType>
{ {
@ -25,19 +25,19 @@
PlanType.EnterpriseMonthly PlanType.EnterpriseMonthly
}; };
} }
<label asp-for="Plan"></label> <label asp-for="Plan" class="form-label"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)"> <select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<option value="">--</option> <option value="">--</option>
</select> </select>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="EnterpriseSeatMinimum"></label> <label asp-for="EnterpriseSeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum"> <input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
</div> </div>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button> <button type="submit" class="btn btn-primary">Create Provider</button>
</form> </form>
</div> </div>

View File

@ -18,7 +18,7 @@
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model) @await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
<div class="d-flex mt-4"> <div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button> <button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex"> <div class="ms-auto d-flex">
<form asp-controller="Providers" asp-action="Edit" asp-route-id="@Model.Provider.Id" <form asp-controller="Providers" asp-action="Edit" asp-route-id="@Model.Provider.Id"
onsubmit="return confirm('Are you sure you want to cancel?')"> onsubmit="return confirm('Are you sure you want to cancel?')">
<button class="btn btn-outline-secondary" type="submit">Cancel</button> <button class="btn btn-outline-secondary" type="submit">Cancel</button>

View File

@ -6,18 +6,18 @@
<h1>Create Reseller Provider</h1> <h1>Create Reseller Provider</h1>
<div> <div>
<form class="form-group" method="post" asp-action="CreateReseller"> <form class="mb-3" method="post" asp-action="CreateReseller">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group"> <div class="mb-3">
<label asp-for="Name"></label> <label asp-for="Name" class="form-label"></label>
<input type="text" class="form-control" asp-for="Name"> <input type="text" class="form-control" asp-for="Name">
</div> </div>
<div class="form-group"> <div class="mb-3">
<label asp-for="BusinessName"></label> <label asp-for="BusinessName" class="form-label"></label>
<input type="text" class="form-control" asp-for="BusinessName"> <input type="text" class="form-control" asp-for="BusinessName">
</div> </div>
<div class="form-group"> <div class="mb-3">
<label asp-for="BillingEmail"></label> <label asp-for="BillingEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="BillingEmail"> <input type="text" class="form-control" asp-for="BillingEmail">
</div> </div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button> <button type="submit" class="btn btn-primary mb-2">Create Provider</button>

View File

@ -34,21 +34,21 @@
<h2>Billing</h2> <h2>Billing</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="BillingEmail"></label> <label asp-for="BillingEmail" class="form-label"></label>
<input type="email" class="form-control" asp-for="BillingEmail" readonly='@(!canEdit)'> <input type="email" class="form-control" asp-for="BillingEmail" readonly='@(!canEdit)'>
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="BillingPhone"></label> <label asp-for="BillingPhone" class="form-label"></label>
<input type="tel" class="form-control" asp-for="BillingPhone"> <input type="tel" class="form-control" asp-for="BillingPhone">
</div> </div>
</div> </div>
</div> </div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable()) @if (Model.Provider.IsBillable())
{ {
switch (Model.Provider.Type) switch (Model.Provider.Type)
{ {
@ -56,58 +56,18 @@
{ {
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="TeamsMonthlySeatMinimum"></label> <label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum"> <input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="EnterpriseMonthlySeatMinimum"></label> <label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum"> <input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewayCustomerId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append">
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</a>
</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewaySubscriptionId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append">
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</a>
</div>
</div>
</div>
</div>
</div>
break; break;
} }
case ProviderType.MultiOrganizationEnterprise: case ProviderType.MultiOrganizationEnterprise:
@ -116,7 +76,7 @@
{ {
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
@{ @{
var multiOrgPlans = new List<PlanType> var multiOrgPlans = new List<PlanType>
{ {
@ -124,15 +84,15 @@
PlanType.EnterpriseMonthly PlanType.EnterpriseMonthly
}; };
} }
<label asp-for="Plan"></label> <label asp-for="Plan" class="form-label"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)"> <select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<option value="">--</option> <option value="">--</option>
</select> </select>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="EnterpriseMinimumSeats"></label> <label asp-for="EnterpriseMinimumSeats" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats"> <input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
</div> </div>
</div> </div>
@ -141,6 +101,40 @@
break; break;
} }
} }
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label asp-for="Gateway" class="form-label"></label>
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label asp-for="GatewayCustomerId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewayCustomerUrl', '_blank')">
<i class="fa fa-external-link"></i>
</button>
</div>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewaySubscriptionUrl', '_blank')">
<i class="fa fa-external-link"></i>
</button>
</div>
</div>
</div>
</div>
} }
</form> </form>
@await Html.PartialAsync("Organizations", Model) @await Html.PartialAsync("Organizations", Model)
@ -151,21 +145,21 @@
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content rounded"> <div class="modal-content rounded">
<div class="p-3"> <div class="p-3">
<h4 class="font-weight-bolder" id="exampleModalLabel">Request provider deletion</h4> <h4 class="fw-bolder" id="exampleModalLabel">Request provider deletion</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<span class="font-weight-light"> <span class="fw-light">
Enter the email of the provider admin that will receive the request to delete the provider portal. Enter the email of the provider admin that will receive the request to delete the provider portal.
</span> </span>
<form> <form>
<div class="form-group"> <div class="mb-3">
<label for="provider-email" class="col-form-label">Provider email</label> <label for="provider-email" class="col-form-label">Provider email</label>
<input type="email" class="form-control" id="provider-email"> <input type="email" class="form-control" id="provider-email">
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="initiateDeleteProvider('@Model.Provider.Id')">Send email request</button> <button type="button" class="btn btn-danger btn-pill" onclick="initiateDeleteProvider('@Model.Provider.Id')">Send email request</button>
</div> </div>
</div> </div>
@ -175,21 +169,21 @@
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content rounded"> <div class="modal-content rounded">
<div class="p-3"> <div class="p-3">
<h4 class="font-weight-bolder" id="exampleModalLabel">Delete provider</h4> <h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<span class="font-weight-light"> <span class="fw-light">
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data. This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
</span> </span>
<form> <form>
<div class="form-group"> <div class="mb-3">
<label for="provider-name" class="col-form-label">Provider name</label> <label for="provider-name" class="col-form-label">Provider name</label>
<input type="text" class="form-control" id="provider-name"> <input type="text" class="form-control" id="provider-name">
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button> <button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
</div> </div>
</div> </div>
@ -199,12 +193,12 @@
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content rounded"> <div class="modal-content rounded">
<div class="modal-body"> <div class="modal-body">
<h4 class="font-weight-bolder">Cannot Delete @Model.Name</h4> <h4 class="fw-bolder">Cannot Delete @Model.Name</h4>
<p class="font-weight-lighter">You must unlink all clients before you can delete @Model.Name.</p> <p class="fw-lighter">You must unlink all clients before you can delete @Model.Name.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-pill" data-dismiss="modal">Ok</button> <button type="button" class="btn btn-primary btn-pill" data-bs-dismiss="modal">Ok</button>
</div> </div>
</div> </div>
</div> </div>
@ -214,15 +208,14 @@
<div class="d-flex mt-4"> <div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button> <button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex"> <div class="ms-auto d-flex">
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button> <button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button> <button id="requestDeletionBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#requestDeletionModal"></button>
<button class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button> <button class="btn btn-outline-danger ms-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button> <button id="deleteBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#DeleteModal"></button>
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
<button id="linkAccWarningBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#linkedWarningModal"></button>
</div> </div>
</div> </div>
} }

View File

@ -11,23 +11,27 @@
<h1>Providers</h1> <h1>Providers</h1>
<div class="row mb-2"> <form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
<div class="col"> <div class="col-12">
<form class="form-inline mb-2" method="get"> <label class="visually-hidden" asp-for="Name">Name</label>
<label class="sr-only" asp-for="Name">Name</label> <input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name"> </div>
<label class="sr-only" asp-for="UserEmail">User email</label> <div class="col-12">
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail"> <label class="visually-hidden" asp-for="UserEmail">User email</label>
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button> <input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
</form> </div>
<div class="col-12">
<button type="submit" class="btn btn-primary" title="Search">
<i class="fa fa-search"></i> Search
</button>
</div> </div>
@if (canCreateProvider) @if (canCreateProvider)
{ {
<div class="col-auto"> <div class="col-auto ms-auto">
<a asp-action="Create" class="btn btn-secondary">Create Provider</a> <a asp-action="Create" class="btn btn-secondary">Create Provider</a>
</div> </div>
} }
</div> </form>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">

View File

@ -24,9 +24,9 @@
<th> <th>
@if (Model.Provider.Type == ProviderType.Reseller) @if (Model.Provider.Type == ProviderType.Reseller)
{ {
<div class="float-right text-nowrap"> <div class="float-end text-nowrap">
<a asp-controller="Providers" asp-action="CreateOrganization" asp-route-providerId="@Model.Provider.Id" class="btn btn-sm btn-primary">New Organization</a> <a asp-controller="Providers" asp-action="CreateOrganization" asp-route-providerId="@Model.Provider.Id" class="btn btn-sm btn-primary text-decoration-none">New Organization</a>
<a asp-controller="Providers" asp-action="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary">Add Existing Organization</a> <a asp-controller="Providers" asp-action="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary text-decoration-none">Add Existing Organization</a>
</div> </div>
} }
</th> </th>
@ -51,16 +51,16 @@
@providerOrganization.Status @providerOrganization.Status
</td> </td>
<td> <td>
<div class="float-right"> <div class="float-end">
@if (canUnlinkFromProvider) @if (canUnlinkFromProvider)
{ {
<a href="#" class="text-danger float-right" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');"> <a href="#" class="text-danger float-end" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
Unlink provider Unlink provider
</a> </a>
} }
@if (providerOrganization.Status == OrganizationStatusType.Pending) @if (providerOrganization.Status == OrganizationStatusType.Pending)
{ {
<a href="#" class="float-right mr-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');"> <a href="#" class="float-end me-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
Resend invitation Resend invitation
</a> </a>
} }

View File

@ -26,8 +26,8 @@
<h2>General</h2> <h2>General</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="Name"></label> <label class="form-label" asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required> <input type="text" class="form-control" asp-for="Name" value="@Model.Name" required>
</div> </div>
</div> </div>
@ -37,17 +37,17 @@
{ {
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label>Client Owner Email</label> <label class="form-label">Client Owner Email</label>
@if (!string.IsNullOrWhiteSpace(Model.Owners)) @if (!string.IsNullOrWhiteSpace(Model.Owners))
{ {
<input type="text" class="form-control" asp-for="Owners" readonly="readonly"> <input type="text" class="form-control" asp-for="Owners" readonly>
} }
else else
{ {
<input type="text" class="form-control" asp-for="Owners" required> <input type="text" class="form-control" asp-for="Owners" required>
} }
<label class="form-check-label small text-muted align-top">This user should be independent of the Provider. If the Provider is disassociated with the organization, this user will maintain ownership of the organization.</label> <div class="form-text mt-0">This user should be independent of the Provider. If the Provider is disassociated with the organization, this user will maintain ownership of the organization.</div>
</div> </div>
</div> </div>
</div> </div>
@ -66,8 +66,8 @@
<h2>Plan</h2> <h2>Plan</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="PlanType"></label> <label class="form-label" asp-for="PlanType"></label>
@{ @{
var planTypes = Enum.GetValues<PlanType>() var planTypes = Enum.GetValues<PlanType>()
.Where(p => .Where(p =>
@ -83,12 +83,12 @@
}) })
.ToList(); .ToList();
} }
<select class="form-control" asp-for="PlanType" asp-items="planTypes" disabled='@(canEditPlan ? null : "disabled")'></select> <select class="form-select" asp-for="PlanType" asp-items="planTypes" disabled='@(canEditPlan ? null : "disabled")'></select>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="Plan"></label> <label class="form-label" asp-for="Plan"></label>
<input type="text" class="form-control" asp-for="Plan" required readonly='@(!canEditPlan)'> <input type="text" class="form-control" asp-for="Plan" required readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
@ -172,28 +172,28 @@
<h2>Password Manager Configuration</h2> <h2>Password Manager Configuration</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="Seats"></label> <label class="form-label" asp-for="Seats"></label>
<input type="number" class="form-control" asp-for="Seats" min="1" readonly='@(!canEditPlan)'> <input type="number" class="form-control" asp-for="Seats" min="1" readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="MaxCollections"></label> <label class="form-label" asp-for="MaxCollections"></label>
<input type="number" class="form-control" asp-for="MaxCollections" min="1" readonly='@(!canEditPlan)'> <input type="number" class="form-control" asp-for="MaxCollections" min="1" readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="MaxStorageGb"></label> <label class="form-label" asp-for="MaxStorageGb"></label>
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPlan)'> <input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4"> <div class="col-4">
<div class="form-group"> <div class="mb-3">
<label asp-for="MaxAutoscaleSeats"></label> <label class="form-label" asp-for="MaxAutoscaleSeats"></label>
<input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1" readonly='@(!canEditPlan)'> <input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1" readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
@ -202,32 +202,32 @@
@if (canViewPlan) @if (canViewPlan)
{ {
<div id="organization-secrets-configuration" hidden="@(!Model.UseSecretsManager)"> <div id="organization-secrets-configuration" @(Model.UseSecretsManager ? null : "lass='d-none'")>
<h2>Secrets Manager Configuration</h2> <h2>Secrets Manager Configuration</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="SmSeats"></label> <label class="form-label" asp-for="SmSeats"></label>
<input type="number" class="form-control" asp-for="SmSeats" min="1" readonly='@(!canEditPlan)'> <input type="number" class="form-control" asp-for="SmSeats" min="1" readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="MaxAutoscaleSmSeats"></label> <label class="form-label" asp-for="MaxAutoscaleSmSeats"></label>
<input type="number" class="form-control" asp-for="MaxAutoscaleSmSeats" min="1" readonly='@(!canEditPlan)'> <input type="number" class="form-control" asp-for="MaxAutoscaleSmSeats" min="1" readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="SmServiceAccounts"></label> <label class="form-label" asp-for="SmServiceAccounts"></label>
<input type="number" class="form-control" asp-for="SmServiceAccounts" min="1" readonly='@(!canEditPlan)'> <input type="number" class="form-control" asp-for="SmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4"> <div class="col-4">
<div class="form-group"> <div class="mb-3">
<label asp-for="MaxAutoscaleSmServiceAccounts"></label> <label class="form-label" asp-for="MaxAutoscaleSmServiceAccounts"></label>
<input type="number" class="form-control" asp-for="MaxAutoscaleSmServiceAccounts" min="1" readonly='@(!canEditPlan)'> <input type="number" class="form-control" asp-for="MaxAutoscaleSmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
</div> </div>
</div> </div>
@ -240,14 +240,14 @@
<h2>Licensing</h2> <h2>Licensing</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="LicenseKey"></label> <label class="form-label" asp-for="LicenseKey"></label>
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'> <input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="ExpirationDate"></label> <label class="form-label" asp-for="ExpirationDate"></label>
<input type="datetime-local" class="form-control" asp-for="ExpirationDate" readonly='@(!canEditLicensing)' step="1"> <input type="datetime-local" class="form-control" asp-for="ExpirationDate" readonly='@(!canEditLicensing)' step="1">
</div> </div>
</div> </div>
@ -259,52 +259,46 @@
<h2>Billing</h2> <h2>Billing</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="BillingEmail"></label> <label class="form-label" asp-for="BillingEmail"></label>
<input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly"> <input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly">
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<div class="form-group"> <label class="form-label" asp-for="Gateway"></label>
<label asp-for="Gateway"></label> <select class="form-select" asp-for="Gateway" disabled="@(!canEditBilling)"
<select class="form-control" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()"> asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option> <option value="">--</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="GatewayCustomerId"></label> <label class="form-label" asp-for="GatewayCustomerId"></label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'> <input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
@if(canLaunchGateway) @if(canLaunchGateway)
{ {
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-customer-link"> <button class="btn btn-secondary" type="button" id="gateway-customer-link">
<i class="fa fa-external-link"></i> <i class="fa fa-external-link"></i>
</button> </button>
</div>
} }
</div> </div>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="GatewaySubscriptionId"></label> <label class="form-label" asp-for="GatewaySubscriptionId"></label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'> <input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway) @if (canLaunchGateway)
{ {
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-subscription-link"> <button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<i class="fa fa-external-link"></i> <i class="fa fa-external-link"></i>
</button> </button>
</div>
} }
</div> </div>
</div> </div>

View File

@ -3,8 +3,8 @@
ViewData["Title"] = "Login"; ViewData["Title"] = "Login";
} }
<div class="row justify-content-md-center"> <div class="row justify-content-center">
<div class="col col-lg-6 col-md-8"> <div class="col-lg-6 col-md-8">
@if(!string.IsNullOrWhiteSpace(Model.Success)) @if(!string.IsNullOrWhiteSpace(Model.Success))
{ {
<div class="alert alert-success" role="alert">@Model.Success</div> <div class="alert alert-success" role="alert">@Model.Success</div>
@ -19,12 +19,12 @@
<form asp-action="" method="post"> <form asp-action="" method="post">
<input type="hidden" asp-for="ReturnUrl" /> <input type="hidden" asp-for="ReturnUrl" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div> <div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group"> <div class="mb-3">
<label asp-for="Email" class="sr-only">Email Address</label> <label asp-for="Email" class="visually-hidden">Email Address</label>
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com" <input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
required autofocus> required autofocus>
<span asp-validation-for="Email" class="invalid-feedback"></span> <span asp-validation-for="Email" class="invalid-feedback"></span>
<small class="form-text text-muted">We'll email you a secure login link.</small> <div class="form-text">We'll email you a secure login link.</div>
</div> </div>
<button class="btn btn-primary" type="submit">Continue</button> <button class="btn btn-primary" type="submit">Continue</button>
</form> </form>

View File

@ -25,22 +25,22 @@ bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
</div> </div>
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2"> <form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div> <div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group"> <div class="mb-3">
<label asp-for="ProviderIds"></label> <label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea> <textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div> </div>
<div class="form-group"> <div class="mb-3">
<input type="submit" value="Run" class="btn btn-primary mb-2"/> <input type="submit" value="Run" class="btn btn-primary"/>
</div> </div>
</form> </form>
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2"> <form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div> <div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group"> <div class="mb-3">
<label asp-for="ProviderIds"></label> <label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea> <textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div> </div>
<div class="form-group"> <div class="mb-3">
<input type="submit" value="See Previous Results" class="btn btn-primary mb-2"/> <input type="submit" value="See Previous Results" class="btn btn-primary"/>
</div> </div>
</form> </form>
</section> </section>

View File

@ -1,4 +1,7 @@
@import "webfonts.scss"; @import "webfonts.scss";
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/mixins";
$primary: #175DDC; $primary: #175DDC;
$primary-accent: #1252A3; $primary-accent: #1252A3;
@ -7,7 +10,8 @@ $info: #555555;
$warning: #bf7e16; $warning: #bf7e16;
$danger: #dd4b39; $danger: #dd4b39;
$theme-colors: ( "primary-accent": $primary-accent ); $theme-colors: map-merge($theme-colors, ("primary-accent": $primary-accent));
$font-family-sans-serif: 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; $font-family-sans-serif: 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
$h1-font-size: 2rem; $h1-font-size: 2rem;
@ -17,7 +21,7 @@ $h4-font-size: 1rem;
$h5-font-size: 1rem; $h5-font-size: 1rem;
$h6-font-size: 1rem; $h6-font-size: 1rem;
@import "bootstrap/scss/bootstrap.scss"; @import "bootstrap/scss/bootstrap";
h1 { h1 {
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
@ -49,3 +53,11 @@ h3 {
.form-check-input { .form-check-input {
margin-top: .45rem; margin-top: .45rem;
} }
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}

View File

@ -110,7 +110,6 @@ public static class RolePermissionMapping
Permission.User_Licensing_View, Permission.User_Licensing_View,
Permission.User_Billing_View, Permission.User_Billing_View,
Permission.User_Billing_LaunchGateway, Permission.User_Billing_LaunchGateway,
Permission.User_Delete,
Permission.Org_List_View, Permission.Org_List_View,
Permission.Org_OrgInformation_View, Permission.Org_OrgInformation_View,
Permission.Org_GeneralDetails_View, Permission.Org_GeneralDetails_View,

View File

@ -105,7 +105,7 @@
<h3>SMTP</h3> <h3>SMTP</h3>
@if(!Bit.Core.Utilities.CoreHelpers.SettingHasValue(Model.GlobalSettings.Mail?.Smtp?.Host)) @if(!Bit.Core.Utilities.CoreHelpers.SettingHasValue(Model.GlobalSettings.Mail?.Smtp?.Host))
{ {
<p class="text-muted">Not configured</p> <p class="text-body-secondary">Not configured</p>
} }
else else
{ {
@ -159,7 +159,7 @@ else
} }
else else
{ {
<span class="text-muted">Not configured</span> <span class="text-body-secondary">Not configured</span>
} }
</dd> </dd>
@ -171,7 +171,7 @@ else
} }
else else
{ {
<span class="text-muted">Not configured</span> <span class="text-body-secondary">Not configured</span>
} }
</dd> </dd>
@ -183,7 +183,7 @@ else
} }
else else
{ {
<span class="text-muted">Not configured</span> <span class="text-body-secondary">Not configured</span>
} }
</dd> </dd>
</dl> </dl>

View File

@ -37,12 +37,12 @@
<a class="navbar-brand" asp-controller="Home" asp-action="Index"> <a class="navbar-brand" asp-controller="Home" asp-action="Index">
<i class="fa fa-lg fa-fw fa-shield"></i> Admin <i class="fa fa-lg fa-fw fa-shield"></i> Admin
</a> </a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarCollapse"> <div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav me-auto mb-2 mb-md-0">
@if (SignInManager.IsSignedIn(User)) @if (SignInManager.IsSignedIn(User))
{ {
@if (canViewUsers) @if (canViewUsers)
@ -69,10 +69,10 @@
{ {
<li class="nav-item dropdown" active-controller="tools"> <li class="nav-item dropdown" active-controller="tools">
<a class="nav-link dropdown-toggle" href="#" id="toolsDropdown" role="button" <a class="nav-link dropdown-toggle" href="#" id="toolsDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Tools Tools
</a> </a>
<div class="dropdown-menu" aria-labelledby="toolsDropdown"> <ul class="dropdown-menu" aria-labelledby="toolsDropdown">
@if (canChargeBraintree) @if (canChargeBraintree)
{ {
<a class="dropdown-item" asp-controller="Tools" asp-action="ChargeBraintree"> <a class="dropdown-item" asp-controller="Tools" asp-action="ChargeBraintree">
@ -121,7 +121,7 @@
Migrate Providers Migrate Providers
</a> </a>
} }
</div> </ul>
</li> </li>
} }
} }

View File

@ -10,30 +10,30 @@
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="UserId"></label> <label asp-for="UserId" class="form-label"></label>
<input type="text" class="form-control" asp-for="UserId"> <input type="text" class="form-control" asp-for="UserId">
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="OrganizationId"></label> <label asp-for="OrganizationId" class="form-label"></label>
<input type="text" class="form-control" asp-for="OrganizationId"> <input type="text" class="form-control" asp-for="OrganizationId">
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="Date"></label> <label asp-for="Date" class="form-label"></label>
<input type="datetime-local" class="form-control" asp-for="Date" required> <input type="datetime-local" class="form-control" asp-for="Date" required>
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="form-group">
<div class="form-group"> <div class="mb-3">
<label asp-for="Type"></label> <label asp-for="Type" class="form-label"></label>
<select class="form-control" asp-for="Type" required <select class="form-select" asp-for="Type" required
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.TransactionType>()"></select> asp-items="Html.GetEnumSelectList<Bit.Core.Enums.TransactionType>()"></select>
</div> </div>
</div> </div>
@ -41,24 +41,20 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="Amount"></label> <label asp-for="Amount" class="form-label"></label>
<div class="input-group mb-3"> <div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">$</span> <span class="input-group-text">$</span>
</div>
<input type="number" min="-1000000.00" max="1000000.00" step="0.01" class="form-control" <input type="number" min="-1000000.00" max="1000000.00" step="0.01" class="form-control"
asp-for="Amount" required placeholder="ex. 10.00"> asp-for="Amount" required placeholder="ex. 10.00">
</div> </div>
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="RefundedAmount"></label> <label asp-for="RefundedAmount" class="form-label"></label>
<div class="input-group mb-3"> <div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">$</span> <span class="input-group-text">$</span>
</div>
<input type="number" min="0.01" max="1000000.00" step="0.01" class="form-control" <input type="number" min="0.01" max="1000000.00" step="0.01" class="form-control"
asp-for="RefundedAmount" placeholder="ex. 10.00"> asp-for="RefundedAmount" placeholder="ex. 10.00">
</div> </div>
@ -69,41 +65,37 @@
<input type="checkbox" class="form-check-input" asp-for="Refunded"> <input type="checkbox" class="form-check-input" asp-for="Refunded">
<label class="form-check-label" asp-for="Refunded"></label> <label class="form-check-label" asp-for="Refunded"></label>
</div> </div>
<div class="form-group"> <div class="mb-3">
<label asp-for="Details"></label> <label asp-for="Details" class="form-label"></label>
<input type="text" class="form-control" asp-for="Details" required> <input type="text" class="form-control" asp-for="Details" required>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<div class="form-group"> <label asp-for="Gateway" class="form-label"></label>
<label asp-for="Gateway"></label> <select class="form-select" asp-for="Gateway"
<select class="form-control" asp-for="Gateway"
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()"> asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option> <option value="">--</option>
</select> </select>
</div> </div>
</div> </div>
</div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="GatewayId"></label> <label asp-for="GatewayId" class="form-label"></label>
<input type="text" class="form-control" asp-for="GatewayId"> <input type="text" class="form-control" asp-for="GatewayId">
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="mb-3">
<div class="form-group"> <label asp-for="PaymentMethod" class="form-label"></label>
<label asp-for="PaymentMethod"></label> <select class="form-select" asp-for="PaymentMethod"
<select class="form-control" asp-for="PaymentMethod"
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.PaymentMethodType>()"> asp-items="Html.GetEnumSelectList<Bit.Core.Enums.PaymentMethodType>()">
<option value="">--</option> <option value="">--</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
</div>
<button type="submit" class="btn btn-primary mb-2">@action Transaction</button> <button type="submit" class="btn btn-primary mb-2">@action Transaction</button>
</form> </form>

View File

@ -9,28 +9,28 @@
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="UserId"></label> <label asp-for="UserId" class="form-label"></label>
<input type="text" class="form-control" asp-for="UserId"> <input type="text" class="form-control" asp-for="UserId">
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="OrganizationId"></label> <label asp-for="OrganizationId" class="form-label"></label>
<input type="text" class="form-control" asp-for="OrganizationId"> <input type="text" class="form-control" asp-for="OrganizationId">
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="InstallationId"></label> <label asp-for="InstallationId" class="form-label"></label>
<input type="text" class="form-control" asp-for="InstallationId"> <input type="text" class="form-control" asp-for="InstallationId">
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="Version"></label> <label asp-for="Version" class="form-label"></label>
<input type="number" class="form-control" asp-for="Version"> <input type="number" class="form-control" asp-for="Version">
</div> </div>
</div> </div>

View File

@ -9,17 +9,17 @@
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="UserId"></label> <label asp-for="UserId" class="form-label"></label>
<input type="text" class="form-control" asp-for="UserId"> <input type="text" class="form-control" asp-for="UserId">
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="OrganizationId"></label> <label asp-for="OrganizationId" class="form-label"></label>
<input type="text" class="form-control" asp-for="OrganizationId"> <input type="text" class="form-control" asp-for="OrganizationId">
</div> </div>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary mb-2">Promote Admin</button> <button type="submit" class="btn btn-primary">Promote Admin</button>
</form> </form>

View File

@ -74,38 +74,38 @@
<div class="alert alert-success"></div> <div class="alert alert-success"></div>
} }
<form method="post"> <form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row"> <div class="row g-3">
<div class="col-6"> <div class="col-md-6">
<label asp-for="Filter.Status">Status</label> <label class="form-label" asp-for="Filter.Status">Status</label>
<select asp-for="Filter.Status" name="filter.Status" class="form-control mr-2"> <select asp-for="Filter.Status" name="filter.Status" class="form-select">
<option asp-selected="Model.Filter.Status == null" value="all">All</option> <option asp-selected="Model.Filter.Status == null" value="all">All</option>
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option> <option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option> <option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
</select> </select>
</div> </div>
<div class="col-6"> <div class="col-md-6">
<label asp-for="Filter.CurrentPeriodEnd">Current Period End</label> <label class="form-label" asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
<div class="input-group"> <div class="input-group">
<div class="input-group-append">
<div class="input-group-text"> <div class="input-group-text">
<span class="mr-1"> <div class="form-check form-check-inline mb-0">
<input type="radio" class="mr-1" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="lt">Before <input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="lt" id="beforeRadio">
</span> <label class="form-check-label me-2" for="beforeRadio">Before</label>
<input type="radio" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="gt">After </div>
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="gt" id="afterRadio">
<label class="form-check-label" for="afterRadio">After</label>
</div> </div>
</div> </div>
@{ @{
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty; var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
} }
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
</div> </div>
</div> </div>
</div> <div class="col-md-6">
<div class="row mt-2"> <label class="form-label" asp-for="Filter.Price">Price ID</label>
<div class="col-6"> <select asp-for="Filter.Price" name="filter.Price" class="form-select">
<label asp-for="Filter.Price">Price ID</label>
<select asp-for="Filter.Price" name="filter.Price" class="form-control mr-2">
<option asp-selected="Model.Filter.Price == null" value="@null">All</option> <option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices) @foreach (var price in Model.Prices)
{ {
@ -113,9 +113,9 @@
} }
</select> </select>
</div> </div>
<div class="col-6"> <div class="col-md-6">
<label asp-for="Filter.TestClock">Test Clock</label> <label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-control mr-2"> <select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option> <option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks) @foreach (var clock in Model.TestClocks)
{ {
@ -123,26 +123,32 @@
} }
</select> </select>
</div> </div>
</div> <div class="col-12 text-end">
<div class="row col-12 d-flex justify-content-end my-3"> <button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search">
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search"><i class="fa fa-search"></i> Search</button> <i class="fa fa-search"></i> Search
</div> </button>
<hr/> </div>
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll"> </div>
<div class="text-center row d-flex justify-content-center"> <hr/>
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
<div class="text-center row d-flex justify-content-center">
<div id="selectAll" class="d-none col-8"> <div id="selectAll" class="d-none col-8">
All @Model.Items.Count subscriptions on this page are selected.<br/> All @Model.Items.Count subscriptions on this page are selected.<br/>
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button> <button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
<span id="selectedAllConfirmation" class="d-none text-muted">✔ All subscriptions for this search are selected.</span><br/> <span id="selectedAllConfirmation" class="d-none text-body-secondary">
<div class="alert alert-warning" role="alert">Please be aware that bulk operations may take several minutes to complete.</div> <i class="fa fa-check"></i> All subscriptions for this search are selected.
</span>
<div class="alert alert-warning mt-2" role="alert">
Please be aware that bulk operations may take several minutes to complete.
</div> </div>
</div> </div>
<div class="table-responsive"> </div>
<table class="table table-striped table-hover"> <div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th> <th>
<div class="form-check form-check-inline"> <div class="form-check">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)"> <input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div> </div>
</th> </th>
@ -191,7 +197,7 @@
@{ @{
var i2 = i; var i2 = i;
} }
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected"> <input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
</div> </div>
</td> </td>
<td> <td>
@ -214,9 +220,9 @@
} }
</tbody> </tbody>
</table> </table>
</div> </div>
<nav class="d-inline-flex"> <nav class="d-inline-flex align-items-center">
<ul class="pagination"> <ul class="pagination mb-0">
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore)) @if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{ {
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore"> <input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
@ -257,25 +263,15 @@
</li> </li>
} }
</ul> </ul>
<span id="bulkActions" class="d-none ml-2"> <span id="bulkActions" class="d-none ms-3">
<span class="d-inline-flex"> <span class="d-inline-flex gap-2">
<button <button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
type="submit"
class="btn btn-primary mr-1"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.Export">
Export Export
</button> </button>
<button <button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
type="submit"
class="btn btn-danger"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.BulkCancel">
Bulk Cancel Bulk Cancel
</button> </button>
</span> </span>
</span> </span>
</nav> </nav>
</form> </form>

View File

@ -27,20 +27,20 @@
</div> </div>
</div> </div>
<form method="post" enctype="multipart/form-data" asp-action="TaxRateUpload"> <form method="post" enctype="multipart/form-data" asp-action="TaxRateUpload">
<div class="form-group"> <div class="mb-3">
<input type="file" name="file" /> <input type="file" class="form-control" name="file" />
</div> </div>
<div class="form-group"> <div class="mb-3">
<input type="submit" value="Upload" class="btn btn-primary mb-2" /> <input type="submit" value="Upload" class="btn btn-primary" />
</div> </div>
</form> </form>
</section> </section>
<hr/> <hr class="my-4">
<h2>View &amp; Manage Tax Rates</h2> <h2>View &amp; Manage Tax Rates</h2>
<a class="btn btn-primary mb-2" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a> <a class="btn btn-primary mb-3" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-hover"> <table class="table table-striped table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style="width: 190px;">Id</th> <th style="width: 190px;">Id</th>
@ -97,7 +97,7 @@
</table> </table>
</div> </div>
<nav> <nav aria-label="Tax rates pagination">
<ul class="pagination"> <ul class="pagination">
@if(Model.PreviousPage.HasValue) @if(Model.PreviousPage.HasValue)
{ {

View File

@ -115,8 +115,8 @@
<h2>Premium</h2> <h2>Premium</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="MaxStorageGb"></label> <label asp-for="MaxStorageGb" class="form-label"></label>
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPremium)'> <input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPremium)'>
</div> </div>
</div> </div>
@ -131,14 +131,14 @@
<h2>Licensing</h2> <h2>Licensing</h2>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="LicenseKey"></label> <label asp-for="LicenseKey" class="form-label"></label>
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'> <input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
</div> </div>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="form-group"> <div class="mb-3">
<label asp-for="PremiumExpirationDate"></label> <label asp-for="PremiumExpirationDate" class="form-label"></label>
<input type="datetime-local" class="form-control" asp-for="PremiumExpirationDate" readonly='@(!canEditLicensing)'> <input type="datetime-local" class="form-control" asp-for="PremiumExpirationDate" readonly='@(!canEditLicensing)'>
</div> </div>
</div> </div>
@ -149,44 +149,38 @@
<h2>Billing</h2> <h2>Billing</h2>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<div class="form-group"> <label asp-for="Gateway" class="form-label"></label>
<label asp-for="Gateway"></label> <select class="form-select" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
<select class="form-control" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()"> asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option> <option value="">--</option>
</select> </select>
</div> </div>
</div> </div>
</div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="GatewayCustomerId"></label> <label asp-for="GatewayCustomerId" class="form-label"></label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'> <input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway) @if (canLaunchGateway)
{ {
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-customer-link"> <button class="btn btn-secondary" type="button" id="gateway-customer-link">
<i class="fa fa-external-link"></i> <i class="fa fa-external-link"></i>
</button> </button>
</div>
} }
</div> </div>
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="form-group"> <div class="mb-3">
<label asp-for="GatewaySubscriptionId"></label> <label asp-for="GatewaySubscriptionId" class="form-label"></label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'> <input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway) @if (canLaunchGateway)
{ {
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-subscription-link"> <button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<i class="fa fa-external-link"></i> <i class="fa fa-external-link"></i>
</button> </button>
</div>
} }
</div> </div>
</div> </div>
@ -196,10 +190,10 @@
</form> </form>
<div class="d-flex mt-4"> <div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button> <button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex"> <div class="ms-auto d-flex">
@if (canUpgradePremium) @if (canUpgradePremium)
{ {
<button class="btn btn-secondary mr-2" type="button" id="upgrade-premium"> <button class="btn btn-secondary me-2" type="button" id="upgrade-premium">
Upgrade Premium Upgrade Premium
</button> </button>
} }

View File

@ -5,10 +5,16 @@
<h1>Users</h1> <h1>Users</h1>
<form class="form-inline mb-2" method="get"> <form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
<label class="sr-only" asp-for="Email">Email</label> <div class="col-12">
<input type="text" class="form-control mb-2 mr-2" placeholder="Email" asp-for="Email" name="email"> <label class="visually-hidden" asp-for="Email">Email</label>
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button> <input type="text" class="form-control" placeholder="Email" asp-for="Email" name="email">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary" title="Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</form> </form>
<div class="table-responsive"> <div class="table-responsive">
@ -49,7 +55,7 @@
} }
else else
{ {
<i class="fa fa-star-o fa-lg fa-fw text-muted" title="Not Premium"></i> <i class="fa fa-star-o fa-lg fa-fw text-body-secondary" title="Not Premium"></i>
} }
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1) @if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
{ {
@ -59,7 +65,7 @@
} }
else else
{ {
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted" <i class="fa fa-plus-square-o fa-lg fa-fw text-body-secondary"
title="No Additional Storage"> title="No Additional Storage">
</i> </i>
} }
@ -69,7 +75,7 @@
} }
else else
{ {
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i> <i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Email Not Verified"></i>
} }
@if (user.TwoFactorEnabled) @if (user.TwoFactorEnabled)
{ {
@ -77,7 +83,7 @@
} }
else else
{ {
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i> <i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
} }
</td> </td>
</tr> </tr>

View File

@ -9,10 +9,9 @@
"version": "0.0.0", "version": "0.0.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"bootstrap": "4.6.2", "bootstrap": "5.3.3",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"jquery": "3.7.1", "jquery": "3.7.1",
"popper.js": "1.16.1",
"toastr": "2.1.4" "toastr": "2.1.4"
}, },
"devDependencies": { "devDependencies": {
@ -385,6 +384,17 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -703,9 +713,9 @@
} }
}, },
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "4.6.2", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==", "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -716,10 +726,8 @@
"url": "https://opencollective.com/bootstrap" "url": "https://opencollective.com/bootstrap"
} }
], ],
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"jquery": "1.9.1 - 3", "@popperjs/core": "^2.11.8"
"popper.js": "^1.16.1"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
@ -1578,17 +1586,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.47", "version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",

View File

@ -8,10 +8,9 @@
"build": "webpack" "build": "webpack"
}, },
"dependencies": { "dependencies": {
"bootstrap": "4.6.2", "bootstrap": "5.3.3",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"jquery": "3.7.1", "jquery": "3.7.1",
"popper.js": "1.16.1",
"toastr": "2.1.4" "toastr": "2.1.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -13,8 +13,6 @@ module.exports = {
entry: { entry: {
site: [ site: [
path.resolve(__dirname, paths.sassDir, "site.scss"), path.resolve(__dirname, paths.sassDir, "site.scss"),
"popper.js",
"bootstrap", "bootstrap",
"jquery", "jquery",
"font-awesome/css/font-awesome.css", "font-awesome/css/font-awesome.css",

View File

@ -2,15 +2,16 @@
using Bit.Api.AdminConsole.Models.Response; using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.Groups; using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -89,11 +90,34 @@ public class GroupsController : Controller
} }
[HttpGet("")] [HttpGet("")]
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId) public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroups(Guid orgId)
{ {
var authorized = var authResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
(await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded; if (!authResult.Succeeded)
if (!authorized) {
throw new NotFoundException();
}
if (_featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails))
{
var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
var responses = groups.Select(g => new GroupDetailsResponseModel(g, []));
return new ListResponseModel<GroupDetailsResponseModel>(responses);
}
var groupDetails = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId);
var detailResponses = groupDetails.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2));
return new ListResponseModel<GroupDetailsResponseModel>(detailResponses);
}
[HttpGet("details")]
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroupDetails(Guid orgId)
{
var authResult = _featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails)
? await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAllDetails)
: await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
if (!authResult.Succeeded)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }

View File

@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;

View File

@ -252,6 +252,12 @@ public class OrganizationsController : Controller
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving."); throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
} }
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id))
{
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.");
}
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id); await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
} }
@ -283,9 +289,7 @@ public class OrganizationsController : Controller
throw new BadRequestException(string.Empty, "User verification failed."); throw new BadRequestException(string.Empty, "User verification failed.");
} }
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (organization.IsValidClient())
if (consolidatedBillingEnabled && organization.IsValidClient())
{ {
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id); var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
@ -316,8 +320,7 @@ public class OrganizationsController : Controller
throw new BadRequestException("Invalid token."); throw new BadRequestException("Invalid token.");
} }
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (organization.IsValidClient())
if (consolidatedBillingEnabled && organization.IsValidClient())
{ {
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id); var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider.IsBillable()) if (provider.IsBillable())
@ -351,7 +354,7 @@ public class OrganizationsController : Controller
{ {
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types // Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
var plan = StaticStore.GetPlan(organization.PlanType); var plan = StaticStore.GetPlan(organization.PlanType);
if (plan.ProductTier != ProductTierType.Enterprise) if (plan.ProductTier is not ProductTierType.Enterprise and not ProductTierType.Teams)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }

View File

@ -1,7 +1,11 @@
using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Api.Response; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
@ -16,7 +20,6 @@ using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
@ -32,6 +35,8 @@ public class PoliciesController : Controller
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IDataProtector _organizationServiceDataProtector; private readonly IDataProtector _organizationServiceDataProtector;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
public PoliciesController( public PoliciesController(
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
@ -41,7 +46,9 @@ public class PoliciesController : Controller
ICurrentContext currentContext, ICurrentContext currentContext,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IDataProtectionProvider dataProtectionProvider, IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory) IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IFeatureService featureService,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
{ {
_policyRepository = policyRepository; _policyRepository = policyRepository;
_policyService = policyService; _policyService = policyService;
@ -53,10 +60,12 @@ public class PoliciesController : Controller
"OrganizationServiceDataProtector"); "OrganizationServiceDataProtector");
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
} }
[HttpGet("{type}")] [HttpGet("{type}")]
public async Task<PolicyResponseModel> Get(Guid orgId, int type) public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
{ {
if (!await _currentContext.ManagePolicies(orgId)) if (!await _currentContext.ManagePolicies(orgId))
{ {
@ -65,10 +74,15 @@ public class PoliciesController : Controller
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type); var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null) if (policy == null)
{ {
return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false }); return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
} }
return new PolicyResponseModel(policy); if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg)
{
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
}
return new PolicyDetailResponseModel(policy);
} }
[HttpGet("")] [HttpGet("")]
@ -81,8 +95,8 @@ public class PoliciesController : Controller
} }
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid); var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
var responses = policies.Select(p => new PolicyResponseModel(p));
return new ListResponseModel<PolicyResponseModel>(responses); return new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p)));
} }
[AllowAnonymous] [AllowAnonymous]

View File

@ -0,0 +1,19 @@
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
public static class PolicyDetailResponses
{
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
{
if (policy.Type is not PolicyType.SingleOrg)
{
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
}
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId));
}
}

View File

@ -0,0 +1,20 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyDetailResponseModel : PolicyResponseModel
{
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
{
}
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
{
CanToggleState = canToggleState;
}
/// <summary>
/// Indicates whether the Policy can be enabled/disabled
/// </summary>
public bool CanToggleState { get; set; } = true;
}

View File

@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
namespace Bit.Core.AdminConsole.Models.Api.Response; namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyResponseModel : ResponseModel public class PolicyResponseModel : ResponseModel
{ {

View File

@ -213,7 +213,7 @@ public class MembersController : Controller
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
await _updateOrganizationUserGroupsCommand.UpdateUserGroupsAsync(existingUser, model.GroupIds, null); await _updateOrganizationUserGroupsCommand.UpdateUserGroupsAsync(existingUser, model.GroupIds);
return new OkResult(); return new OkResult();
} }

View File

@ -41,14 +41,13 @@ public class PoliciesController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)] [ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(PolicyType type) public async Task<IActionResult> Get(PolicyType type)
{ {
var policy = await _policyRepository.GetByOrganizationIdTypeAsync( var policy = await _policyRepository.GetByOrganizationIdTypeAsync(_currentContext.OrganizationId.Value, type);
_currentContext.OrganizationId.Value, type);
if (policy == null) if (policy == null)
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
var response = new PolicyResponseModel(policy);
return new JsonResult(response); return new JsonResult(new PolicyResponseModel(policy));
} }
/// <summary> /// <summary>
@ -62,9 +61,8 @@ public class PoliciesController : Controller
public async Task<IActionResult> List() public async Task<IActionResult> List()
{ {
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value); var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
var policyResponses = policies.Select(p => new PolicyResponseModel(p));
var response = new ListResponseModel<PolicyResponseModel>(policyResponses); return new JsonResult(new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p))));
return new JsonResult(response);
} }
/// <summary> /// <summary>

View File

@ -18,7 +18,6 @@ public abstract class MemberBaseModel
Type = user.Type; Type = user.Type;
ExternalId = user.ExternalId; ExternalId = user.ExternalId;
ResetPasswordEnrolled = user.ResetPasswordKey != null;
if (Type == OrganizationUserType.Custom) if (Type == OrganizationUserType.Custom)
{ {
@ -35,7 +34,6 @@ public abstract class MemberBaseModel
Type = user.Type; Type = user.Type;
ExternalId = user.ExternalId; ExternalId = user.ExternalId;
ResetPasswordEnrolled = user.ResetPasswordKey != null;
if (Type == OrganizationUserType.Custom) if (Type == OrganizationUserType.Custom)
{ {
@ -55,11 +53,7 @@ public abstract class MemberBaseModel
/// <example>external_id_123456</example> /// <example>external_id_123456</example>
[StringLength(300)] [StringLength(300)]
public string ExternalId { get; set; } public string ExternalId { get; set; }
/// <summary>
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
/// </summary>
[Required]
public bool ResetPasswordEnrolled { get; set; }
/// <summary> /// <summary>
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will /// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
/// default to false. /// default to false.

View File

@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
Email = user.Email; Email = user.Email;
Status = user.Status; Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null;
} }
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled, public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
TwoFactorEnabled = twoFactorEnabled; TwoFactorEnabled = twoFactorEnabled;
Status = user.Status; Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null;
} }
/// <summary> /// <summary>
@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
/// The associated collections that this member can access. /// The associated collections that this member can access.
/// </summary> /// </summary>
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; } public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
/// <summary>
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
/// </summary>
[Required]
public bool ResetPasswordEnrolled { get; }
} }

View File

@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<UserSecretsId>bitwarden-Api</UserSecretsId> <UserSecretsId>bitwarden-Api</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish> <MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>

View File

@ -1,10 +1,10 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Response; using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Response; using Bit.Api.Vault.Models.Response;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;

View File

@ -4,15 +4,14 @@ using Bit.Api.Auth.Models.Response.TwoFactor;
using Bit.Api.Models.Request; using Bit.Api.Models.Request;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Utilities;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens; using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Fido2NetLib; using Fido2NetLib;
@ -29,11 +28,10 @@ public class TwoFactorController : Controller
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly GlobalSettings _globalSettings;
private readonly UserManager<User> _userManager; private readonly UserManager<User> _userManager;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand; private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
private readonly IFeatureService _featureService; private readonly IDuoUniversalTokenService _duoUniversalTokenService;
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector; private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector; private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
@ -41,22 +39,20 @@ public class TwoFactorController : Controller
IUserService userService, IUserService userService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
GlobalSettings globalSettings,
UserManager<User> userManager, UserManager<User> userManager,
ICurrentContext currentContext, ICurrentContext currentContext,
IVerifyAuthRequestCommand verifyAuthRequestCommand, IVerifyAuthRequestCommand verifyAuthRequestCommand,
IFeatureService featureService, IDuoUniversalTokenService duoUniversalConfigService,
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector, IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector) IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector)
{ {
_userService = userService; _userService = userService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService; _organizationService = organizationService;
_globalSettings = globalSettings;
_userManager = userManager; _userManager = userManager;
_currentContext = currentContext; _currentContext = currentContext;
_verifyAuthRequestCommand = verifyAuthRequestCommand; _verifyAuthRequestCommand = verifyAuthRequestCommand;
_featureService = featureService; _duoUniversalTokenService = duoUniversalConfigService;
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
} }
@ -184,21 +180,7 @@ public class TwoFactorController : Controller
public async Task<TwoFactorDuoResponseModel> PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model) public async Task<TwoFactorDuoResponseModel> PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model)
{ {
var user = await CheckAsync(model, true); var user = await CheckAsync(model, true);
try if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
{
// for backwards compatibility - will be removed with PM-8107
DuoApi duoApi = null;
if (model.ClientId != null && model.ClientSecret != null)
{
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
}
else
{
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
}
await duoApi.JSONApiCall("GET", "/auth/v2/check");
}
catch (DuoException)
{ {
throw new BadRequestException( throw new BadRequestException(
"Duo configuration settings are not valid. Please re-check the Duo Admin panel."); "Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
@ -241,21 +223,7 @@ public class TwoFactorController : Controller
} }
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException(); var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException();
try if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
{
// for backwards compatibility - will be removed with PM-8107
DuoApi duoApi = null;
if (model.ClientId != null && model.ClientSecret != null)
{
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
}
else
{
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
}
await duoApi.JSONApiCall("GET", "/auth/v2/check");
}
catch (DuoException)
{ {
throw new BadRequestException( throw new BadRequestException(
"Duo configuration settings are not valid. Please re-check the Duo Admin panel."); "Duo configuration settings are not valid. Please re-check the Duo Admin panel.");

View File

@ -2,8 +2,8 @@
using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.Models; using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities;
using Bit.Core.Entities; using Bit.Core.Entities;
using Fido2NetLib; using Fido2NetLib;
@ -43,21 +43,16 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques
public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject
{ {
/* /*
To support both v2 and v4 we need to remove the required annotation from the properties. String lengths based on Duo's documentation
todo - the required annotation will be added back in PM-8107. https://github.com/duosecurity/duo_universal_csharp/blob/main/DuoUniversal/Client.cs
*/ */
[StringLength(50)]
public string ClientId { get; set; }
[StringLength(50)]
public string ClientSecret { get; set; }
//todo - will remove SKey and IKey with PM-8107
[StringLength(50)]
public string IntegrationKey { get; set; }
//todo - will remove SKey and IKey with PM-8107
[StringLength(50)]
public string SecretKey { get; set; }
[Required] [Required]
[StringLength(50)] [StringLength(20, MinimumLength = 20, ErrorMessage = "Client Id must be exactly 20 characters.")]
public string ClientId { get; set; }
[Required]
[StringLength(40, MinimumLength = 40, ErrorMessage = "Client Secret must be exactly 40 characters.")]
public string ClientSecret { get; set; }
[Required]
public string Host { get; set; } public string Host { get; set; }
public User ToUser(User existingUser) public User ToUser(User existingUser)
@ -65,22 +60,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
var providers = existingUser.GetTwoFactorProviders(); var providers = existingUser.GetTwoFactorProviders();
if (providers == null) if (providers == null)
{ {
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>(); providers = [];
} }
else if (providers.ContainsKey(TwoFactorProviderType.Duo)) else if (providers.ContainsKey(TwoFactorProviderType.Duo))
{ {
providers.Remove(TwoFactorProviderType.Duo); providers.Remove(TwoFactorProviderType.Duo);
} }
Temporary_SyncDuoParams();
providers.Add(TwoFactorProviderType.Duo, new TwoFactorProvider providers.Add(TwoFactorProviderType.Duo, new TwoFactorProvider
{ {
MetaData = new Dictionary<string, object> MetaData = new Dictionary<string, object>
{ {
//todo - will remove SKey and IKey with PM-8107
["SKey"] = SecretKey,
["IKey"] = IntegrationKey,
["ClientSecret"] = ClientSecret, ["ClientSecret"] = ClientSecret,
["ClientId"] = ClientId, ["ClientId"] = ClientId,
["Host"] = Host ["Host"] = Host
@ -96,22 +86,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
var providers = existingOrg.GetTwoFactorProviders(); var providers = existingOrg.GetTwoFactorProviders();
if (providers == null) if (providers == null)
{ {
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>(); providers = [];
} }
else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo)) else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo))
{ {
providers.Remove(TwoFactorProviderType.OrganizationDuo); providers.Remove(TwoFactorProviderType.OrganizationDuo);
} }
Temporary_SyncDuoParams();
providers.Add(TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider providers.Add(TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider
{ {
MetaData = new Dictionary<string, object> MetaData = new Dictionary<string, object>
{ {
//todo - will remove SKey and IKey with PM-8107
["SKey"] = SecretKey,
["IKey"] = IntegrationKey,
["ClientSecret"] = ClientSecret, ["ClientSecret"] = ClientSecret,
["ClientId"] = ClientId, ["ClientId"] = ClientId,
["Host"] = Host ["Host"] = Host
@ -124,34 +109,22 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext) public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{ {
if (!DuoApi.ValidHost(Host)) var results = new List<ValidationResult>();
if (string.IsNullOrWhiteSpace(ClientId))
{ {
yield return new ValidationResult("Host is invalid.", [nameof(Host)]); results.Add(new ValidationResult("ClientId is required.", [nameof(ClientId)]));
}
if (string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId) &&
string.IsNullOrWhiteSpace(SecretKey) && string.IsNullOrWhiteSpace(IntegrationKey))
{
yield return new ValidationResult("Neither v2 or v4 values are valid.", [nameof(IntegrationKey), nameof(SecretKey), nameof(ClientSecret), nameof(ClientId)]);
}
} }
/* if (string.IsNullOrWhiteSpace(ClientSecret))
use this method to ensure that both v2 params and v4 params are in sync
todo will be removed in pm-8107
*/
private void Temporary_SyncDuoParams()
{ {
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret results.Add(new ValidationResult("ClientSecret is required.", [nameof(ClientSecret)]));
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
{
SecretKey = ClientSecret;
IntegrationKey = ClientId;
} }
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
if (string.IsNullOrWhiteSpace(Host) || !DuoUniversalTokenService.ValidDuoHost(Host))
{ {
ClientSecret = SecretKey; results.Add(new ValidationResult("Host is invalid.", [nameof(Host)]));
ClientId = IntegrationKey;
} }
return results;
} }
} }

View File

@ -13,37 +13,26 @@ public class TwoFactorDuoResponseModel : ResponseModel
public TwoFactorDuoResponseModel(User user) public TwoFactorDuoResponseModel(User user)
: base(ResponseObj) : base(ResponseObj)
{ {
if (user == null) ArgumentNullException.ThrowIfNull(user);
{
throw new ArgumentNullException(nameof(user));
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo); var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
Build(provider); Build(provider);
} }
public TwoFactorDuoResponseModel(Organization org) public TwoFactorDuoResponseModel(Organization organization)
: base(ResponseObj) : base(ResponseObj)
{ {
if (org == null) ArgumentNullException.ThrowIfNull(organization);
{
throw new ArgumentNullException(nameof(org));
}
var provider = org.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo); var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
Build(provider); Build(provider);
} }
public bool Enabled { get; set; } public bool Enabled { get; set; }
public string Host { get; set; } public string Host { get; set; }
//TODO - will remove SecretKey with PM-8107
public string SecretKey { get; set; }
//TODO - will remove IntegrationKey with PM-8107
public string IntegrationKey { get; set; }
public string ClientSecret { get; set; } public string ClientSecret { get; set; }
public string ClientId { get; set; } public string ClientId { get; set; }
// updated build to assist in the EDD migration for the Duo 2FA provider
private void Build(TwoFactorProvider provider) private void Build(TwoFactorProvider provider)
{ {
if (provider?.MetaData != null && provider.MetaData.Count > 0) if (provider?.MetaData != null && provider.MetaData.Count > 0)
@ -54,36 +43,13 @@ public class TwoFactorDuoResponseModel : ResponseModel
{ {
Host = (string)host; Host = (string)host;
} }
//todo - will remove SKey and IKey with PM-8107
// check Skey and IKey first if they exist
if (provider.MetaData.TryGetValue("SKey", out var sKey))
{
ClientSecret = MaskKey((string)sKey);
SecretKey = MaskKey((string)sKey);
}
if (provider.MetaData.TryGetValue("IKey", out var iKey))
{
IntegrationKey = (string)iKey;
ClientId = (string)iKey;
}
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
if (provider.MetaData.TryGetValue("ClientSecret", out var clientSecret)) if (provider.MetaData.TryGetValue("ClientSecret", out var clientSecret))
{ {
if (!string.IsNullOrWhiteSpace((string)clientSecret)) ClientSecret = MaskSecret((string)clientSecret);
{
ClientSecret = MaskKey((string)clientSecret);
SecretKey = MaskKey((string)clientSecret);
}
} }
if (provider.MetaData.TryGetValue("ClientId", out var clientId)) if (provider.MetaData.TryGetValue("ClientId", out var clientId))
{
if (!string.IsNullOrWhiteSpace((string)clientId))
{ {
ClientId = (string)clientId; ClientId = (string)clientId;
IntegrationKey = (string)clientId;
}
} }
} }
else else
@ -92,30 +58,7 @@ public class TwoFactorDuoResponseModel : ResponseModel
} }
} }
/* private static string MaskSecret(string key)
use this method to ensure that both v2 params and v4 params are in sync
todo will be removed in pm-8107
*/
private void Temporary_SyncDuoParams()
{
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
{
SecretKey = ClientSecret;
IntegrationKey = ClientId;
}
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
{
ClientSecret = SecretKey;
ClientId = IntegrationKey;
}
else
{
throw new InvalidDataException("Invalid Duo parameters.");
}
}
private static string MaskKey(string key)
{ {
if (string.IsNullOrWhiteSpace(key) || key.Length <= 6) if (string.IsNullOrWhiteSpace(key) || key.Length <= 6)
{ {

View File

@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
new ErrorResponseModel(message), new ErrorResponseModel(message),
statusCode: StatusCodes.Status500InternalServerError); statusCode: StatusCodes.Status500InternalServerError);
public static JsonHttpResult<ErrorResponseModel> Unauthorized() => public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
TypedResults.Json( TypedResults.Json(
new ErrorResponseModel("Unauthorized."), new ErrorResponseModel(message),
statusCode: StatusCodes.Status401Unauthorized); statusCode: StatusCodes.Status401Unauthorized);
} }
} }

View File

@ -1,5 +1,4 @@
using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Context; using Bit.Core.Context;
@ -9,7 +8,6 @@ namespace Bit.Api.Billing.Controllers;
public abstract class BaseProviderController( public abstract class BaseProviderController(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger, ILogger<BaseProviderController> logger,
IProviderRepository providerRepository, IProviderRepository providerRepository,
IUserService userService) : BaseBillingController IUserService userService) : BaseBillingController
@ -26,15 +24,6 @@ public abstract class BaseProviderController(
Guid providerId, Guid providerId,
Func<Guid, bool> checkAuthorization) Func<Guid, bool> checkAuthorization)
{ {
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
logger.LogError(
"Cannot run Consolidated Billing operation for provider ({ProviderID}) while feature flag is disabled",
providerId);
return (null, Error.NotFound());
}
var provider = await providerRepository.GetByIdAsync(providerId); var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null) if (provider == null)

View File

@ -1,6 +1,9 @@
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response.Organizations; using Bit.Api.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -31,6 +34,8 @@ public class OrganizationSponsorshipsController : Controller
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand; private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository;
private readonly IFeatureService _featureService;
public OrganizationSponsorshipsController( public OrganizationSponsorshipsController(
IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationSponsorshipRepository organizationSponsorshipRepository,
@ -45,7 +50,9 @@ public class OrganizationSponsorshipsController : Controller
IRemoveSponsorshipCommand removeSponsorshipCommand, IRemoveSponsorshipCommand removeSponsorshipCommand,
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand, ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
IUserService userService, IUserService userService,
ICurrentContext currentContext) ICurrentContext currentContext,
IPolicyRepository policyRepository,
IFeatureService featureService)
{ {
_organizationSponsorshipRepository = organizationSponsorshipRepository; _organizationSponsorshipRepository = organizationSponsorshipRepository;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -60,6 +67,8 @@ public class OrganizationSponsorshipsController : Controller
_syncSponsorshipsCommand = syncSponsorshipsCommand; _syncSponsorshipsCommand = syncSponsorshipsCommand;
_userService = userService; _userService = userService;
_currentContext = currentContext; _currentContext = currentContext;
_policyRepository = policyRepository;
_featureService = featureService;
} }
[Authorize("Application")] [Authorize("Application")]
@ -94,9 +103,20 @@ public class OrganizationSponsorshipsController : Controller
[Authorize("Application")] [Authorize("Application")]
[HttpPost("validate-token")] [HttpPost("validate-token")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<bool> PreValidateSponsorshipToken([FromQuery] string sponsorshipToken) public async Task<PreValidateSponsorshipResponseModel> PreValidateSponsorshipToken([FromQuery] string sponsorshipToken)
{ {
return (await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email)).valid; var isFreeFamilyPolicyEnabled = false;
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
if (isValid && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship) && sponsorship.SponsoringOrganizationId.HasValue)
{
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy);
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
}
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
return response;
} }
[Authorize("Application")] [Authorize("Application")]

View File

@ -19,14 +19,13 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")] [Authorize("Application")]
public class ProviderBillingController( public class ProviderBillingController(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger, ILogger<BaseProviderController> logger,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository, IProviderRepository providerRepository,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService) IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{ {
[HttpGet("invoices")] [HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId) public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)

View File

@ -14,14 +14,13 @@ namespace Bit.Api.Billing.Controllers;
[Route("providers/{providerId:guid}/clients")] [Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController( public class ProviderClientsController(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger, ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository, IProviderRepository providerRepository,
IProviderService providerService, IProviderService providerService,
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService) IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{ {
[HttpPost] [HttpPost]
public async Task<IResult> CreateAsync( public async Task<IResult> CreateAsync(
@ -102,15 +101,27 @@ public class ProviderClientsController(
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (clientOrganization.Seats != requestBody.AssignedSeats) if (clientOrganization is not { Status: OrganizationStatusType.Managed })
{ {
await providerBillingService.AssignSeatsToClientOrganization( return Error.ServerError();
provider,
clientOrganization,
requestBody.AssignedSeats);
} }
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
provider,
clientOrganization.PlanType,
seatAdjustment);
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
{
return Error.Unauthorized("Service users cannot purchase additional seats.");
}
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
clientOrganization.Name = requestBody.Name; clientOrganization.Name = requestBody.Name;
clientOrganization.Seats = requestBody.AssignedSeats;
await organizationRepository.ReplaceAsync(clientOrganization); await organizationRepository.ReplaceAsync(clientOrganization);

View File

@ -6,12 +6,14 @@ public record OrganizationMetadataResponse(
bool IsEligibleForSelfHost, bool IsEligibleForSelfHost,
bool IsManaged, bool IsManaged,
bool IsOnSecretsManagerStandalone, bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid) bool IsSubscriptionUnpaid,
bool HasSubscription)
{ {
public static OrganizationMetadataResponse From(OrganizationMetadata metadata) public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
=> new( => new(
metadata.IsEligibleForSelfHost, metadata.IsEligibleForSelfHost,
metadata.IsManaged, metadata.IsManaged,
metadata.IsOnSecretsManagerStandalone, metadata.IsOnSecretsManagerStandalone,
metadata.IsSubscriptionUnpaid); metadata.IsSubscriptionUnpaid,
metadata.HasSubscription);
} }

View File

@ -23,7 +23,6 @@ using Microsoft.OpenApi.Models;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.UserFeatures; using Bit.Core.Auth.UserFeatures;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
@ -32,6 +31,8 @@ using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Tools.ReportFeatures;
#if !OSS #if !OSS
@ -176,6 +177,7 @@ public class Startup
services.AddOrganizationSubscriptionServices(); services.AddOrganizationSubscriptionServices();
services.AddCoreLocalizationServices(); services.AddCoreLocalizationServices();
services.AddBillingOperations(); services.AddBillingOperations();
services.AddReportingServices();
// Authorization Handlers // Authorization Handlers
services.AddAuthorizationHandlers(); services.AddAuthorizationHandlers();

View File

@ -1,13 +1,13 @@
using Bit.Api.Tools.Models.Response; using Bit.Api.Tools.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Api.Tools.Models.Response;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Tools.Entities;
using Bit.Core.Services; using Bit.Core.Tools.Models.Data;
using Bit.Core.Vault.Queries; using Bit.Core.Tools.ReportFeatures.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.Tools.ReportFeatures.Requests;
using Bit.Core.Tools.Requests;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers;
[Authorize("Application")] [Authorize("Application")]
public class ReportsController : Controller public class ReportsController : Controller
{ {
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly IGroupRepository _groupRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
private readonly IApplicationCacheService _applicationCacheService; private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
public ReportsController( public ReportsController(
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
IGroupRepository groupRepository,
ICollectionRepository collectionRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
IOrganizationCiphersQuery organizationCiphersQuery, IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
IApplicationCacheService applicationCacheService, IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
) )
{ {
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_groupRepository = groupRepository;
_collectionRepository = collectionRepository;
_currentContext = currentContext; _currentContext = currentContext;
_organizationCiphersQuery = organizationCiphersQuery; _memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
_applicationCacheService = applicationCacheService; _addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
} }
/// <summary>
/// Organization member information containing a list of cipher ids
/// assigned
/// </summary>
/// <param name="orgId">Organzation Id</param>
/// <returns>IEnumerable of MemberCipherDetailsResponseModel</returns>
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
[HttpGet("member-cipher-details/{orgId}")]
public async Task<IEnumerable<MemberCipherDetailsResponseModel>> GetMemberCipherDetails(Guid orgId)
{
// Using the AccessReports permission here until new permissions
// are needed for more control over reports
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
}
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
return responses;
}
/// <summary>
/// Access details for an organization member. Includes the member information,
/// group collection assignment, and item counts
/// </summary>
/// <param name="orgId">Organization Id</param>
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
[HttpGet("member-access/{orgId}")] [HttpGet("member-access/{orgId}")]
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId) public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
{ {
@ -52,26 +74,91 @@ public class ReportsController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
new OrganizationUserUserDetailsQueryRequest
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
return responses;
}
/// <summary>
/// Contains the organization member info, the cipher ids associated with the member,
/// and details on their collections, groups, and permissions
/// </summary>
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param>
/// <returns>IEnumerable of MemberAccessCipherDetails</returns>
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
{ {
OrganizationId = orgId, var memberCipherDetails =
IncludeCollections = true, await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
IncludeGroups = true return memberCipherDetails;
}); }
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId); /// <summary>
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId); /// Get the password health report applications for an organization
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId); /// </summary>
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId); /// <param name="orgId">A valid Organization Id</param>
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); /// <returns>An Enumerable of PasswordHealthReportApplication </returns>
/// <exception cref="NotFoundException">If the user lacks access</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpGet("password-health-report-applications/{orgId}")]
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
{
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
}
var reports = MemberAccessReportResponseModel.CreateReport( return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
orgGroups, }
orgCollectionsWithAccess,
orgItems, /// <summary>
organizationUsersTwoFactorEnabled, /// Adds a new record into PasswordHealthReportApplication
orgAbility); /// </summary>
return reports; /// <param name="request">A single instance of PasswordHealthReportApplication Model</param>
/// <returns>A single instance of PasswordHealthReportApplication</returns>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
/// <exception cref="NotFoundException">If the user lacks access</exception>
[HttpPost("password-health-report-application")]
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplication(
[FromBody] PasswordHealthReportApplicationModel request)
{
if (!await _currentContext.AccessReports(request.OrganizationId))
{
throw new NotFoundException();
}
var commandRequest = new AddPasswordHealthReportApplicationRequest
{
OrganizationId = request.OrganizationId,
Url = request.Url
};
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequest);
}
/// <summary>
/// Adds multiple records into PasswordHealthReportApplication
/// </summary>
/// <param name="request">A enumerable of PasswordHealthReportApplicationModel</param>
/// <returns>An Enumerable of PasswordHealthReportApplication</returns>
/// <exception cref="NotFoundException">If user does not have access to the OrganizationId</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpPost("password-health-report-applications")]
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> request)
{
if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false))
{
throw new NotFoundException();
}
var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest
{
OrganizationId = request.OrganizationId,
Url = request.Url
}).ToList();
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequests);
} }
} }

View File

@ -0,0 +1,7 @@
namespace Bit.Api.Tools.Models;
public class PasswordHealthReportApplicationModel
{
public Guid OrganizationId { get; set; }
public string Url { get; set; }
}

View File

@ -1,30 +1,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.Tools.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Vault.Models.Data;
namespace Bit.Api.Tools.Models.Response; namespace Bit.Api.Tools.Models.Response;
/// <summary>
/// Member access details. The individual item for the detailed member access
/// report. A collection can be assigned directly to a user without a group or
/// the user can be assigned to a collection through a group. Group level permissions
/// can override collection level permissions.
/// </summary>
public class MemberAccessReportAccessDetails
{
public Guid? CollectionId { get; set; }
public Guid? GroupId { get; set; }
public string GroupName { get; set; }
public string CollectionName { get; set; }
public int ItemCount { get; set; }
public bool? ReadOnly { get; set; }
public bool? HidePasswords { get; set; }
public bool? Manage { get; set; }
}
/// <summary> /// <summary>
/// Contains the collections and group collections a user has access to including /// Contains the collections and group collections a user has access to including
/// the permission level for the collection and group collection. /// the permission level for the collection and group collection.
@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
public int TotalItemCount { get; set; } public int TotalItemCount { get; set; }
public Guid? UserGuid { get; set; } public Guid? UserGuid { get; set; }
public bool UsesKeyConnector { get; set; } public bool UsesKeyConnector { get; set; }
public IEnumerable<MemberAccessReportAccessDetails> AccessDetails { get; set; } public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
/// <summary> public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
/// Generates a report for all members of an organization. Containing summary information
/// such as item, collection, and group counts. As well as detailed information on the
/// user and group collections along with their permissions
/// </summary>
/// <param name="orgGroups">Organization groups collection</param>
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
/// <param name="orgAbility">Organization ability for account recovery status</param>
/// <returns>List of the MemberAccessReportResponseModel</returns>;
public static IEnumerable<MemberAccessReportResponseModel> CreateReport(
ICollection<Group> orgGroups,
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
OrganizationAbility orgAbility)
{ {
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user); this.UserName = memberAccessCipherDetails.UserName;
// Create a dictionary to lookup the group names later. this.Email = memberAccessCipherDetails.Email;
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
// Get collections grouped and into a dictionary for counts this.GroupsCount = memberAccessCipherDetails.GroupsCount;
var collectionItems = orgItems this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
.SelectMany(x => x.CollectionIds, this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
(x, b) => new { CipherId = x.Id, CollectionId = b }) this.UserGuid = memberAccessCipherDetails.UserGuid;
.GroupBy(y => y.CollectionId, this.AccessDetails = memberAccessCipherDetails.AccessDetails;
(key, g) => new { CollectionId = key, Ciphers = g });
var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count());
// Loop through the org users and populate report and access data
var memberAccessReport = new List<MemberAccessReportResponseModel>();
foreach (var user in orgUsers)
{
// Take the collections/groups and create the access details items
var groupAccessDetails = new List<MemberAccessReportAccessDetails>();
var userCollectionAccessDetails = new List<MemberAccessReportAccessDetails>();
foreach (var tCollect in orgCollectionsWithAccess)
{
var itemCounts = collectionItemCounts.TryGetValue(tCollect.Item1.Id, out var itemCount) ? itemCount : 0;
if (tCollect.Item2.Groups.Count() > 0)
{
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
new MemberAccessReportAccessDetails
{
CollectionId = tCollect.Item1.Id,
CollectionName = tCollect.Item1.Name,
GroupId = x.Id,
GroupName = groupNameDictionary[x.Id],
ReadOnly = x.ReadOnly,
HidePasswords = x.HidePasswords,
Manage = x.Manage,
ItemCount = itemCounts,
});
groupAccessDetails.AddRange(groupDetails);
} }
// All collections assigned to users and their permissions
if (tCollect.Item2.Users.Count() > 0)
{
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
new MemberAccessReportAccessDetails
{
CollectionId = tCollect.Item1.Id,
CollectionName = tCollect.Item1.Name,
ReadOnly = x.ReadOnly,
HidePasswords = x.HidePasswords,
Manage = x.Manage,
ItemCount = itemCounts,
});
userCollectionAccessDetails.AddRange(userCollectionDetails);
}
}
var report = new MemberAccessReportResponseModel
{
UserName = user.Name,
Email = user.Email,
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
UserGuid = user.Id,
UsesKeyConnector = user.UsesKeyConnector
};
var userAccessDetails = new List<MemberAccessReportAccessDetails>();
if (user.Groups.Any())
{
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
userAccessDetails.AddRange(userGroups);
}
// There can be edge cases where groups don't have a collection
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
if (groupsWithoutCollections.Count() > 0)
{
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails
{
GroupId = x,
GroupName = groupNameDictionary[x],
ItemCount = 0
});
userAccessDetails.AddRange(emptyGroups);
}
if (user.Collections.Any())
{
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
userAccessDetails.AddRange(userCollections);
}
report.AccessDetails = userAccessDetails;
report.TotalItemCount = collectionItems
.Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId))
.SelectMany(x => x.Ciphers)
.GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault())
.Count();
// Distinct items only
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
report.CollectionsCount = distinctItems.Count();
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
memberAccessReport.Add(report);
}
return memberAccessReport;
}
} }

View File

@ -0,0 +1,24 @@
using Bit.Core.Tools.Models.Data;
namespace Bit.Api.Tools.Models.Response;
public class MemberCipherDetailsResponseModel
{
public string UserName { get; set; }
public string Email { get; set; }
public bool UsesKeyConnector { get; set; }
/// <summary>
/// A distinct list of the cipher ids associated with
/// the organization member
/// </summary>
public IEnumerable<string> CipherIds { get; set; }
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
{
this.UserName = memberAccessCipherDetails.UserName;
this.Email = memberAccessCipherDetails.Email;
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
this.CipherIds = memberAccessCipherDetails.CipherIds;
}
}

View File

@ -1,5 +1,5 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.Groups; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;

View File

@ -1,62 +0,0 @@
#nullable enable
using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
/// <summary>
/// Handles authorization logic for Group operations.
/// This uses new logic implemented in the Flexible Collections initiative.
/// </summary>
public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequirement>
{
private readonly ICurrentContext _currentContext;
public GroupAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
GroupOperationRequirement requirement)
{
// Acting user is not authenticated, fail
if (!_currentContext.UserId.HasValue)
{
context.Fail();
return;
}
if (requirement.OrganizationId == default)
{
context.Fail();
return;
}
var org = _currentContext.GetOrganization(requirement.OrganizationId);
switch (requirement)
{
case not null when requirement.Name == nameof(GroupOperations.ReadAll):
await CanReadAllAsync(context, requirement, org);
break;
}
}
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
CurrentContextOrganization? org)
{
// All users of an organization can read all groups belonging to the organization for collection access management
if (org is not null)
{
context.Succeed(requirement);
return;
}
// Allow provider users to read all groups if they are a provider for the target organization
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
{
context.Succeed(requirement);
}
}
}

View File

@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
public class GroupOperationRequirement : OperationAuthorizationRequirement
{
public Guid OrganizationId { get; init; }
public GroupOperationRequirement(string name, Guid organizationId)
{
Name = name;
OrganizationId = organizationId;
}
}
public static class GroupOperations
{
public static GroupOperationRequirement ReadAll(Guid organizationId)
{
return new GroupOperationRequirement(nameof(ReadAll), organizationId);
}
}

View File

@ -99,7 +99,10 @@ public class CiphersController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
} }
[HttpGet("{id}/full-details")] [HttpGet("{id}/full-details")]
@ -600,10 +603,10 @@ public class CiphersController : Controller
[HttpPut("{id}/collections-admin")] [HttpPut("{id}/collections-admin")]
[HttpPost("{id}/collections-admin")] [HttpPost("{id}/collections-admin")]
public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
{ {
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id)); var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
if (cipher == null || !cipher.OrganizationId.HasValue || if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) !await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
@ -621,6 +624,11 @@ public class CiphersController : Controller
} }
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true); await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
} }
[HttpPost("bulk-collections")] [HttpPost("bulk-collections")]

View File

@ -112,7 +112,7 @@ public class SyncController : Controller
private ICollection<CipherDetails> FilterSSHKeys(ICollection<CipherDetails> ciphers) private ICollection<CipherDetails> FilterSSHKeys(ICollection<CipherDetails> ciphers)
{ {
if (_currentContext.ClientVersion >= _sshKeyCipherMinimumVersion) if (_currentContext.ClientVersion >= _sshKeyCipherMinimumVersion || _featureService.IsEnabled(FeatureFlagKeys.SSHVersionCheckQAOverride))
{ {
return ciphers; return ciphers;
} }

View File

@ -1,7 +1,7 @@
using Bit.Api.Models.Response; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Response;
using Bit.Api.Tools.Models.Response; using Bit.Api.Tools.Models.Response;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;

View File

@ -1,7 +1,6 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -42,36 +41,26 @@ public class ProviderEventService(
case HandledStripeWebhook.InvoiceCreated: case HandledStripeWebhook.InvoiceCreated:
{ {
var clients = var clients =
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId)) await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId);
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId); var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
var enterpriseProviderPlan = var invoiceItems = new List<ProviderInvoiceItem>();
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
var teamsProviderPlan = foreach (var client in clients)
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
{ {
logger.LogError("Provider {ProviderID} is missing or has misconfigured provider plans", parsedProviderId); if (client.Status != OrganizationStatusType.Managed)
{
throw new Exception("Cannot record invoice line items for Provider with missing or misconfigured provider plans"); continue;
} }
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); var plan = StaticStore.Plans.Single(x => x.Name == client.Plan && providerPlans.Any(y => y.PlanType == x.Type));
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100; var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage; var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage; invoiceItems.Add(new ProviderInvoiceItem
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
{ {
ProviderId = parsedProviderId, ProviderId = parsedProviderId,
InvoiceId = invoice.Id, InvoiceId = invoice.Id,
@ -81,58 +70,36 @@ public class ProviderEventService(
PlanName = client.Plan, PlanName = client.Plan,
AssignedSeats = client.Seats ?? 0, AssignedSeats = client.Seats ?? 0,
UsedSeats = client.OccupiedSeats ?? 0, UsedSeats = client.OccupiedSeats ?? 0,
Total = client.Plan == enterprisePlan.Name Total = (client.Seats ?? 0) * discountedSeatPrice
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice });
: (client.Seats ?? 0) * discountedTeamsSeatPrice }
}).ToList();
if (enterpriseProviderPlan.PurchasedSeats is null or 0) foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0))
{ {
var enterpriseClientSeats = invoiceItems var plan = StaticStore.GetPlan(providerPlan.PlanType);
.Where(item => item.PlanName == enterprisePlan.Name)
var clientSeats = invoiceItems
.Where(item => item.PlanName == plan.Name)
.Sum(item => item.AssignedSeats); .Sum(item => item.AssignedSeats);
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0; var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
if (unassignedEnterpriseSeats > 0)
{
invoiceItems.Add(new ProviderInvoiceItem invoiceItems.Add(new ProviderInvoiceItem
{ {
ProviderId = parsedProviderId, ProviderId = parsedProviderId,
InvoiceId = invoice.Id, InvoiceId = invoice.Id,
InvoiceNumber = invoice.Number, InvoiceNumber = invoice.Number,
ClientName = "Unassigned seats", ClientName = "Unassigned seats",
PlanName = enterprisePlan.Name, PlanName = plan.Name,
AssignedSeats = unassignedEnterpriseSeats, AssignedSeats = unassignedSeats,
UsedSeats = 0, UsedSeats = 0,
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice Total = unassignedSeats * discountedSeatPrice
}); });
} }
}
if (teamsProviderPlan.PurchasedSeats is null or 0)
{
var teamsClientSeats = invoiceItems
.Where(item => item.PlanName == teamsPlan.Name)
.Sum(item => item.AssignedSeats);
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
if (unassignedTeamsSeats > 0)
{
invoiceItems.Add(new ProviderInvoiceItem
{
ProviderId = parsedProviderId,
InvoiceId = invoice.Id,
InvoiceNumber = invoice.Number,
ClientName = "Unassigned seats",
PlanName = teamsPlan.Name,
AssignedSeats = unassignedTeamsSeats,
UsedSeats = 0,
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
});
}
}
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync)); await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));

View File

@ -13,7 +13,7 @@
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com" <input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
required autofocus> required autofocus>
<span asp-validation-for="Email" class="invalid-feedback"></span> <span asp-validation-for="Email" class="invalid-feedback"></span>
<small class="form-text text-muted">We'll email you a secure login link.</small> <small class="form-text text-body-secondary">We'll email you a secure login link.</small>
</div> </div>
<button class="btn btn-primary btn-block" type="submit">Continue</button> <button class="btn btn-primary btn-block" type="submit">Continue</button>
</form> </form>

View File

@ -15,6 +15,7 @@ public enum PolicyType : byte
DisablePersonalVaultExport = 10, DisablePersonalVaultExport = 10,
ActivateAutofill = 11, ActivateAutofill = 11,
AutomaticAppLogIn = 12, AutomaticAppLogIn = 12,
FreeFamiliesSponsorshipPolicy = 13
} }
public static class PolicyTypeExtensions public static class PolicyTypeExtensions
@ -40,6 +41,7 @@ public static class PolicyTypeExtensions
PolicyType.DisablePersonalVaultExport => "Remove individual vault export", PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
PolicyType.ActivateAutofill => "Active auto-fill", PolicyType.ActivateAutofill => "Active auto-fill",
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications", PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications",
PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship"
}; };
} }
} }

View File

@ -0,0 +1,43 @@
#nullable enable
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.Context;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
public class GroupAuthorizationHandler(ICurrentContext currentContext)
: AuthorizationHandler<GroupOperationRequirement, OrganizationScope>
{
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
GroupOperationRequirement requirement, OrganizationScope organizationScope)
{
var authorized = requirement switch
{
not null when requirement.Name == nameof(GroupOperations.ReadAll) =>
await CanReadAllAsync(organizationScope),
not null when requirement.Name == nameof(GroupOperations.ReadAllDetails) =>
await CanViewGroupDetailsAsync(organizationScope),
_ => false
};
if (requirement is not null && authorized)
{
context.Succeed(requirement);
}
}
private async Task<bool> CanReadAllAsync(OrganizationScope organizationScope) =>
currentContext.GetOrganization(organizationScope) is not null
|| await currentContext.ProviderUserForOrgAsync(organizationScope);
private async Task<bool> CanViewGroupDetailsAsync(OrganizationScope organizationScope) =>
currentContext.GetOrganization(organizationScope) is
{ Type: OrganizationUserType.Owner } or
{ Type: OrganizationUserType.Admin } or
{
Permissions: { ManageGroups: true } or
{ ManageUsers: true }
} ||
await currentContext.ProviderUserForOrgAsync(organizationScope);
}

View File

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
public class GroupOperationRequirement : OperationAuthorizationRequirement
{
public GroupOperationRequirement(string name)
{
Name = name;
}
}
public static class GroupOperations
{
public static readonly GroupOperationRequirement ReadAll = new(nameof(ReadAll));
public static readonly GroupOperationRequirement ReadAllDetails = new(nameof(ReadAllDetails));
}

View File

@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IOrganizationService _organizationService;
private readonly ILogger<VerifyOrganizationDomainCommand> _logger; private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
public VerifyOrganizationDomainCommand( public VerifyOrganizationDomainCommand(
@ -30,7 +29,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
IPolicyService policyService, IPolicyService policyService,
IFeatureService featureService, IFeatureService featureService,
IOrganizationService organizationService,
ILogger<VerifyOrganizationDomainCommand> logger) ILogger<VerifyOrganizationDomainCommand> logger)
{ {
_organizationDomainRepository = organizationDomainRepository; _organizationDomainRepository = organizationDomainRepository;
@ -39,7 +37,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
_globalSettings = globalSettings; _globalSettings = globalSettings;
_policyService = policyService; _policyService = policyService;
_featureService = featureService; _featureService = featureService;
_organizationService = organizationService;
_logger = logger; _logger = logger;
} }

View File

@ -1,4 +1,5 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;

View File

@ -1,5 +1,5 @@
using Bit.Core.Context; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.Services; using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
@ -7,14 +7,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authoriza
public class OrganizationUserUserMiniDetailsAuthorizationHandler : public class OrganizationUserUserMiniDetailsAuthorizationHandler :
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope> AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
{ {
private readonly IApplicationCacheService _applicationCacheService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
public OrganizationUserUserMiniDetailsAuthorizationHandler( public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext)
IApplicationCacheService applicationCacheService,
ICurrentContext currentContext)
{ {
_applicationCacheService = applicationCacheService;
_currentContext = currentContext; _currentContext = currentContext;
} }

View File

@ -4,5 +4,5 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interface
public interface IUpdateOrganizationUserGroupsCommand public interface IUpdateOrganizationUserGroupsCommand
{ {
Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds, Guid? loggedInUserId); Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds);
} }

View File

@ -9,25 +9,18 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
public class UpdateOrganizationUserGroupsCommand : IUpdateOrganizationUserGroupsCommand public class UpdateOrganizationUserGroupsCommand : IUpdateOrganizationUserGroupsCommand
{ {
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IOrganizationService _organizationService;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
public UpdateOrganizationUserGroupsCommand( public UpdateOrganizationUserGroupsCommand(
IEventService eventService, IEventService eventService,
IOrganizationService organizationService,
IOrganizationUserRepository organizationUserRepository) IOrganizationUserRepository organizationUserRepository)
{ {
_eventService = eventService; _eventService = eventService;
_organizationService = organizationService;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
} }
public async Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds, Guid? loggedInUserId) public async Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds)
{ {
if (loggedInUserId.HasValue)
{
await _organizationService.ValidateOrganizationUserUpdatePermissions(organizationUser.OrganizationId, organizationUser.Type, null, organizationUser.GetPermissions());
}
await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupIds); await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupIds);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups);
} }

View File

@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled) if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
{ {
var missingRequiredPolicyTypes = validator.RequiredPolicies var missingRequiredPolicyTypes = validator.RequiredPolicies
.Where(requiredPolicyType => .Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
.ToList(); .ToList();
if (missingRequiredPolicyTypes.Count != 0) if (missingRequiredPolicyTypes.Count != 0)

View File

@ -18,5 +18,6 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>(); services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>(); services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>(); services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
} }
} }

View File

@ -0,0 +1,46 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class FreeFamiliesForEnterprisePolicyValidator(
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IMailService mailService,
IOrganizationRepository organizationRepository)
: IPolicyValidator
{
public PolicyType Type => PolicyType.FreeFamiliesSponsorshipPolicy;
public IEnumerable<PolicyType> RequiredPolicies => [];
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })
{
await NotifiesUserWithApplicablePoliciesAsync(policyUpdate);
}
}
private async Task NotifiesUserWithApplicablePoliciesAsync(PolicyUpdate policy)
{
var organizationSponsorships = (await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(policy.OrganizationId))
.Where(p => p.SponsoredOrganizationId is not null)
.ToList();
var organization = await organizationRepository.GetByIdAsync(policy.OrganizationId);
var organizationName = organization?.Name;
foreach (var org in organizationSponsorships)
{
var offerAcceptanceDate = org.ValidUntil!.Value.AddDays(-7).ToString("MM/dd/yyyy");
await mailService.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(org.FriendlyName, offerAcceptanceDate,
org.SponsoredOrganizationId.ToString(), organizationName);
}
}
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
}

View File

@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
public SingleOrgPolicyValidator( public SingleOrgPolicyValidator(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
IRemoveOrganizationUserCommand removeOrganizationUserCommand) IFeatureService featureService,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_mailService = mailService; _mailService = mailService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
} }
public IEnumerable<PolicyType> RequiredPolicies => []; public IEnumerable<PolicyType> RequiredPolicies => [];
@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator
if (policyUpdate is not { Enabled: true }) if (policyUpdate is not { Enabled: true })
{ {
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
var validateDecryptionErrorMessage = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
if (!string.IsNullOrWhiteSpace(validateDecryptionErrorMessage))
{
return validateDecryptionErrorMessage;
} }
return ""; if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
{
return "The Single organization policy is required for organizations that have enabled domain verification.";
}
}
return string.Empty;
} }
} }

View File

@ -1,6 +1,6 @@
#nullable enable #nullable enable
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; namespace Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
/// <summary> /// <summary>
/// A typed wrapper for an organization Guid. This is used for authorization checks /// A typed wrapper for an organization Guid. This is used for authorization checks

View File

@ -15,6 +15,7 @@ using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
@ -444,13 +445,6 @@ public class OrganizationService : IOrganizationService
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup) public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup)
{ {
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (!consolidatedBillingEnabled)
{
throw new InvalidOperationException($"{nameof(SignupClientAsync)} is only for use within Consolidated Billing");
}
var plan = StaticStore.GetPlan(signup.Plan); var plan = StaticStore.GetPlan(signup.Plan);
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager"); ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
@ -1443,10 +1437,7 @@ public class OrganizationService : IOrganizationService
if (provider is { Enabled: true }) if (provider is { Enabled: true })
{ {
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (provider.IsBillable())
if (consolidatedBillingEnabled && provider.Type == ProviderType.Msp &&
provider.Status == ProviderStatusType.Billable)
{ {
return (false, "Seat limit has been reached. Please contact your provider to add more seats."); return (false, "Seat limit has been reached. Please contact your provider to add more seats.");
} }

View File

@ -289,7 +289,7 @@ public class PolicyService : IPolicyService
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id)) && await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
{ {
throw new BadRequestException("Organization has verified domains."); throw new BadRequestException("The Single organization policy is required for organizations that have enabled domain verification.");
} }
} }

View File

@ -1,86 +0,0 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities.Duo;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
public class DuoWebTokenProvider : IUserTwoFactorTokenProvider<User>
{
private readonly IServiceProvider _serviceProvider;
private readonly GlobalSettings _globalSettings;
public DuoWebTokenProvider(
IServiceProvider serviceProvider,
GlobalSettings globalSettings)
{
_serviceProvider = serviceProvider;
_globalSettings = globalSettings;
}
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return false;
}
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
}
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return null;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return null;
}
var signatureRequest = DuoWeb.SignRequest((string)provider.MetaData["IKey"],
(string)provider.MetaData["SKey"], _globalSettings.Duo.AKey, user.Email);
return signatureRequest;
}
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return false;
}
var response = DuoWeb.VerifyResponse((string)provider.MetaData["IKey"], (string)provider.MetaData["SKey"],
_globalSettings.Duo.AKey, token);
return response == user.Email;
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
}
}

View File

@ -1,76 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities.Duo;
using Bit.Core.Entities;
using Bit.Core.Settings;
namespace Bit.Core.Auth.Identity;
public interface IOrganizationDuoWebTokenProvider : IOrganizationTwoFactorTokenProvider { }
public class OrganizationDuoWebTokenProvider : IOrganizationDuoWebTokenProvider
{
private readonly GlobalSettings _globalSettings;
public OrganizationDuoWebTokenProvider(GlobalSettings globalSettings)
{
_globalSettings = globalSettings;
}
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult(false);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
var canGenerate = organization.TwoFactorProviderIsEnabled(TwoFactorProviderType.OrganizationDuo)
&& HasProperMetaData(provider);
return Task.FromResult(canGenerate);
}
public Task<string> GenerateAsync(Organization organization, User user)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult<string>(null);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
if (!HasProperMetaData(provider))
{
return Task.FromResult<string>(null);
}
var signatureRequest = DuoWeb.SignRequest(provider.MetaData["IKey"].ToString(),
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, user.Email);
return Task.FromResult(signatureRequest);
}
public Task<bool> ValidateAsync(string token, Organization organization, User user)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult(false);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
if (!HasProperMetaData(provider))
{
return Task.FromResult(false);
}
var response = DuoWeb.VerifyResponse(provider.MetaData["IKey"].ToString(),
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, token);
return Task.FromResult(response == user.Email);
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
}
}

View File

@ -1,172 +0,0 @@
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
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;
/*
PM-5156 addresses tech debt
Interface to allow for DI, will end up being removed as part of the removal of the old Duo SDK v2 flows.
This service is to support SDK v4 flows for Duo. At some time in the future we will need
to combine this service with the DuoWebTokenProvider and OrganizationDuoWebTokenProvider to support SDK v4.
*/
public interface ITemporaryDuoWebV4SDKService
{
Task<string> GenerateAsync(TwoFactorProvider provider, User user);
Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user);
}
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
/// </summary>
/// <param name="currentContext">used to fetch initiating Client</param>
/// <param name="globalSettings">used to fetch vault URL for Redirect URL</param>
public TemporaryDuoWebV4SDKService(
ICurrentContext currentContext,
GlobalSettings globalSettings,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
ILogger<TemporaryDuoWebV4SDKService> logger)
{
_currentContext = currentContext;
_globalSettings = globalSettings;
_tokenDataFactory = tokenDataFactory;
_logger = logger;
}
/// <summary>
/// Provider agnostic (either Duo or OrganizationDuo) method to generate a Duo Auth URL
/// </summary>
/// <param name="provider">Either Duo or OrganizationDuo</param>
/// <param name="user">self</param>
/// <returns>AuthUrl for DUO SDK v4</returns>
public async Task<string> GenerateAsync(TwoFactorProvider provider, User user)
{
if (!HasProperMetaData(provider))
{
if (!HasProperMetaData_SDKV2(provider))
{
return null;
}
}
var duoClient = await BuildDuoClientAsync(provider);
if (duoClient == null)
{
return null;
}
var state = _tokenDataFactory.Protect(new DuoUserStateTokenable(user));
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
return authUrl;
}
/// <summary>
/// Validates Duo SDK v4 response
/// </summary>
/// <param name="token">response form Duo</param>
/// <param name="provider">TwoFactorProviderType Duo or OrganizationDuo</param>
/// <param name="user">self</param>
/// <returns>true or false depending on result of verification</returns>
public async Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user)
{
if (!HasProperMetaData(provider))
{
if (!HasProperMetaData_SDKV2(provider))
{
return false;
}
}
var duoClient = await BuildDuoClientAsync(provider);
if (duoClient == null)
{
return false;
}
var parts = token.Split("|");
var authCode = parts[0];
var state = parts[1];
_tokenDataFactory.TryUnprotect(state, out var tokenable);
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
{
return false;
}
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
// their authCode with a victims credentials
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
return res.AuthResult.Result == "allow";
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("ClientId") &&
provider.MetaData.ContainsKey("ClientSecret") && provider.MetaData.ContainsKey("Host");
}
/// <summary>
/// Checks if the metadata for SDK V2 is present.
/// Transitional method to support Duo during v4 database rename
/// </summary>
/// <param name="provider">The TwoFactorProvider object to check.</param>
/// <returns>True if the provider has the proper metadata; otherwise, false.</returns>
private bool HasProperMetaData_SDKV2(TwoFactorProvider provider)
{
if (provider?.MetaData != null &&
provider.MetaData.TryGetValue("IKey", out var iKey) &&
provider.MetaData.TryGetValue("SKey", out var sKey) &&
provider.MetaData.ContainsKey("Host"))
{
provider.MetaData.Add("ClientId", iKey);
provider.MetaData.Add("ClientSecret", sKey);
return true;
}
else
{
return false;
}
}
/// <summary>
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation
/// </summary>
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
/// <returns>Duo.Client object or null</returns>
private async Task<Duo.Client> BuildDuoClientAsync(TwoFactorProvider provider)
{
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
// to redirect back to the initiating client
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
var client = new Duo.ClientBuilder(
(string)provider.MetaData["ClientId"],
(string)provider.MetaData["ClientSecret"],
(string)provider.MetaData["Host"],
redirectUri).Build();
if (!await client.DoHealthCheck(true))
{
_logger.LogError("Unable to connect to Duo. Health check failed.");
return null;
}
return client;
}
}

View File

@ -6,7 +6,7 @@ using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using OtpNet; using OtpNet;
namespace Bit.Core.Auth.Identity; namespace Bit.Core.Auth.Identity.TokenProviders;
public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider<User> public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider<User>
{ {

View File

@ -0,0 +1,102 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Tokens;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Duo = DuoUniversal;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class DuoUniversalTokenProvider(
IServiceProvider serviceProvider,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
IDuoUniversalTokenService duoUniversalTokenService) : IUserTwoFactorTokenProvider<User>
{
/// <summary>
/// We need the IServiceProvider to resolve the IUserService. There is a complex dependency dance
/// occurring between IUserService, which extends the UserManager<User>, and the usage of the
/// UserManager<User> within this class. Trying to resolve the IUserService using the DI pipeline
/// will not allow the server to start and it will hang and give no helpful indication as to the problem.
/// </summary>
private readonly IServiceProvider _serviceProvider = serviceProvider;
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory = tokenDataFactory;
private readonly IDuoUniversalTokenService _duoUniversalTokenService = duoUniversalTokenService;
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
var provider = await GetDuoTwoFactorProvider(user, userService);
if (provider == null)
{
return false;
}
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
}
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
var duoClient = await GetDuoClientAsync(user);
if (duoClient == null)
{
return null;
}
return _duoUniversalTokenService.GenerateAuthUrl(duoClient, _tokenDataFactory, user);
}
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var duoClient = await GetDuoClientAsync(user);
if (duoClient == null)
{
return false;
}
return await _duoUniversalTokenService.RequestDuoValidationAsync(duoClient, _tokenDataFactory, user, token);
}
/// <summary>
/// Get the Duo Two Factor Provider for the user if they have access to Duo
/// </summary>
/// <param name="user">Active User</param>
/// <returns>null or Duo TwoFactorProvider</returns>
private async Task<TwoFactorProvider> GetDuoTwoFactorProvider(User user, IUserService userService)
{
if (!await userService.CanAccessPremium(user))
{
return null;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!_duoUniversalTokenService.HasProperDuoMetadata(provider))
{
return null;
}
return provider;
}
/// <summary>
/// Uses the User to fetch a valid TwoFactorProvider and use it to create a Duo.Client
/// </summary>
/// <param name="user">active user</param>
/// <returns>null or Duo TwoFactorProvider</returns>
private async Task<Duo.Client> GetDuoClientAsync(User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
var provider = await GetDuoTwoFactorProvider(user, userService);
if (provider == null)
{
return null;
}
var duoClient = await _duoUniversalTokenService.BuildDuoTwoFactorClientAsync(provider);
if (duoClient == null)
{
return null;
}
return duoClient;
}
}

View File

@ -0,0 +1,177 @@
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Duo = DuoUniversal;
namespace Bit.Core.Auth.Identity.TokenProviders;
/// <summary>
/// OrganizationDuo and Duo TwoFactorProviderTypes both use the same flows so both of those Token Providers will
/// have this class injected to utilize these methods
/// </summary>
public interface IDuoUniversalTokenService
{
/// <summary>
/// Generates the Duo Auth URL for the user to be redirected to Duo for 2FA. This
/// Auth URL also lets the Duo Service know where to redirect the user back to after
/// the 2FA process is complete.
/// </summary>
/// <param name="duoClient">A not null valid Duo.Client</param>
/// <param name="tokenDataFactory">This service creates the state token for added security</param>
/// <param name="user">currently active user</param>
/// <returns>a URL in string format</returns>
string GenerateAuthUrl(
Duo.Client duoClient,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
User user);
/// <summary>
/// Makes the request to Duo to validate the authCode and state token
/// </summary>
/// <param name="duoClient">A not null valid Duo.Client</param>
/// <param name="tokenDataFactory">Factory for decrypting the state</param>
/// <param name="user">self</param>
/// <param name="token">token received from the client</param>
/// <returns>boolean based on result from Duo</returns>
Task<bool> RequestDuoValidationAsync(
Duo.Client duoClient,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
User user,
string token);
/// <summary>
/// Generates a Duo.Client object for use with Duo SDK v4. This method is to validate a Duo configuration
/// when adding or updating the configuration. This method makes a web request to Duo to verify the configuration.
/// Throws exception if configuration is invalid.
/// </summary>
/// <param name="clientSecret">Duo client Secret</param>
/// <param name="clientId">Duo client Id</param>
/// <param name="host">Duo host</param>
/// <returns>Boolean</returns>
Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host);
/// <summary>
/// Checks provider for the correct Duo metadata: ClientId, ClientSecret, and Host. Does no validation on the data.
/// it is assumed to be correct. The only way to have the data written to the Database is after verification
/// occurs.
/// </summary>
/// <param name="provider">Host being checked for proper data</param>
/// <returns>true if all three are present; false if one is missing or the host is incorrect</returns>
bool HasProperDuoMetadata(TwoFactorProvider provider);
/// <summary>
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation.
/// This method is made public so that it is easier to test. If the method was private then there would not be an
/// easy way to mock the response. Since this makes a web request it is difficult to mock.
/// </summary>
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
/// <returns>Duo.Client object or null</returns>
Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider);
}
public class DuoUniversalTokenService(
ICurrentContext currentContext,
GlobalSettings globalSettings) : IDuoUniversalTokenService
{
private readonly ICurrentContext _currentContext = currentContext;
private readonly GlobalSettings _globalSettings = globalSettings;
public string GenerateAuthUrl(
Duo.Client duoClient,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
User user)
{
var state = tokenDataFactory.Protect(new DuoUserStateTokenable(user));
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
return authUrl;
}
public async Task<bool> RequestDuoValidationAsync(
Duo.Client duoClient,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
User user,
string token)
{
var parts = token.Split("|");
var authCode = parts[0];
var state = parts[1];
tokenDataFactory.TryUnprotect(state, out var tokenable);
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
{
return false;
}
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
// their authCode with a victims credentials
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
return res.AuthResult.Result == "allow";
}
public async Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host)
{
// Do some simple checks to ensure data integrity
if (!ValidDuoHost(host) ||
string.IsNullOrWhiteSpace(clientSecret) ||
string.IsNullOrWhiteSpace(clientId))
{
return false;
}
// The AuthURI is not important for this health check so we pass in a non-empty string
var client = new Duo.ClientBuilder(clientId, clientSecret, host, "non-empty").Build();
// This could throw an exception, the false flag will allow the exception to bubble up
return await client.DoHealthCheck(false);
}
public bool HasProperDuoMetadata(TwoFactorProvider provider)
{
return provider?.MetaData != null &&
provider.MetaData.ContainsKey("ClientId") &&
provider.MetaData.ContainsKey("ClientSecret") &&
provider.MetaData.ContainsKey("Host") &&
ValidDuoHost((string)provider.MetaData["Host"]);
}
/// <summary>
/// Checks the host string to make sure it meets Duo's Guidelines before attempting to create a Duo.Client.
/// </summary>
/// <param name="host">string representing the Duo Host</param>
/// <returns>true if the host is valid false otherwise</returns>
public static bool ValidDuoHost(string host)
{
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
{
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
uri.Host.StartsWith("api-") &&
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
}
return false;
}
public async Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider)
{
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
// to redirect back to the initiating client
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
var client = new Duo.ClientBuilder(
(string)provider.MetaData["ClientId"],
(string)provider.MetaData["ClientSecret"],
(string)provider.MetaData["Host"],
redirectUri).Build();
if (!await client.DoHealthCheck(false))
{
return null;
}
return client;
}
}

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