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

Merge remote-tracking branch 'origin/main' into km/pm-10563

# Conflicts:
#	src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
This commit is contained in:
Maciej Zieniuk 2024-11-19 19:45:31 +00:00
commit 59ae407fc1
No known key found for this signature in database
GPG Key ID: 9CACE59F1272ACD9
372 changed files with 46195 additions and 4914 deletions

View File

@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"swashbuckle.aspnetcore.cli": { "swashbuckle.aspnetcore.cli": {
"version": "6.8.1", "version": "6.9.0",
"commands": ["swagger"] "commands": ["swagger"]
}, },
"dotnet-ef": { "dotnet-ef": {

23
.github/CODEOWNERS vendored
View File

@ -4,13 +4,22 @@
# #
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# DevOps for Actions and other workflow changes ## Docker files have shared ownership ##
.github/workflows @bitwarden/dept-devops **/Dockerfile
**/*.Dockerfile
**/.dockerignore
**/entrypoint.sh
# DevOps for Docker changes ## BRE team owns these workflows ##
**/Dockerfile @bitwarden/dept-devops .github/workflows/publish.yml @bitwarden/dept-bre
**/*.Dockerfile @bitwarden/dept-devops
**/.dockerignore @bitwarden/dept-devops ## These are shared workflows ##
.github/workflows/_move_finalization_db_scripts.yml
.github/workflows/build.yml
.github/workflows/cleanup-after-pr.yml
.github/workflows/cleanup-rc-branch.yml
.github/workflows/release.yml
.github/workflows/repository-management.yml
# Database Operations for database changes # Database Operations for database changes
src/Sql/** @bitwarden/dept-dbops src/Sql/** @bitwarden/dept-dbops
@ -60,6 +69,6 @@ src/EventsProcessor @bitwarden/team-admin-console-dev
src/Admin/Controllers/ToolsController.cs @bitwarden/team-billing-dev src/Admin/Controllers/ToolsController.cs @bitwarden/team-billing-dev
src/Admin/Views/Tools @bitwarden/team-billing-dev src/Admin/Views/Tools @bitwarden/team-billing-dev
# Multiple owners - DO NOT REMOVE (DevOps) # Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json **/packages.lock.json
Directory.Build.props Directory.Build.props

19
.github/renovate.json vendored
View File

@ -29,7 +29,7 @@
"commitMessagePrefix": "[deps] DevOps:" "commitMessagePrefix": "[deps] DevOps:"
}, },
{ {
"matchPackageNames": ["DnsClient", "Quartz"], "matchPackageNames": ["DnsClient"],
"description": "Admin Console owned dependencies", "description": "Admin Console owned dependencies",
"commitMessagePrefix": "[deps] AC:", "commitMessagePrefix": "[deps] AC:",
"reviewers": ["team:team-admin-console-dev"] "reviewers": ["team:team-admin-console-dev"]
@ -42,14 +42,7 @@
}, },
{ {
"matchPackageNames": [ "matchPackageNames": [
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs", "Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"DuoUniversal", "DuoUniversal",
"Fido2.AspNet", "Fido2.AspNet",
"Duende.IdentityServer", "Duende.IdentityServer",
@ -128,8 +121,16 @@
}, },
{ {
"matchPackageNames": [ "matchPackageNames": [
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"Microsoft.AspNetCore.Authentication.JwtBearer", "Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.AspNetCore.Http" "Microsoft.AspNetCore.Http",
"Quartz"
], ],
"description": "Platform owned dependencies", "description": "Platform owned dependencies",
"commitMessagePrefix": "[deps] Platform:", "commitMessagePrefix": "[deps] Platform:",

View File

@ -29,7 +29,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch - name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -53,7 +53,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@ -107,7 +107,7 @@ jobs:
devops-alerts-slack-webhook-url" devops-alerts-slack-webhook-url"
- name: Import GPG keys - name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0 uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
with: with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@ -7,21 +7,30 @@ 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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 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@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
@ -67,13 +76,15 @@ jobs:
node: true node: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 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@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Set up Node - name: Set up Node
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with: with:
cache: "npm" cache: "npm"
cache-dependency-path: "**/package-lock.json" cache-dependency-path: "**/package-lock.json"
@ -120,7 +131,8 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
security-events: write security-events: write
needs: build-artifacts needs:
- build-artifacts
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -172,7 +184,9 @@ jobs:
dotnet: true dotnet: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 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:
@ -212,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")
@ -274,14 +288,14 @@ jobs:
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@49e50b215b647c5ec97abb66f69af73c46a4ca08 # v5.0.1 uses: anchore/scan-action@5ed195cc06065322983cae4bb31e2a751feb86fd # v5.2.0
with: with:
image: ${{ steps.image-tags.outputs.primary_tag }} image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false fail-build: false
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} sarif_file: ${{ steps.container-scan.outputs.sarif }}
@ -291,10 +305,12 @@ jobs:
needs: build-docker needs: build-docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 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@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -305,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
@ -347,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
@ -361,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
@ -369,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
@ -377,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
@ -452,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
@ -466,10 +493,12 @@ jobs:
- win-x64 - win-x64
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 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@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |
@ -501,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
@ -533,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
@ -567,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
@ -594,7 +629,7 @@ jobs:
workflow_id: '_update_ephemeral_tags.yml', workflow_id: '_update_ephemeral_tags.yml',
ref: 'main', ref: 'main',
inputs: { inputs: {
ephemeral_env_branch: '${{ github.head_ref }}' ephemeral_env_branch: process.env.GITHUB_HEAD_REF
} }
}) })
@ -613,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

@ -0,0 +1,59 @@
name: Ephemeral environment cleanup
on:
pull_request:
types: [unlabeled]
jobs:
validate-pr:
name: Validate PR
runs-on: ubuntu-24.04
outputs:
config-exists: ${{ steps.validate-config.outputs.config-exists }}
steps:
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate config exists in path
id: validate-config
run: |
if [[ -f "ephemeral-environments/$GITHUB_HEAD_REF.yaml" ]]; then
echo "Ephemeral environment config found in path, continuing."
echo "config-exists=true" >> $GITHUB_OUTPUT
fi
cleanup-config:
name: Cleanup ephemeral environment
runs-on: ubuntu-24.04
needs: validate-pr
if: ${{ needs.validate-pr.outputs.config-exists }}
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger Ephemeral Environment cleanup
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'devops',
workflow_id: '_ephemeral_environment_pr_manager.yml',
ref: 'main',
inputs: {
ephemeral_env_branch: process.env.GITHUB_HEAD_REF,
cleanup_config: true,
project: 'server'
}
})

View File

@ -23,7 +23,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main - name: Checkout main
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Collect - name: Collect
id: collect id: collect

View File

@ -6,13 +6,13 @@ on:
types: [labeled, unlabeled, opened, reopened, synchronize] types: [labeled, unlabeled, opened, reopened, synchronize]
jobs: jobs:
enforce-label: enforce-label:
if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') }} if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') || contains(github.event.*.labels.*.name, 'ephemeral-environment') }}
name: Enforce label name: Enforce label
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check for label - name: Check for label
run: | run: |
echo "PRs with the hold or needs-qa labels cannot be merged" echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged"
echo "### :x: PRs with the hold or needs-qa labels cannot be merged" >> $GITHUB_STEP_SUMMARY echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY
exit 1 exit 1

View File

@ -28,7 +28,7 @@ jobs:
label: "DB-migrations-changed" label: "DB-migrations-changed"
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 2 fetch-depth: 2

View File

@ -98,7 +98,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION" echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up project name - name: Set up project name
id: setup id: setup

View File

@ -36,7 +36,7 @@ jobs:
fi fi
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check release version - name: Check release version
id: version id: version

View File

