mirror of
https://github.com/bitwarden/server.git
synced 2025-02-16 01:51:21 +01:00
Merge remote-tracking branch 'origin/main' into ac-warnings
This commit is contained in:
commit
0f2d51edc4
@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "6.7.0",
|
||||
"version": "6.9.0",
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
|
@ -9,7 +9,8 @@ services:
|
||||
command: sleep infinity
|
||||
|
||||
bitwarden_mssql:
|
||||
image: mcr.microsoft.com/azure-sql-edge:latest
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
../../dev/.env
|
||||
@ -17,7 +18,7 @@ services:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_PID: Developer
|
||||
volumes:
|
||||
- edgesql_dev_data:/var/opt/mssql
|
||||
- mssql_dev_data:/var/opt/mssql
|
||||
- ../../util/Migrator:/mnt/migrator/
|
||||
- ../../dev/helpers/mssql:/mnt/helpers
|
||||
- ../../dev/.data/mssql:/mnt/data
|
||||
@ -29,4 +30,4 @@ services:
|
||||
network_mode: service:bitwarden_server
|
||||
|
||||
volumes:
|
||||
edgesql_dev_data:
|
||||
mssql_dev_data:
|
||||
|
26
.github/CODEOWNERS
vendored
26
.github/CODEOWNERS
vendored
@ -4,13 +4,22 @@
|
||||
#
|
||||
# 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
|
||||
.github/workflows @bitwarden/dept-devops
|
||||
## Docker files have shared ownership ##
|
||||
**/Dockerfile
|
||||
**/*.Dockerfile
|
||||
**/.dockerignore
|
||||
**/entrypoint.sh
|
||||
|
||||
# DevOps for Docker changes
|
||||
**/Dockerfile @bitwarden/dept-devops
|
||||
**/*.Dockerfile @bitwarden/dept-devops
|
||||
**/.dockerignore @bitwarden/dept-devops
|
||||
## BRE team owns these workflows ##
|
||||
.github/workflows/publish.yml @bitwarden/dept-bre
|
||||
|
||||
## 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
|
||||
src/Sql/** @bitwarden/dept-dbops
|
||||
@ -26,6 +35,9 @@ util/SqliteMigrations/** @bitwarden/dept-dbops
|
||||
bitwarden_license/src/Sso @bitwarden/team-auth-dev
|
||||
src/Identity @bitwarden/team-auth-dev
|
||||
|
||||
# Key Management team
|
||||
**/KeyManagement @bitwarden/team-key-management-dev
|
||||
|
||||
**/SecretsManager @bitwarden/team-secrets-manager-dev
|
||||
**/Tools @bitwarden/team-tools-dev
|
||||
|
||||
@ -57,6 +69,6 @@ src/EventsProcessor @bitwarden/team-admin-console-dev
|
||||
src/Admin/Controllers/ToolsController.cs @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
|
||||
Directory.Build.props
|
||||
|
19
.github/renovate.json
vendored
19
.github/renovate.json
vendored
@ -29,7 +29,7 @@
|
||||
"commitMessagePrefix": "[deps] DevOps:"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["DnsClient", "Quartz"],
|
||||
"matchPackageNames": ["DnsClient"],
|
||||
"description": "Admin Console owned dependencies",
|
||||
"commitMessagePrefix": "[deps] AC:",
|
||||
"reviewers": ["team:team-admin-console-dev"]
|
||||
@ -42,14 +42,7 @@
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"AspNetCoreRateLimit",
|
||||
"AspNetCoreRateLimit.Redis",
|
||||
"Azure.Data.Tables",
|
||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||
"Azure.Messaging.EventGrid",
|
||||
"Azure.Messaging.ServiceBus",
|
||||
"Azure.Storage.Blobs",
|
||||
"Azure.Storage.Queues",
|
||||
"DuoUniversal",
|
||||
"Fido2.AspNet",
|
||||
"Duende.IdentityServer",
|
||||
@ -128,8 +121,16 @@
|
||||
},
|
||||
{
|
||||
"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.Http"
|
||||
"Microsoft.AspNetCore.Http",
|
||||
"Quartz"
|
||||
],
|
||||
"description": "Platform owned dependencies",
|
||||
"commitMessagePrefix": "[deps] Platform:",
|
||||
|
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: _move_finalization_db_scripts
|
||||
run-name: Move finalization database scripts
|
||||
|
||||
@ -30,7 +29,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
||||
@ -54,7 +53,7 @@ jobs:
|
||||
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -108,7 +107,7 @@ jobs:
|
||||
devops-alerts-slack-webhook-url"
|
||||
|
||||
- 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:
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
|
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Automatic responses
|
||||
on:
|
||||
issues:
|
||||
|
168
.github/workflows/build.yml
vendored
168
.github/workflows/build.yml
vendored
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Build
|
||||
|
||||
on:
|
||||
@ -8,21 +7,30 @@ on:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
|
||||
- name: Verify format
|
||||
run: dotnet format --verify-no-changes
|
||||
@ -68,13 +76,15 @@ jobs:
|
||||
node: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
@ -110,7 +120,7 @@ jobs:
|
||||
ls -atlh ../../../
|
||||
|
||||
- name: Upload project artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: ${{ matrix.project_name }}.zip
|
||||
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
||||
@ -121,7 +131,8 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
security-events: write
|
||||
needs: build-artifacts
|
||||
needs:
|
||||
- build-artifacts
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -173,7 +184,9 @@ jobs:
|
||||
dotnet: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Check branch to publish
|
||||
env:
|
||||
@ -213,7 +226,7 @@ jobs:
|
||||
- name: Generate Docker image tag
|
||||
id: tag
|
||||
run: |
|
||||
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
|
||||
else
|
||||
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
|
||||
@ -263,7 +276,7 @@ jobs:
|
||||
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
||||
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
|
||||
with:
|
||||
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
||||
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
||||
@ -275,14 +288,14 @@ jobs:
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4.1.2
|
||||
uses: anchore/scan-action@5ed195cc06065322983cae4bb31e2a751feb86fd # v5.2.0
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
|
||||
uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
|
||||
@ -292,10 +305,12 @@ jobs:
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- 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
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -306,12 +321,12 @@ jobs:
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
|
||||
- name: Make Docker stubs
|
||||
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: |
|
||||
# Set proper setup image based on branch
|
||||
case "${{ github.ref }}" in
|
||||
case "$GITHUB_REF" in
|
||||
"refs/heads/main")
|
||||
SETUP_IMAGE="$_AZ_REGISTRY/setup:dev"
|
||||
;;
|
||||
@ -348,38 +363,48 @@ jobs:
|
||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||
|
||||
- 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: |
|
||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||
|
||||
- name: Upload Docker stub US artifact
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
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
|
||||
with:
|
||||
name: docker-stub-US.zip
|
||||
path: docker-stub-US.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub EU artifact
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
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
|
||||
with:
|
||||
name: docker-stub-EU.zip
|
||||
path: docker-stub-EU.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub US checksum artifact
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
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
|
||||
with:
|
||||
name: docker-stub-US-sha256.txt
|
||||
path: docker-stub-US-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub EU checksum artifact
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
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
|
||||
with:
|
||||
name: docker-stub-EU-sha256.txt
|
||||
path: docker-stub-EU-sha256.txt
|
||||
@ -403,12 +428,12 @@ jobs:
|
||||
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
|
||||
|
||||
- name: Upload Public API Swagger artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: swagger.json
|
||||
path: swagger.json
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
- name: Build Internal API Swagger
|
||||
run: |
|
||||
cd ./src/Api
|
||||
@ -416,17 +441,17 @@ jobs:
|
||||
dotnet tool restore
|
||||
echo "Publish API"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
|
||||
dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
|
||||
./obj/build-output/publish/Api.dll internal
|
||||
|
||||
|
||||
cd ../Identity
|
||||
|
||||
|
||||
echo "Restore Identity tools"
|
||||
dotnet tool restore
|
||||
echo "Publish Identity"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
|
||||
dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
|
||||
./obj/build-output/publish/Identity.dll v1
|
||||
cd ../..
|
||||
@ -437,23 +462,24 @@ jobs:
|
||||
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
|
||||
|
||||
- name: Upload Internal API Swagger artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: internal.json
|
||||
path: internal.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Identity Swagger artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: identity.json
|
||||
path: identity.json
|
||||
if-no-files-found: error
|
||||
if-no-files-found: error
|
||||
|
||||
build-mssqlmigratorutility:
|
||||
name: Build MSSQL migrator utility
|
||||
runs-on: ubuntu-22.04
|
||||
needs: lint
|
||||
needs:
|
||||
- lint
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@ -467,10 +493,12 @@ jobs:
|
||||
- win-x64
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@ -486,7 +514,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact for Windows
|
||||
if: ${{ contains(matrix.target, 'win') == true }}
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
|
||||
@ -494,7 +522,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact
|
||||
if: ${{ contains(matrix.target, 'win') == false }}
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
||||
@ -502,8 +530,10 @@ jobs:
|
||||
|
||||
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
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -528,15 +558,16 @@ jobs:
|
||||
workflow_id: 'build-unified.yml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
server_branch: '${{ github.ref }}'
|
||||
server_branch: process.env.GITHUB_REF
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
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
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -566,6 +597,42 @@ jobs:
|
||||
}
|
||||
})
|
||||
|
||||
trigger-ee-updates:
|
||||
name: Trigger Ephemeral Environment updates
|
||||
if: |
|
||||
github.event_name == 'pull_request_target'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-docker
|
||||
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 update
|
||||
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: '_update_ephemeral_tags.yml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
|
||||
}
|
||||
})
|
||||
|
||||
check-failures:
|
||||
name: Check for failures
|
||||
if: always()
|
||||
@ -581,9 +648,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: |
|
||||
(github.ref == 'refs/heads/main'
|
||||
|| github.ref == 'refs/heads/rc'
|
||||
|| github.ref == 'refs/heads/hotfix-rc')
|
||||
github.event_name != 'pull_request_target'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
|
1
.github/workflows/cleanup-after-pr.yml
vendored
1
.github/workflows/cleanup-after-pr.yml
vendored
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Container registry cleanup
|
||||
|
||||
on:
|
||||
|
59
.github/workflows/cleanup-ephemeral-environment.yml
vendored
Normal file
59
.github/workflows/cleanup-ephemeral-environment.yml
vendored
Normal 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'
|
||||
}
|
||||
})
|
3
.github/workflows/cleanup-rc-branch.yml
vendored
3
.github/workflows/cleanup-rc-branch.yml
vendored
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Cleanup RC Branch
|
||||
|
||||
on:
|
||||
@ -24,7 +23,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
2
.github/workflows/code-references.yml
vendored
2
.github/workflows/code-references.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
|
102
.github/workflows/container-registry-purge.yml
vendored
102
.github/workflows/container-registry-purge.yml
vendored
@ -1,102 +0,0 @@
|
||||
---
|
||||
name: Container registry purge
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * SUN"
|
||||
workflow_dispatch:
|
||||
inputs: {}
|
||||
|
||||
jobs:
|
||||
purge:
|
||||
name: Purge old images
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
- name: Purge images
|
||||
env:
|
||||
REGISTRY: bitwardenprod
|
||||
AGO_DUR_VER: "180d"
|
||||
AGO_DUR: "30d"
|
||||
run: |
|
||||
REPO_LIST=$(az acr repository list -n $REGISTRY -o tsv)
|
||||
for REPO in $REPO_LIST
|
||||
do
|
||||
|
||||
PURGE_LATEST=""
|
||||
PURGE_VERSION=""
|
||||
PURGE_ELSE=""
|
||||
|
||||
TAG_LIST=$(az acr repository show-tags -n $REGISTRY --repository $REPO -o tsv)
|
||||
for TAG in $TAG_LIST
|
||||
do
|
||||
if [ $TAG = "latest" ] || [ $TAG = "dev" ]; then
|
||||
PURGE_LATEST+="--filter '$REPO:$TAG' "
|
||||
elif [[ $TAG =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
PURGE_VERSION+="--filter '$REPO:$TAG' "
|
||||
else
|
||||
PURGE_ELSE+="--filter '$REPO:$TAG' "
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -z "$PURGE_LATEST" ]
|
||||
then
|
||||
PURGE_LATEST_CMD="acr purge $PURGE_LATEST --ago $AGO_DUR_VER --untagged --keep 1"
|
||||
az acr run --cmd "$PURGE_LATEST_CMD" --registry $REGISTRY /dev/null &
|
||||
fi
|
||||
|
||||
if [ ! -z "$PURGE_VERSION" ]
|
||||
then
|
||||
PURGE_VERSION_CMD="acr purge $PURGE_VERSION --ago $AGO_DUR_VER --untagged"
|
||||
az acr run --cmd "$PURGE_VERSION_CMD" --registry $REGISTRY /dev/null &
|
||||
fi
|
||||
|
||||
if [ ! -z "$PURGE_ELSE" ]
|
||||
then
|
||||
PURGE_ELSE_CMD="acr purge $PURGE_ELSE --ago $AGO_DUR --untagged"
|
||||
az acr run --cmd "$PURGE_ELSE_CMD" --registry $REGISTRY /dev/null &
|
||||
fi
|
||||
|
||||
wait
|
||||
|
||||
done
|
||||
|
||||
check-failures:
|
||||
name: Check for failures
|
||||
if: always()
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [purge]
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: |
|
||||
(github.ref == 'refs/heads/main'
|
||||
|| github.ref == 'refs/heads/rc'
|
||||
|| github.ref == 'refs/heads/hotfix-rc')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
if: failure()
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
if: failure()
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
if: failure()
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
with:
|
||||
status: ${{ job.status }}
|
7
.github/workflows/enforce-labels.yml
vendored
7
.github/workflows/enforce-labels.yml
vendored
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Enforce PR labels
|
||||
|
||||
on:
|
||||
@ -7,13 +6,13 @@ on:
|
||||
types: [labeled, unlabeled, opened, reopened, synchronize]
|
||||
jobs:
|
||||
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
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Check for label
|
||||
run: |
|
||||
echo "PRs with the hold or needs-qa labels cannot be merged"
|
||||
echo "### :x: PRs with the hold or needs-qa labels cannot be merged" >> $GITHUB_STEP_SUMMARY
|
||||
echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged"
|
||||
echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
|
3
.github/workflows/protect-files.yml
vendored
3
.github/workflows/protect-files.yml
vendored
@ -1,7 +1,6 @@
|
||||
# Runs if there are changes to the paths: list.
|
||||
# Starts a matrix job to check for modified files, then sets output based on the results.
|
||||
# The input decides if the label job is ran, adding a label to the PR.
|
||||
---
|
||||
name: Protect files
|
||||
|
||||
on:
|
||||
@ -29,7 +28,7 @@ jobs:
|
||||
label: "DB-migrations-changed"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
|
181
.github/workflows/publish.yml
vendored
Normal file
181
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,181 @@
|
||||
name: Publish
|
||||
run-name: Publish ${{ inputs.publish_type }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_type:
|
||||
description: "Publish Options"
|
||||
required: true
|
||||
default: "Initial Publish"
|
||||
type: choice
|
||||
options:
|
||||
- Initial Publish
|
||||
- Redeploy
|
||||
- Dry Run
|
||||
version:
|
||||
description: 'Version to publish (default: latest release)'
|
||||
required: true
|
||||
type: string
|
||||
default: latest
|
||||
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
branch-name: ${{ steps.branch.outputs.branch-name }}
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
release-version: ${{ steps.version-output.outputs.version }}
|
||||
steps:
|
||||
- name: Version output
|
||||
id: version-output
|
||||
run: |
|
||||
if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then
|
||||
VERSION=$(curl "https://api.github.com/repos/bitwarden/server/releases" | jq -c '.[] | select(.tag_name) | .tag_name' | head -1 | grep -ohE '20[0-9]{2}\.([1-9]|1[0-2])\.[0-9]+')
|
||||
echo "Latest Released Version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Release Version: ${{ inputs.version }}"
|
||||
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get branch name
|
||||
id: branch
|
||||
run: |
|
||||
BRANCH_NAME=$(basename ${{ github.ref }})
|
||||
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub deployment
|
||||
uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7
|
||||
id: deployment
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
initial-status: 'in_progress'
|
||||
environment: 'production'
|
||||
description: 'Deployment ${{ steps.version-output.outputs.release-version }} from branch ${{ github.ref_name }}'
|
||||
task: release
|
||||
|
||||
publish-docker:
|
||||
name: Publish Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
env:
|
||||
_RELEASE_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_BRANCH_NAME: ${{ needs.setup.outputs.branch-name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- project_name: Admin
|
||||
- project_name: Api
|
||||
- project_name: Attachments
|
||||
- project_name: Billing
|
||||
- project_name: Events
|
||||
- project_name: EventsProcessor
|
||||
- project_name: Icons
|
||||
- project_name: Identity
|
||||
- project_name: MsSql
|
||||
- project_name: MsSqlMigratorUtility
|
||||
- project_name: Nginx
|
||||
- project_name: Notifications
|
||||
- project_name: Scim
|
||||
- project_name: Server
|
||||
- project_name: Setup
|
||||
- project_name: Sso
|
||||
steps:
|
||||
- name: Print environment
|
||||
env:
|
||||
RELEASE_OPTION: ${{ inputs.publish_type }}
|
||||
run: |
|
||||
whoami
|
||||
docker --version
|
||||
echo "GitHub ref: $GITHUB_REF"
|
||||
echo "GitHub event: $GITHUB_EVENT"
|
||||
echo "Github Release Option: $RELEASE_OPTION"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up project name
|
||||
id: setup
|
||||
run: |
|
||||
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
|
||||
echo "Matrix name: ${{ matrix.project_name }}"
|
||||
echo "PROJECT_NAME: $PROJECT_NAME"
|
||||
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
########## ACR PROD ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
- name: Log in to Azure ACR
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
|
||||
- name: Pull latest project image
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
|
||||
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest
|
||||
else
|
||||
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
|
||||
fi
|
||||
|
||||
- name: Tag version and latest
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun
|
||||
else
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:latest
|
||||
fi
|
||||
|
||||
- name: Push version and latest image
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun
|
||||
else
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:latest
|
||||
fi
|
||||
|
||||
- name: Log out of Docker
|
||||
run: docker logout
|
||||
|
||||
update-deployment:
|
||||
name: Update Deployment Status
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- setup
|
||||
- publish-docker
|
||||
if: ${{ always() && inputs.publish_type != 'Dry Run' }}
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Update deployment status to Success
|
||||
if: ${{ inputs.publish_type != 'Dry Run' && success() }}
|
||||
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
state: 'success'
|
||||
deployment-id: ${{ needs.setup.outputs.deployment-id }}
|
||||
|
||||
- name: Update deployment status to Failure
|
||||
if: ${{ inputs.publish_type != 'Dry Run' && failure() }}
|
||||
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
state: 'failure'
|
||||
deployment-id: ${{ needs.setup.outputs.deployment-id }}
|
96
.github/workflows/release.yml
vendored
96
.github/workflows/release.yml
vendored
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Release
|
||||
run-name: Release ${{ inputs.release_type }}
|
||||
|
||||
@ -37,7 +36,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Check release version
|
||||
id: version
|
||||
@ -53,99 +52,6 @@ jobs:
|
||||
BRANCH_NAME=$(basename ${{ github.ref }})
|
||||
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
release-docker:
|
||||
name: Build Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
env:
|
||||
_RELEASE_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_BRANCH_NAME: ${{ needs.setup.outputs.branch-name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- project_name: Admin
|
||||
- project_name: Api
|
||||
- project_name: Attachments
|
||||
- project_name: Billing
|
||||
- project_name: Events
|
||||
- project_name: EventsProcessor
|
||||
- project_name: Icons
|
||||
- project_name: Identity
|
||||
- project_name: MsSql
|
||||
- project_name: MsSqlMigratorUtility
|
||||
- project_name: Nginx
|
||||
- project_name: Notifications
|
||||
- project_name: Scim
|
||||
- project_name: Server
|
||||
- project_name: Setup
|
||||
- project_name: Sso
|
||||
steps:
|
||||
- name: Print environment
|
||||
env:
|
||||
RELEASE_OPTION: ${{ inputs.release_type }}
|
||||
run: |
|
||||
whoami
|
||||
docker --version
|
||||
echo "GitHub ref: $GITHUB_REF"
|
||||
echo "GitHub event: $GITHUB_EVENT"
|
||||
echo "Github Release Option: $RELEASE_OPTION"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up project name
|
||||
id: setup
|
||||
run: |
|
||||
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
|
||||
echo "Matrix name: ${{ matrix.project_name }}"
|
||||
echo "PROJECT_NAME: $PROJECT_NAME"
|
||||
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
########## ACR PROD ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
- name: Log in to Azure ACR
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
|
||||
- name: Pull latest project image
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest
|
||||
else
|
||||
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
|
||||
fi
|
||||
|
||||
- name: Tag version and latest
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun
|
||||
else
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:latest
|
||||
fi
|
||||
|
||||
- name: Push version and latest image
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun
|
||||
else
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:latest
|
||||
fi
|
||||
|
||||
- name: Log out of Docker
|
||||
run: docker logout
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-22.04
|
||||
|
278
.github/workflows/repository-management.yml
vendored
Normal file
278
.github/workflows/repository-management.yml
vendored
Normal file
@ -0,0 +1,278 @@
|
||||
name: Repository management
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
task:
|
||||
default: "Version Bump"
|
||||
description: "Task to execute"
|
||||
options:
|
||||
- "Version Bump"
|
||||
- "Version Bump and Cut rc"
|
||||
- "Version Bump and Cut hotfix-rc"
|
||||
required: true
|
||||
type: choice
|
||||
target_ref:
|
||||
default: "main"
|
||||
description: "Branch/Tag to target for cut"
|
||||
required: true
|
||||
type: string
|
||||
version_number_override:
|
||||
description: "New version override (leave blank for automatic calculation, example: '2024.1.0')"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
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:
|
||||
name: Cut branch
|
||||
if: ${{ needs.setup.outputs.branch != 'none' }}
|
||||
needs: setup
|
||||
runs-on: ubuntu-24.04
|
||||
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
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
|
||||
env:
|
||||
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||
run: |
|
||||
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
|
||||
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cut branch
|
||||
env:
|
||||
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||
run: |
|
||||
git switch --quiet --create $BRANCH_NAME
|
||||
git push --quiet --set-upstream origin $BRANCH_NAME
|
||||
|
||||
|
||||
bump_version:
|
||||
name: Bump Version
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- cut_branch
|
||||
- setup
|
||||
outputs:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
steps:
|
||||
- name: Validate version input format
|
||||
if: ${{ inputs.version_number_override != '' }}
|
||||
uses: bitwarden/gh-actions/version-check@main
|
||||
with:
|
||||
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
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-utils
|
||||
|
||||
- name: Get current version
|
||||
id: current-version
|
||||
run: |
|
||||
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
||||
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify input version
|
||||
if: ${{ inputs.version_number_override != '' }}
|
||||
env:
|
||||
CURRENT_VERSION: ${{ steps.current-version.outputs.version }}
|
||||
NEW_VERSION: ${{ inputs.version_number_override }}
|
||||
run: |
|
||||
# Error if version has not changed.
|
||||
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
|
||||
echo "Specified override version is the same as the current version." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if version is newer.
|
||||
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Version is newer than the current version."
|
||||
else
|
||||
echo "Version is older than the current version." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Calculate next release version
|
||||
if: ${{ inputs.version_number_override == '' }}
|
||||
id: calculate-next-version
|
||||
uses: bitwarden/gh-actions/version-next@main
|
||||
with:
|
||||
version: ${{ steps.current-version.outputs.version }}
|
||||
|
||||
- name: Bump version props - Version Override
|
||||
if: ${{ inputs.version_number_override != '' }}
|
||||
id: bump-version-override
|
||||
uses: bitwarden/gh-actions/version-bump@main
|
||||
with:
|
||||
file_path: "Directory.Build.props"
|
||||
version: ${{ inputs.version_number_override }}
|
||||
|
||||
- name: Bump version props - Automatic Calculation
|
||||
if: ${{ inputs.version_number_override == '' }}
|
||||
id: bump-version-automatic
|
||||
uses: bitwarden/gh-actions/version-bump@main
|
||||
with:
|
||||
file_path: "Directory.Build.props"
|
||||
version: ${{ steps.calculate-next-version.outputs.version }}
|
||||
|
||||
- name: Set final version output
|
||||
id: set-final-version-output
|
||||
env:
|
||||
VERSION: ${{ inputs.version_number_override }}
|
||||
run: |
|
||||
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
|
||||
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit files
|
||||
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
|
||||
|
||||
- name: Push changes
|
||||
run: git push
|
||||
|
||||
|
||||
cherry_pick:
|
||||
name: Cherry-Pick Commit(s)
|
||||
if: ${{ needs.setup.outputs.branch != 'none' }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- bump_version
|
||||
- setup
|
||||
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
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-utils
|
||||
|
||||
- name: Perform cherry-pick(s)
|
||||
env:
|
||||
CUT_BRANCH: ${{ needs.setup.outputs.branch }}
|
||||
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 [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
|
||||
|
||||
# If the 'rc' branch exists:
|
||||
if [[ $(git ls-remote --heads origin rc) ]]; then
|
||||
|
||||
# Chery-pick from 'rc' into 'hotfix-rc'
|
||||
cherry_pick rc hotfix-rc
|
||||
|
||||
# Cherry-pick from 'main' into 'rc'
|
||||
cherry_pick main rc
|
||||
|
||||
# If the 'rc' branch does not exist:
|
||||
else
|
||||
|
||||
# Cherry-pick from 'main' into 'hotfix-rc'
|
||||
cherry_pick main hotfix-rc
|
||||
|
||||
fi
|
||||
|
||||
# If we are cutting 'rc':
|
||||
elif [[ "$CUT_BRANCH" == "rc" ]]; then
|
||||
|
||||
# Cherry-pick from 'main' into 'rc'
|
||||
cherry_pick main rc
|
||||
|
||||
fi
|
||||
|
||||
|
||||
move_future_db_scripts:
|
||||
name: Move finalization database scripts
|
||||
needs: cherry_pick
|
||||
uses: ./.github/workflows/_move_finalization_db_scripts.yml
|
||||
secrets: inherit
|
12
.github/workflows/scan.yml
vendored
12
.github/workflows/scan.yml
vendored
@ -26,12 +26,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with Checkmarx
|
||||
uses: checkmarx/ast-github-action@1fe318de2993222574e6249750ba9000a4e2a6cd # 2.0.33
|
||||
uses: checkmarx/ast-github-action@f0869bd1a37fddc06499a096101e6c900e815d81 # 2.0.36
|
||||
env:
|
||||
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
|
||||
with:
|
||||
@ -46,7 +46,7 @@ jobs:
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
|
||||
uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
|
||||
@ -60,19 +60,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
|
||||
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: "zulu"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
|
||||
- name: Install SonarCloud scanner
|
||||
run: dotnet tool install dotnet-sonarscanner -g
|
||||
|
1
.github/workflows/stale-bot.yml
vendored
1
.github/workflows/stale-bot.yml
vendored
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Staleness
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
66
.github/workflows/test-database.yml
vendored
66
.github/workflows/test-database.yml
vendored
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Database testing
|
||||
|
||||
on:
|
||||
@ -31,15 +30,34 @@ on:
|
||||
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
|
||||
|
||||
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:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-22.04
|
||||
needs: check-test-secrets
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
|
||||
- name: Restore tools
|
||||
run: dotnet tool restore
|
||||
@ -52,10 +70,15 @@ jobs:
|
||||
docker compose --profile mssql --profile postgres --profile mysql up -d
|
||||
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
|
||||
- name: Sleep
|
||||
run: sleep 15s
|
||||
|
||||
|
||||
- name: Checking pending model changes (MySQL)
|
||||
working-directory: "util/MySqlMigrations"
|
||||
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
|
||||
@ -84,6 +107,12 @@ jobs:
|
||||
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
|
||||
env:
|
||||
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
|
||||
working-directory: "util/PostgresMigrations"
|
||||
@ -112,12 +141,31 @@ jobs:
|
||||
# Default Sqlite
|
||||
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
||||
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"
|
||||
shell: pwsh
|
||||
|
||||
- name: Print MySQL Logs
|
||||
if: failure()
|
||||
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
|
||||
if: failure()
|
||||
run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
|
||||
|
||||
- name: Print MSSQL Logs
|
||||
if: failure()
|
||||
run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||
if: always()
|
||||
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
path: "**/*-test-results.trx"
|
||||
@ -135,10 +183,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@ -152,7 +200,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload DACPAC
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: sql.dacpac
|
||||
path: Sql.dacpac
|
||||
@ -178,7 +226,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Report validation results
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: report.xml
|
||||
path: |
|
||||
|
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@ -46,10 +46,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@ -77,7 +77,7 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0
|
||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0
|
||||
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
259
.github/workflows/version-bump.yml
vendored
259
.github/workflows/version-bump.yml
vendored
@ -1,259 +0,0 @@
|
||||
---
|
||||
name: Version Bump
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_number_override:
|
||||
description: "New version override (leave blank for automatic calculation, example: '2024.1.0')"
|
||||
required: false
|
||||
type: string
|
||||
cut_rc_branch:
|
||||
description: "Cut RC branch?"
|
||||
default: true
|
||||
type: boolean
|
||||
enable_slack_notification:
|
||||
description: "Enable Slack notifications for upcoming release?"
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
bump_version:
|
||||
name: Bump Version
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
steps:
|
||||
- name: Validate version input
|
||||
if: ${{ inputs.version_number_override != '' }}
|
||||
uses: bitwarden/gh-actions/version-check@main
|
||||
with:
|
||||
version: ${{ inputs.version_number_override }}
|
||||
|
||||
- name: Slack Notification Check
|
||||
run: |
|
||||
if [[ "${{ inputs.enable_slack_notification }}" == true ]]; then
|
||||
echo "Slack notifications enabled."
|
||||
else
|
||||
echo "Slack notifications disabled."
|
||||
fi
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Check if RC branch exists
|
||||
if: ${{ inputs.cut_rc_branch == true }}
|
||||
run: |
|
||||
remote_rc_branch_check=$(git ls-remote --heads origin rc | wc -l)
|
||||
if [[ "${remote_rc_branch_check}" -gt 0 ]]; then
|
||||
echo "Remote RC branch exists."
|
||||
echo "Please delete current RC branch before running again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-gpg-private-key,
|
||||
github-gpg-private-key-passphrase,
|
||||
github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Import GPG key
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0
|
||||
with:
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
git_user_signingkey: true
|
||||
git_commit_gpgsign: true
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
|
||||
git config --local user.name "bitwarden-devops-bot"
|
||||
|
||||
- name: Create version branch
|
||||
id: create-branch
|
||||
run: |
|
||||
NAME=version_bump_${{ github.ref_name }}_$(date +"%Y-%m-%d")
|
||||
git switch -c $NAME
|
||||
echo "name=$NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install xmllint
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-utils
|
||||
|
||||
- name: Get current version
|
||||
id: current-version
|
||||
run: |
|
||||
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
||||
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify input version
|
||||
if: ${{ inputs.version_number_override != '' }}
|
||||
env:
|
||||
CURRENT_VERSION: ${{ steps.current-version.outputs.version }}
|
||||
NEW_VERSION: ${{ inputs.version_number_override }}
|
||||
run: |
|
||||
# Error if version has not changed.
|
||||
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
|
||||
echo "Version has not changed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if version is newer.
|
||||
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Version check successful."
|
||||
else
|
||||
echo "Version check failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Calculate next release version
|
||||
if: ${{ inputs.version_number_override == '' }}
|
||||
id: calculate-next-version
|
||||
uses: bitwarden/gh-actions/version-next@main
|
||||
with:
|
||||
version: ${{ steps.current-version.outputs.version }}
|
||||
|
||||
- name: Bump version props - Version Override
|
||||
if: ${{ inputs.version_number_override != '' }}
|
||||
id: bump-version-override
|
||||
uses: bitwarden/gh-actions/version-bump@main
|
||||
with:
|
||||
file_path: "Directory.Build.props"
|
||||
version: ${{ inputs.version_number_override }}
|
||||
|
||||
- name: Bump version props - Automatic Calculation
|
||||
if: ${{ inputs.version_number_override == '' }}
|
||||
id: bump-version-automatic
|
||||
uses: bitwarden/gh-actions/version-bump@main
|
||||
with:
|
||||
file_path: "Directory.Build.props"
|
||||
version: ${{ steps.calculate-next-version.outputs.version }}
|
||||
|
||||
- name: Set final version output
|
||||
id: set-final-version-output
|
||||
run: |
|
||||
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
|
||||
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
|
||||
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if version changed
|
||||
id: version-changed
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "changes_to_commit=TRUE" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changes_to_commit=FALSE" >> $GITHUB_OUTPUT
|
||||
echo "No changes to commit!";
|
||||
fi
|
||||
|
||||
- name: Commit files
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
|
||||
|
||||
- name: Push changes
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
|
||||
run: git push -u origin $PR_BRANCH
|
||||
|
||||
- name: Create version PR
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
id: create-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
|
||||
TITLE: "Bump version to ${{ steps.set-final-version-output.outputs.version }}"
|
||||
run: |
|
||||
PR_URL=$(gh pr create --title "$TITLE" \
|
||||
--base "main" \
|
||||
--head "$PR_BRANCH" \
|
||||
--label "version update" \
|
||||
--label "automated pr" \
|
||||
--body "
|
||||
## Type of change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature development
|
||||
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
|
||||
- [ ] Build/deploy pipeline (DevOps)
|
||||
- [X] Other
|
||||
|
||||
## Objective
|
||||
Automated version bump to ${{ steps.set-final-version-output.outputs.version }}")
|
||||
echo "pr_number=${PR_URL##*/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Approve PR
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
|
||||
run: gh pr review $PR_NUMBER --approve
|
||||
|
||||
- name: Merge PR
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
|
||||
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
|
||||
|
||||
- name: Report upcoming release version to Slack
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' && inputs.enable_slack_notification == true }}
|
||||
uses: bitwarden/gh-actions/report-upcoming-release-version@main
|
||||
with:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
project: ${{ github.repository }}
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
cut_rc:
|
||||
name: Cut RC branch
|
||||
if: ${{ inputs.cut_rc_branch == true }}
|
||||
needs: bump_version
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Install xmllint
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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: Cut RC branch
|
||||
run: |
|
||||
git switch --quiet --create rc
|
||||
git push --quiet --set-upstream origin rc
|
||||
|
||||
move-future-db-scripts:
|
||||
name: Move finalization database scripts
|
||||
needs: cut_rc
|
||||
uses: ./.github/workflows/_move_finalization_db_scripts.yml
|
||||
secrets: inherit
|
17
.vscode/launch.json
vendored
17
.vscode/launch.json
vendored
@ -33,6 +33,21 @@
|
||||
"preLaunchTask": "buildIdentityApiAdmin",
|
||||
"stopAll": true
|
||||
},
|
||||
{
|
||||
"name": "API, Identity, SSO",
|
||||
"configurations": [
|
||||
"run-API",
|
||||
"run-Identity",
|
||||
"run-Sso"
|
||||
],
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "AA_compounds",
|
||||
"order": 4
|
||||
},
|
||||
"preLaunchTask": "buildIdentityApiSso",
|
||||
"stopAll": true
|
||||
},
|
||||
{
|
||||
"name": "Full Server",
|
||||
"configurations": [
|
||||
@ -49,7 +64,7 @@
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "AA_compounds",
|
||||
"order": 4
|
||||
"order": 5
|
||||
},
|
||||
"preLaunchTask": "buildFullServer",
|
||||
"stopAll": true
|
||||
|
13
.vscode/tasks.json
vendored
13
.vscode/tasks.json
vendored
@ -26,6 +26,19 @@
|
||||
"$msCompile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "buildIdentityApiSso",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildIdentity",
|
||||
"buildAPI",
|
||||
"buildSso"
|
||||
],
|
||||
"problemMatcher": [
|
||||
"$msCompile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "buildFullServer",
|
||||
"hide": true,
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2024.8.1</Version>
|
||||
<Version>2024.11.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.29102.190
|
||||
@ -124,6 +124,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventsProcessor.Test", "tes
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notifications.Test", "test\Notifications.Test\Notifications.Test.csproj", "{90D85D8F-5577-4570-A96E-5A2E185F0F6F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -308,6 +310,10 @@ Global
|
||||
{90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@ -357,6 +363,7 @@ Global
|
||||
{916AFD8C-30AF-49B6-A5C9-28CA1B5D9298} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||
|
@ -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.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -10,7 +9,6 @@ using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||
|
||||
@ -21,25 +19,43 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public CreateProviderCommand(
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IProviderService providerService,
|
||||
IUserRepository userRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IFeatureService featureService)
|
||||
IProviderPlanRepository providerPlanRepository)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_providerService = providerService;
|
||||
_userRepository = userRepository;
|
||||
_providerPlanRepository = providerPlanRepository;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
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);
|
||||
if (owner == null)
|
||||
@ -47,12 +63,7 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
|
||||
}
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
provider.Gateway = GatewayType.Stripe;
|
||||
}
|
||||
provider.Gateway = GatewayType.Stripe;
|
||||
|
||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
|
||||
|
||||
@ -64,27 +75,10 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
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 _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
|
||||
}
|
||||
|
||||
public async Task CreateResellerAsync(Provider provider)
|
||||
{
|
||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
||||
return provider.Id;
|
||||
}
|
||||
|
||||
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
|
||||
@ -95,9 +89,9 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
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,
|
||||
PlanType = planType,
|
||||
@ -105,5 +99,6 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
};
|
||||
await _providerPlanRepository.CreateAsync(plan);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
@ -27,6 +26,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
||||
|
||||
public RemoveOrganizationFromProviderCommand(
|
||||
IEventService eventService,
|
||||
@ -37,7 +37,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
IStripeAdapter stripeAdapter,
|
||||
IFeatureService featureService,
|
||||
IProviderBillingService providerBillingService,
|
||||
ISubscriberService subscriberService)
|
||||
ISubscriberService subscriberService,
|
||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
|
||||
{
|
||||
_eventService = eventService;
|
||||
_mailService = mailService;
|
||||
@ -48,6 +49,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
_featureService = featureService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_subscriberService = subscriberService;
|
||||
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
||||
}
|
||||
|
||||
public async Task RemoveOrganizationFromProvider(
|
||||
@ -63,7 +65,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
throw new BadRequestException("Failed to remove organization. Please contact support.");
|
||||
}
|
||||
|
||||
if (!await _organizationService.HasConfirmedOwnersExceptAsync(
|
||||
if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
includeProvider: false))
|
||||
@ -98,11 +100,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
Provider provider,
|
||||
IEnumerable<string> organizationOwnerEmails)
|
||||
{
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled &&
|
||||
provider.Status == ProviderStatusType.Billable &&
|
||||
organization.Status == OrganizationStatusType.Managed &&
|
||||
if (provider.IsBillable() &&
|
||||
organization.IsValidClient() &&
|
||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
@ -155,7 +154,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
DaysUntilDue = 30
|
||||
});
|
||||
|
||||
await _subscriberService.RemovePaymentMethod(organization);
|
||||
await _subscriberService.RemovePaymentSource(organization);
|
||||
}
|
||||
|
||||
await _mailService.SendProviderUpdatePaymentMethod(
|
||||
|
@ -8,7 +8,6 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
@ -101,24 +100,16 @@ public class ProviderService : IProviderService
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
|
||||
providerUser.Key = key;
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
@ -392,7 +383,9 @@ public class ProviderService : IProviderService
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
ThrowOnInvalidPlanType(organization.PlanType);
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
ThrowOnInvalidPlanType(provider.Type, organization.PlanType);
|
||||
|
||||
if (organization.UseSecretsManager)
|
||||
{
|
||||
@ -407,8 +400,6 @@ public class ProviderService : IProviderService
|
||||
Key = key,
|
||||
};
|
||||
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
await ApplyProviderPriceRateAsync(organization, provider);
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
|
||||
@ -545,13 +536,9 @@ public class ProviderService : IProviderService
|
||||
{
|
||||
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) = consolidatedBillingEnabled
|
||||
? await _organizationService.SignupClientAsync(organizationSignup)
|
||||
: await _organizationService.SignUpAsync(organizationSignup, true);
|
||||
var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
@ -687,11 +674,24 @@ public class ProviderService : IProviderService
|
||||
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))
|
||||
|
@ -2,16 +2,14 @@
|
||||
using Bit.Commercial.Core.Billing.Models;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -26,7 +24,6 @@ using Stripe;
|
||||
namespace Bit.Commercial.Core.Billing;
|
||||
|
||||
public class ProviderBillingService(
|
||||
ICurrentContext currentContext,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -34,38 +31,76 @@ public class ProviderBillingService(
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IProviderBillingService
|
||||
{
|
||||
public async Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats)
|
||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||
|
||||
if (seats < 0)
|
||||
if (plan == null)
|
||||
{
|
||||
throw new BillingException(
|
||||
"You cannot assign negative seats to a client.",
|
||||
"MSP cannot assign negative seats to a client organization");
|
||||
throw new BadRequestException("Provider plan not found.");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
@ -170,72 +205,16 @@ public class ProviderBillingService(
|
||||
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(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||
|
||||
if (provider.Type != ProviderType.Msp)
|
||||
{
|
||||
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id);
|
||||
var seatMinimum = providerPlan.SeatMinimum ?? 0;
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
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 currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||
|
||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||
|
||||
@ -262,13 +241,6 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal <= 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(
|
||||
seatMinimum,
|
||||
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(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
@ -379,42 +371,23 @@ public class ProviderBillingService(
|
||||
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||
|
||||
var teamsProviderPlan =
|
||||
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
||||
foreach (var providerPlan in providerPlans)
|
||||
{
|
||||
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
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
@ -456,144 +429,90 @@ public class ProviderBillingService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateSeatMinimums(
|
||||
Provider provider,
|
||||
int enterpriseSeatMinimum,
|
||||
int teamsSeatMinimum)
|
||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0)
|
||||
if (command.Configuration.Any(x => x.SeatsMinimum < 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 providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
|
||||
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
|
||||
foreach (var newPlanConfiguration in command.Configuration)
|
||||
{
|
||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager
|
||||
.StripeProviderPortalSeatPlanId;
|
||||
var providerPlan =
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
|
||||
|
||||
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId);
|
||||
|
||||
if (enterpriseProviderPlan.PurchasedSeats == 0)
|
||||
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
|
||||
{
|
||||
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum)
|
||||
{
|
||||
enterpriseProviderPlan.PurchasedSeats =
|
||||
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
|
||||
var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager
|
||||
.StripeProviderPortalSeatPlanId;
|
||||
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
if (providerPlan.PurchasedSeats == 0)
|
||||
{
|
||||
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
|
||||
{
|
||||
Id = enterpriseSubscriptionItem.Id,
|
||||
Price = enterprisePriceId,
|
||||
Quantity = enterpriseProviderPlan.AllocatedSeats
|
||||
});
|
||||
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
|
||||
|
||||
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
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
||||
|
||||
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
|
||||
{
|
||||
Id = enterpriseSubscriptionItem.Id,
|
||||
Price = enterprisePriceId,
|
||||
Quantity = enterpriseSeatMinimum
|
||||
});
|
||||
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
|
||||
}
|
||||
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)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||
}
|
||||
}
|
||||
@ -620,4 +539,32 @@ public class ProviderBillingService(
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -28,16 +28,16 @@ public class CreateProjectCommand : ICreateProjectCommand
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
public async Task<Project> CreateAsync(Project project, Guid id, ClientType clientType)
|
||||
public async Task<Project> CreateAsync(Project project, Guid id, IdentityClientType identityClientType)
|
||||
{
|
||||
if (clientType != ClientType.User && clientType != ClientType.ServiceAccount)
|
||||
if (identityClientType != IdentityClientType.User && identityClientType != IdentityClientType.ServiceAccount)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var createdProject = await _projectRepository.CreateAsync(project);
|
||||
|
||||
if (clientType == ClientType.User)
|
||||
if (identityClientType == IdentityClientType.User)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(createdProject.OrganizationId, id);
|
||||
|
||||
@ -52,7 +52,7 @@ public class CreateProjectCommand : ICreateProjectCommand
|
||||
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> { accessPolicy });
|
||||
|
||||
}
|
||||
else if (clientType == ClientType.ServiceAccount)
|
||||
else if (identityClientType == IdentityClientType.ServiceAccount)
|
||||
{
|
||||
var serviceAccountProjectAccessPolicy = new ServiceAccountProjectAccessPolicy()
|
||||
{
|
||||
|
@ -21,7 +21,7 @@ public class AccessClientQuery : IAccessClientQuery
|
||||
ClaimsPrincipal claimsPrincipal, Guid organizationId)
|
||||
{
|
||||
var orgAdmin = await _currentContext.OrganizationAdmin(organizationId);
|
||||
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
|
||||
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
|
||||
var userId = _userService.GetProperUserId(claimsPrincipal).Value;
|
||||
return (accessClient, userId);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Scim.Context;
|
||||
|
||||
@ -11,6 +12,32 @@ public class ScimContext : IScimContext
|
||||
{
|
||||
private bool _builtHttpContext;
|
||||
|
||||
// See IP list from Ping in docs: https://support.pingidentity.com/s/article/PingOne-IP-Addresses
|
||||
private static readonly HashSet<string> _pingIpAddresses =
|
||||
[
|
||||
"18.217.152.87",
|
||||
"52.14.10.143",
|
||||
"13.58.49.148",
|
||||
"34.211.92.81",
|
||||
"54.214.158.219",
|
||||
"34.218.98.164",
|
||||
"15.223.133.47",
|
||||
"3.97.84.38",
|
||||
"15.223.19.71",
|
||||
"3.97.98.120",
|
||||
"52.60.115.173",
|
||||
"3.97.202.223",
|
||||
"18.184.65.93",
|
||||
"52.57.244.92",
|
||||
"18.195.7.252",
|
||||
"108.128.67.71",
|
||||
"34.246.158.102",
|
||||
"108.128.250.27",
|
||||
"52.63.103.92",
|
||||
"13.54.131.18",
|
||||
"52.62.204.36"
|
||||
];
|
||||
|
||||
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
|
||||
public ScimConfig ScimConfiguration { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
@ -55,10 +82,18 @@ public class ScimContext : IScimContext
|
||||
RequestScimProvider = ScimProviderType.Okta;
|
||||
}
|
||||
}
|
||||
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
httpContext.Request.Headers.ContainsKey("Adscimversion"))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.AzureAd;
|
||||
}
|
||||
|
||||
var ipAddress = CoreHelpers.GetIpAddress(httpContext, globalSettings);
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
_pingIpAddresses.Contains(ipAddress))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.Ping;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,30 +17,27 @@ namespace Bit.Scim.Controllers.v2;
|
||||
[ExceptionHandlerFilter]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IGetUsersListQuery _getUsersListQuery;
|
||||
private readonly IDeleteOrganizationUserCommand _deleteOrganizationUserCommand;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IPatchUserCommand _patchUserCommand;
|
||||
private readonly IPostUserCommand _postUserCommand;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
|
||||
public UsersController(
|
||||
IUserService userService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IGetUsersListQuery getUsersListQuery,
|
||||
IDeleteOrganizationUserCommand deleteOrganizationUserCommand,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IPatchUserCommand patchUserCommand,
|
||||
IPostUserCommand postUserCommand,
|
||||
ILogger<UsersController> logger)
|
||||
{
|
||||
_userService = userService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_getUsersListQuery = getUsersListQuery;
|
||||
_deleteOrganizationUserCommand = deleteOrganizationUserCommand;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_patchUserCommand = patchUserCommand;
|
||||
_postUserCommand = postUserCommand;
|
||||
_logger = logger;
|
||||
@ -60,17 +57,15 @@ public class UsersController : Controller
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get(
|
||||
Guid organizationId,
|
||||
[FromQuery] string filter,
|
||||
[FromQuery] int? count,
|
||||
[FromQuery] int? startIndex)
|
||||
[FromQuery] GetUsersQueryParamModel model)
|
||||
{
|
||||
var usersListQueryResult = await _getUsersListQuery.GetUsersListAsync(organizationId, filter, count, startIndex);
|
||||
var usersListQueryResult = await _getUsersListQuery.GetUsersListAsync(organizationId, model);
|
||||
var scimListResponseModel = new ScimListResponseModel<ScimUserResponseModel>
|
||||
{
|
||||
Resources = usersListQueryResult.userList.Select(u => new ScimUserResponseModel(u)).ToList(),
|
||||
ItemsPerPage = count.GetValueOrDefault(usersListQueryResult.userList.Count()),
|
||||
ItemsPerPage = model.Count,
|
||||
TotalResults = usersListQueryResult.totalResults,
|
||||
StartIndex = startIndex.GetValueOrDefault(1),
|
||||
StartIndex = model.StartIndex,
|
||||
};
|
||||
return Ok(scimListResponseModel);
|
||||
}
|
||||
@ -98,7 +93,7 @@ public class UsersController : Controller
|
||||
|
||||
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM, _userService);
|
||||
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
@ -120,7 +115,7 @@ public class UsersController : Controller
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
|
||||
{
|
||||
await _deleteOrganizationUserCommand.DeleteUserAsync(organizationId, id, EventSystemUser.SCIM);
|
||||
await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, id, EventSystemUser.SCIM);
|
||||
return new NoContentResult();
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000
|
||||
|
@ -43,7 +43,8 @@ public class PutGroupCommand : IPutGroupCommand
|
||||
|
||||
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
|
||||
{
|
||||
if (_scimContext.RequestScimProvider != ScimProviderType.Okta)
|
||||
if (_scimContext.RequestScimProvider != ScimProviderType.Okta &&
|
||||
_scimContext.RequestScimProvider != ScimProviderType.Ping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
12
bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs
Normal file
12
bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public class GetUsersQueryParamModel
|
||||
{
|
||||
public string Filter { get; init; } = string.Empty;
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int Count { get; init; } = 50;
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int StartIndex { get; init; } = 1;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.SecretsManager.Repositories.Noop;
|
||||
@ -68,6 +69,8 @@ public class Startup
|
||||
// Services
|
||||
services.AddBaseServices(globalSettings);
|
||||
services.AddDefaultServices(globalSettings);
|
||||
services.AddDistributedCache(globalSettings);
|
||||
services.AddBillingOperations();
|
||||
|
||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
|
||||
|
@ -13,22 +13,28 @@ public class GetUsersListQuery : IGetUsersListQuery
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex)
|
||||
public async Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, GetUsersQueryParamModel userQueryParams)
|
||||
{
|
||||
string emailFilter = null;
|
||||
string usernameFilter = null;
|
||||
string externalIdFilter = null;
|
||||
|
||||
int count = userQueryParams.Count;
|
||||
int startIndex = userQueryParams.StartIndex;
|
||||
string filter = userQueryParams.Filter;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
if (filter.StartsWith("userName eq "))
|
||||
var filterLower = filter.ToLowerInvariant();
|
||||
if (filterLower.StartsWith("username eq "))
|
||||
{
|
||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
|
||||
usernameFilter = filterLower.Substring(12).Trim('"');
|
||||
if (usernameFilter.Contains("@"))
|
||||
{
|
||||
emailFilter = usernameFilter;
|
||||
}
|
||||
}
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
else if (filterLower.StartsWith("externalid eq "))
|
||||
{
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
@ -55,11 +61,11 @@ public class GetUsersListQuery : IGetUsersListQuery
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
||||
else if (string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
userList = orgUsers.OrderBy(ou => ou.Email)
|
||||
.Skip(startIndex.Value - 1)
|
||||
.Take(count.Value)
|
||||
.Skip(startIndex - 1)
|
||||
.Take(count)
|
||||
.ToList();
|
||||
totalResults = orgUsers.Count;
|
||||
}
|
||||
|
@ -4,5 +4,5 @@ namespace Bit.Scim.Users.Interfaces;
|
||||
|
||||
public interface IGetUsersListQuery
|
||||
{
|
||||
Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex);
|
||||
Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, GetUsersQueryParamModel userQueryParams);
|
||||
}
|
||||
|
@ -9,18 +9,15 @@ namespace Bit.Scim.Users;
|
||||
|
||||
public class PatchUserCommand : IPatchUserCommand
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ILogger<PatchUserCommand> _logger;
|
||||
|
||||
public PatchUserCommand(
|
||||
IUserService userService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
ILogger<PatchUserCommand> logger)
|
||||
{
|
||||
_userService = userService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_logger = logger;
|
||||
@ -74,7 +71,7 @@ public class PatchUserCommand : IPatchUserCommand
|
||||
{
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM, _userService);
|
||||
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
return true;
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
|
@ -40,4 +40,10 @@ if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
&& update-ca-certificates
|
||||
fi
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
fi
|
||||
|
||||
exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll
|
||||
|
@ -6,6 +6,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.SecretsManager.Repositories.Noop;
|
||||
@ -80,6 +81,7 @@ public class Startup
|
||||
services.AddBaseServices(globalSettings);
|
||||
services.AddDefaultServices(globalSettings);
|
||||
services.AddCoreLocalizationServices();
|
||||
services.AddBillingOperations();
|
||||
|
||||
// TODO: Remove when OrganizationUser methods are moved out of OrganizationService, this noop dependency should
|
||||
// TODO: no longer be required - see PM-1880
|
||||
|
@ -23,7 +23,7 @@
|
||||
@RenderBody()
|
||||
</div>
|
||||
|
||||
<div class="container footer text-muted">
|
||||
<div class="container footer text-body-secondary">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
© @DateTime.Now.Year, Bitwarden Inc.
|
||||
|
@ -46,4 +46,10 @@ if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
&& update-ca-certificates
|
||||
fi
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
fi
|
||||
|
||||
exec gosu $USERNAME:$GROUPNAME dotnet /app/Sso.dll
|
||||
|
850
bitwarden_license/src/Sso/package-lock.json
generated
850
bitwarden_license/src/Sso/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,16 +10,15 @@
|
||||
"dependencies": {
|
||||
"bootstrap": "5.3.3",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"popper.js": "1.16.1"
|
||||
"jquery": "3.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.0",
|
||||
"mini-css-extract-plugin": "2.9.0",
|
||||
"sass": "1.77.8",
|
||||
"sass-loader": "16.0.0",
|
||||
"webpack": "5.93.0",
|
||||
"mini-css-extract-plugin": "2.9.1",
|
||||
"sass": "1.79.5",
|
||||
"sass-loader": "16.0.2",
|
||||
"webpack": "5.95.0",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
@ -13,8 +13,6 @@ module.exports = {
|
||||
entry: {
|
||||
site: [
|
||||
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
||||
|
||||
"popper.js",
|
||||
"bootstrap",
|
||||
"jquery",
|
||||
"font-awesome/css/font-awesome.css",
|
||||
|
@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -19,23 +20,30 @@ public class CreateProviderCommandTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Invalid owner.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
|
||||
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
|
||||
}
|
||||
@ -43,11 +51,52 @@ public class CreateProviderCommandTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.Reseller;
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateResellerAsync(provider);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(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);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
@ -75,7 +75,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
[],
|
||||
includeProvider: false)
|
||||
@ -98,7 +98,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
organization.GatewayCustomerId = null;
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
[],
|
||||
includeProvider: false)
|
||||
@ -141,7 +141,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
[],
|
||||
includeProvider: false)
|
||||
@ -154,9 +154,6 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(GetSubscription(organization.GatewaySubscriptionId));
|
||||
|
||||
@ -173,7 +170,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||
options.DaysUntilDue == 30));
|
||||
|
||||
await sutProvider.GetDependency<ISubscriberService>().Received(1).RemovePaymentMethod(organization);
|
||||
await sutProvider.GetDependency<ISubscriberService>().Received(1).RemovePaymentSource(organization);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
|
||||
|
||||
@ -208,7 +205,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
|
||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
[],
|
||||
includeProvider: false)
|
||||
@ -221,9 +218,6 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
|
||||
|
@ -1,6 +1,5 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -55,36 +54,8 @@ public class ProviderServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key,
|
||||
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
sutProvider.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,
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
@ -100,9 +71,6 @@ public class ProviderServiceTests
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
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,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||
organization.UseSecretsManager = true;
|
||||
|
||||
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,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
|
||||
@ -549,8 +517,8 @@ public class ProviderServiceTests
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
var expectedPlanType = PlanType.EnterpriseAnnually;
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
var expectedPlanType = PlanType.EnterpriseMonthly;
|
||||
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
|
||||
@ -579,12 +547,12 @@ public class ProviderServiceTests
|
||||
BackdateProviderCreationDate(provider, newCreationDate);
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.Plan = "Enterprise (Annually)";
|
||||
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||
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);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
@ -663,11 +631,11 @@ public class ProviderServiceTests
|
||||
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
|
||||
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);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup, true)
|
||||
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||
.Returns((organization, null as OrganizationUser, new Collection()));
|
||||
|
||||
var providerOrganization =
|
||||
@ -688,7 +656,7 @@ public class ProviderServiceTests
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException(
|
||||
public async Task CreateOrganizationAsync_InvalidPlanType_ThrowsBadRequestException(
|
||||
Provider provider,
|
||||
OrganizationSignup organizationSignup,
|
||||
Organization organization,
|
||||
@ -696,8 +664,6 @@ public class ProviderServiceTests
|
||||
User user,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
|
||||
provider.Type = ProviderType.Msp;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
|
||||
@ -717,7 +683,7 @@ public class ProviderServiceTests
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync(
|
||||
public async Task CreateOrganizationAsync_InvokeSignupClientAsync(
|
||||
Provider provider,
|
||||
OrganizationSignup organizationSignup,
|
||||
Organization organization,
|
||||
@ -725,8 +691,6 @@ public class ProviderServiceTests
|
||||
User user,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
|
||||
provider.Type = ProviderType.Msp;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
|
||||
@ -771,11 +735,11 @@ public class ProviderServiceTests
|
||||
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
|
||||
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
|
||||
{
|
||||
organizationSignup.Plan = PlanType.EnterpriseAnnually;
|
||||
organizationSignup.Plan = PlanType.EnterpriseMonthly;
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup, true)
|
||||
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||
.Returns((organization, null as OrganizationUser, defaultCollection));
|
||||
|
||||
var providerOrganization =
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -30,7 +30,7 @@ public class CreateProjectCommandTests
|
||||
.CreateAsync(Arg.Any<Project>())
|
||||
.Returns(data);
|
||||
|
||||
await sutProvider.Sut.CreateAsync(data, userId, sutProvider.GetDependency<ICurrentContext>().ClientType);
|
||||
await sutProvider.Sut.CreateAsync(data, userId, sutProvider.GetDependency<ICurrentContext>().IdentityClientType);
|
||||
|
||||
await sutProvider.GetDependency<IProjectRepository>().Received(1)
|
||||
.CreateAsync(Arg.Is(data));
|
||||
|
@ -236,6 +236,46 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetList_SearchUserNameWithoutOptionalParameters_Success()
|
||||
{
|
||||
string filter = "userName eq user2@example.com";
|
||||
int? itemsPerPage = null;
|
||||
int? startIndex = null;
|
||||
var expectedResponse = new ScimListResponseModel<ScimUserResponseModel>
|
||||
{
|
||||
ItemsPerPage = 50, //default value
|
||||
TotalResults = 1,
|
||||
StartIndex = 1, //default value
|
||||
Resources = new List<ScimUserResponseModel>
|
||||
{
|
||||
new ScimUserResponseModel
|
||||
{
|
||||
Id = ScimApplicationFactory.TestOrganizationUserId2,
|
||||
DisplayName = "Test User 2",
|
||||
ExternalId = "UB",
|
||||
Active = true,
|
||||
Emails = new List<BaseScimUserModel.EmailModel>
|
||||
{
|
||||
new BaseScimUserModel.EmailModel { Primary = true, Type = "work", Value = "user2@example.com" }
|
||||
},
|
||||
Groups = new List<string>(),
|
||||
Name = new BaseScimUserModel.NameModel("Test User 2"),
|
||||
UserName = "user2@example.com",
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaListResponse }
|
||||
};
|
||||
|
||||
var context = await _factory.UsersGetListAsync(ScimApplicationFactory.TestOrganizationId1, filter, itemsPerPage, startIndex);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
|
||||
var responseModel = JsonSerializer.Deserialize<ScimListResponseModel<ScimUserResponseModel>>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_Success()
|
||||
{
|
||||
|
@ -9,7 +9,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
|
@ -24,7 +24,7 @@ public class GetUsersListQueryTests
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns(organizationUserUserDetails);
|
||||
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, null, count, startIndex);
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Count = count, StartIndex = startIndex });
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetManyDetailsByOrganizationAsync(organizationId);
|
||||
|
||||
@ -49,7 +49,7 @@ public class GetUsersListQueryTests
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns(organizationUserUserDetails);
|
||||
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null);
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Filter = filter });
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetManyDetailsByOrganizationAsync(organizationId);
|
||||
|
||||
@ -71,7 +71,7 @@ public class GetUsersListQueryTests
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns(organizationUserUserDetails);
|
||||
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null);
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Filter = filter });
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetManyDetailsByOrganizationAsync(organizationId);
|
||||
|
||||
@ -96,7 +96,7 @@ public class GetUsersListQueryTests
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns(organizationUserUserDetails);
|
||||
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null);
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Filter = filter });
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetManyDetailsByOrganizationAsync(organizationId);
|
||||
|
||||
@ -120,7 +120,7 @@ public class GetUsersListQueryTests
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns(organizationUserUserDetails);
|
||||
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null);
|
||||
var result = await sutProvider.Sut.GetUsersListAsync(organizationId, new GetUsersQueryParamModel { Filter = filter });
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetManyDetailsByOrganizationAsync(organizationId);
|
||||
|
||||
|
@ -43,7 +43,7 @@ public class PatchUserCommandTests
|
||||
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM, Arg.Any<IUserService>());
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -71,7 +71,7 @@ public class PatchUserCommandTests
|
||||
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM, Arg.Any<IUserService>());
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -147,7 +147,7 @@ public class PatchUserCommandTests
|
||||
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM, default);
|
||||
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
|
1
dev/.gitignore
vendored
1
dev/.gitignore
vendored
@ -5,7 +5,6 @@ secrets.json
|
||||
# Docker container configurations
|
||||
.env
|
||||
authsources.php
|
||||
directory.ldif
|
||||
|
||||
# Development certificates
|
||||
identity_server_dev.crt
|
||||
|
@ -2,13 +2,14 @@ version: "3.9"
|
||||
|
||||
services:
|
||||
mssql:
|
||||
image: mcr.microsoft.com/azure-sql-edge:latest
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
platform: linux/amd64
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_SA_PASSWORD: ${MSSQL_PASSWORD}
|
||||
MSSQL_PID: Developer
|
||||
volumes:
|
||||
- edgesql_dev_data:/var/opt/mssql
|
||||
- mssql_dev_data:/var/opt/mssql
|
||||
- ../util/Migrator:/mnt/migrator/
|
||||
- ./helpers/mssql:/mnt/helpers
|
||||
- ./.data/mssql:/mnt/data
|
||||
@ -58,7 +59,9 @@ services:
|
||||
container_name: bw-mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
command:
|
||||
- --default-authentication-plugin=mysql_native_password
|
||||
- --innodb-print-all-deadlocks=ON
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: vault_dev
|
||||
@ -81,20 +84,6 @@ services:
|
||||
profiles:
|
||||
- idp
|
||||
|
||||
open-ldap:
|
||||
image: osixia/openldap:1.5.0
|
||||
command: --copy-service
|
||||
environment:
|
||||
LDAP_ORGANISATION: "Bitwarden"
|
||||
LDAP_DOMAIN: "bitwarden.com"
|
||||
volumes:
|
||||
- ./directory.ldif:/container/service/slapd/assets/config/bootstrap/ldif/output.ldif
|
||||
ports:
|
||||
- "389:389"
|
||||
- "636:636"
|
||||
profiles:
|
||||
- ldap
|
||||
|
||||
reverse-proxy:
|
||||
image: nginx:alpine
|
||||
container_name: reverse-proxy
|
||||
@ -107,6 +96,6 @@ services:
|
||||
- proxy
|
||||
|
||||
volumes:
|
||||
edgesql_dev_data:
|
||||
mssql_dev_data:
|
||||
postgres_dev_data:
|
||||
mysql_dev_data:
|
||||
|
@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.13.12" />
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -14,6 +14,10 @@
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Billing\Controllers\" />
|
||||
<Folder Include="Billing\Models\" />
|
||||
</ItemGroup>
|
||||
|
||||
<Choose>
|
||||
<When Condition="!$(DefineConstants.Contains('OSS'))">
|
||||
|
@ -5,13 +5,14 @@ using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
@ -54,8 +55,8 @@ public class OrganizationsController : Controller
|
||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationsController(
|
||||
IOrganizationService organizationService,
|
||||
@ -81,8 +82,8 @@ public class OrganizationsController : Controller
|
||||
IServiceAccountRepository serviceAccountRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
|
||||
IFeatureService featureService,
|
||||
IProviderBillingService providerBillingService)
|
||||
IProviderBillingService providerBillingService,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -107,8 +108,8 @@ public class OrganizationsController : Controller
|
||||
_serviceAccountRepository = serviceAccountRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
|
||||
_featureService = featureService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Org_List_View)]
|
||||
@ -231,15 +232,37 @@ public class OrganizationsController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
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 &&
|
||||
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
||||
{
|
||||
throw new BadRequestException("Plan does not support Secrets Manager");
|
||||
TempData["Error"] = "Plan does not support Secrets Manager";
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
await HandlePotentialProviderSeatScalingAsync(
|
||||
existingOrganizationData,
|
||||
model);
|
||||
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
||||
{
|
||||
@ -262,9 +285,7 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
||||
if (organization.IsValidClient())
|
||||
{
|
||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
@ -394,10 +415,9 @@ public class OrganizationsController : Controller
|
||||
|
||||
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))
|
||||
{
|
||||
organization.Enabled = model.Enabled;
|
||||
@ -449,7 +469,62 @@ public class OrganizationsController : Controller
|
||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
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,
|
||||
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]
|
||||
[ValidateAntiForgeryToken]
|
||||
[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)
|
||||
{
|
||||
@ -128,19 +175,51 @@ public class ProvidersController : Controller
|
||||
}
|
||||
|
||||
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:
|
||||
await _createProviderCommand.CreateMspAsync(
|
||||
provider,
|
||||
model.OwnerEmail,
|
||||
model.TeamsMonthlySeatMinimum,
|
||||
model.EnterpriseMonthlySeatMinimum);
|
||||
break;
|
||||
case ProviderType.Reseller:
|
||||
await _createProviderCommand.CreateResellerAsync(provider);
|
||||
break;
|
||||
return View(model);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@ -162,27 +241,13 @@ public class ProvidersController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> Edit(Guid id)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(id);
|
||||
var provider = await GetEditModel(id);
|
||||
if (provider == null)
|
||||
{
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
|
||||
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
|
||||
{
|
||||
return View(new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>()));
|
||||
}
|
||||
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
|
||||
return View(new ProviderEditModel(
|
||||
provider, users, providerOrganizations,
|
||||
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)));
|
||||
return View(provider);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -198,44 +263,93 @@ public class ProvidersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
if (provider.Type != model.Type)
|
||||
{
|
||||
var oldModel = await GetEditModel(id);
|
||||
ModelState.AddModelError(nameof(model.Type), "Provider type cannot be changed.");
|
||||
return View(oldModel);
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var oldModel = await GetEditModel(id);
|
||||
ModelState[nameof(ProviderEditModel.BillingEmail)]!.RawValue = oldModel.BillingEmail;
|
||||
return View(oldModel);
|
||||
}
|
||||
|
||||
model.ToProvider(provider);
|
||||
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
|
||||
if (providerPlans.Count == 0)
|
||||
switch (provider.Type)
|
||||
{
|
||||
var newProviderPlans = new List<ProviderPlan>
|
||||
{
|
||||
new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 },
|
||||
new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }
|
||||
};
|
||||
case ProviderType.Msp:
|
||||
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||
provider.Id,
|
||||
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)
|
||||
{
|
||||
await _providerPlanRepository.CreateAsync(newProviderPlan);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await _providerBillingService.UpdateSeatMinimums(
|
||||
provider,
|
||||
model.EnterpriseMonthlySeatMinimum,
|
||||
model.TeamsMonthlySeatMinimum);
|
||||
// 1. Change the plan and take over any old values.
|
||||
var changeMoePlanCommand = new ChangeProviderPlanCommand(
|
||||
existingMoePlan.Id,
|
||||
model.Plan!.Value,
|
||||
provider.GatewaySubscriptionId);
|
||||
await _providerBillingService.ChangePlan(changeMoePlanCommand);
|
||||
|
||||
// 2. Update the seat minimums.
|
||||
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||
provider.Id,
|
||||
provider.GatewaySubscriptionId,
|
||||
[
|
||||
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
|
||||
]);
|
||||
await _providerBillingService.UpdateSeatMinimums(updateMoeSeatMinimumsCommand);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
private async Task<ProviderEditModel> GetEditModel(Guid id)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(id);
|
||||
if (provider == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
|
||||
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
|
||||
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
|
||||
}
|
||||
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
|
||||
return new ProviderEditModel(
|
||||
provider, users, providerOrganizations,
|
||||
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Provider_ResendEmailInvite)]
|
||||
public async Task<IActionResult> ResendInvite(Guid ownerId, Guid providerId)
|
||||
{
|
||||
@ -341,7 +455,7 @@ public class ProvidersController : Controller
|
||||
return BadRequest("Provider does not exist");
|
||||
}
|
||||
|
||||
if (!string.Equals(providerName.Trim(), provider.Name, StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest("Invalid provider name");
|
||||
}
|
||||
|
45
src/Admin/AdminConsole/Models/CreateMspProviderModel.cs
Normal file
45
src/Admin/AdminConsole/Models/CreateMspProviderModel.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,84 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class CreateProviderModel : IValidatableObject
|
||||
public class CreateProviderModel
|
||||
{
|
||||
public CreateProviderModel() { }
|
||||
|
||||
[Display(Name = "Provider Type")]
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
48
src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs
Normal file
48
src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
@ -181,7 +181,6 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
*/
|
||||
public object GetPlansHelper() =>
|
||||
StaticStore.Plans
|
||||
.Where(p => p.SupportsSecretsManager)
|
||||
.Select(p =>
|
||||
{
|
||||
var plan = new
|
||||
|
@ -1,13 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class ProviderEditModel : ProviderViewModel
|
||||
public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
||||
{
|
||||
public ProviderEditModel() { }
|
||||
|
||||
@ -30,6 +32,14 @@ public class ProviderEditModel : ProviderViewModel
|
||||
GatewaySubscriptionId = provider.GatewaySubscriptionId;
|
||||
GatewayCustomerUrl = gatewayCustomerUrl;
|
||||
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
||||
Type = provider.Type;
|
||||
|
||||
if (Type == ProviderType.MultiOrganizationEnterprise)
|
||||
{
|
||||
var plan = providerPlans.SingleOrDefault();
|
||||
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
|
||||
Plan = plan?.PlanType;
|
||||
}
|
||||
}
|
||||
|
||||
[Display(Name = "Billing Email")]
|
||||
@ -52,17 +62,61 @@ public class ProviderEditModel : ProviderViewModel
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
public string GatewayCustomerUrl { get; }
|
||||
public string GatewaySubscriptionUrl { get; }
|
||||
[Display(Name = "Provider Type")]
|
||||
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)
|
||||
{
|
||||
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
|
||||
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
|
||||
existingProvider.Gateway = Gateway;
|
||||
existingProvider.GatewayCustomerId = GatewayCustomerId;
|
||||
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
|
||||
switch (Type)
|
||||
{
|
||||
case ProviderType.Msp:
|
||||
existingProvider.Gateway = Gateway;
|
||||
existingProvider.GatewayCustomerId = GatewayCustomerId;
|
||||
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
|
||||
break;
|
||||
}
|
||||
return existingProvider;
|
||||
}
|
||||
|
||||
private static int GetSeatMinimum(IEnumerable<ProviderPlan> providerPlans, PlanType planType)
|
||||
=> providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType)?.SeatMinimum ?? 0;
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
switch (Type)
|
||||
{
|
||||
case ProviderType.Reseller:
|
||||
if (string.IsNullOrWhiteSpace(BillingEmail))
|
||||
{
|
||||
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
|
||||
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -103,19 +103,19 @@
|
||||
|
||||
<div class="d-flex mt-4">
|
||||
<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)
|
||||
{
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
}
|
||||
@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');">
|
||||
Unlink provider
|
||||
</button>
|
||||
@ -124,7 +124,7 @@
|
||||
{
|
||||
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
|
||||
<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 asp-action="Delete" asp-route-id="@Model.Organization.Id"
|
||||
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
|
||||
|
@ -5,21 +5,31 @@
|
||||
|
||||
<h1>Organizations</h1>
|
||||
|
||||
<form class="form-inline mb-2" method="get">
|
||||
<label class="sr-only" asp-for="Name">Name</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name">
|
||||
<label class="sr-only" asp-for="UserEmail">User email</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
||||
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="Name">Name</label>
|
||||
<input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
|
||||
</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)
|
||||
{
|
||||
<label class="sr-only" asp-for="Paid">Customer</label>
|
||||
<select class="form-control mb-2 mr-2" asp-for="Paid" name="paid">
|
||||
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
|
||||
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
|
||||
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
|
||||
</select>
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="Paid">Customer</label>
|
||||
<select class="form-select" asp-for="Paid" name="paid">
|
||||
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
|
||||
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
|
||||
<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>
|
||||
|
||||
<div class="table-responsive">
|
||||
@ -68,7 +78,7 @@
|
||||
}
|
||||
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)
|
||||
@ -78,7 +88,7 @@
|
||||
}
|
||||
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>
|
||||
}
|
||||
@if(org.Enabled)
|
||||
@ -88,7 +98,7 @@
|
||||
}
|
||||
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())
|
||||
{
|
||||
@ -96,7 +106,7 @@
|
||||
}
|
||||
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>
|
||||
</tr>
|
||||
|
@ -1,73 +1,86 @@
|
||||
@model OrganizationViewModel
|
||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||
@model OrganizationViewModel
|
||||
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4 col-lg-3">Id</dt>
|
||||
<dd class="col-sm-8 col-lg-9"><code>@Model.Organization.Id</code></dd>
|
||||
<dd id="org-id" class="col-sm-8 col-lg-9"><code>@Model.Organization.Id</code></dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Plan</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.Organization.Plan</dd>
|
||||
<dd id="org-plan" class="col-sm-8 col-lg-9">@Model.Organization.Plan</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Expires</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.ExpirationDate?.ToString() ?? "-")</dd>
|
||||
<dd id="org-expiration-date" class="col-sm-8 col-lg-9">@(Model.Organization.ExpirationDate?.ToString() ?? "-")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Users</dt>
|
||||
<dd class="col-sm-8 col-lg-9">
|
||||
<dd id="org-user-seats" class="col-sm-8 col-lg-9">
|
||||
@Model.OccupiedSeatCount / @(Model.Organization.Seats?.ToString() ?? "-")
|
||||
(<span title="Invited">@Model.UserInvitedCount</span> /
|
||||
<span title="Accepted">@Model.UserAcceptedCount</span> /
|
||||
<span title="Confirmed">@Model.UserConfirmedCount</span>)
|
||||
(<span id="org-invited-users" title="Invited">@Model.UserInvitedCount</span> /
|
||||
<span id="org-accepted-users" title="Accepted">@Model.UserAcceptedCount</span> /
|
||||
<span id="org-confirmed-users" title="Confirmed">@Model.UserConfirmedCount</span>)
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Owners</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(string.IsNullOrWhiteSpace(Model.Owners) ? "None" : Model.Owners)</dd>
|
||||
<dd id="org-owner" class="col-sm-8 col-lg-9">@(string.IsNullOrWhiteSpace(Model.Owners) ? "None" : Model.Owners)</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Admins</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(string.IsNullOrWhiteSpace(Model.Admins) ? "None" : Model.Admins)</dd>
|
||||
<dd id="org-admins" class="col-sm-8 col-lg-9">@(string.IsNullOrWhiteSpace(Model.Admins) ? "None" : Model.Admins)</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Using 2FA</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.TwoFactorIsEnabled() ? "Yes" : "No")</dd>
|
||||
<dd id="org-2fa" class="col-sm-8 col-lg-9">@(Model.Organization.TwoFactorIsEnabled() ? "Yes" : "No")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Groups</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.GroupCount</dd>
|
||||
<dd id="org-group-count" class="col-sm-8 col-lg-9">@Model.GroupCount</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Policies</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.PolicyCount</dd>
|
||||
<dd id="org-policy-count" class="col-sm-8 col-lg-9">@Model.PolicyCount</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Public/Private Keys</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.HasPublicPrivateKeys ? "Yes" : "No")</dd>
|
||||
<dd id="org-has-keys" class="col-sm-8 col-lg-9">@(Model.HasPublicPrivateKeys ? "Yes" : "No")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Created</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.Organization.CreationDate.ToString()</dd>
|
||||
<dd id="org-creation-date" class="col-sm-8 col-lg-9">@Model.Organization.CreationDate.ToString()</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Modified</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.Organization.RevisionDate.ToString()</dd>
|
||||
<dd id="org-modified-date" class="col-sm-8 col-lg-9">@Model.Organization.RevisionDate.ToString()</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Password Manager</h2>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4 col-lg-3">Items</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.CipherCount</dd>
|
||||
<dd id="pm-item-count" class="col-sm-8 col-lg-9">@Model.CipherCount</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Collections</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.CollectionCount</dd>
|
||||
<dd id="pm-collection-count" class="col-sm-8 col-lg-9">@Model.CollectionCount</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Administrators manage all collections</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.AllowAdminAccessToAllCollectionItems ? "On" : "Off")</dd>
|
||||
<dd id="pm-manage-collections" class="col-sm-8 col-lg-9">@(Model.Organization.AllowAdminAccessToAllCollectionItems ? "On" : "Off")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Limit collection creation to administrators</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionCreationDeletion ? "On" : "Off")</dd>
|
||||
@if (!FeatureService.IsEnabled(Bit.Core.FeatureFlagKeys.LimitCollectionCreationDeletionSplit))
|
||||
{
|
||||
<dt class="col-sm-4 col-lg-3">Limit collection creation to administrators</dt>
|
||||
<dd id="pm-collection-creation" class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionCreationDeletion ? "On" : "Off")</dd>
|
||||
}
|
||||
else
|
||||
{
|
||||
<dt class="col-sm-4 col-lg-3">Limit collection creation to administrators</dt>
|
||||
<dd id="pm-collection-creation" class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionCreation ? "On" : "Off")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Limit collection deletion to administrators</dt>
|
||||
<dd id="pm-collection-deletion" class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionDeletion ? "On" : "Off")</dd>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<h2>Secrets Manager</h2>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4 col-lg-3">Secrets</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.SecretsCount: "N/A")</dd>
|
||||
<dd id="sm-secret-count" class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.SecretsCount: "N/A")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Projects</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.ProjectsCount: "N/A")</dd>
|
||||
<dd id="sm-project-count" class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.ProjectsCount: "N/A")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Machine Accounts</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.ServiceAccountsCount: "N/A")</dd>
|
||||
<dd id="sm-machine-account" class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.ServiceAccountsCount: "N/A")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Secrets Manager Seats</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.OccupiedSmSeatsCount: "N/A" )</dd>
|
||||
<dd id="sm-seat-count" class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.OccupiedSmSeatsCount: "N/A" )</dd>
|
||||
</dl>
|
||||
|
@ -9,12 +9,18 @@
|
||||
<h1>Add Existing Organization</h1>
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<form class="form-inline mb-2" method="get" asp-route-id="@providerId">
|
||||
<label class="sr-only" asp-for="OrganizationName"></label>
|
||||
<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="sr-only" asp-for="OrganizationOwnerEmail"></label>
|
||||
<input type="email" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
|
||||
<button type="submit" class="btn btn-primary mb-2" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
|
||||
<form class="row g-3 align-items-center mb-2" method="get" asp-route-id="@providerId">
|
||||
<div class="col">
|
||||
<label class="visually-hidden" asp-for="OrganizationName"></label>
|
||||
<input type="text" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,80 +1,48 @@
|
||||
@using Bit.SharedWeb.Utilities
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core
|
||||
|
||||
@model CreateProviderModel
|
||||
|
||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Provider";
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function toggleProviderTypeInfo(value) {
|
||||
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
|
||||
document.getElementById('info-' + value).classList.remove('d-none');
|
||||
}
|
||||
</script>
|
||||
var providerTypes = Enum.GetValues<ProviderType>()
|
||||
.OrderBy(x => x.GetDisplayAttribute().Order)
|
||||
.ToList();
|
||||
|
||||
if (!FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
|
||||
{
|
||||
providerTypes.Remove(ProviderType.MultiOrganizationEnterprise);
|
||||
}
|
||||
}
|
||||
|
||||
<h1>Create Provider</h1>
|
||||
|
||||
<form method="post">
|
||||
<form method="post" asp-action="Create">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="Type" class="h2"></label>
|
||||
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType)))
|
||||
<div class="mb-3">
|
||||
<label asp-for="Type" class="form-label h2"></label>
|
||||
@foreach (var providerType in providerTypes)
|
||||
{
|
||||
var providerTypeValue = (int)providerType;
|
||||
<div class="form-check">
|
||||
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input", onclick=$"toggleProviderTypeInfo({providerTypeValue})" })
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
|
||||
<br/>
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @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 class="mb-3">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-check">
|
||||
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label", @for = $"providerType-{providerTypeValue}" })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-body-secondary ps-4", @for = $"providerType-{providerTypeValue}" })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<button type="submit" class="btn btn-primary mb-2">Next</button>
|
||||
</form>
|
||||
|
31
src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml
Normal file
31
src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml
Normal 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>
|
@ -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>
|
@ -18,7 +18,7 @@
|
||||
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
|
||||
<div class="d-flex mt-4">
|
||||
<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"
|
||||
onsubmit="return confirm('Are you sure you want to cancel?')">
|
||||
<button class="btn btn-outline-secondary" type="submit">Cancel</button>
|
||||
|
25
src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml
Normal file
25
src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml
Normal 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>
|
@ -1,6 +1,9 @@
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Core
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Bit.Core.Billing.Extensions
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||
|
||||
@ -16,6 +19,8 @@
|
||||
@await Html.PartialAsync("_ViewInformation", Model)
|
||||
@await Html.PartialAsync("Admins", Model)
|
||||
<form method="post" id="edit-form">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<input type="hidden" asp-for="Type" readonly>
|
||||
<h2>General</h2>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4 col-lg-3">Name</dt>
|
||||
@ -29,72 +34,103 @@
|
||||
<h2>Billing</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingEmail"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="BillingEmail" class="form-label"></label>
|
||||
<input type="email" class="form-control" asp-for="BillingEmail" readonly='@(!canEdit)'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingPhone"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="BillingPhone" class="form-label"></label>
|
||||
<input type="tel" class="form-control" asp-for="BillingPhone">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
|
||||
@if (Model.Provider.IsBillable())
|
||||
{
|
||||
<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 class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label asp-for="Gateway"></label>
|
||||
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
switch (Model.Provider.Type)
|
||||
{
|
||||
case ProviderType.Msp:
|
||||
{
|
||||
<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>
|
||||
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 class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewayCustomerId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="GatewayCustomerId" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
||||
<div class="input-group-append">
|
||||
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewayCustomerUrl', '_blank')">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
||||
<div class="input-group-append">
|
||||
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewaySubscriptionUrl', '_blank')">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -109,21 +145,21 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content rounded">
|
||||
<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 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.
|
||||
</span>
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<div class="mb-3">
|
||||
<label for="provider-email" class="col-form-label">Provider email</label>
|
||||
<input type="email" class="form-control" id="provider-email">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -133,21 +169,21 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content rounded">
|
||||
<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 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.
|
||||
</span>
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<div class="mb-3">
|
||||
<label for="provider-name" class="col-form-label">Provider name</label>
|
||||
<input type="text" class="form-control" id="provider-name">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -157,12 +193,12 @@
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content rounded">
|
||||
<div class="modal-body">
|
||||
<h4 class="font-weight-bolder">Cannot Delete @Model.Name</h4>
|
||||
<p class="font-weight-lighter">You must unlink all clients before you can delete @Model.Name.</p>
|
||||
<h4 class="fw-bolder">Cannot Delete @Model.Name</h4>
|
||||
<p class="fw-lighter">You must unlink all clients before you can delete @Model.Name.</p>
|
||||
</div>
|
||||
<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-primary btn-pill" data-dismiss="modal">Ok</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-bs-dismiss="modal">Ok</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -172,18 +208,14 @@
|
||||
|
||||
<div class="d-flex mt-4">
|
||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider))
|
||||
{
|
||||
<div class="ml-auto d-flex">
|
||||
<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>
|
||||
<div class="ms-auto d-flex">
|
||||
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</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 id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
|
||||
<button class="btn btn-outline-danger ms-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</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>
|
||||
|
||||
</div>
|
||||
}
|
||||
<button id="linkAccWarningBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#linkedWarningModal"></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@ -11,23 +11,27 @@
|
||||
|
||||
<h1>Providers</h1>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<form class="form-inline mb-2" method="get">
|
||||
<label class="sr-only" asp-for="Name">Name</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name">
|
||||
<label class="sr-only" asp-for="UserEmail">User email</label>
|
||||
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
||||
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
|
||||
</form>
|
||||
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
|
||||
<div class="col-12">
|
||||
<label class="visually-hidden" asp-for="Name">Name</label>
|
||||
<input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
|
||||
</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>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary" title="Search">
|
||||
<i class="fa fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
@if (canCreateProvider)
|
||||
{
|
||||
<div class="col-auto">
|
||||
<div class="col-auto ms-auto">
|
||||
<a asp-action="Create" class="btn btn-secondary">Create Provider</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
|
@ -24,9 +24,9 @@
|
||||
<th>
|
||||
@if (Model.Provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
<div class="float-right 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="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary">Add Existing Organization</a>
|
||||
<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 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 text-decoration-none">Add Existing Organization</a>
|
||||
</div>
|
||||
}
|
||||
</th>
|
||||
@ -51,16 +51,16 @@
|
||||
@providerOrganization.Status
|
||||
</td>
|
||||
<td>
|
||||
<div class="float-right">
|
||||
<div class="float-end">
|
||||
@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
|
||||
</a>
|
||||
}
|
||||
@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
|
||||
</a>
|
||||
}
|
||||
|
@ -20,9 +20,10 @@
|
||||
|
||||
function deleteProvider(id) {
|
||||
const providerName = $('#DeleteModal input#provider-name').val();
|
||||
const encodedProviderName = encodeURIComponent(providerName);
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${providerName}`,
|
||||
url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${encodedProviderName}`,
|
||||
dataType: 'json',
|
||||
contentType: false,
|
||||
processData: false,
|
||||
|
@ -26,8 +26,8 @@
|
||||
<h2>General</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="Name"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="Name"></label>
|
||||
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required>
|
||||
</div>
|
||||
</div>
|
||||
@ -37,17 +37,17 @@
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label>Client Owner Email</label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client Owner Email</label>
|
||||
@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
|
||||
{
|
||||
<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>
|
||||
@ -66,8 +66,8 @@
|
||||
<h2>Plan</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="PlanType"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="PlanType"></label>
|
||||
@{
|
||||
var planTypes = Enum.GetValues<PlanType>()
|
||||
.Where(p =>
|
||||
@ -83,12 +83,12 @@
|
||||
})
|
||||
.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 class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="Plan"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="Plan"></label>
|
||||
<input type="text" class="form-control" asp-for="Plan" required readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
@ -172,28 +172,28 @@
|
||||
<h2>Password Manager Configuration</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="Seats"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="Seats"></label>
|
||||
<input type="number" class="form-control" asp-for="Seats" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxCollections"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxCollections"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxCollections" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxStorageGb"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxStorageGb"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxAutoscaleSeats"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxAutoscaleSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
@ -202,32 +202,32 @@
|
||||
|
||||
@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>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="SmSeats"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="SmSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="SmSeats" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxAutoscaleSmSeats"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxAutoscaleSmSeats"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSmSeats" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="SmServiceAccounts"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="SmServiceAccounts"></label>
|
||||
<input type="number" class="form-control" asp-for="SmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="form-group">
|
||||
<label asp-for="MaxAutoscaleSmServiceAccounts"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="MaxAutoscaleSmServiceAccounts"></label>
|
||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
|
||||
</div>
|
||||
</div>
|
||||
@ -240,14 +240,14 @@
|
||||
<h2>Licensing</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="LicenseKey"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="LicenseKey"></label>
|
||||
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="ExpirationDate"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="ExpirationDate"></label>
|
||||
<input type="datetime-local" class="form-control" asp-for="ExpirationDate" readonly='@(!canEditLicensing)' step="1">
|
||||
</div>
|
||||
</div>
|
||||
@ -259,52 +259,46 @@
|
||||
<h2>Billing</h2>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingEmail"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="BillingEmail"></label>
|
||||
<input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label asp-for="Gateway"></label>
|
||||
<select class="form-control" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="Gateway"></label>
|
||||
<select class="form-select" asp-for="Gateway" disabled="@(!canEditBilling)"
|
||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewayCustomerId"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="GatewayCustomerId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
|
||||
@if(canLaunchGateway)
|
||||
{
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="GatewaySubscriptionId"></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
|
||||
@if (canLaunchGateway)
|
||||
{
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,8 +3,8 @@
|
||||
ViewData["Title"] = "Login";
|
||||
}
|
||||
|
||||
<div class="row justify-content-md-center">
|
||||
<div class="col col-lg-6 col-md-8">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 col-md-8">
|
||||
@if(!string.IsNullOrWhiteSpace(Model.Success))
|
||||
{
|
||||
<div class="alert alert-success" role="alert">@Model.Success</div>
|
||||
@ -19,12 +19,12 @@
|
||||
<form asp-action="" method="post">
|
||||
<input type="hidden" asp-for="ReturnUrl" />
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Email" class="sr-only">Email Address</label>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Email" class="visually-hidden">Email Address</label>
|
||||
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
||||
required autofocus>
|
||||
<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>
|
||||
<button class="btn btn-primary" type="submit">Continue</button>
|
||||
</form>
|
||||
|
83
src/Admin/Billing/Controllers/MigrateProvidersController.cs
Normal file
83
src/Admin/Billing/Controllers/MigrateProvidersController.cs
Normal file
@ -0,0 +1,83 @@
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core.Billing.Migration.Models;
|
||||
using Bit.Core.Billing.Migration.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Admin.Billing.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("migrate-providers")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class MigrateProvidersController(
|
||||
IProviderMigrator providerMigrator) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
[RequirePermission(Permission.Tools_MigrateProviders)]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View(new MigrateProvidersRequestModel());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(Permission.Tools_MigrateProviders)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostAsync(MigrateProvidersRequestModel request)
|
||||
{
|
||||
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
|
||||
|
||||
if (providerIds.Count == 0)
|
||||
{
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
foreach (var providerId in providerIds)
|
||||
{
|
||||
await providerMigrator.Migrate(providerId);
|
||||
}
|
||||
|
||||
return RedirectToAction("Results", new { ProviderIds = string.Join("\r\n", providerIds) });
|
||||
}
|
||||
|
||||
[HttpGet("results")]
|
||||
[RequirePermission(Permission.Tools_MigrateProviders)]
|
||||
public async Task<IActionResult> ResultsAsync(MigrateProvidersRequestModel request)
|
||||
{
|
||||
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
|
||||
|
||||
if (providerIds.Count == 0)
|
||||
{
|
||||
return View(Array.Empty<ProviderMigrationResult>());
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(providerIds.Select(providerMigrator.GetResult));
|
||||
|
||||
return View(results);
|
||||
}
|
||||
|
||||
[HttpGet("results/{providerId:guid}")]
|
||||
[RequirePermission(Permission.Tools_MigrateProviders)]
|
||||
public async Task<IActionResult> DetailsAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var result = await providerMigrator.GetResult(providerId);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
return View(result);
|
||||
}
|
||||
|
||||
private static List<Guid> GetProviderIdsFromInput(string text) => !string.IsNullOrEmpty(text)
|
||||
? text.Split(
|
||||
["\r\n", "\r", "\n"],
|
||||
StringSplitOptions.TrimEntries
|
||||
)
|
||||
.Select(id => new Guid(id))
|
||||
.ToList()
|
||||
: [];
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Admin.Billing.Models.ProcessStripeEvents;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Admin.Billing.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("process-stripe-events")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class ProcessStripeEventsController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IGlobalSettings globalSettings) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
public ActionResult Index()
|
||||
{
|
||||
return View(new EventsFormModel());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ProcessAsync([FromForm] EventsFormModel model)
|
||||
{
|
||||
var eventIds = model.GetEventIds();
|
||||
|
||||
const string baseEndpoint = "stripe/recovery/events";
|
||||
|
||||
var endpoint = model.Inspect ? $"{baseEndpoint}/inspect" : $"{baseEndpoint}/process";
|
||||
|
||||
var (response, failedResponseMessage) = await PostAsync(endpoint, new EventsRequestBody
|
||||
{
|
||||
EventIds = eventIds
|
||||
});
|
||||
|
||||
if (response == null)
|
||||
{
|
||||
return StatusCode((int)failedResponseMessage.StatusCode, "An error occurred during your request.");
|
||||
}
|
||||
|
||||
response.ActionType = model.Inspect ? EventActionType.Inspect : EventActionType.Process;
|
||||
|
||||
return View("Results", response);
|
||||
}
|
||||
|
||||
private async Task<(EventsResponseBody, HttpResponseMessage)> PostAsync(
|
||||
string endpoint,
|
||||
EventsRequestBody requestModel)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("InternalBilling");
|
||||
client.BaseAddress = new Uri(globalSettings.BaseServiceUri.InternalBilling);
|
||||
|
||||
var json = JsonSerializer.Serialize(requestModel);
|
||||
var requestBody = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
var responseMessage = await client.PostAsync(endpoint, requestBody);
|
||||
|
||||
if (!responseMessage.IsSuccessStatusCode)
|
||||
{
|
||||
return (null, responseMessage);
|
||||
}
|
||||
|
||||
var responseContent = await responseMessage.Content.ReadAsStringAsync();
|
||||
|
||||
var response = JsonSerializer.Deserialize<EventsResponseBody>(responseContent);
|
||||
|
||||
return (response, null);
|
||||
}
|
||||
}
|
10
src/Admin/Billing/Models/MigrateProvidersRequestModel.cs
Normal file
10
src/Admin/Billing/Models/MigrateProvidersRequestModel.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Admin.Billing.Models;
|
||||
|
||||
public class MigrateProvidersRequestModel
|
||||
{
|
||||
[Required]
|
||||
[Display(Name = "Provider IDs")]
|
||||
public string ProviderIds { get; set; }
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
|
||||
|
||||
public class EventsFormModel : IValidatableObject
|
||||
{
|
||||
[Required]
|
||||
public string EventIds { get; set; }
|
||||
|
||||
[Required]
|
||||
[DisplayName("Inspect Only")]
|
||||
public bool Inspect { get; set; }
|
||||
|
||||
public List<string> GetEventIds() =>
|
||||
EventIds?.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(eventId => eventId.Trim())
|
||||
.ToList() ?? [];
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
var eventIds = GetEventIds();
|
||||
|
||||
if (eventIds.Any(eventId => !eventId.StartsWith("evt_")))
|
||||
{
|
||||
yield return new ValidationResult("Event Ids must start with 'evt_'.");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
|
||||
|
||||
public class EventsRequestBody
|
||||
{
|
||||
[JsonPropertyName("eventIds")]
|
||||
public List<string> EventIds { get; set; }
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
|
||||
|
||||
public class EventsResponseBody
|
||||
{
|
||||
[JsonPropertyName("events")]
|
||||
public List<EventResponseBody> Events { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public EventActionType ActionType { get; set; }
|
||||
}
|
||||
|
||||
public class EventResponseBody
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string URL { get; set; }
|
||||
|
||||
[JsonPropertyName("apiVersion")]
|
||||
public string APIVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonPropertyName("createdUTC")]
|
||||
public DateTime CreatedUTC { get; set; }
|
||||
|
||||
[JsonPropertyName("processingError")]
|
||||
public string ProcessingError { get; set; }
|
||||
}
|
||||
|
||||
public enum EventActionType
|
||||
{
|
||||
Inspect,
|
||||
Process
|
||||
}
|
39
src/Admin/Billing/Views/MigrateProviders/Details.cshtml
Normal file
39
src/Admin/Billing/Views/MigrateProviders/Details.cshtml
Normal file
@ -0,0 +1,39 @@
|
||||
@using System.Text.Json
|
||||
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult
|
||||
@{
|
||||
ViewData["Title"] = "Results";
|
||||
}
|
||||
|
||||
<h1>Migrate Providers</h1>
|
||||
<h2>Migration Details: @Model.ProviderName</h2>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4 col-lg-3">Id</dt>
|
||||
<dd class="col-sm-8 col-lg-9"><code>@Model.ProviderId</code></dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Result</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.Result</dd>
|
||||
</dl>
|
||||
<h3>Client Organizations</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Result</th>
|
||||
<th>Previous State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var clientResult in Model.Clients)
|
||||
{
|
||||
<tr>
|
||||
<td>@clientResult.OrganizationId</td>
|
||||
<td>@clientResult.OrganizationName</td>
|
||||
<td>@clientResult.Result</td>
|
||||
<td><pre>@Html.Raw(JsonSerializer.Serialize(clientResult.PreviousState))</pre></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
46
src/Admin/Billing/Views/MigrateProviders/Index.cshtml
Normal file
46
src/Admin/Billing/Views/MigrateProviders/Index.cshtml
Normal file
@ -0,0 +1,46 @@
|
||||
@model Bit.Admin.Billing.Models.MigrateProvidersRequestModel;
|
||||
@{
|
||||
ViewData["Title"] = "Migrate Providers";
|
||||
}
|
||||
|
||||
<h1>Migrate Providers</h1>
|
||||
<h2>Bulk Consolidated Billing Migration Tool</h2>
|
||||
<section>
|
||||
<p>
|
||||
This tool allows you to provide a list of IDs for Providers that you would like to migrate to Consolidated Billing.
|
||||
Because of the expensive nature of the operation, you can only migrate 10 Providers at a time.
|
||||
</p>
|
||||
<p class="alert alert-warning">
|
||||
Updates made through this tool are irreversible without manual intervention.
|
||||
</p>
|
||||
<p>Example Input (Please enter each Provider ID separated by a new line):</p>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<pre class="mb-0">f513affc-2290-4336-879e-21ec3ecf3e78
|
||||
f7a5cb0d-4b74-445c-8d8c-232d1d32bbe2
|
||||
bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
|
||||
174e82fc-70c3-448d-9fe7-00bad2a3ab00
|
||||
22a4bbbf-58e3-4e4c-a86a-a0d7caf4ff14</pre>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="ProviderIds"></label>
|
||||
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="submit" value="Run" class="btn btn-primary"/>
|
||||
</div>
|
||||
</form>
|
||||
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="ProviderIds"></label>
|
||||
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="submit" value="See Previous Results" class="btn btn-primary"/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
28
src/Admin/Billing/Views/MigrateProviders/Results.cshtml
Normal file
28
src/Admin/Billing/Views/MigrateProviders/Results.cshtml
Normal file
@ -0,0 +1,28 @@
|
||||
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult[]
|
||||
@{
|
||||
ViewData["Title"] = "Results";
|
||||
}
|
||||
|
||||
<h1>Migrate Providers</h1>
|
||||
<h2>Results</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var result in Model)
|
||||
{
|
||||
<tr>
|
||||
<td><a href="@Url.Action("Details", "MigrateProviders", new { providerId = result.ProviderId })">@result.ProviderId</a></td>
|
||||
<td>@result.ProviderName</td>
|
||||
<td>@result.Result</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
25
src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml
Normal file
25
src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml
Normal file
@ -0,0 +1,25 @@
|
||||
@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsFormModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Process Stripe Events";
|
||||
}
|
||||
|
||||
<h1>Process Stripe Events</h1>
|
||||
<form method="post" asp-controller="ProcessStripeEvents" asp-action="Process">
|
||||
<div class="row">
|
||||
<div class="col-1">
|
||||
<div class="form-group">
|
||||
<input type="submit" value="Process" class="btn btn-primary mb-2"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<div class="form-group form-check">
|
||||
<input type="checkbox" class="form-check-input" asp-for="Inspect">
|
||||
<label class="form-check-label" asp-for="Inspect"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<textarea id="event-ids" type="text" class="form-control" rows="100" asp-for="EventIds"></textarea>
|
||||
</div>
|
||||
</form>
|
49
src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml
Normal file
49
src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml
Normal file
@ -0,0 +1,49 @@
|
||||
@using Bit.Admin.Billing.Models.ProcessStripeEvents
|
||||
@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsResponseBody
|
||||
|
||||
@{
|
||||
var title = Model.ActionType == EventActionType.Inspect ? "Inspect Stripe Events" : "Process Stripe Events";
|
||||
ViewData["Title"] = title;
|
||||
}
|
||||
|
||||
<h1>@title</h1>
|
||||
<h2>Results</h2>
|
||||
|
||||
<div class="table-responsive">
|
||||
@if (!Model.Events.Any())
|
||||
{
|
||||
<p>No data found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>API Version</th>
|
||||
<th>Created</th>
|
||||
@if (Model.ActionType == EventActionType.Process)
|
||||
{
|
||||
<th>Processing Error</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var eventResponseBody in Model.Events)
|
||||
{
|
||||
<tr>
|
||||
<td><a href="@eventResponseBody.URL">@eventResponseBody.Id</a></td>
|
||||
<td>@eventResponseBody.Type</td>
|
||||
<td>@eventResponseBody.APIVersion</td>
|
||||
<td>@eventResponseBody.CreatedUTC</td>
|
||||
@if (Model.ActionType == EventActionType.Process)
|
||||
{
|
||||
<td>@eventResponseBody.ProcessingError</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
5
src/Admin/Billing/Views/_ViewImports.cshtml
Normal file
5
src/Admin/Billing/Views/_ViewImports.cshtml
Normal file
@ -0,0 +1,5 @@
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Bit.Admin.AdminConsole
|
||||
@using Bit.Admin.AdminConsole.Models
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@addTagHelper "*, Admin"
|
3
src/Admin/Billing/Views/_ViewStart.cshtml
Normal file
3
src/Admin/Billing/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
using Bit.Admin.Enums;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Models;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@ -24,9 +24,9 @@ public class UsersController : Controller
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IAccessControlService _accessControlService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public UsersController(
|
||||
IUserRepository userRepository,
|
||||
@ -34,18 +34,18 @@ public class UsersController : Controller
|
||||
IPaymentService paymentService,
|
||||
GlobalSettings globalSettings,
|
||||
IAccessControlService accessControlService,
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IUserService userService,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_cipherRepository = cipherRepository;
|
||||
_paymentService = paymentService;
|
||||
_globalSettings = globalSettings;
|
||||
_accessControlService = accessControlService;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_userService = userService;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.User_List_View)]
|
||||
@ -64,14 +64,12 @@ public class UsersController : Controller
|
||||
var skip = (page - 1) * count;
|
||||
var users = await _userRepository.SearchAsync(email, skip, count);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||
{
|
||||
TempData["UsersTwoFactorIsEnabled"] = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id));
|
||||
}
|
||||
var twoFactorAuthLookup = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id))).ToList();
|
||||
var userModels = UserViewModel.MapViewModels(users, twoFactorAuthLookup).ToList();
|
||||
|
||||
return View(new UsersModel
|
||||
{
|
||||
Items = users as List<User>,
|
||||
Items = userModels,
|
||||
Email = string.IsNullOrWhiteSpace(email) ? null : email,
|
||||
Page = page,
|
||||
Count = count,
|
||||
@ -82,13 +80,17 @@ public class UsersController : Controller
|
||||
public async Task<IActionResult> View(Guid id)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(id);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
return View(new UserViewModel(user, ciphers));
|
||||
|
||||
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
|
||||
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain));
|
||||
}
|
||||
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
@ -103,7 +105,9 @@ public class UsersController : Controller
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
var billingInfo = await _paymentService.GetBillingAsync(user);
|
||||
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
|
||||
return View(new UserEditModel(user, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
|
||||
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
|
||||
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -157,4 +161,12 @@ public class UsersController : Controller
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user