@ -3,12 +3,13 @@ name: Repository management
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
branch_to_cut: task:
default: "rc" default: "Version Bump"
description: "Branch to cut" description: "Task to execute"
options: options:
- "rc" - "Version Bump"
- "hotfix-rc" - "Version Bump and Cut rc"
- "Version Bump and Cut hotfix-rc"
required: true required: true
type: choice type: choice
target_ref: target_ref:
@ -22,18 +23,50 @@ on:
type: string type: string
jobs: jobs:
setup:
name: Setup
runs-on: ubuntu-24.04
outputs:
branch: ${{ steps.set-branch.outputs.branch }}
steps:
- name: Set branch
id: set-branch
env:
TASK: ${{ inputs.task }}
run: |
if [[ "$TASK" == "Version Bump" ]]; then
BRANCH="none"
elif [[ "$TASK" == "Version Bump and Cut rc" ]]; then
BRANCH="rc"
elif [[ "$TASK" == "Version Bump and Cut hotfix-rc" ]]; then
BRANCH="hotfix-rc"
fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
cut_branch: cut_branch:
name: Cut branch name: Cut branch
runs-on: ubuntu-22.04 if: ${{ needs.setup.outputs.branch != 'none' }}
needs: setup
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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ inputs.target_ref }} ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}
- name: Check if ${{ inputs.branch_to_cut }} branch exists - name: Check if ${{ needs.setup.outputs.branch }} branch exists
env: env:
BRANCH_NAME: ${{ inputs.branch_to_cut }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
@ -42,7 +75,7 @@ jobs:
- name: Cut branch - name: Cut branch
env: env:
BRANCH_NAME: ${{ inputs.branch_to_cut }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
git switch --quiet --create $BRANCH_NAME git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME git push --quiet --set-upstream origin $BRANCH_NAME
@ -50,8 +83,11 @@ jobs:
bump_version: bump_version:
name: Bump Version name: Bump Version
runs-on: ubuntu-22.04 if: ${{ always() }}
needs: cut_branch runs-on: ubuntu-24.04
needs:
- cut_branch
- setup
outputs: outputs:
version: ${{ steps.set-final-version-output.outputs.version }} version: ${{ steps.set-final-version-output.outputs.version }}
steps: steps:
@ -61,10 +97,23 @@ 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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ steps.app-token.outputs.token }}
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Install xmllint - name: Install xmllint
run: | run: |
@ -123,85 +172,76 @@ jobs:
- name: Set final version output - name: Set final version output
id: set-final-version-output id: set-final-version-output
env:
VERSION: ${{ inputs.version_number_override }}
run: | run: |
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
fi fi
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Commit files - name: Commit files
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes - name: Push changes
run: | run: git push
git pull -pt
git push
cherry_pick: cherry_pick:
name: Cherry-Pick Commit(s) name: Cherry-Pick Commit(s)
runs-on: ubuntu-22.04 if: ${{ needs.setup.outputs.branch != 'none' }}
needs: bump_version runs-on: ubuntu-24.04
needs:
- bump_version
- 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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ steps.app-token.outputs.token }}
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Install xmllint - name: Install xmllint
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libxml2-utils sudo apt-get install -y libxml2-utils
- name: Verify version has been updated
env:
NEW_VERSION: ${{ needs.bump_version.outputs.version }}
run: |
# Wait for version to change.
while : ; do
echo "Waiting for version to be updated..."
git pull --force
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
# If the versions don't match we continue the loop, otherwise we break out of the loop.
[[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break
sleep 10
done
- name: Get last version commit(s)
id: get-commits
run: |
git switch main
MAIN_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
echo "main_commit=$MAIN_COMMIT" >> $GITHUB_OUTPUT
if [[ $(git ls-remote --heads origin rc) ]]; then
git switch rc
RC_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
echo "rc_commit=$RC_COMMIT" >> $GITHUB_OUTPUT
RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "rc_version=$RC_VERSION" >> $GITHUB_OUTPUT
fi
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Perform cherry-pick(s) - name: Perform cherry-pick(s)
env: env:
CUT_BRANCH: ${{ inputs.branch_to_cut }} CUT_BRANCH: ${{ needs.setup.outputs.branch }}
MAIN_COMMIT: ${{ steps.get-commits.outputs.main_commit }}
RC_COMMIT: ${{ steps.get-commits.outputs.rc_commit }}
RC_VERSION: ${{ steps.get-commits.outputs.rc_version }}
run: | run: |
# Function for cherry-picking
cherry_pick () {
local source_branch=$1
local destination_branch=$2
# Get project commit/version from source branch
git switch $source_branch
SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
SOURCE_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
# Get project commit/version from destination branch
git switch $destination_branch
DESTINATION_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then
git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT
git push -u origin $destination_branch
fi
# If we are cutting 'hotfix-rc': # If we are cutting 'hotfix-rc':
if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
@ -209,25 +249,16 @@ jobs:
if [[ $(git ls-remote --heads origin rc) ]]; then if [[ $(git ls-remote --heads origin rc) ]]; then
# Chery-pick from 'rc' into 'hotfix-rc' # Chery-pick from 'rc' into 'hotfix-rc'
git switch hotfix-rc cherry_pick rc hotfix-rc
HOTFIX_RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
if [[ "$HOTFIX_RC_VERSION" != "$RC_VERSION" ]]; then
git cherry-pick --strategy-option=theirs -x $RC_COMMIT
git push -u origin hotfix-rc
fi
# Cherry-pick from 'main' into 'rc' # Cherry-pick from 'main' into 'rc'
git switch rc cherry_pick main rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin rc
# If the 'rc' branch does not exist: # If the 'rc' branch does not exist:
else else
# Cherry-pick from 'main' into 'hotfix-rc' # Cherry-pick from 'main' into 'hotfix-rc'
git switch hotfix-rc cherry_pick main hotfix-rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin hotfix-rc
fi fi
@ -235,9 +266,7 @@ jobs:
elif [[ "$CUT_BRANCH" == "rc" ]]; then elif [[ "$CUT_BRANCH" == "rc" ]]; then
# Cherry-pick from 'main' into 'rc' # Cherry-pick from 'main' into 'rc'
git switch rc cherry_pick main rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin rc
fi fi

View File

@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }} --output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub - name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with: with:
sarif_file: cx_result.sarif sarif_file: cx_result.sarif
@ -60,19 +60,19 @@ jobs:
steps: steps:
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0 uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with: with:
java-version: 17 java-version: 17
distribution: "zulu" distribution: "zulu"
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Install SonarCloud scanner - name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g run: dotnet tool install dotnet-sonarscanner -g

View File

@ -30,15 +30,34 @@ on:
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
jobs: jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
test: test:
name: Run tests name: Run tests
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: check-test-secrets
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Restore tools - name: Restore tools
run: dotnet tool restore run: dotnet tool restore
@ -51,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
@ -84,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"'
@ -111,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
@ -118,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")'
@ -128,7 +165,7 @@ jobs:
- name: Report test results - name: Report test results
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: always() if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
with: with:
name: Test Results name: Test Results
path: "**/*-test-results.trx" path: "**/*-test-results.trx"
@ -146,10 +183,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

@ -46,10 +46,10 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

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

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,25 +19,43 @@ 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);
await Task.WhenAll(
CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats),
CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats));
}
public async Task CreateResellerAsync(Provider provider)
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
}
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
{
var providerId = await CreateProviderAsync(provider, ownerEmail);
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
}
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
{ {
var owner = await _userRepository.GetByEmailAsync(ownerEmail); var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null) if (owner == null)
@ -47,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); provider.Gateway = GatewayType.Stripe;
if (isConsolidatedBillingEnabled)
{
provider.Gateway = GatewayType.Stripe;
}
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending); await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
@ -64,27 +75,10 @@ public class CreateProviderCommand : ICreateProviderCommand
Status = ProviderUserStatusType.Confirmed, Status = ProviderUserStatusType.Confirmed,
}; };
if (isConsolidatedBillingEnabled)
{
var providerPlans = new List<ProviderPlan>
{
CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats),
CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats)
};
foreach (var providerPlan in providerPlans)
{
await _providerPlanRepository.CreateAsync(providerPlan);
}
}
await _providerUserRepository.CreateAsync(providerUser); await _providerUserRepository.CreateAsync(providerUser);
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email); await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task CreateResellerAsync(Provider provider) return provider.Id;
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
} }
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status) private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
@ -95,9 +89,9 @@ public class CreateProviderCommand : ICreateProviderCommand
await _providerRepository.CreateAsync(provider); await _providerRepository.CreateAsync(provider);
} }
private ProviderPlan CreateProviderPlan(Guid providerId, PlanType planType, int seatMinimum) private async Task CreateProviderPlanAsync(Guid providerId, PlanType planType, int seatMinimum)
{ {
return new ProviderPlan var plan = new ProviderPlan
{ {
ProviderId = providerId, ProviderId = providerId,
PlanType = planType, PlanType = planType,
@ -105,5 +99,6 @@ public class CreateProviderCommand : ICreateProviderCommand
PurchasedSeats = 0, PurchasedSeats = 0,
AllocatedSeats = 0 AllocatedSeats = 0
}; };
await _providerPlanRepository.CreateAsync(plan);
} }
} }

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,24 +100,16 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner."); throw new BadRequestException("Invalid owner.");
} }
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
{ {
provider.Status = ProviderStatusType.Created; throw new BadRequestException("Both address and postal code are required to set up your provider.");
await _providerRepository.UpsertAsync(provider);
}
else
{
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.");
}
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
provider.Status = ProviderStatusType.Billable;
await _providerRepository.UpsertAsync(provider);
} }
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
provider.Status = ProviderStatusType.Billable;
await _providerRepository.UpsertAsync(provider);
providerUser.Key = key; providerUser.Key = key;
await _providerUserRepository.ReplaceAsync(providerUser); await _providerUserRepository.ReplaceAsync(providerUser);
@ -392,7 +383,9 @@ public class ProviderService : IProviderService
var organization = await _organizationRepository.GetByIdAsync(organizationId); var organization = await _organizationRepository.GetByIdAsync(organizationId);
ThrowOnInvalidPlanType(organization.PlanType); var provider = await _providerRepository.GetByIdAsync(providerId);
ThrowOnInvalidPlanType(provider.Type, organization.PlanType);
if (organization.UseSecretsManager) if (organization.UseSecretsManager)
{ {
@ -407,8 +400,6 @@ public class ProviderService : IProviderService
Key = key, Key = key,
}; };
var provider = await _providerRepository.GetByIdAsync(providerId);
await ApplyProviderPriceRateAsync(organization, provider); await ApplyProviderPriceRateAsync(organization, provider);
await _providerOrganizationRepository.CreateAsync(providerOrganization); await _providerOrganizationRepository.CreateAsync(providerOrganization);
@ -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(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,11 +674,24 @@ public class ProviderService : IProviderService
return confirmedOwnersIds.Except(providerUserIds).Any(); return confirmedOwnersIds.Except(providerUserIds).Any();
} }
private void ThrowOnInvalidPlanType(PlanType requestedType, bool consolidatedBillingEnabled = false) private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType)
{ {
if (consolidatedBillingEnabled && requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly)) switch (providerType)
{ {
throw new BadRequestException($"Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); case ProviderType.Msp:
if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
{
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
}
break;
case ProviderType.MultiOrganizationEnterprise:
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
{
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
}
break;
default:
throw new BadRequestException($"Unsupported provider type {providerType}.");
} }
if (ProviderDisallowedOrganizationTypes.Contains(requestedType)) if (ProviderDisallowedOrganizationTypes.Contains(requestedType))

View File

@ -2,16 +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.Context; using Bit.Core.Billing.Services.Contracts;
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;
@ -26,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,
@ -34,38 +31,76 @@ 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.");
}
await organizationRepository.ReplaceAsync(organization); 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 CreateCustomerForClientOrganization( public async Task CreateCustomerForClientOrganization(
@ -170,72 +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.Type != ProviderType.Msp) var seatMinimum = providerPlan.SeatMinimum ?? 0;
{
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id);
throw new BillingException(); var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
}
if (!planType.SupportsConsolidatedBilling())
{
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} as it does not support consolidated billing", provider.Id, planType.ToString());
throw new BillingException();
}
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;
@ -262,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);
@ -297,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)
@ -379,42 +371,23 @@ public class ProviderBillingService(
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>(); var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var teamsProviderPlan = foreach (var providerPlan in providerPlans)
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
{ {
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id); var plan = StaticStore.GetPlan(providerPlan.PlanType);
throw new BillingException(); if (!providerPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name);
throw new BillingException();
}
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = providerPlan.SeatMinimum
});
} }
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = teamsProviderPlan.SeatMinimum
});
var enterpriseProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Enterprise plan", provider.Id);
throw new BillingException();
}
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = enterpriseProviderPlan.SeatMinimum
});
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions AutomaticTax = new SubscriptionAutomaticTaxOptions
@ -456,144 +429,90 @@ public class ProviderBillingService(
} }
} }
public async Task UpdateSeatMinimums( public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
Provider provider,
int enterpriseSeatMinimum,
int teamsSeatMinimum)
{ {
ArgumentNullException.ThrowIfNull(provider); if (command.Configuration.Any(x => x.SeatsMinimum < 0))
if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0)
{ {
throw new BadRequestException("Provider seat minimums must be at least 0."); throw new BadRequestException("Provider seat minimums must be at least 0.");
} }
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId); Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>(); var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
var enterpriseProviderPlan = foreach (var newPlanConfiguration in command.Configuration)
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
{ {
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager var providerPlan =
.StripeProviderPortalSeatPlanId; providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId); if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
if (enterpriseProviderPlan.PurchasedSeats == 0)
{ {
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum) var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager
{ .StripeProviderPortalSeatPlanId;
enterpriseProviderPlan.PurchasedSeats = var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions if (providerPlan.PurchasedSeats == 0)
{
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
{ {
Id = enterpriseSubscriptionItem.Id, providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
Price = enterprisePriceId,
Quantity = enterpriseProviderPlan.AllocatedSeats subscriptionItemOptionsList.Add(new SubscriptionItemOptions
}); {
Id = subscriptionItem.Id,
Price = priceId,
Quantity = providerPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum
});
}
} }
else else
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
{ {
Id = enterpriseSubscriptionItem.Id, providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
Price = enterprisePriceId, }
Quantity = enterpriseSeatMinimum else
}); {
providerPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum
});
}
} }
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
await providerPlanRepository.ReplaceAsync(providerPlan);
} }
else
{
var totalEnterpriseSeats = enterpriseProviderPlan.SeatMinimum + enterpriseProviderPlan.PurchasedSeats;
if (enterpriseSeatMinimum <= totalEnterpriseSeats)
{
enterpriseProviderPlan.PurchasedSeats = totalEnterpriseSeats - enterpriseSeatMinimum;
}
else
{
enterpriseProviderPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = enterpriseSubscriptionItem.Id,
Price = enterprisePriceId,
Quantity = enterpriseSeatMinimum
});
}
}
enterpriseProviderPlan.SeatMinimum = enterpriseSeatMinimum;
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan);
}
var teamsProviderPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan.SeatMinimum != teamsSeatMinimum)
{
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager
.StripeProviderPortalSeatPlanId;
var teamsSubscriptionItem = subscription.Items.First(item => item.Price.Id == teamsPriceId);
if (teamsProviderPlan.PurchasedSeats == 0)
{
if (teamsProviderPlan.AllocatedSeats > teamsSeatMinimum)
{
teamsProviderPlan.PurchasedSeats = teamsProviderPlan.AllocatedSeats - teamsSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsProviderPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
else
{
var totalTeamsSeats = teamsProviderPlan.SeatMinimum + teamsProviderPlan.PurchasedSeats;
if (teamsSeatMinimum <= totalTeamsSeats)
{
teamsProviderPlan.PurchasedSeats = totalTeamsSeats - teamsSeatMinimum;
}
else
{
teamsProviderPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
teamsProviderPlan.SeatMinimum = teamsSeatMinimum;
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
} }
if (subscriptionItemOptionsList.Count > 0) if (subscriptionItemOptionsList.Count > 0)
{ {
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList }); new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
} }
} }
@ -620,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

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
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.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -19,23 +20,30 @@ public class CreateProviderCommandTests
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default)); () => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message); Assert.Contains("Invalid owner.", exception.Message);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
var userRepository = sutProvider.GetDependency<IUserRepository>(); var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user); userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default); await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
} }
@ -43,11 +51,52 @@ public class CreateProviderCommandTests
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Reseller; provider.Type = ProviderType.Reseller;
// Act
await sutProvider.Sut.CreateResellerAsync(provider); await sutProvider.Sut.CreateResellerAsync(provider);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default); await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
} }
[Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
Provider provider,
User user,
PlanType plan,
int minimumSeats,
SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.MultiOrganizationEnterprise;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
}
[Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
Provider provider,
SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message);
}
} }

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,36 +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)
{
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.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) SutProvider<ProviderService> sutProvider)
{ {
providerUser.ProviderId = provider.Id; providerUser.ProviderId = provider.Id;
@ -100,9 +71,6 @@ public class ProviderServiceTests
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector") sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector); .Returns(protector);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>(); var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
var customer = new Customer { Id = "customer_id" }; var customer = new Customer { Id = "customer_id" };
@ -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

@ -9,7 +9,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" /> <PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" /> <PackageReference Include="xunit" Version="$(XUnitVersion)" />

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

@ -14,6 +14,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; 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.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -107,9 +108,15 @@ public class ProvidersController : Controller
}); });
} }
public IActionResult Create(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null) public IActionResult Create()
{ {
return View(new CreateProviderModel return View(new CreateProviderModel());
}
[HttpGet("providers/create/msp")]
public IActionResult CreateMsp(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null)
{
return View(new CreateMspProviderModel
{ {
OwnerEmail = ownerEmail, OwnerEmail = ownerEmail,
TeamsMonthlySeatMinimum = teamsMinimumSeats, TeamsMonthlySeatMinimum = teamsMinimumSeats,
@ -117,10 +124,50 @@ public class ProvidersController : Controller
}); });
} }
[HttpGet("providers/create/reseller")]
public IActionResult CreateReseller()
{
return View(new CreateResellerProviderModel());
}
[HttpGet("providers/create/multi-organization-enterprise")]
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
return RedirectToAction("Create");
}
return View(new CreateMultiOrganizationEnterpriseProviderModel
{
OwnerEmail = ownerEmail,
EnterpriseSeatMinimum = enterpriseMinimumSeats
});
}
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)] [RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> Create(CreateProviderModel model) public IActionResult Create(CreateProviderModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
return model.Type switch
{
ProviderType.Msp => RedirectToAction("CreateMsp"),
ProviderType.Reseller => RedirectToAction("CreateReseller"),
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
_ => View(model)
};
}
[HttpPost("providers/create/msp")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMsp(CreateMspProviderModel model)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@ -128,19 +175,51 @@ public class ProvidersController : Controller
} }
var provider = model.ToProvider(); var provider = model.ToProvider();
switch (provider.Type)
await _createProviderCommand.CreateMspAsync(
provider,
model.OwnerEmail,
model.TeamsMonthlySeatMinimum,
model.EnterpriseMonthlySeatMinimum);
return RedirectToAction("Edit", new { id = provider.Id });
}
[HttpPost("providers/create/reseller")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateReseller(CreateResellerProviderModel model)
{
if (!ModelState.IsValid)
{ {
case ProviderType.Msp: return View(model);
await _createProviderCommand.CreateMspAsync(
provider,
model.OwnerEmail,
model.TeamsMonthlySeatMinimum,
model.EnterpriseMonthlySeatMinimum);
break;
case ProviderType.Reseller:
await _createProviderCommand.CreateResellerAsync(provider);
break;
} }
var provider = model.ToProvider();
await _createProviderCommand.CreateResellerAsync(provider);
return RedirectToAction("Edit", new { id = provider.Id });
}
[HttpPost("providers/create/multi-organization-enterprise")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var provider = model.ToProvider();
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
return RedirectToAction("Create");
}
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
provider,
model.OwnerEmail,
model.Plan.Value,
model.EnterpriseSeatMinimum);
return RedirectToAction("Edit", new { id = provider.Id }); return RedirectToAction("Edit", new { id = provider.Id });
} }
@ -203,34 +282,46 @@ 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 });
} }
var providerPlans = await _providerPlanRepository.GetByProviderId(id); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
if (providerPlans.Count == 0) switch (provider.Type)
{ {
var newProviderPlans = new List<ProviderPlan> case ProviderType.Msp:
{ var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }, provider.Id,
new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 } provider.GatewaySubscriptionId,
}; [
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
break;
case ProviderType.MultiOrganizationEnterprise:
{
var existingMoePlan = providerPlans.Single();
foreach (var newProviderPlan in newProviderPlans) // 1. Change the plan and take over any old values.
{ var changeMoePlanCommand = new ChangeProviderPlanCommand(
await _providerPlanRepository.CreateAsync(newProviderPlan); existingMoePlan.Id,
} model.Plan!.Value,
} provider.GatewaySubscriptionId);
else await _providerBillingService.ChangePlan(changeMoePlanCommand);
{
await _providerBillingService.UpdateSeatMinimums( // 2. Update the seat minimums.
provider, var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
model.EnterpriseMonthlySeatMinimum, provider.Id,
model.TeamsMonthlySeatMinimum); provider.GatewaySubscriptionId,
[
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
]);
await _providerBillingService.UpdateSeatMinimums(updateMoeSeatMinimumsCommand);
break;
}
} }
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
@ -247,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

@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateMspProviderModel : IValidatableObject
{
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Teams (Monthly) Seat Minimum")]
public int TeamsMonthlySeatMinimum { get; set; }
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
public int EnterpriseMonthlySeatMinimum { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Type = ProviderType.Msp
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (TeamsMonthlySeatMinimum < 0)
{
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
}
if (EnterpriseMonthlySeatMinimum < 0)
{
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
}
}
}

View File

@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Enterprise Seat Minimum")]
public int EnterpriseSeatMinimum { get; set; }
[Display(Name = "Plan")]
[Required]
public PlanType? Plan { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Type = ProviderType.MultiOrganizationEnterprise
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (EnterpriseSeatMinimum < 0)
{
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
}
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
{
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan);
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
}
}
}

View File

@ -1,84 +1,8 @@
using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models; namespace Bit.Admin.AdminConsole.Models;
public class CreateProviderModel : IValidatableObject public class CreateProviderModel
{ {
public CreateProviderModel() { }
[Display(Name = "Provider Type")]
public ProviderType Type { get; set; } public ProviderType Type { get; set; }
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
[Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; }
[Display(Name = "Teams (Monthly) Seat Minimum")]
public int TeamsMonthlySeatMinimum { get; set; }
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
public int EnterpriseMonthlySeatMinimum { get; set; }
public virtual Provider ToProvider()
{
return new Provider()
{
Type = Type,
Name = Name,
BusinessName = BusinessName,
BillingEmail = BillingEmail?.ToLowerInvariant().Trim()
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
{
case ProviderType.Msp:
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (TeamsMonthlySeatMinimum < 0)
{
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
}
if (EnterpriseMonthlySeatMinimum < 0)
{
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
}
break;
case ProviderType.Reseller:
if (string.IsNullOrWhiteSpace(Name))
{
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
yield return new ValidationResult($"The {nameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BusinessName))
{
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BillingEmail))
{
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
break;
}
}
} }

View File

@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateResellerProviderModel : IValidatableObject
{
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
[Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Name = Name,
BusinessName = BusinessName,
BillingEmail = BillingEmail?.ToLowerInvariant().Trim(),
Type = ProviderType.Reseller
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(Name))
{
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
yield return new ValidationResult($"The {nameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BusinessName))
{
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BillingEmail))
{
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
}
}

View File

@ -33,6 +33,13 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
GatewayCustomerUrl = gatewayCustomerUrl; GatewayCustomerUrl = gatewayCustomerUrl;
GatewaySubscriptionUrl = gatewaySubscriptionUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type; Type = provider.Type;
if (Type == ProviderType.MultiOrganizationEnterprise)
{
var plan = providerPlans.SingleOrDefault();
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
Plan = plan?.PlanType;
}
} }
[Display(Name = "Billing Email")] [Display(Name = "Billing Email")]
@ -58,13 +65,24 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
[Display(Name = "Provider Type")] [Display(Name = "Provider Type")]
public ProviderType Type { get; set; } public ProviderType Type { get; set; }
[Display(Name = "Plan")]
public PlanType? Plan { get; set; }
[Display(Name = "Enterprise Seats Minimum")]
public int? EnterpriseMinimumSeats { get; set; }
public virtual Provider ToProvider(Provider existingProvider) public virtual Provider ToProvider(Provider existingProvider)
{ {
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
existingProvider.Gateway = Gateway; switch (Type)
existingProvider.GatewayCustomerId = GatewayCustomerId; {
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; case ProviderType.Msp:
existingProvider.Gateway = Gateway;
existingProvider.GatewayCustomerId = GatewayCustomerId;
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
break;
}
return existingProvider; return existingProvider;
} }
@ -82,6 +100,23 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
} }
break; break;
case ProviderType.MultiOrganizationEnterprise:
if (Plan == null)
{
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
yield return new ValidationResult($"The {displayName} field is required.");
}
if (EnterpriseMinimumSeats == null)
{
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {displayName} field is required.");
}
if (EnterpriseMinimumSeats < 0)
{
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {displayName} field cannot be less than 0.");
}
break;
} }
} }
} }

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>
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option> <select class="form-select" asp-for="Paid" name="paid">
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option> <option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option> <option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
</select> <option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
</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

@ -1,80 +1,48 @@
@using Bit.SharedWeb.Utilities @using Bit.SharedWeb.Utilities
@using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core @using Bit.Core
@model CreateProviderModel @model CreateProviderModel
@inject Bit.Core.Services.IFeatureService FeatureService @inject Bit.Core.Services.IFeatureService FeatureService
@{ @{
ViewData["Title"] = "Create Provider"; ViewData["Title"] = "Create Provider";
}
@section Scripts { var providerTypes = Enum.GetValues<ProviderType>()
<script> .OrderBy(x => x.GetDisplayAttribute().Order)
function toggleProviderTypeInfo(value) { .ToList();
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
document.getElementById('info-' + value).classList.remove('d-none'); if (!FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
} {
</script> providerTypes.Remove(ProviderType.MultiOrganizationEnterprise);
}
} }
<h1>Create Provider</h1> <h1>Create Provider</h1>
<form method="post" asp-action="Create">
<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="mb-3">
<div class="form-group"> <label asp-for="Type" class="form-label h2"></label>
<label asp-for="Type" class="h2"></label> @foreach (var providerType in providerTypes)
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType)))
{ {
var providerTypeValue = (int)providerType; var providerTypeValue = (int)providerType;
<div class="form-check"> <div class="mb-3">
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input", onclick=$"toggleProviderTypeInfo({providerTypeValue})" }) <div class="row">
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" }) <div class="col">
<br/> <div class="form-check">
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @for = $"providerType-{providerTypeValue}" }) @Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
</div> @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label", @for = $"providerType-{providerTypeValue}" })
} </div>
</div>
<div id="@($"info-{(int)ProviderType.Msp}")" class="form-group @(Model.Type != ProviderType.Msp ? "d-none" : string.Empty)">
<h2>MSP Info</h2>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="TeamsMonthlySeatMinimum"></label>
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div> </div>
</div> </div>
<div class="col-sm"> <div class="row">
<div class="form-group"> <div class="col">
<label asp-for="EnterpriseMonthlySeatMinimum"></label> @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-body-secondary ps-4", @for = $"providerType-{providerTypeValue}" })
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div> </div>
</div> </div>
</div> </div>
} }
</div> </div>
<button type="submit" class="btn btn-primary mb-2">Next</button>
<div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)">
<h2>Reseller Info</h2>
<div class="form-group">
<label asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name">
</div>
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
<div class="form-group">
<label asp-for="BillingEmail"></label>
<input type="text" class="form-control" asp-for="BillingEmail">
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form> </form>

View File

@ -0,0 +1,31 @@
@model CreateMspProviderModel
@{
ViewData["Title"] = "Create Managed Service Provider";
}
<h1>Create Managed Service Provider</h1>
<div>
<form method="post" asp-action="CreateMsp">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form>
</div>

View File

@ -0,0 +1,43 @@
@using Bit.Core.Billing.Enums
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model CreateMultiOrganizationEnterpriseProviderModel
@{
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
}
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
<div>
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
<div class="row">
<div class="col-sm">
<div class="mb-3">
@{
var multiOrgPlans = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan" class="form-label"></label>
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<option value="">--</option>
</select>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label asp-for="EnterpriseSeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Create Provider</button>
</form>
</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

@ -0,0 +1,25 @@
@model CreateResellerProviderModel
@{
ViewData["Title"] = "Create Reseller Provider";
}
<h1>Create Reseller Provider</h1>
<div>
<form class="mb-3" method="post" asp-action="CreateReseller">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Name" class="form-label"></label>
<input type="text" class="form-control" asp-for="Name">
</div>
<div class="mb-3">
<label asp-for="BusinessName" class="form-label"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
<div class="mb-3">
<label asp-for="BillingEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="BillingEmail">
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form>
</div>

View File

@ -1,6 +1,9 @@
@using Bit.Admin.Enums; @using Bit.Admin.Enums;
@using Bit.Core @using Bit.Core
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums
@using Bit.Core.Billing.Extensions @using Bit.Core.Billing.Extensions
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject Bit.Admin.Services.IAccessControlService AccessControlService @inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService @inject Bit.Core.Services.IFeatureService FeatureService
@ -31,72 +34,103 @@
<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())
{ {
<div class="row"> switch (Model.Provider.Type)
<div class="col-sm"> {
<div class="form-group"> case ProviderType.Msp:
<label asp-for="TeamsMonthlySeatMinimum"></label> {
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum"> <div class="row">
</div> <div class="col-sm">
</div> <div class="mb-3">
<div class="col-sm"> <label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
<div class="form-group"> <input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
<label asp-for="EnterpriseMonthlySeatMinimum"></label> </div>
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum"> </div>
</div> <div class="col-sm">
</div> <div class="mb-3">
</div> <label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
<div class="row"> <input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
<div class="col-sm"> </div>
<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>
break;
}
case ProviderType.MultiOrganizationEnterprise:
{
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) && Model.Provider.Type == ProviderType.MultiOrganizationEnterprise)
{
<div class="row">
<div class="col-sm">
<div class="mb-3">
@{
var multiOrgPlans = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan" class="form-label"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<option value="">--</option>
</select>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label asp-for="EnterpriseMinimumSeats" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
</div>
</div>
</div>
}
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> </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 asp-for="GatewayCustomerId" class="form-label"></label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId"> <input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append"> <button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewayCustomerUrl', '_blank')">
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank"> <i class="fa fa-external-link"></i>
<i class="fa fa-external-link"></i> </button>
</a>
</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 asp-for="GatewaySubscriptionId" class="form-label"></label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId"> <input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append"> <button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewaySubscriptionUrl', '_blank')">
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank"> <i class="fa fa-external-link"></i>
<i class="fa fa-external-link"></i> </button>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -111,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>
@ -135,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>
@ -159,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>
@ -174,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

@ -4,6 +4,7 @@ using Bit.Admin.Enums;
using Bit.Admin.Models; using Bit.Admin.Models;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -24,6 +25,8 @@ public class UsersController : Controller
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IAccessControlService _accessControlService; private readonly IAccessControlService _accessControlService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
public UsersController( public UsersController(
IUserRepository userRepository, IUserRepository userRepository,
@ -31,7 +34,9 @@ public class UsersController : Controller
IPaymentService paymentService, IPaymentService paymentService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IAccessControlService accessControlService, IAccessControlService accessControlService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserService userService,
IFeatureService featureService)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
@ -39,6 +44,8 @@ public class UsersController : Controller
_globalSettings = globalSettings; _globalSettings = globalSettings;
_accessControlService = accessControlService; _accessControlService = accessControlService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_userService = userService;
_featureService = featureService;
} }
[RequirePermission(Permission.User_List_View)] [RequirePermission(Permission.User_List_View)]
@ -82,8 +89,8 @@ public class UsersController : Controller
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers)); return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain));
} }
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
@ -99,7 +106,8 @@ public class UsersController : Controller
var billingInfo = await _paymentService.GetBillingAsync(user); var billingInfo = await _paymentService.GetBillingAsync(user);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings)); var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain));
} }
[HttpPost] [HttpPost]
@ -153,4 +161,12 @@ public class UsersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
// TODO: Feature flag to be removed in PM-14207
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
{
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
? await _userService.IsManagedByAnyOrganizationAsync(userId)
: null;
}
} }

View File

@ -0,0 +1,19 @@

using Bit.SharedWeb.Utilities;
// ReSharper disable once CheckNamespace
namespace Microsoft.AspNetCore.Mvc.Rendering;
public static class HtmlHelper
{
public static IEnumerable<SelectListItem> GetEnumSelectList<T>(this IHtmlHelper htmlHelper, IEnumerable<T> values)
where T : Enum
{
return values.Select(v => new SelectListItem
{
Text = v.GetDisplayAttribute().Name,
Value = v.ToString()
});
}
}

View File

@ -20,9 +20,11 @@ public class UserEditModel
IEnumerable<Cipher> ciphers, IEnumerable<Cipher> ciphers,
BillingInfo billingInfo, BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo, BillingHistoryInfo billingHistoryInfo,
GlobalSettings globalSettings) GlobalSettings globalSettings,
bool? domainVerified
)
{ {
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers); User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, domainVerified);
BillingInfo = billingInfo; BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo; BillingHistoryInfo = billingHistoryInfo;

View File

@ -14,6 +14,7 @@ public class UserViewModel
public bool Premium { get; } public bool Premium { get; }
public short? MaxStorageGb { get; } public short? MaxStorageGb { get; }
public bool EmailVerified { get; } public bool EmailVerified { get; }
public bool? DomainVerified { get; }
public bool TwoFactorEnabled { get; } public bool TwoFactorEnabled { get; }
public DateTime AccountRevisionDate { get; } public DateTime AccountRevisionDate { get; }
public DateTime RevisionDate { get; } public DateTime RevisionDate { get; }
@ -35,6 +36,7 @@ public class UserViewModel
bool premium, bool premium,
short? maxStorageGb, short? maxStorageGb,
bool emailVerified, bool emailVerified,
bool? domainVerified,
bool twoFactorEnabled, bool twoFactorEnabled,
DateTime accountRevisionDate, DateTime accountRevisionDate,
DateTime revisionDate, DateTime revisionDate,
@ -56,6 +58,7 @@ public class UserViewModel
Premium = premium; Premium = premium;
MaxStorageGb = maxStorageGb; MaxStorageGb = maxStorageGb;
EmailVerified = emailVerified; EmailVerified = emailVerified;
DomainVerified = domainVerified;
TwoFactorEnabled = twoFactorEnabled; TwoFactorEnabled = twoFactorEnabled;
AccountRevisionDate = accountRevisionDate; AccountRevisionDate = accountRevisionDate;
RevisionDate = revisionDate; RevisionDate = revisionDate;
@ -73,10 +76,10 @@ public class UserViewModel
public static IEnumerable<UserViewModel> MapViewModels( public static IEnumerable<UserViewModel> MapViewModels(
IEnumerable<User> users, IEnumerable<User> users,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) => IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
users.Select(user => MapViewModel(user, lookup)); users.Select(user => MapViewModel(user, lookup, false));
public static UserViewModel MapViewModel(User user, public static UserViewModel MapViewModel(User user,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) => IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup, bool? domainVerified) =>
new( new(
user.Id, user.Id,
user.Name, user.Name,
@ -86,6 +89,7 @@ public class UserViewModel
user.Premium, user.Premium,
user.MaxStorageGb, user.MaxStorageGb,
user.EmailVerified, user.EmailVerified,
domainVerified,
IsTwoFactorEnabled(user, lookup), IsTwoFactorEnabled(user, lookup),
user.AccountRevisionDate, user.AccountRevisionDate,
user.RevisionDate, user.RevisionDate,
@ -100,9 +104,9 @@ public class UserViewModel
Array.Empty<Cipher>()); Array.Empty<Cipher>());
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) => public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) =>
MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>()); MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>(), false);
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers) => public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers, bool? domainVerified) =>
new( new(
user.Id, user.Id,
user.Name, user.Name,
@ -112,6 +116,7 @@ public class UserViewModel
user.Premium, user.Premium,
user.MaxStorageGb, user.MaxStorageGb,
user.EmailVerified, user.EmailVerified,
domainVerified,
isTwoFactorEnabled, isTwoFactorEnabled,
user.AccountRevisionDate, user.AccountRevisionDate,
user.RevisionDate, user.RevisionDate,

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

@ -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,39 +65,35 @@
<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>

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,208 +74,204 @@
<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 class="col-md-6">
<label class="form-label" asp-for="Filter.Price">Price ID</label>
<select asp-for="Filter.Price" name="filter.Price" class="form-select">
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices)
{
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks)
{
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
}
</select>
</div>
<div class="col-12 text-end">
<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>
</div> </div>
</div> </div>
</div> <hr/>
<div class="row mt-2"> <input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
<div class="col-6"> <div class="text-center row d-flex justify-content-center">
<label asp-for="Filter.Price">Price ID</label> <div id="selectAll" class="d-none col-8">
<select asp-for="Filter.Price" name="filter.Price" class="form-control mr-2"> All @Model.Items.Count subscriptions on this page are selected.<br/>
<option asp-selected="Model.Filter.Price == null" value="@null">All</option> <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>
@foreach (var price in Model.Prices) <span id="selectedAllConfirmation" class="d-none text-body-secondary">
{ <i class="fa fa-check"></i> All subscriptions for this search are selected.
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option> </span>
} <div class="alert alert-warning mt-2" role="alert">
</select> Please be aware that bulk operations may take several minutes to complete.
</div>
</div>
</div> </div>
<div class="col-6"> <div class="table-responsive">
<label asp-for="Filter.TestClock">Test Clock</label> <table class="table table-striped table-hover align-middle">
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-control mr-2"> <thead>
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks)
{
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
}
</select>
</div>
</div>
<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"><i class="fa fa-search"></i> Search</button>
</div>
<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">
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>
<span id="selectedAllConfirmation" class="d-none text-muted">✔ All subscriptions for this search are selected.</span><br/>
<div class="alert alert-warning" role="alert">Please be aware that bulk operations may take several minutes to complete.</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>
<div class="form-check form-check-inline">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div>
</th>
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product Tier</th>
<th>Current Period End</th>
</tr>
</thead>
<tbody>
@if (!Model.Items.Any())
{
<tr> <tr>
<td colspan="6">No results to list.</td> <th>
<div class="form-check">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div>
</th>
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product Tier</th>
<th>Current Period End</th>
</tr> </tr>
} </thead>
else <tbody>
{ @if (!Model.Items.Any())
@for (var i = 0; i < Model.Items.Count; i++)
{ {
<tr> <tr>
<td> <td colspan="6">No results to list.</td>
@{
var i0 = i;
}
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
@for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
{
var i1 = i;
var j1 = j;
<input
type="hidden"
asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
}
<div class="form-check">
@{
var i2 = i;
}
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
</div>
</td>
<td>
@Model.Items[i].Subscription.Id
</td>
<td>
@Model.Items[i].Subscription.Customer?.Email
</td>
<td>
@Model.Items[i].Subscription.Status
</td>
<td>
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
</td>
<td>
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
</td>
</tr> </tr>
} }
} else
</tbody> {
</table> @for (var i = 0; i < Model.Items.Count; i++)
</div> {
<nav class="d-inline-flex"> <tr>
<ul class="pagination"> <td>
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{ @{
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore"> var i0 = i;
<li class="page-item"> }
<button <input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
type="submit" <input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
class="page-link" <input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
name="action" <input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
asp-for="Action" <input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
value="@StripeSubscriptionsAction.PreviousPage"> <input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
Previous
</button> @for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
</li> {
} var i1 = i;
else var j1 = j;
{ <input
<li class="page-item disabled"> type="hidden"
<a class="page-link" href="#" tabindex="-1">Previous</a> asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
</li> value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
} }
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter)) <div class="form-check">
{
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter"> @{
<li class="page-item"> var i2 = i;
<button class="page-link" }
<input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
</div>
</td>
<td>
@Model.Items[i].Subscription.Id
</td>
<td>
@Model.Items[i].Subscription.Customer?.Email
</td>
<td>
@Model.Items[i].Subscription.Status
</td>
<td>
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
</td>
<td>
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav class="d-inline-flex align-items-center">
<ul class="pagination mb-0">
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
<li class="page-item">
<button
type="submit" type="submit"
class="page-link"
name="action" name="action"
asp-for="Action" asp-for="Action"
value="@StripeSubscriptionsAction.NextPage"> value="@StripeSubscriptionsAction.PreviousPage">
Next Previous
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
{
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
<li class="page-item">
<button class="page-link"
type="submit"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.NextPage">
Next
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
<span id="bulkActions" class="d-none ms-3">
<span class="d-inline-flex gap-2">
<button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
Export
</button> </button>
</li> <button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
} Bulk Cancel
else </button>
{ </span>
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
<span id="bulkActions" class="d-none ml-2">
<span class="d-inline-flex">
<button
type="submit"
class="btn btn-primary mr-1"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.Export">
Export
</button>
<button
type="submit"
class="btn btn-danger"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.BulkCancel">
Bulk Cancel
</button>
</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

@ -1,4 +1,4 @@
@model UserViewModel @model UserViewModel
<dl class="row"> <dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt> <dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd> <dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd>
@ -12,6 +12,11 @@
<dt class="col-sm-4 col-lg-3">Email Verified</dt> <dt class="col-sm-4 col-lg-3">Email Verified</dt>
<dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd>
@if(Model.DomainVerified.HasValue){
<dt class="col-sm-4 col-lg-3">Domain Verified</dt>
<dd class="col-sm-8 col-lg-9">@(Model.DomainVerified.Value == true ? "Yes" : "No")</dd>
}
<dt class="col-sm-4 col-lg-3">Using 2FA</dt> <dt class="col-sm-4 col-lg-3">Using 2FA</dt>
<dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd>

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

@ -1,6 +1,5 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
@ -9,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;
@ -53,6 +53,8 @@ public class OrganizationUsersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IFeatureService _featureService;
public OrganizationUsersController( public OrganizationUsersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -73,7 +75,9 @@ public class OrganizationUsersController : Controller
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand) IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IFeatureService featureService)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -94,29 +98,34 @@ public class OrganizationUsersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
_featureService = featureService;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<OrganizationUserDetailsResponseModel> Get(string id, bool includeGroups = false) public async Task<OrganizationUserDetailsResponseModel> Get(Guid id, bool includeGroups = false)
{ {
var organizationUser = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(new Guid(id)); var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.Item1.OrganizationId)) if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.OrganizationId))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var response = new OrganizationUserDetailsResponseModel(organizationUser.Item1, organizationUser.Item2); var managedByOrganization = await GetManagedByOrganizationStatusAsync(
organizationUser.OrganizationId,
[organizationUser.Id]);
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections);
if (includeGroups) if (includeGroups)
{ {
response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Item1.Id); response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Id);
} }
return response; return response;
} }
[HttpGet("mini-details")] [HttpGet("mini-details")]
[RequireFeature(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi)]
public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId) public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId)
{ {
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId),
@ -150,11 +159,13 @@ public class OrganizationUsersController : Controller
} }
); );
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
var responses = organizationUsers var responses = organizationUsers
.Select(o => .Select(o =>
{ {
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled); var managedByOrganization = organizationUsersManagementStatus[o.Id];
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization);
return orgUser; return orgUser;
}); });
@ -534,7 +545,7 @@ public class OrganizationUsersController : Controller
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("{id}/delete-account")] [HttpDelete("{id}/delete-account")]
[HttpPost("{id}/delete-account")] [HttpPost("{id}/delete-account")]
public async Task DeleteAccount(Guid orgId, Guid id, [FromBody] SecretVerificationRequestModel model) public async Task DeleteAccount(Guid orgId, Guid id)
{ {
if (!await _currentContext.ManageUsers(orgId)) if (!await _currentContext.ManageUsers(orgId))
{ {
@ -547,19 +558,13 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
} }
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("delete-account")] [HttpDelete("delete-account")]
[HttpPost("delete-account")] [HttpPost("delete-account")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] SecureOrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
if (!await _currentContext.ManageUsers(orgId)) if (!await _currentContext.ManageUsers(orgId))
{ {
@ -572,12 +577,6 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r => return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
@ -682,4 +681,15 @@ public class OrganizationUsersController : Controller
return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r => return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
} }
private async Task<IDictionary<Guid, bool>> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
return userIds.ToDictionary(kvp => kvp, kvp => false);
}
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds);
return usersOrganizationManagementStatus;
}
} }

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;
@ -25,28 +29,29 @@ public class PoliciesController : Controller
{ {
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly IOrganizationService _organizationService;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
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,
IPolicyService policyService, IPolicyService policyService,
IOrganizationService organizationService,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IUserService userService, IUserService userService,
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;
_organizationService = organizationService;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_userService = userService; _userService = userService;
_currentContext = currentContext; _currentContext = currentContext;
@ -55,23 +60,29 @@ public class PoliciesController : Controller
"OrganizationServiceDataProtector"); "OrganizationServiceDataProtector");
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
} }
[HttpGet("{type}")] [HttpGet("{type}")]
public async Task<PolicyResponseModel> Get(string orgId, int type) public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
{ {
var orgIdGuid = new Guid(orgId); if (!await _currentContext.ManagePolicies(orgId))
if (!await _currentContext.ManagePolicies(orgIdGuid))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgIdGuid, (PolicyType)type); var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null) if (policy == null)
{ {
throw new NotFoundException(); 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("")]
@ -84,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]
@ -185,7 +196,7 @@ public class PoliciesController : Controller
} }
var userId = _userService.GetProperUserId(User); var userId = _userService.GetProperUserId(User);
await _policyService.SaveAsync(policy, _organizationService, userId); await _policyService.SaveAsync(policy, userId);
return new PolicyResponseModel(policy); return new PolicyResponseModel(policy);
} }
} }

View File

@ -1,10 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class SecureOrganizationUserBulkRequestModel : SecretVerificationRequestModel
{
[Required]
public IEnumerable<Guid> Ids { get; set; }
}

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

@ -64,20 +64,27 @@ public class OrganizationUserResponseModel : ResponseModel
public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserDetailsResponseModel(OrganizationUser organizationUser, public OrganizationUserDetailsResponseModel(
OrganizationUser organizationUser,
bool managedByOrganization,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool managedByOrganization,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public bool ManagedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
@ -110,7 +117,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool twoFactorEnabled, string obj = "organizationUserUserDetails") bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
: base(organizationUser, obj) : base(organizationUser, obj)
{ {
if (organizationUser == null) if (organizationUser == null)
@ -127,6 +134,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
Groups = organizationUser.Groups; Groups = organizationUser.Groups;
// Prevent reset password when using key connector. // Prevent reset password when using key connector.
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
ManagedByOrganization = managedByOrganization;
} }
public string Name { get; set; } public string Name { get; set; }
@ -134,6 +142,11 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
public string AvatarColor { get; set; } public string AvatarColor { get; set; }
public bool TwoFactorEnabled { get; set; } public bool TwoFactorEnabled { get; set; }
public bool SsoBound { get; set; } public bool SsoBound { get; set; }
/// <summary>
/// Indicates if the organization manages the user. If a user is "managed" by an organization,
/// the organization has greater control over their account, and some user actions are restricted.
/// </summary>
public bool ManagedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
public IEnumerable<Guid> Groups { get; set; } public IEnumerable<Guid> Groups { get; set; }
} }

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

@ -71,14 +71,13 @@ public class MembersController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)] [ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(Guid id) public async Task<IActionResult> Get(Guid id)
{ {
var userDetails = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id); var (orgUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
var orgUser = userDetails?.Item1;
if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId) if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId)
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser), var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser),
userDetails.Item2); collections);
return new JsonResult(response); return new JsonResult(response);
} }

View File

@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.Enums;
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.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -18,18 +17,15 @@ public class PoliciesController : Controller
{ {
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly IOrganizationService _organizationService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
public PoliciesController( public PoliciesController(
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IPolicyService policyService, IPolicyService policyService,
IOrganizationService organizationService,
ICurrentContext currentContext) ICurrentContext currentContext)
{ {
_policyRepository = policyRepository; _policyRepository = policyRepository;
_policyService = policyService; _policyService = policyService;
_organizationService = organizationService;
_currentContext = currentContext; _currentContext = currentContext;
} }
@ -45,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>
@ -66,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>
@ -96,7 +90,7 @@ public class PoliciesController : Controller
{ {
policy = model.ToPolicy(policy); policy = model.ToPolicy(policy);
} }
await _policyService.SaveAsync(policy, _organizationService, null); await _policyService.SaveAsync(policy, null);
var response = new PolicyResponseModel(policy); var response = new PolicyResponseModel(policy);
return new JsonResult(response); return new JsonResult(response);
} }

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>
@ -35,7 +34,7 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" /> <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" /> <PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -148,6 +148,13 @@ public class AccountsController : Controller
throw new BadRequestException("MasterPasswordHash", "Invalid password."); throw new BadRequestException("MasterPasswordHash", "Invalid password.");
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
}
await _userService.InitiateEmailChangeAsync(user, model.NewEmail); await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
} }
@ -165,6 +172,13 @@ public class AccountsController : Controller
throw new BadRequestException("You cannot change your email when using Key Connector."); throw new BadRequestException("You cannot change your email when using Key Connector.");
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
}
var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail, var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail,
model.NewMasterPasswordHash, model.Token, model.Key); model.NewMasterPasswordHash, model.Token, model.Key);
if (result.Succeeded) if (result.Succeeded)
@ -566,6 +580,13 @@ public class AccountsController : Controller
} }
else else
{ {
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
}
var result = await _userService.DeleteAsync(user); var result = await _userService.DeleteAsync(user);
if (result.Succeeded) if (result.Succeeded)
{ {

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
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
{ {
SecretKey = ClientSecret; results.Add(new ValidationResult("ClientSecret is required.", [nameof(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

@ -26,7 +26,7 @@ public class OrganizationBillingController(
[HttpGet("metadata")] [HttpGet("metadata")]
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId) public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
{ {
if (!await currentContext.AccessMembersTab(organizationId)) if (!await currentContext.OrganizationUser(organizationId))
{ {
return Error.Unauthorized(); return Error.Unauthorized();
} }

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)
@ -93,7 +92,8 @@ public class ProviderBillingController(
subscription, subscription,
providerPlans, providerPlans,
taxInformation, taxInformation,
subscriptionSuspension); subscriptionSuspension,
provider);
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }

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

@ -12,7 +12,7 @@ public class CreateClientOrganizationRequestBody
[Required(ErrorMessage = "'ownerEmail' must be provided")] [Required(ErrorMessage = "'ownerEmail' must be provided")]
public string OwnerEmail { get; set; } public string OwnerEmail { get; set; }
[EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, ErrorMessage = "'planType' must be Teams (Monthly) or Enterprise (Monthly)")] [EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, PlanType.EnterpriseAnnually, ErrorMessage = "'planType' must be Teams (Monthly), Enterprise (Monthly) or Enterprise (Annually)")]
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "'seats' must be greater than 0")] [Range(1, int.MaxValue, ErrorMessage = "'seats' must be greater than 0")]

View File

@ -4,10 +4,16 @@ namespace Bit.Api.Billing.Models.Responses;
public record OrganizationMetadataResponse( public record OrganizationMetadataResponse(
bool IsEligibleForSelfHost, bool IsEligibleForSelfHost,
bool IsOnSecretsManagerStandalone) bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid,
bool HasSubscription)
{ {
public static OrganizationMetadataResponse From(OrganizationMetadata metadata) public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
=> new( => new(
metadata.IsEligibleForSelfHost, metadata.IsEligibleForSelfHost,
metadata.IsOnSecretsManagerStandalone); metadata.IsManaged,
metadata.IsOnSecretsManagerStandalone,
metadata.IsSubscriptionUnpaid,
metadata.HasSubscription);
} }

View File

@ -1,4 +1,7 @@
using Bit.Core.Billing.Entities; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Stripe; using Stripe;
@ -14,7 +17,8 @@ public record ProviderSubscriptionResponse(
decimal AccountCredit, decimal AccountCredit,
TaxInformation TaxInformation, TaxInformation TaxInformation,
DateTime? CancelAt, DateTime? CancelAt,
SubscriptionSuspension Suspension) SubscriptionSuspension Suspension,
ProviderType ProviderType)
{ {
private const string _annualCadence = "Annual"; private const string _annualCadence = "Annual";
private const string _monthlyCadence = "Monthly"; private const string _monthlyCadence = "Monthly";
@ -23,7 +27,8 @@ public record ProviderSubscriptionResponse(
Subscription subscription, Subscription subscription,
ICollection<ProviderPlan> providerPlans, ICollection<ProviderPlan> providerPlans,
TaxInformation taxInformation, TaxInformation taxInformation,
SubscriptionSuspension subscriptionSuspension) SubscriptionSuspension subscriptionSuspension,
Provider provider)
{ {
var providerPlanResponses = providerPlans var providerPlanResponses = providerPlans
.Where(providerPlan => providerPlan.IsConfigured()) .Where(providerPlan => providerPlan.IsConfigured())
@ -35,6 +40,8 @@ public record ProviderSubscriptionResponse(
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
return new ProviderPlanResponse( return new ProviderPlanResponse(
plan.Name, plan.Name,
plan.Type,
plan.ProductTier,
configuredProviderPlan.SeatMinimum, configuredProviderPlan.SeatMinimum,
configuredProviderPlan.PurchasedSeats, configuredProviderPlan.PurchasedSeats,
configuredProviderPlan.AssignedSeats, configuredProviderPlan.AssignedSeats,
@ -53,12 +60,15 @@ public record ProviderSubscriptionResponse(
accountCredit, accountCredit,
taxInformation, taxInformation,
subscription.CancelAt, subscription.CancelAt,
subscriptionSuspension); subscriptionSuspension,
provider.Type);
} }
} }
public record ProviderPlanResponse( public record ProviderPlanResponse(
string PlanName, string PlanName,
PlanType Type,
ProductTierType ProductTier,
int SeatMinimum, int SeatMinimum,
int PurchasedSeats, int PurchasedSeats,
int AssignedSeats, int AssignedSeats,

View File

@ -196,8 +196,8 @@ public class DevicesController : Controller
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HttpPost("{id}/delete")] [HttpPost("{id}/deactivate")]
public async Task Delete(string id) public async Task Deactivate(string id)
{ {
var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value);
if (device == null) if (device == null)
@ -205,7 +205,7 @@ public class DevicesController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
await _deviceService.DeleteAsync(device); await _deviceService.DeactivateAsync(device);
} }
[AllowAnonymous] [AllowAnonymous]

View File

@ -46,7 +46,7 @@ public class PushController : Controller
public async Task PostDelete([FromBody] PushDeviceRequestModel model) public async Task PostDelete([FromBody] PushDeviceRequestModel model)
{ {
CheckUsage(); CheckUsage();
await _pushRegistrationService.DeleteRegistrationAsync(Prefix(model.Id), model.Type); await _pushRegistrationService.DeleteRegistrationAsync(Prefix(model.Id));
} }
[HttpPut("add-organization")] [HttpPut("add-organization")]
@ -54,7 +54,7 @@ public class PushController : Controller
{ {
CheckUsage(); CheckUsage();
await _pushRegistrationService.AddUserRegistrationOrganizationAsync( await _pushRegistrationService.AddUserRegistrationOrganizationAsync(
model.Devices.Select(d => new KeyValuePair<string, Core.Enums.DeviceType>(Prefix(d.Id), d.Type)), model.Devices.Select(d => Prefix(d.Id)),
Prefix(model.OrganizationId)); Prefix(model.OrganizationId));
} }
@ -63,7 +63,7 @@ public class PushController : Controller
{ {
CheckUsage(); CheckUsage();
await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync( await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(
model.Devices.Select(d => new KeyValuePair<string, Core.Enums.DeviceType>(Prefix(d.Id), d.Type)), model.Devices.Select(d => Prefix(d.Id)),
Prefix(model.OrganizationId)); Prefix(model.OrganizationId));
} }

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();

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