mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
Merge branch 'main' into vault/pm-11249/attachment-cipher-date
This commit is contained in:
commit
9743b1f9eb
91
.github/workflows/build.yml
vendored
91
.github/workflows/build.yml
vendored
@ -7,18 +7,27 @@ on:
|
|||||||
- "main"
|
- "main"
|
||||||
- "rc"
|
- "rc"
|
||||||
- "hotfix-rc"
|
- "hotfix-rc"
|
||||||
pull_request:
|
pull_request_target:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check-run:
|
||||||
|
name: Check PR run
|
||||||
|
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
needs:
|
||||||
|
- check-run
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
@ -68,6 +77,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
@ -115,24 +126,6 @@ jobs:
|
|||||||
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
check-akv-secrets:
|
|
||||||
name: Check for AKV secrets
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
outputs:
|
|
||||||
available: ${{ steps.check-akv-secrets.outputs.available }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check
|
|
||||||
id: check-akv-secrets
|
|
||||||
run: |
|
|
||||||
if [ "${{ secrets.AZURE_PROD_KV_CREDENTIALS }}" != '' ]; then
|
|
||||||
echo "available=true" >> $GITHUB_OUTPUT;
|
|
||||||
else
|
|
||||||
echo "available=false" >> $GITHUB_OUTPUT;
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
name: Build Docker images
|
name: Build Docker images
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@ -140,8 +133,6 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
needs:
|
needs:
|
||||||
- build-artifacts
|
- build-artifacts
|
||||||
- check-akv-secrets
|
|
||||||
if: ${{ needs.check-akv-secrets.outputs.available == 'true' }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@ -194,6 +185,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Check branch to publish
|
- name: Check branch to publish
|
||||||
env:
|
env:
|
||||||
@ -233,7 +226,7 @@ jobs:
|
|||||||
- name: Generate Docker image tag
|
- name: Generate Docker image tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then
|
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||||
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
|
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
|
||||||
else
|
else
|
||||||
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
|
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
|
||||||
@ -313,6 +306,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
@ -326,9 +321,9 @@ jobs:
|
|||||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||||
|
|
||||||
- name: Make Docker stubs
|
- name: Make Docker stubs
|
||||||
if: github.ref == 'refs/heads/main' ||
|
if: |
|
||||||
github.ref == 'refs/heads/rc' ||
|
github.event_name != 'pull_request_target'
|
||||||
github.ref == 'refs/heads/hotfix-rc'
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
run: |
|
run: |
|
||||||
# Set proper setup image based on branch
|
# Set proper setup image based on branch
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
@ -368,13 +363,17 @@ jobs:
|
|||||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||||
|
|
||||||
- name: Make Docker stub checksums
|
- name: Make Docker stub checksums
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
run: |
|
run: |
|
||||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||||
|
|
||||||
- name: Upload Docker stub US artifact
|
- name: Upload Docker stub US artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-US.zip
|
name: docker-stub-US.zip
|
||||||
@ -382,7 +381,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub EU artifact
|
- name: Upload Docker stub EU artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-EU.zip
|
name: docker-stub-EU.zip
|
||||||
@ -390,7 +391,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub US checksum artifact
|
- name: Upload Docker stub US checksum artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-US-sha256.txt
|
name: docker-stub-US-sha256.txt
|
||||||
@ -398,7 +401,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub EU checksum artifact
|
- name: Upload Docker stub EU checksum artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-EU-sha256.txt
|
name: docker-stub-EU-sha256.txt
|
||||||
@ -473,7 +478,8 @@ jobs:
|
|||||||
build-mssqlmigratorutility:
|
build-mssqlmigratorutility:
|
||||||
name: Build MSSQL migrator utility
|
name: Build MSSQL migrator utility
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: lint
|
needs:
|
||||||
|
- lint
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@ -488,6 +494,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
@ -522,8 +530,10 @@ jobs:
|
|||||||
|
|
||||||
self-host-build:
|
self-host-build:
|
||||||
name: Trigger self-host build
|
name: Trigger self-host build
|
||||||
|
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to Azure - CI subscription
|
- name: Log in to Azure - CI subscription
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||||
@ -554,9 +564,10 @@ jobs:
|
|||||||
|
|
||||||
trigger-k8s-deploy:
|
trigger-k8s-deploy:
|
||||||
name: Trigger k8s deploy
|
name: Trigger k8s deploy
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to Azure - CI subscription
|
- name: Log in to Azure - CI subscription
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||||
@ -588,9 +599,12 @@ jobs:
|
|||||||
|
|
||||||
trigger-ee-updates:
|
trigger-ee-updates:
|
||||||
name: Trigger Ephemeral Environment updates
|
name: Trigger Ephemeral Environment updates
|
||||||
if: github.ref != 'refs/heads/main' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
if: |
|
||||||
|
github.event_name == 'pull_request_target'
|
||||||
|
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to Azure - CI subscription
|
- name: Log in to Azure - CI subscription
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||||
@ -634,9 +648,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check if any job failed
|
- name: Check if any job failed
|
||||||
if: |
|
if: |
|
||||||
(github.ref == 'refs/heads/main'
|
github.event_name != 'pull_request_target'
|
||||||
|| github.ref == 'refs/heads/rc'
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
|| github.ref == 'refs/heads/hotfix-rc')
|
|
||||||
&& contains(needs.*.result, 'failure')
|
&& contains(needs.*.result, 'failure')
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|
||||||
|
35
.github/workflows/repository-management.yml
vendored
35
.github/workflows/repository-management.yml
vendored
@ -28,7 +28,6 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
outputs:
|
outputs:
|
||||||
branch: ${{ steps.set-branch.outputs.branch }}
|
branch: ${{ steps.set-branch.outputs.branch }}
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set branch
|
- name: Set branch
|
||||||
id: set-branch
|
id: set-branch
|
||||||
@ -45,13 +44,6 @@ jobs:
|
|||||||
|
|
||||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Generate GH App token
|
|
||||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
|
||||||
id: app-token
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
|
||||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
|
||||||
|
|
||||||
|
|
||||||
cut_branch:
|
cut_branch:
|
||||||
name: Cut branch
|
name: Cut branch
|
||||||
@ -59,11 +51,18 @@ jobs:
|
|||||||
needs: setup
|
needs: setup
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
|
- name: Generate GH App token
|
||||||
|
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||||
|
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||||
|
|
||||||
- name: Check out target ref
|
- name: Check out target ref
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.target_ref }}
|
ref: ${{ inputs.target_ref }}
|
||||||
token: ${{ needs.setup.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
|
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
|
||||||
env:
|
env:
|
||||||
@ -98,11 +97,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: ${{ inputs.version_number_override }}
|
version: ${{ inputs.version_number_override }}
|
||||||
|
|
||||||
|
- name: Generate GH App token
|
||||||
|
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||||
|
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||||
|
|
||||||
- name: Check out branch
|
- name: Check out branch
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
token: ${{ needs.setup.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
- name: Configure Git
|
- name: Configure Git
|
||||||
run: |
|
run: |
|
||||||
@ -190,11 +196,18 @@ jobs:
|
|||||||
- bump_version
|
- bump_version
|
||||||
- setup
|
- setup
|
||||||
steps:
|
steps:
|
||||||
|
- name: Generate GH App token
|
||||||
|
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||||
|
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||||
|
|
||||||
- name: Check out main branch
|
- name: Check out main branch
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
token: ${{ needs.setup.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
- name: Configure Git
|
- name: Configure Git
|
||||||
run: |
|
run: |
|
||||||
|
18
.github/workflows/test-database.yml
vendored
18
.github/workflows/test-database.yml
vendored
@ -70,6 +70,11 @@ jobs:
|
|||||||
docker compose --profile mssql --profile postgres --profile mysql up -d
|
docker compose --profile mssql --profile postgres --profile mysql up -d
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Add MariaDB for unified
|
||||||
|
# Use a different port than MySQL
|
||||||
|
run: |
|
||||||
|
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
|
||||||
|
|
||||||
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
|
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
|
||||||
- name: Sleep
|
- name: Sleep
|
||||||
run: sleep 15s
|
run: sleep 15s
|
||||||
@ -103,6 +108,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
|
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
|
||||||
|
|
||||||
|
- name: Migrate MariaDB
|
||||||
|
working-directory: "util/MySqlMigrations"
|
||||||
|
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
|
||||||
|
env:
|
||||||
|
CONN_STR: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
||||||
|
|
||||||
- name: Migrate Postgres
|
- name: Migrate Postgres
|
||||||
working-directory: "util/PostgresMigrations"
|
working-directory: "util/PostgresMigrations"
|
||||||
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
|
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
|
||||||
@ -130,6 +141,9 @@ jobs:
|
|||||||
# Default Sqlite
|
# Default Sqlite
|
||||||
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
||||||
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
||||||
|
# Unified MariaDB
|
||||||
|
BW_TEST_DATABASES__4__TYPE: "MySql"
|
||||||
|
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
||||||
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx"
|
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx"
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
@ -137,6 +151,10 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
|
run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
|
||||||
|
|
||||||
|
- name: Print MariaDB Logs
|
||||||
|
if: failure()
|
||||||
|
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")'
|
||||||
|
|
||||||
- name: Print Postgres Logs
|
- name: Print Postgres Logs
|
||||||
if: failure()
|
if: failure()
|
||||||
run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
|
run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using Bit.Core;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
@ -10,7 +9,6 @@ using Bit.Core.Billing.Repositories;
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
|
||||||
|
|
||||||
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||||
|
|
||||||
@ -21,35 +19,28 @@ public class CreateProviderCommand : ICreateProviderCommand
|
|||||||
private readonly IProviderService _providerService;
|
private readonly IProviderService _providerService;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||||
private readonly IFeatureService _featureService;
|
|
||||||
|
|
||||||
public CreateProviderCommand(
|
public CreateProviderCommand(
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
IProviderService providerService,
|
IProviderService providerService,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository)
|
||||||
IFeatureService featureService)
|
|
||||||
{
|
{
|
||||||
_providerRepository = providerRepository;
|
_providerRepository = providerRepository;
|
||||||
_providerUserRepository = providerUserRepository;
|
_providerUserRepository = providerUserRepository;
|
||||||
_providerService = providerService;
|
_providerService = providerService;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_providerPlanRepository = providerPlanRepository;
|
_providerPlanRepository = providerPlanRepository;
|
||||||
_featureService = featureService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
|
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
|
||||||
{
|
{
|
||||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||||
|
|
||||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
await Task.WhenAll(
|
||||||
|
CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats),
|
||||||
if (isConsolidatedBillingEnabled)
|
CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats));
|
||||||
{
|
|
||||||
await CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats);
|
|
||||||
await CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateResellerAsync(Provider provider)
|
public async Task CreateResellerAsync(Provider provider)
|
||||||
@ -61,13 +52,8 @@ public class CreateProviderCommand : ICreateProviderCommand
|
|||||||
{
|
{
|
||||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||||
|
|
||||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
|
||||||
|
|
||||||
if (isConsolidatedBillingEnabled)
|
|
||||||
{
|
|
||||||
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
|
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
|
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
|
||||||
{
|
{
|
||||||
@ -77,12 +63,7 @@ public class CreateProviderCommand : ICreateProviderCommand
|
|||||||
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
|
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
|
||||||
|
|
||||||
if (isConsolidatedBillingEnabled)
|
|
||||||
{
|
|
||||||
provider.Gateway = GatewayType.Stripe;
|
provider.Gateway = GatewayType.Stripe;
|
||||||
}
|
|
||||||
|
|
||||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
|
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
|
||||||
|
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
using Bit.Core;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
@ -102,11 +100,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
|||||||
Provider provider,
|
Provider provider,
|
||||||
IEnumerable<string> organizationOwnerEmails)
|
IEnumerable<string> organizationOwnerEmails)
|
||||||
{
|
{
|
||||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
if (provider.IsBillable() &&
|
||||||
|
organization.IsValidClient() &&
|
||||||
if (isConsolidatedBillingEnabled &&
|
|
||||||
provider.Status == ProviderStatusType.Billable &&
|
|
||||||
organization.Status == OrganizationStatusType.Managed &&
|
|
||||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||||
{
|
{
|
||||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||||
|
@ -8,7 +8,6 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
@ -101,13 +100,6 @@ public class ProviderService : IProviderService
|
|||||||
throw new BadRequestException("Invalid owner.");
|
throw new BadRequestException("Invalid owner.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
|
||||||
{
|
|
||||||
provider.Status = ProviderStatusType.Created;
|
|
||||||
await _providerRepository.UpsertAsync(provider);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||||
@ -118,7 +110,6 @@ public class ProviderService : IProviderService
|
|||||||
provider.GatewaySubscriptionId = subscription.Id;
|
provider.GatewaySubscriptionId = subscription.Id;
|
||||||
provider.Status = ProviderStatusType.Billable;
|
provider.Status = ProviderStatusType.Billable;
|
||||||
await _providerRepository.UpsertAsync(provider);
|
await _providerRepository.UpsertAsync(provider);
|
||||||
}
|
|
||||||
|
|
||||||
providerUser.Key = key;
|
providerUser.Key = key;
|
||||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||||
@ -545,13 +536,9 @@ public class ProviderService : IProviderService
|
|||||||
{
|
{
|
||||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||||
|
|
||||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable();
|
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan);
|
||||||
|
|
||||||
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan, consolidatedBillingEnabled);
|
var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup);
|
||||||
|
|
||||||
var (organization, _, defaultCollection) = consolidatedBillingEnabled
|
|
||||||
? await _organizationService.SignupClientAsync(organizationSignup)
|
|
||||||
: await _organizationService.SignUpAsync(organizationSignup);
|
|
||||||
|
|
||||||
var providerOrganization = new ProviderOrganization
|
var providerOrganization = new ProviderOrganization
|
||||||
{
|
{
|
||||||
@ -687,9 +674,7 @@ public class ProviderService : IProviderService
|
|||||||
return confirmedOwnersIds.Except(providerUserIds).Any();
|
return confirmedOwnersIds.Except(providerUserIds).Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType, bool consolidatedBillingEnabled = false)
|
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType)
|
||||||
{
|
|
||||||
if (consolidatedBillingEnabled)
|
|
||||||
{
|
{
|
||||||
switch (providerType)
|
switch (providerType)
|
||||||
{
|
{
|
||||||
@ -708,7 +693,6 @@ public class ProviderService : IProviderService
|
|||||||
default:
|
default:
|
||||||
throw new BadRequestException($"Unsupported provider type {providerType}.");
|
throw new BadRequestException($"Unsupported provider type {providerType}.");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
|
if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
|
||||||
{
|
{
|
||||||
|
@ -2,17 +2,14 @@
|
|||||||
using Bit.Commercial.Core.Billing.Models;
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing;
|
using Bit.Core.Billing;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Billing.Services.Contracts;
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
using Bit.Core.Context;
|
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
@ -27,7 +24,6 @@ using Stripe;
|
|||||||
namespace Bit.Commercial.Core.Billing;
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
public class ProviderBillingService(
|
public class ProviderBillingService(
|
||||||
ICurrentContext currentContext,
|
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<ProviderBillingService> logger,
|
ILogger<ProviderBillingService> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -35,39 +31,77 @@ public class ProviderBillingService(
|
|||||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderRepository providerRepository,
|
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService) : IProviderBillingService
|
ISubscriberService subscriberService) : IProviderBillingService
|
||||||
{
|
{
|
||||||
public async Task AssignSeatsToClientOrganization(
|
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||||
Provider provider,
|
|
||||||
Organization organization,
|
|
||||||
int seats)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(organization);
|
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||||
|
|
||||||
if (seats < 0)
|
if (plan == null)
|
||||||
{
|
{
|
||||||
throw new BillingException(
|
throw new BadRequestException("Provider plan not found.");
|
||||||
"You cannot assign negative seats to a client.",
|
|
||||||
"MSP cannot assign negative seats to a client organization");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seats == organization.Seats)
|
if (plan.PlanType == command.NewPlan)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var seatAdjustment = seats - (organization.Seats ?? 0);
|
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
|
||||||
|
|
||||||
await ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
plan.PlanType = command.NewPlan;
|
||||||
|
await providerPlanRepository.ReplaceAsync(plan);
|
||||||
|
|
||||||
organization.Seats = seats;
|
Subscription subscription;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new ConflictException("Subscription not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
||||||
|
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
||||||
|
|
||||||
|
var updateOptions = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
|
||||||
|
Quantity = oldSubscriptionItem!.Quantity
|
||||||
|
},
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = oldSubscriptionItem.Id,
|
||||||
|
Deleted = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
||||||
|
|
||||||
|
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||||
|
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||||
|
// 2. Assign PlanType & PlanName to Organization
|
||||||
|
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
||||||
|
|
||||||
|
foreach (var providerOrganization in providerOrganizations)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||||
|
}
|
||||||
|
organization.PlanType = command.NewPlan;
|
||||||
|
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
|
||||||
await organizationRepository.ReplaceAsync(organization);
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task CreateCustomerForClientOrganization(
|
public async Task CreateCustomerForClientOrganization(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
@ -171,65 +205,16 @@ public class ProviderBillingService(
|
|||||||
return memoryStream.ToArray();
|
return memoryStream.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
|
||||||
Guid providerId,
|
|
||||||
PlanType planType)
|
|
||||||
{
|
|
||||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
|
||||||
|
|
||||||
if (provider == null)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
"Could not find provider ({ID}) when retrieving assigned seat total",
|
|
||||||
providerId);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.Type == ProviderType.Reseller)
|
|
||||||
{
|
|
||||||
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
|
||||||
|
|
||||||
var plan = StaticStore.GetPlan(planType);
|
|
||||||
|
|
||||||
return providerOrganizations
|
|
||||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
|
||||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ScaleSeats(
|
public async Task ScaleSeats(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
PlanType planType,
|
PlanType planType,
|
||||||
int seatAdjustment)
|
int seatAdjustment)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(provider);
|
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||||
|
|
||||||
if (!provider.SupportsConsolidatedBilling())
|
var seatMinimum = providerPlan.SeatMinimum ?? 0;
|
||||||
{
|
|
||||||
logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id);
|
|
||||||
|
|
||||||
throw new BillingException();
|
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||||
}
|
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
|
||||||
|
|
||||||
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
|
|
||||||
|
|
||||||
if (providerPlan == null || !providerPlan.IsConfigured())
|
|
||||||
{
|
|
||||||
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
|
|
||||||
|
|
||||||
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
|
|
||||||
|
|
||||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||||
|
|
||||||
@ -256,13 +241,6 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
||||||
newlyAssignedSeatTotal > seatMinimum)
|
newlyAssignedSeatTotal > seatMinimum)
|
||||||
{
|
{
|
||||||
if (!currentContext.ProviderProviderAdmin(provider.Id))
|
|
||||||
{
|
|
||||||
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await update(
|
await update(
|
||||||
seatMinimum,
|
seatMinimum,
|
||||||
newlyAssignedSeatTotal);
|
newlyAssignedSeatTotal);
|
||||||
@ -291,6 +269,26 @@ public class ProviderBillingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SeatAdjustmentResultsInPurchase(
|
||||||
|
Provider provider,
|
||||||
|
PlanType planType,
|
||||||
|
int seatAdjustment)
|
||||||
|
{
|
||||||
|
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||||
|
|
||||||
|
var seatMinimum = providerPlan.SeatMinimum;
|
||||||
|
|
||||||
|
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||||
|
|
||||||
|
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||||
|
|
||||||
|
return
|
||||||
|
// Below the limit to above the limit
|
||||||
|
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
|
||||||
|
// Above the limit to further above the limit
|
||||||
|
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Customer> SetupCustomer(
|
public async Task<Customer> SetupCustomer(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo)
|
TaxInfo taxInfo)
|
||||||
@ -431,75 +429,6 @@ public class ProviderBillingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
|
||||||
{
|
|
||||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
|
||||||
|
|
||||||
if (plan == null)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Provider plan not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.PlanType == command.NewPlan)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
|
|
||||||
|
|
||||||
plan.PlanType = command.NewPlan;
|
|
||||||
await providerPlanRepository.ReplaceAsync(plan);
|
|
||||||
|
|
||||||
Subscription subscription;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
throw new ConflictException("Subscription not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
|
||||||
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
|
||||||
|
|
||||||
var updateOptions = new SubscriptionUpdateOptions
|
|
||||||
{
|
|
||||||
Items =
|
|
||||||
[
|
|
||||||
new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
|
|
||||||
Quantity = oldSubscriptionItem!.Quantity
|
|
||||||
},
|
|
||||||
new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Id = oldSubscriptionItem.Id,
|
|
||||||
Deleted = true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
|
||||||
|
|
||||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
|
||||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
|
||||||
// 2. Assign PlanType & PlanName to Organization
|
|
||||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
|
||||||
|
|
||||||
foreach (var providerOrganization in providerOrganizations)
|
|
||||||
{
|
|
||||||
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
|
||||||
if (organization == null)
|
|
||||||
{
|
|
||||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
|
||||||
}
|
|
||||||
organization.PlanType = command.NewPlan;
|
|
||||||
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
|
|
||||||
await organizationRepository.ReplaceAsync(organization);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||||
{
|
{
|
||||||
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
||||||
@ -610,4 +539,32 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Replace with SPROC
|
||||||
|
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
|
||||||
|
{
|
||||||
|
var providerOrganizations =
|
||||||
|
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
|
||||||
|
|
||||||
|
var plan = StaticStore.GetPlan(planType);
|
||||||
|
|
||||||
|
return providerOrganizations
|
||||||
|
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
||||||
|
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with SPROC
|
||||||
|
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
|
||||||
|
{
|
||||||
|
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||||
|
|
||||||
|
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
|
||||||
|
|
||||||
|
if (providerPlan == null || !providerPlan.IsConfigured())
|
||||||
|
{
|
||||||
|
throw new BillingException(message: "Provider plan is missing or misconfigured");
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerPlan;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
@RenderBody()
|
@RenderBody()
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container footer text-muted">
|
<div class="container footer text-body-secondary">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
© @DateTime.Now.Year, Bitwarden Inc.
|
© @DateTime.Now.Year, Bitwarden Inc.
|
||||||
|
37
bitwarden_license/src/Sso/package-lock.json
generated
37
bitwarden_license/src/Sso/package-lock.json
generated
@ -9,10 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "-",
|
"license": "-",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "4.6.2",
|
"bootstrap": "5.3.3",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"jquery": "3.7.1",
|
"jquery": "3.7.1"
|
||||||
"popper.js": "1.16.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"css-loader": "7.1.2",
|
"css-loader": "7.1.2",
|
||||||
@ -384,6 +383,17 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
@ -702,9 +712,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "4.6.2",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||||
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
|
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -715,10 +725,8 @@
|
|||||||
"url": "https://opencollective.com/bootstrap"
|
"url": "https://opencollective.com/bootstrap"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"jquery": "1.9.1 - 3",
|
"@popperjs/core": "^2.11.8"
|
||||||
"popper.js": "^1.16.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@ -1577,17 +1585,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/popper.js": {
|
|
||||||
"version": "1.16.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
|
||||||
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
|
|
||||||
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/popperjs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.47",
|
"version": "8.4.47",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
||||||
|
@ -8,10 +8,9 @@
|
|||||||
"build": "webpack"
|
"build": "webpack"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "4.6.2",
|
"bootstrap": "5.3.3",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"jquery": "3.7.1",
|
"jquery": "3.7.1"
|
||||||
"popper.js": "1.16.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"css-loader": "7.1.2",
|
"css-loader": "7.1.2",
|
||||||
|
@ -13,8 +13,6 @@ module.exports = {
|
|||||||
entry: {
|
entry: {
|
||||||
site: [
|
site: [
|
||||||
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
||||||
|
|
||||||
"popper.js",
|
|
||||||
"bootstrap",
|
"bootstrap",
|
||||||
"jquery",
|
"jquery",
|
||||||
"font-awesome/css/font-awesome.css",
|
"font-awesome/css/font-awesome.css",
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
@ -155,9 +154,6 @@ public class RemoveOrganizationFromProviderCommandTests
|
|||||||
"b@example.com"
|
"b@example.com"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
|
||||||
.Returns(false);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||||
.Returns(GetSubscription(organization.GatewaySubscriptionId));
|
.Returns(GetSubscription(organization.GatewaySubscriptionId));
|
||||||
|
|
||||||
@ -222,9 +218,6 @@ public class RemoveOrganizationFromProviderCommandTests
|
|||||||
"b@example.com"
|
"b@example.com"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
|
||||||
.Returns(true);
|
|
||||||
|
|
||||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||||
|
|
||||||
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
|
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using Bit.Commercial.Core.AdminConsole.Services;
|
using Bit.Commercial.Core.AdminConsole.Services;
|
||||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
@ -55,8 +54,8 @@ public class ProviderServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key,
|
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
||||||
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
|
[ProviderUser] ProviderUser providerUser,
|
||||||
SutProvider<ProviderService> sutProvider)
|
SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
providerUser.ProviderId = provider.Id;
|
providerUser.ProviderId = provider.Id;
|
||||||
@ -71,37 +70,6 @@ public class ProviderServiceTests
|
|||||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||||
.Returns(protector);
|
.Returns(protector);
|
||||||
sutProvider.Create();
|
|
||||||
|
|
||||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
|
||||||
|
|
||||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key);
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(provider);
|
|
||||||
await sutProvider.GetDependency<IProviderUserRepository>().Received()
|
|
||||||
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task CompleteSetupAsync_ConsolidatedBilling_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
|
||||||
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
|
|
||||||
SutProvider<ProviderService> sutProvider)
|
|
||||||
{
|
|
||||||
providerUser.ProviderId = provider.Id;
|
|
||||||
providerUser.UserId = user.Id;
|
|
||||||
var userService = sutProvider.GetDependency<IUserService>();
|
|
||||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
|
||||||
|
|
||||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
|
||||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
|
||||||
|
|
||||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
|
||||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
|
||||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
|
||||||
.Returns(protector);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
|
||||||
.Returns(true);
|
|
||||||
|
|
||||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||||
|
|
||||||
@ -489,7 +457,7 @@ public class ProviderServiceTests
|
|||||||
public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key,
|
public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key,
|
||||||
SutProvider<ProviderService> sutProvider)
|
SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||||
organization.UseSecretsManager = true;
|
organization.UseSecretsManager = true;
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||||
@ -506,7 +474,7 @@ public class ProviderServiceTests
|
|||||||
public async Task AddOrganization_Success(Provider provider, Organization organization, string key,
|
public async Task AddOrganization_Success(Provider provider, Organization organization, string key,
|
||||||
SutProvider<ProviderService> sutProvider)
|
SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||||
|
|
||||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||||
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
|
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
|
||||||
@ -549,8 +517,8 @@ public class ProviderServiceTests
|
|||||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||||
|
|
||||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||||
var expectedPlanType = PlanType.EnterpriseAnnually;
|
var expectedPlanType = PlanType.EnterpriseMonthly;
|
||||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||||
|
|
||||||
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
|
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
|
||||||
@ -579,12 +547,12 @@ public class ProviderServiceTests
|
|||||||
BackdateProviderCreationDate(provider, newCreationDate);
|
BackdateProviderCreationDate(provider, newCreationDate);
|
||||||
provider.Type = ProviderType.Msp;
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
organization.PlanType = PlanType.EnterpriseMonthly;
|
||||||
organization.Plan = "Enterprise (Annually)";
|
organization.Plan = "Enterprise (Monthly)";
|
||||||
|
|
||||||
var expectedPlanType = PlanType.EnterpriseAnnually2020;
|
var expectedPlanType = PlanType.EnterpriseMonthly2020;
|
||||||
|
|
||||||
var expectedPlanId = "2020-enterprise-org-seat-annually";
|
var expectedPlanId = "2020-enterprise-org-seat-monthly";
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||||
@ -663,11 +631,11 @@ public class ProviderServiceTests
|
|||||||
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
|
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
|
||||||
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
|
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
organizationSignup.Plan = PlanType.EnterpriseAnnually;
|
organizationSignup.Plan = PlanType.EnterpriseMonthly;
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||||
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup)
|
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||||
.Returns((organization, null as OrganizationUser, new Collection()));
|
.Returns((organization, null as OrganizationUser, new Collection()));
|
||||||
|
|
||||||
var providerOrganization =
|
var providerOrganization =
|
||||||
@ -688,7 +656,7 @@ public class ProviderServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, OrganizationCustomize, BitAutoData]
|
[Theory, OrganizationCustomize, BitAutoData]
|
||||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException(
|
public async Task CreateOrganizationAsync_InvalidPlanType_ThrowsBadRequestException(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
OrganizationSignup organizationSignup,
|
OrganizationSignup organizationSignup,
|
||||||
Organization organization,
|
Organization organization,
|
||||||
@ -696,8 +664,6 @@ public class ProviderServiceTests
|
|||||||
User user,
|
User user,
|
||||||
SutProvider<ProviderService> sutProvider)
|
SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
|
||||||
|
|
||||||
provider.Type = ProviderType.Msp;
|
provider.Type = ProviderType.Msp;
|
||||||
provider.Status = ProviderStatusType.Billable;
|
provider.Status = ProviderStatusType.Billable;
|
||||||
|
|
||||||
@ -717,7 +683,7 @@ public class ProviderServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, OrganizationCustomize, BitAutoData]
|
[Theory, OrganizationCustomize, BitAutoData]
|
||||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync(
|
public async Task CreateOrganizationAsync_InvokeSignupClientAsync(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
OrganizationSignup organizationSignup,
|
OrganizationSignup organizationSignup,
|
||||||
Organization organization,
|
Organization organization,
|
||||||
@ -725,8 +691,6 @@ public class ProviderServiceTests
|
|||||||
User user,
|
User user,
|
||||||
SutProvider<ProviderService> sutProvider)
|
SutProvider<ProviderService> sutProvider)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
|
||||||
|
|
||||||
provider.Type = ProviderType.Msp;
|
provider.Type = ProviderType.Msp;
|
||||||
provider.Status = ProviderStatusType.Billable;
|
provider.Status = ProviderStatusType.Billable;
|
||||||
|
|
||||||
@ -771,11 +735,11 @@ public class ProviderServiceTests
|
|||||||
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
|
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
|
||||||
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
|
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
|
||||||
{
|
{
|
||||||
organizationSignup.Plan = PlanType.EnterpriseAnnually;
|
organizationSignup.Plan = PlanType.EnterpriseMonthly;
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||||
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup)
|
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||||
.Returns((organization, null as OrganizationUser, defaultCollection));
|
.Returns((organization, null as OrganizationUser, defaultCollection));
|
||||||
|
|
||||||
var providerOrganization =
|
var providerOrganization =
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -5,8 +5,10 @@ using Bit.Admin.Services;
|
|||||||
using Bit.Admin.Utilities;
|
using Bit.Admin.Utilities;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -53,8 +55,8 @@ public class OrganizationsController : Controller
|
|||||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||||
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
|
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
|
||||||
private readonly IFeatureService _featureService;
|
|
||||||
private readonly IProviderBillingService _providerBillingService;
|
private readonly IProviderBillingService _providerBillingService;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public OrganizationsController(
|
public OrganizationsController(
|
||||||
IOrganizationService organizationService,
|
IOrganizationService organizationService,
|
||||||
@ -80,8 +82,8 @@ public class OrganizationsController : Controller
|
|||||||
IServiceAccountRepository serviceAccountRepository,
|
IServiceAccountRepository serviceAccountRepository,
|
||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
|
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
|
||||||
IFeatureService featureService,
|
IProviderBillingService providerBillingService,
|
||||||
IProviderBillingService providerBillingService)
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_organizationService = organizationService;
|
_organizationService = organizationService;
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
@ -106,8 +108,8 @@ public class OrganizationsController : Controller
|
|||||||
_serviceAccountRepository = serviceAccountRepository;
|
_serviceAccountRepository = serviceAccountRepository;
|
||||||
_providerOrganizationRepository = providerOrganizationRepository;
|
_providerOrganizationRepository = providerOrganizationRepository;
|
||||||
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
|
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
|
||||||
_featureService = featureService;
|
|
||||||
_providerBillingService = providerBillingService;
|
_providerBillingService = providerBillingService;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequirePermission(Permission.Org_List_View)]
|
[RequirePermission(Permission.Org_List_View)]
|
||||||
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
|
|||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
|
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
|
||||||
{
|
{
|
||||||
var organization = await GetOrganization(id, model);
|
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Could not find organization to update.";
|
||||||
|
return RedirectToAction("Index");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingOrganizationData = new Organization
|
||||||
|
{
|
||||||
|
Id = organization.Id,
|
||||||
|
Status = organization.Status,
|
||||||
|
PlanType = organization.PlanType,
|
||||||
|
Seats = organization.Seats
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateOrganization(organization, model);
|
||||||
|
|
||||||
if (organization.UseSecretsManager &&
|
if (organization.UseSecretsManager &&
|
||||||
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
||||||
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
|
|||||||
return RedirectToAction("Edit", new { id });
|
return RedirectToAction("Edit", new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await HandlePotentialProviderSeatScalingAsync(
|
||||||
|
existingOrganizationData,
|
||||||
|
model);
|
||||||
|
|
||||||
await _organizationRepository.ReplaceAsync(organization);
|
await _organizationRepository.ReplaceAsync(organization);
|
||||||
|
|
||||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
||||||
{
|
{
|
||||||
@ -262,9 +285,7 @@ public class OrganizationsController : Controller
|
|||||||
return RedirectToAction("Index");
|
return RedirectToAction("Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
if (organization.IsValidClient())
|
||||||
|
|
||||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
|
||||||
{
|
{
|
||||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
@ -394,10 +415,9 @@ public class OrganizationsController : Controller
|
|||||||
|
|
||||||
return Json(null);
|
return Json(null);
|
||||||
}
|
}
|
||||||
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
|
|
||||||
{
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
|
||||||
|
|
||||||
|
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
|
||||||
|
{
|
||||||
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
|
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
|
||||||
{
|
{
|
||||||
organization.Enabled = model.Enabled;
|
organization.Enabled = model.Enabled;
|
||||||
@ -449,7 +469,62 @@ public class OrganizationsController : Controller
|
|||||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||||
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return organization;
|
private async Task HandlePotentialProviderSeatScalingAsync(
|
||||||
|
Organization organization,
|
||||||
|
OrganizationEditModel update)
|
||||||
|
{
|
||||||
|
var scaleMSPOnClientOrganizationUpdate =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
|
||||||
|
|
||||||
|
if (!scaleMSPOnClientOrganizationUpdate)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
|
// No scaling required
|
||||||
|
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
|
||||||
|
organization is not { Status: OrganizationStatusType.Managed } ||
|
||||||
|
!organization.Seats.HasValue ||
|
||||||
|
update is { Seats: null, PlanType: null } ||
|
||||||
|
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
|
||||||
|
(PlanTypesMatch() && SeatsMatch()))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only scale the plan
|
||||||
|
if (!PlanTypesMatch() && SeatsMatch())
|
||||||
|
{
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
|
||||||
|
}
|
||||||
|
// Only scale the seats
|
||||||
|
else if (PlanTypesMatch() && !SeatsMatch())
|
||||||
|
{
|
||||||
|
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
||||||
|
}
|
||||||
|
// Scale both
|
||||||
|
else if (!PlanTypesMatch() && !SeatsMatch())
|
||||||
|
{
|
||||||
|
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||||
|
var planTypeAdjustment = organization.Seats.Value;
|
||||||
|
var totalAdjustment = seatAdjustment + planTypeAdjustment;
|
||||||
|
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
bool PlanTypesMatch()
|
||||||
|
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
|
||||||
|
|
||||||
|
bool SeatsMatch()
|
||||||
|
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -282,9 +282,7 @@ public class ProvidersController : Controller
|
|||||||
await _providerRepository.ReplaceAsync(provider);
|
await _providerRepository.ReplaceAsync(provider);
|
||||||
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
|
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
|
||||||
|
|
||||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
if (!provider.IsBillable())
|
||||||
|
|
||||||
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
|
|
||||||
{
|
{
|
||||||
return RedirectToAction("Edit", new { id });
|
return RedirectToAction("Edit", new { id });
|
||||||
}
|
}
|
||||||
@ -340,10 +338,7 @@ public class ProvidersController : Controller
|
|||||||
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
|
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
|
||||||
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
|
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
|
||||||
|
|
||||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
if (!provider.IsBillable())
|
||||||
|
|
||||||
|
|
||||||
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
|
|
||||||
{
|
{
|
||||||
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
|
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
|
||||||
}
|
}
|
||||||
|
@ -103,19 +103,19 @@
|
|||||||
|
|
||||||
<div class="d-flex mt-4">
|
<div class="d-flex mt-4">
|
||||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||||
<div class="ml-auto d-flex">
|
<div class="ms-auto d-flex">
|
||||||
@if (canInitiateTrial && Model.Provider is null)
|
@if (canInitiateTrial && Model.Provider is null)
|
||||||
{
|
{
|
||||||
<button class="btn btn-secondary mr-2" type="button" id="teams-trial">
|
<button class="btn btn-secondary me-2" type="button" id="teams-trial">
|
||||||
Teams Trial
|
Teams Trial
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary mr-2" type="button" id="enterprise-trial">
|
<button class="btn btn-secondary me-2" type="button" id="enterprise-trial">
|
||||||
Enterprise Trial
|
Enterprise Trial
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-danger mr-2"
|
<button class="btn btn-outline-danger me-2"
|
||||||
onclick="return unlinkProvider('@Model.Organization.Id');">
|
onclick="return unlinkProvider('@Model.Organization.Id');">
|
||||||
Unlink provider
|
Unlink provider
|
||||||
</button>
|
</button>
|
||||||
@ -124,7 +124,7 @@
|
|||||||
{
|
{
|
||||||
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
|
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
|
||||||
<input type="hidden" name="AdminEmail" id="AdminEmail" />
|
<input type="hidden" name="AdminEmail" id="AdminEmail" />
|
||||||
<button class="btn btn-danger mr-2" type="submit">Request Delete</button>
|
<button class="btn btn-danger me-2" type="submit">Request Delete</button>
|
||||||
</form>
|
</form>
|
||||||
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
|
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
|
||||||
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
|
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
|
||||||
|
@ -5,21 +5,31 @@
|
|||||||
|
|
||||||
<h1>Organizations</h1>
|
<h1>Organizations</h1>
|
||||||
|
|
||||||
<form class="form-inline mb-2" method="get">
|
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
|
||||||
<label class="sr-only" asp-for="Name">Name</label>
|
<div class="col-12">
|
||||||
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name">
|
<label class="visually-hidden" asp-for="Name">Name</label>
|
||||||
<label class="sr-only" asp-for="UserEmail">User email</label>
|
<input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
|
||||||
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="visually-hidden" asp-for="UserEmail">User email</label>
|
||||||
|
<input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
||||||
|
</div>
|
||||||
@if(!Model.SelfHosted)
|
@if(!Model.SelfHosted)
|
||||||
{
|
{
|
||||||
<label class="sr-only" asp-for="Paid">Customer</label>
|
<div class="col-12">
|
||||||
<select class="form-control mb-2 mr-2" asp-for="Paid" name="paid">
|
<label class="visually-hidden" asp-for="Paid">Customer</label>
|
||||||
|
<select class="form-select" asp-for="Paid" name="paid">
|
||||||
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
|
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
|
||||||
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
|
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
|
||||||
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
|
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary" title="Search">
|
||||||
|
<i class="fa fa-search"></i> Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -68,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-smile-o fa-lg fa-fw text-muted" title="Freeloader"></i>
|
<i class="fa fa-smile-o fa-lg fa-fw text-body-secondary" title="Freeloader"></i>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if(org.MaxStorageGb.HasValue && org.MaxStorageGb > 1)
|
@if(org.MaxStorageGb.HasValue && org.MaxStorageGb > 1)
|
||||||
@ -78,7 +88,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
|
<i class="fa fa-plus-square-o fa-lg fa-fw text-body-secondary"
|
||||||
title="No Additional Storage"></i>
|
title="No Additional Storage"></i>
|
||||||
}
|
}
|
||||||
@if(org.Enabled)
|
@if(org.Enabled)
|
||||||
@ -88,7 +98,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Disabled"></i>
|
<i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Disabled"></i>
|
||||||
}
|
}
|
||||||
@if(org.TwoFactorIsEnabled())
|
@if(org.TwoFactorIsEnabled())
|
||||||
{
|
{
|
||||||
@ -96,7 +106,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
<i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -9,12 +9,18 @@
|
|||||||
<h1>Add Existing Organization</h1>
|
<h1>Add Existing Organization</h1>
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<form class="form-inline mb-2" method="get" asp-route-id="@providerId">
|
<form class="row g-3 align-items-center mb-2" method="get" asp-route-id="@providerId">
|
||||||
<label class="sr-only" asp-for="OrganizationName"></label>
|
<div class="col">
|
||||||
<input type="text" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
|
<label class="visually-hidden" asp-for="OrganizationName"></label>
|
||||||
<label class="sr-only" asp-for="OrganizationOwnerEmail"></label>
|
<input type="text" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
|
||||||
<input type="email" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
|
</div>
|
||||||
<button type="submit" class="btn btn-primary mb-2" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
|
<div class="col">
|
||||||
|
<label class="visually-hidden" asp-for="OrganizationOwnerEmail"></label>
|
||||||
|
<input type="email" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-primary" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,23 +22,23 @@
|
|||||||
<h1>Create Provider</h1>
|
<h1>Create Provider</h1>
|
||||||
<form method="post" asp-action="Create">
|
<form method="post" asp-action="Create">
|
||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Type" class="h2"></label>
|
<label asp-for="Type" class="form-label h2"></label>
|
||||||
@foreach (var providerType in providerTypes)
|
@foreach (var providerType in providerTypes)
|
||||||
{
|
{
|
||||||
var providerTypeValue = (int)providerType;
|
var providerTypeValue = (int)providerType;
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
|
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
|
||||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
|
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label", @for = $"providerType-{providerTypeValue}" })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted align-top", @for = $"providerType-{providerTypeValue}" })
|
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-body-secondary ps-4", @for = $"providerType-{providerTypeValue}" })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,39 +1,31 @@
|
|||||||
@using Bit.Core.AdminConsole.Enums.Provider
|
|
||||||
@using Bit.Core
|
|
||||||
|
|
||||||
@model CreateMspProviderModel
|
@model CreateMspProviderModel
|
||||||
|
|
||||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Create Managed Service Provider";
|
ViewData["Title"] = "Create Managed Service Provider";
|
||||||
}
|
}
|
||||||
|
|
||||||
<h1>Create Managed Service Provider</h1>
|
<h1>Create Managed Service Provider</h1>
|
||||||
<div>
|
<div>
|
||||||
<form class="form-group" method="post" asp-action="CreateMsp">
|
<form method="post" asp-action="CreateMsp">
|
||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="OwnerEmail"></label>
|
<label asp-for="OwnerEmail" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||||
</div>
|
</div>
|
||||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
|
||||||
{
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
<label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
|
||||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
<label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
|
||||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,17 +7,17 @@
|
|||||||
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
|
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
|
||||||
}
|
}
|
||||||
|
|
||||||
<h1>Create Multi-organization Enterprise Provider</h1>
|
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
|
||||||
<div>
|
<div>
|
||||||
<form class="form-group" method="post" asp-action="CreateMultiOrganizationEnterprise">
|
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
|
||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="OwnerEmail"></label>
|
<label asp-for="OwnerEmail" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
@{
|
@{
|
||||||
var multiOrgPlans = new List<PlanType>
|
var multiOrgPlans = new List<PlanType>
|
||||||
{
|
{
|
||||||
@ -25,19 +25,19 @@
|
|||||||
PlanType.EnterpriseMonthly
|
PlanType.EnterpriseMonthly
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
<label asp-for="Plan"></label>
|
<label asp-for="Plan" class="form-label"></label>
|
||||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||||
<option value="">--</option>
|
<option value="">--</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="EnterpriseSeatMinimum"></label>
|
<label asp-for="EnterpriseSeatMinimum" class="form-label"></label>
|
||||||
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
|
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
<button type="submit" class="btn btn-primary">Create Provider</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
|
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
|
||||||
<div class="d-flex mt-4">
|
<div class="d-flex mt-4">
|
||||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||||
<div class="ml-auto d-flex">
|
<div class="ms-auto d-flex">
|
||||||
<form asp-controller="Providers" asp-action="Edit" asp-route-id="@Model.Provider.Id"
|
<form asp-controller="Providers" asp-action="Edit" asp-route-id="@Model.Provider.Id"
|
||||||
onsubmit="return confirm('Are you sure you want to cancel?')">
|
onsubmit="return confirm('Are you sure you want to cancel?')">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Cancel</button>
|
<button class="btn btn-outline-secondary" type="submit">Cancel</button>
|
||||||
|
@ -6,18 +6,18 @@
|
|||||||
|
|
||||||
<h1>Create Reseller Provider</h1>
|
<h1>Create Reseller Provider</h1>
|
||||||
<div>
|
<div>
|
||||||
<form class="form-group" method="post" asp-action="CreateReseller">
|
<form class="mb-3" method="post" asp-action="CreateReseller">
|
||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Name"></label>
|
<label asp-for="Name" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="Name">
|
<input type="text" class="form-control" asp-for="Name">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="BusinessName"></label>
|
<label asp-for="BusinessName" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="BusinessName">
|
<input type="text" class="form-control" asp-for="BusinessName">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="BillingEmail"></label>
|
<label asp-for="BillingEmail" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="BillingEmail">
|
<input type="text" class="form-control" asp-for="BillingEmail">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||||
|
@ -34,21 +34,21 @@
|
|||||||
<h2>Billing</h2>
|
<h2>Billing</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="BillingEmail"></label>
|
<label asp-for="BillingEmail" class="form-label"></label>
|
||||||
<input type="email" class="form-control" asp-for="BillingEmail" readonly='@(!canEdit)'>
|
<input type="email" class="form-control" asp-for="BillingEmail" readonly='@(!canEdit)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="BillingPhone"></label>
|
<label asp-for="BillingPhone" class="form-label"></label>
|
||||||
<input type="tel" class="form-control" asp-for="BillingPhone">
|
<input type="tel" class="form-control" asp-for="BillingPhone">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
|
@if (Model.Provider.IsBillable())
|
||||||
{
|
{
|
||||||
switch (Model.Provider.Type)
|
switch (Model.Provider.Type)
|
||||||
{
|
{
|
||||||
@ -56,58 +56,18 @@
|
|||||||
{
|
{
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
<label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
|
||||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
<label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
|
||||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm">
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="Gateway"></label>
|
|
||||||
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
|
||||||
<option value="">--</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="GatewayCustomerId"></label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
|
||||||
<div class="input-group-append">
|
|
||||||
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
|
|
||||||
<i class="fa fa-external-link"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="GatewaySubscriptionId"></label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
|
||||||
<div class="input-group-append">
|
|
||||||
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
|
|
||||||
<i class="fa fa-external-link"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ProviderType.MultiOrganizationEnterprise:
|
case ProviderType.MultiOrganizationEnterprise:
|
||||||
@ -116,7 +76,7 @@
|
|||||||
{
|
{
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
@{
|
@{
|
||||||
var multiOrgPlans = new List<PlanType>
|
var multiOrgPlans = new List<PlanType>
|
||||||
{
|
{
|
||||||
@ -124,15 +84,15 @@
|
|||||||
PlanType.EnterpriseMonthly
|
PlanType.EnterpriseMonthly
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
<label asp-for="Plan"></label>
|
<label asp-for="Plan" class="form-label"></label>
|
||||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||||
<option value="">--</option>
|
<option value="">--</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="EnterpriseMinimumSeats"></label>
|
<label asp-for="EnterpriseMinimumSeats" class="form-label"></label>
|
||||||
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
|
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -141,6 +101,40 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="Gateway" class="form-label"></label>
|
||||||
|
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||||
|
<option value="">--</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="GatewayCustomerId" class="form-label"></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
||||||
|
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewayCustomerUrl', '_blank')">
|
||||||
|
<i class="fa fa-external-link"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
||||||
|
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewaySubscriptionUrl', '_blank')">
|
||||||
|
<i class="fa fa-external-link"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
@await Html.PartialAsync("Organizations", Model)
|
@await Html.PartialAsync("Organizations", Model)
|
||||||
@ -151,21 +145,21 @@
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content rounded">
|
<div class="modal-content rounded">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<h4 class="font-weight-bolder" id="exampleModalLabel">Request provider deletion</h4>
|
<h4 class="fw-bolder" id="exampleModalLabel">Request provider deletion</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span class="font-weight-light">
|
<span class="fw-light">
|
||||||
Enter the email of the provider admin that will receive the request to delete the provider portal.
|
Enter the email of the provider admin that will receive the request to delete the provider portal.
|
||||||
</span>
|
</span>
|
||||||
<form>
|
<form>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label for="provider-email" class="col-form-label">Provider email</label>
|
<label for="provider-email" class="col-form-label">Provider email</label>
|
||||||
<input type="email" class="form-control" id="provider-email">
|
<input type="email" class="form-control" id="provider-email">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-danger btn-pill" onclick="initiateDeleteProvider('@Model.Provider.Id')">Send email request</button>
|
<button type="button" class="btn btn-danger btn-pill" onclick="initiateDeleteProvider('@Model.Provider.Id')">Send email request</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -175,21 +169,21 @@
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content rounded">
|
<div class="modal-content rounded">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<h4 class="font-weight-bolder" id="exampleModalLabel">Delete provider</h4>
|
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span class="font-weight-light">
|
<span class="fw-light">
|
||||||
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
|
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
|
||||||
</span>
|
</span>
|
||||||
<form>
|
<form>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label for="provider-name" class="col-form-label">Provider name</label>
|
<label for="provider-name" class="col-form-label">Provider name</label>
|
||||||
<input type="text" class="form-control" id="provider-name">
|
<input type="text" class="form-control" id="provider-name">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
|
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -199,12 +193,12 @@
|
|||||||
<div class="modal-dialog" role="document">
|
<div class="modal-dialog" role="document">
|
||||||
<div class="modal-content rounded">
|
<div class="modal-content rounded">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<h4 class="font-weight-bolder">Cannot Delete @Model.Name</h4>
|
<h4 class="fw-bolder">Cannot Delete @Model.Name</h4>
|
||||||
<p class="font-weight-lighter">You must unlink all clients before you can delete @Model.Name.</p>
|
<p class="fw-lighter">You must unlink all clients before you can delete @Model.Name.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary btn-pill" data-dismiss="modal">Ok</button>
|
<button type="button" class="btn btn-primary btn-pill" data-bs-dismiss="modal">Ok</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -214,15 +208,14 @@
|
|||||||
|
|
||||||
<div class="d-flex mt-4">
|
<div class="d-flex mt-4">
|
||||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||||
<div class="ml-auto d-flex">
|
<div class="ms-auto d-flex">
|
||||||
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
|
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
|
||||||
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
|
<button id="requestDeletionBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#requestDeletionModal"></button>
|
||||||
|
|
||||||
<button class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
|
<button class="btn btn-outline-danger ms-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
|
||||||
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
|
<button id="deleteBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#DeleteModal"></button>
|
||||||
|
|
||||||
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
|
|
||||||
|
|
||||||
|
<button id="linkAccWarningBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#linkedWarningModal"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -11,23 +11,27 @@
|
|||||||
|
|
||||||
<h1>Providers</h1>
|
<h1>Providers</h1>
|
||||||
|
|
||||||
<div class="row mb-2">
|
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
|
||||||
<div class="col">
|
<div class="col-12">
|
||||||
<form class="form-inline mb-2" method="get">
|
<label class="visually-hidden" asp-for="Name">Name</label>
|
||||||
<label class="sr-only" asp-for="Name">Name</label>
|
<input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
|
||||||
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name">
|
</div>
|
||||||
<label class="sr-only" asp-for="UserEmail">User email</label>
|
<div class="col-12">
|
||||||
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
<label class="visually-hidden" asp-for="UserEmail">User email</label>
|
||||||
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
|
<input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
|
||||||
</form>
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary" title="Search">
|
||||||
|
<i class="fa fa-search"></i> Search
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (canCreateProvider)
|
@if (canCreateProvider)
|
||||||
{
|
{
|
||||||
<div class="col-auto">
|
<div class="col-auto ms-auto">
|
||||||
<a asp-action="Create" class="btn btn-secondary">Create Provider</a>
|
<a asp-action="Create" class="btn btn-secondary">Create Provider</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-hover">
|
<table class="table table-striped table-hover">
|
||||||
|
@ -24,9 +24,9 @@
|
|||||||
<th>
|
<th>
|
||||||
@if (Model.Provider.Type == ProviderType.Reseller)
|
@if (Model.Provider.Type == ProviderType.Reseller)
|
||||||
{
|
{
|
||||||
<div class="float-right text-nowrap">
|
<div class="float-end text-nowrap">
|
||||||
<a asp-controller="Providers" asp-action="CreateOrganization" asp-route-providerId="@Model.Provider.Id" class="btn btn-sm btn-primary">New Organization</a>
|
<a asp-controller="Providers" asp-action="CreateOrganization" asp-route-providerId="@Model.Provider.Id" class="btn btn-sm btn-primary text-decoration-none">New Organization</a>
|
||||||
<a asp-controller="Providers" asp-action="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary">Add Existing Organization</a>
|
<a asp-controller="Providers" asp-action="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary text-decoration-none">Add Existing Organization</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</th>
|
</th>
|
||||||
@ -51,16 +51,16 @@
|
|||||||
@providerOrganization.Status
|
@providerOrganization.Status
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="float-right">
|
<div class="float-end">
|
||||||
@if (canUnlinkFromProvider)
|
@if (canUnlinkFromProvider)
|
||||||
{
|
{
|
||||||
<a href="#" class="text-danger float-right" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
|
<a href="#" class="text-danger float-end" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
|
||||||
Unlink provider
|
Unlink provider
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if (providerOrganization.Status == OrganizationStatusType.Pending)
|
@if (providerOrganization.Status == OrganizationStatusType.Pending)
|
||||||
{
|
{
|
||||||
<a href="#" class="float-right mr-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
|
<a href="#" class="float-end me-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
|
||||||
Resend invitation
|
Resend invitation
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
@ -26,8 +26,8 @@
|
|||||||
<h2>General</h2>
|
<h2>General</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Name"></label>
|
<label class="form-label" asp-for="Name"></label>
|
||||||
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required>
|
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -37,17 +37,17 @@
|
|||||||
{
|
{
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label>Client Owner Email</label>
|
<label class="form-label">Client Owner Email</label>
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.Owners))
|
@if (!string.IsNullOrWhiteSpace(Model.Owners))
|
||||||
{
|
{
|
||||||
<input type="text" class="form-control" asp-for="Owners" readonly="readonly">
|
<input type="text" class="form-control" asp-for="Owners" readonly>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<input type="text" class="form-control" asp-for="Owners" required>
|
<input type="text" class="form-control" asp-for="Owners" required>
|
||||||
}
|
}
|
||||||
<label class="form-check-label small text-muted align-top">This user should be independent of the Provider. If the Provider is disassociated with the organization, this user will maintain ownership of the organization.</label>
|
<div class="form-text mt-0">This user should be independent of the Provider. If the Provider is disassociated with the organization, this user will maintain ownership of the organization.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -66,8 +66,8 @@
|
|||||||
<h2>Plan</h2>
|
<h2>Plan</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="PlanType"></label>
|
<label class="form-label" asp-for="PlanType"></label>
|
||||||
@{
|
@{
|
||||||
var planTypes = Enum.GetValues<PlanType>()
|
var planTypes = Enum.GetValues<PlanType>()
|
||||||
.Where(p =>
|
.Where(p =>
|
||||||
@ -83,12 +83,12 @@
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
<select class="form-control" asp-for="PlanType" asp-items="planTypes" disabled='@(canEditPlan ? null : "disabled")'></select>
|
<select class="form-select" asp-for="PlanType" asp-items="planTypes" disabled='@(canEditPlan ? null : "disabled")'></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Plan"></label>
|
<label class="form-label" asp-for="Plan"></label>
|
||||||
<input type="text" class="form-control" asp-for="Plan" required readonly='@(!canEditPlan)'>
|
<input type="text" class="form-control" asp-for="Plan" required readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -172,28 +172,28 @@
|
|||||||
<h2>Password Manager Configuration</h2>
|
<h2>Password Manager Configuration</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Seats"></label>
|
<label class="form-label" asp-for="Seats"></label>
|
||||||
<input type="number" class="form-control" asp-for="Seats" min="1" readonly='@(!canEditPlan)'>
|
<input type="number" class="form-control" asp-for="Seats" min="1" readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="MaxCollections"></label>
|
<label class="form-label" asp-for="MaxCollections"></label>
|
||||||
<input type="number" class="form-control" asp-for="MaxCollections" min="1" readonly='@(!canEditPlan)'>
|
<input type="number" class="form-control" asp-for="MaxCollections" min="1" readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="MaxStorageGb"></label>
|
<label class="form-label" asp-for="MaxStorageGb"></label>
|
||||||
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPlan)'>
|
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="MaxAutoscaleSeats"></label>
|
<label class="form-label" asp-for="MaxAutoscaleSeats"></label>
|
||||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1" readonly='@(!canEditPlan)'>
|
<input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1" readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -202,32 +202,32 @@
|
|||||||
|
|
||||||
@if (canViewPlan)
|
@if (canViewPlan)
|
||||||
{
|
{
|
||||||
<div id="organization-secrets-configuration" hidden="@(!Model.UseSecretsManager)">
|
<div id="organization-secrets-configuration" @(Model.UseSecretsManager ? null : "lass='d-none'")>
|
||||||
<h2>Secrets Manager Configuration</h2>
|
<h2>Secrets Manager Configuration</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="SmSeats"></label>
|
<label class="form-label" asp-for="SmSeats"></label>
|
||||||
<input type="number" class="form-control" asp-for="SmSeats" min="1" readonly='@(!canEditPlan)'>
|
<input type="number" class="form-control" asp-for="SmSeats" min="1" readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="MaxAutoscaleSmSeats"></label>
|
<label class="form-label" asp-for="MaxAutoscaleSmSeats"></label>
|
||||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSmSeats" min="1" readonly='@(!canEditPlan)'>
|
<input type="number" class="form-control" asp-for="MaxAutoscaleSmSeats" min="1" readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="SmServiceAccounts"></label>
|
<label class="form-label" asp-for="SmServiceAccounts"></label>
|
||||||
<input type="number" class="form-control" asp-for="SmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
|
<input type="number" class="form-control" asp-for="SmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="MaxAutoscaleSmServiceAccounts"></label>
|
<label class="form-label" asp-for="MaxAutoscaleSmServiceAccounts"></label>
|
||||||
<input type="number" class="form-control" asp-for="MaxAutoscaleSmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
|
<input type="number" class="form-control" asp-for="MaxAutoscaleSmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -240,14 +240,14 @@
|
|||||||
<h2>Licensing</h2>
|
<h2>Licensing</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="LicenseKey"></label>
|
<label class="form-label" asp-for="LicenseKey"></label>
|
||||||
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
|
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="ExpirationDate"></label>
|
<label class="form-label" asp-for="ExpirationDate"></label>
|
||||||
<input type="datetime-local" class="form-control" asp-for="ExpirationDate" readonly='@(!canEditLicensing)' step="1">
|
<input type="datetime-local" class="form-control" asp-for="ExpirationDate" readonly='@(!canEditLicensing)' step="1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -259,52 +259,46 @@
|
|||||||
<h2>Billing</h2>
|
<h2>Billing</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="BillingEmail"></label>
|
<label class="form-label" asp-for="BillingEmail"></label>
|
||||||
<input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly">
|
<input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<div class="form-group">
|
<label class="form-label" asp-for="Gateway"></label>
|
||||||
<label asp-for="Gateway"></label>
|
<select class="form-select" asp-for="Gateway" disabled="@(!canEditBilling)"
|
||||||
<select class="form-control" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
|
|
||||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||||
<option value="">--</option>
|
<option value="">--</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="GatewayCustomerId"></label>
|
<label class="form-label" asp-for="GatewayCustomerId"></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
|
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
|
||||||
@if(canLaunchGateway)
|
@if(canLaunchGateway)
|
||||||
{
|
{
|
||||||
<div class="input-group-append">
|
|
||||||
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
||||||
<i class="fa fa-external-link"></i>
|
<i class="fa fa-external-link"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="GatewaySubscriptionId"></label>
|
<label class="form-label" asp-for="GatewaySubscriptionId"></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
|
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
|
||||||
@if (canLaunchGateway)
|
@if (canLaunchGateway)
|
||||||
{
|
{
|
||||||
<div class="input-group-append">
|
|
||||||
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
||||||
<i class="fa fa-external-link"></i>
|
<i class="fa fa-external-link"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,8 +3,8 @@
|
|||||||
ViewData["Title"] = "Login";
|
ViewData["Title"] = "Login";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row justify-content-md-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col col-lg-6 col-md-8">
|
<div class="col-lg-6 col-md-8">
|
||||||
@if(!string.IsNullOrWhiteSpace(Model.Success))
|
@if(!string.IsNullOrWhiteSpace(Model.Success))
|
||||||
{
|
{
|
||||||
<div class="alert alert-success" role="alert">@Model.Success</div>
|
<div class="alert alert-success" role="alert">@Model.Success</div>
|
||||||
@ -19,12 +19,12 @@
|
|||||||
<form asp-action="" method="post">
|
<form asp-action="" method="post">
|
||||||
<input type="hidden" asp-for="ReturnUrl" />
|
<input type="hidden" asp-for="ReturnUrl" />
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Email" class="sr-only">Email Address</label>
|
<label asp-for="Email" class="visually-hidden">Email Address</label>
|
||||||
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
||||||
required autofocus>
|
required autofocus>
|
||||||
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
||||||
<small class="form-text text-muted">We'll email you a secure login link.</small>
|
<div class="form-text">We'll email you a secure login link.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" type="submit">Continue</button>
|
<button class="btn btn-primary" type="submit">Continue</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -25,22 +25,22 @@ bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
|
|||||||
</div>
|
</div>
|
||||||
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
|
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
|
||||||
<div asp-validation-summary="All" class="text-danger"></div>
|
<div asp-validation-summary="All" class="text-danger"></div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="ProviderIds"></label>
|
<label class="form-label" asp-for="ProviderIds"></label>
|
||||||
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<input type="submit" value="Run" class="btn btn-primary mb-2"/>
|
<input type="submit" value="Run" class="btn btn-primary"/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
|
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
|
||||||
<div asp-validation-summary="All" class="text-danger"></div>
|
<div asp-validation-summary="All" class="text-danger"></div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="ProviderIds"></label>
|
<label class="form-label" asp-for="ProviderIds"></label>
|
||||||
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<input type="submit" value="See Previous Results" class="btn btn-primary mb-2"/>
|
<input type="submit" value="See Previous Results" class="btn btn-primary"/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
@import "webfonts.scss";
|
@import "webfonts.scss";
|
||||||
|
@import "bootstrap/scss/functions";
|
||||||
|
@import "bootstrap/scss/variables";
|
||||||
|
@import "bootstrap/scss/mixins";
|
||||||
|
|
||||||
$primary: #175DDC;
|
$primary: #175DDC;
|
||||||
$primary-accent: #1252A3;
|
$primary-accent: #1252A3;
|
||||||
@ -7,7 +10,8 @@ $info: #555555;
|
|||||||
$warning: #bf7e16;
|
$warning: #bf7e16;
|
||||||
$danger: #dd4b39;
|
$danger: #dd4b39;
|
||||||
|
|
||||||
$theme-colors: ( "primary-accent": $primary-accent );
|
$theme-colors: map-merge($theme-colors, ("primary-accent": $primary-accent));
|
||||||
|
|
||||||
$font-family-sans-serif: 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
$font-family-sans-serif: 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||||
|
|
||||||
$h1-font-size: 2rem;
|
$h1-font-size: 2rem;
|
||||||
@ -17,7 +21,7 @@ $h4-font-size: 1rem;
|
|||||||
$h5-font-size: 1rem;
|
$h5-font-size: 1rem;
|
||||||
$h6-font-size: 1rem;
|
$h6-font-size: 1rem;
|
||||||
|
|
||||||
@import "bootstrap/scss/bootstrap.scss";
|
@import "bootstrap/scss/bootstrap";
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
border-bottom: 1px solid $border-color;
|
border-bottom: 1px solid $border-color;
|
||||||
@ -49,3 +53,11 @@ h3 {
|
|||||||
.form-check-input {
|
.form-check-input {
|
||||||
margin-top: .45rem;
|
margin-top: .45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -110,7 +110,6 @@ public static class RolePermissionMapping
|
|||||||
Permission.User_Licensing_View,
|
Permission.User_Licensing_View,
|
||||||
Permission.User_Billing_View,
|
Permission.User_Billing_View,
|
||||||
Permission.User_Billing_LaunchGateway,
|
Permission.User_Billing_LaunchGateway,
|
||||||
Permission.User_Delete,
|
|
||||||
Permission.Org_List_View,
|
Permission.Org_List_View,
|
||||||
Permission.Org_OrgInformation_View,
|
Permission.Org_OrgInformation_View,
|
||||||
Permission.Org_GeneralDetails_View,
|
Permission.Org_GeneralDetails_View,
|
||||||
|
@ -105,7 +105,7 @@
|
|||||||
<h3>SMTP</h3>
|
<h3>SMTP</h3>
|
||||||
@if(!Bit.Core.Utilities.CoreHelpers.SettingHasValue(Model.GlobalSettings.Mail?.Smtp?.Host))
|
@if(!Bit.Core.Utilities.CoreHelpers.SettingHasValue(Model.GlobalSettings.Mail?.Smtp?.Host))
|
||||||
{
|
{
|
||||||
<p class="text-muted">Not configured</p>
|
<p class="text-body-secondary">Not configured</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -159,7 +159,7 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted">Not configured</span>
|
<span class="text-body-secondary">Not configured</span>
|
||||||
}
|
}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
@ -171,7 +171,7 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted">Not configured</span>
|
<span class="text-body-secondary">Not configured</span>
|
||||||
}
|
}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
@ -183,7 +183,7 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted">Not configured</span>
|
<span class="text-body-secondary">Not configured</span>
|
||||||
}
|
}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
@ -37,12 +37,12 @@
|
|||||||
<a class="navbar-brand" asp-controller="Home" asp-action="Index">
|
<a class="navbar-brand" asp-controller="Home" asp-action="Index">
|
||||||
<i class="fa fa-lg fa-fw fa-shield"></i> Admin
|
<i class="fa fa-lg fa-fw fa-shield"></i> Admin
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
|
||||||
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
|
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
<div class="collapse navbar-collapse" id="navbarCollapse">
|
||||||
<ul class="navbar-nav mr-auto">
|
<ul class="navbar-nav me-auto mb-2 mb-md-0">
|
||||||
@if (SignInManager.IsSignedIn(User))
|
@if (SignInManager.IsSignedIn(User))
|
||||||
{
|
{
|
||||||
@if (canViewUsers)
|
@if (canViewUsers)
|
||||||
@ -69,10 +69,10 @@
|
|||||||
{
|
{
|
||||||
<li class="nav-item dropdown" active-controller="tools">
|
<li class="nav-item dropdown" active-controller="tools">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="toolsDropdown" role="button"
|
<a class="nav-link dropdown-toggle" href="#" id="toolsDropdown" role="button"
|
||||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
Tools
|
Tools
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu" aria-labelledby="toolsDropdown">
|
<ul class="dropdown-menu" aria-labelledby="toolsDropdown">
|
||||||
@if (canChargeBraintree)
|
@if (canChargeBraintree)
|
||||||
{
|
{
|
||||||
<a class="dropdown-item" asp-controller="Tools" asp-action="ChargeBraintree">
|
<a class="dropdown-item" asp-controller="Tools" asp-action="ChargeBraintree">
|
||||||
@ -121,7 +121,7 @@
|
|||||||
Migrate Providers
|
Migrate Providers
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,30 +10,30 @@
|
|||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="UserId"></label>
|
<label asp-for="UserId" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="UserId">
|
<input type="text" class="form-control" asp-for="UserId">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="OrganizationId"></label>
|
<label asp-for="OrganizationId" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="OrganizationId">
|
<input type="text" class="form-control" asp-for="OrganizationId">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Date"></label>
|
<label asp-for="Date" class="form-label"></label>
|
||||||
<input type="datetime-local" class="form-control" asp-for="Date" required>
|
<input type="datetime-local" class="form-control" asp-for="Date" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Type"></label>
|
<label asp-for="Type" class="form-label"></label>
|
||||||
<select class="form-control" asp-for="Type" required
|
<select class="form-select" asp-for="Type" required
|
||||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.TransactionType>()"></select>
|
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.TransactionType>()"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -41,24 +41,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Amount"></label>
|
<label asp-for="Amount" class="form-label"></label>
|
||||||
<div class="input-group mb-3">
|
<div class="input-group">
|
||||||
<div class="input-group-prepend">
|
|
||||||
<span class="input-group-text">$</span>
|
<span class="input-group-text">$</span>
|
||||||
</div>
|
|
||||||
<input type="number" min="-1000000.00" max="1000000.00" step="0.01" class="form-control"
|
<input type="number" min="-1000000.00" max="1000000.00" step="0.01" class="form-control"
|
||||||
asp-for="Amount" required placeholder="ex. 10.00">
|
asp-for="Amount" required placeholder="ex. 10.00">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="RefundedAmount"></label>
|
<label asp-for="RefundedAmount" class="form-label"></label>
|
||||||
<div class="input-group mb-3">
|
<div class="input-group">
|
||||||
<div class="input-group-prepend">
|
|
||||||
<span class="input-group-text">$</span>
|
<span class="input-group-text">$</span>
|
||||||
</div>
|
|
||||||
<input type="number" min="0.01" max="1000000.00" step="0.01" class="form-control"
|
<input type="number" min="0.01" max="1000000.00" step="0.01" class="form-control"
|
||||||
asp-for="RefundedAmount" placeholder="ex. 10.00">
|
asp-for="RefundedAmount" placeholder="ex. 10.00">
|
||||||
</div>
|
</div>
|
||||||
@ -69,41 +65,37 @@
|
|||||||
<input type="checkbox" class="form-check-input" asp-for="Refunded">
|
<input type="checkbox" class="form-check-input" asp-for="Refunded">
|
||||||
<label class="form-check-label" asp-for="Refunded"></label>
|
<label class="form-check-label" asp-for="Refunded"></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Details"></label>
|
<label asp-for="Details" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="Details" required>
|
<input type="text" class="form-control" asp-for="Details" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<div class="form-group">
|
<label asp-for="Gateway" class="form-label"></label>
|
||||||
<label asp-for="Gateway"></label>
|
<select class="form-select" asp-for="Gateway"
|
||||||
<select class="form-control" asp-for="Gateway"
|
|
||||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||||
<option value="">--</option>
|
<option value="">--</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="GatewayId"></label>
|
<label asp-for="GatewayId" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="GatewayId">
|
<input type="text" class="form-control" asp-for="GatewayId">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<div class="form-group">
|
<label asp-for="PaymentMethod" class="form-label"></label>
|
||||||
<label asp-for="PaymentMethod"></label>
|
<select class="form-select" asp-for="PaymentMethod"
|
||||||
<select class="form-control" asp-for="PaymentMethod"
|
|
||||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.PaymentMethodType>()">
|
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.PaymentMethodType>()">
|
||||||
<option value="">--</option>
|
<option value="">--</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary mb-2">@action Transaction</button>
|
<button type="submit" class="btn btn-primary mb-2">@action Transaction</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -9,28 +9,28 @@
|
|||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="UserId"></label>
|
<label asp-for="UserId" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="UserId">
|
<input type="text" class="form-control" asp-for="UserId">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="OrganizationId"></label>
|
<label asp-for="OrganizationId" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="OrganizationId">
|
<input type="text" class="form-control" asp-for="OrganizationId">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="InstallationId"></label>
|
<label asp-for="InstallationId" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="InstallationId">
|
<input type="text" class="form-control" asp-for="InstallationId">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="Version"></label>
|
<label asp-for="Version" class="form-label"></label>
|
||||||
<input type="number" class="form-control" asp-for="Version">
|
<input type="number" class="form-control" asp-for="Version">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,17 +9,17 @@
|
|||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="UserId"></label>
|
<label asp-for="UserId" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="UserId">
|
<input type="text" class="form-control" asp-for="UserId">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="OrganizationId"></label>
|
<label asp-for="OrganizationId" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="OrganizationId">
|
<input type="text" class="form-control" asp-for="OrganizationId">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary mb-2">Promote Admin</button>
|
<button type="submit" class="btn btn-primary">Promote Admin</button>
|
||||||
</form>
|
</form>
|
@ -74,38 +74,38 @@
|
|||||||
<div class="alert alert-success"></div>
|
<div class="alert alert-success"></div>
|
||||||
}
|
}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
<div class="row">
|
<div class="row g-3">
|
||||||
<div class="col-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Filter.Status">Status</label>
|
<label class="form-label" asp-for="Filter.Status">Status</label>
|
||||||
<select asp-for="Filter.Status" name="filter.Status" class="form-control mr-2">
|
<select asp-for="Filter.Status" name="filter.Status" class="form-select">
|
||||||
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
|
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
|
||||||
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
|
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
|
||||||
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
|
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
|
<label class="form-label" asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="input-group-append">
|
|
||||||
<div class="input-group-text">
|
<div class="input-group-text">
|
||||||
<span class="mr-1">
|
<div class="form-check form-check-inline mb-0">
|
||||||
<input type="radio" class="mr-1" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="lt">Before
|
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="lt" id="beforeRadio">
|
||||||
</span>
|
<label class="form-check-label me-2" for="beforeRadio">Before</label>
|
||||||
<input type="radio" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="gt">After
|
</div>
|
||||||
|
<div class="form-check form-check-inline mb-0">
|
||||||
|
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="gt" id="afterRadio">
|
||||||
|
<label class="form-check-label" for="afterRadio">After</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@{
|
@{
|
||||||
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
|
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
|
||||||
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
|
|
||||||
}
|
}
|
||||||
|
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-md-6">
|
||||||
<div class="row mt-2">
|
<label class="form-label" asp-for="Filter.Price">Price ID</label>
|
||||||
<div class="col-6">
|
<select asp-for="Filter.Price" name="filter.Price" class="form-select">
|
||||||
<label asp-for="Filter.Price">Price ID</label>
|
|
||||||
<select asp-for="Filter.Price" name="filter.Price" class="form-control mr-2">
|
|
||||||
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
|
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
|
||||||
@foreach (var price in Model.Prices)
|
@foreach (var price in Model.Prices)
|
||||||
{
|
{
|
||||||
@ -113,9 +113,9 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Filter.TestClock">Test Clock</label>
|
<label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
|
||||||
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-control mr-2">
|
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
|
||||||
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
|
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
|
||||||
@foreach (var clock in Model.TestClocks)
|
@foreach (var clock in Model.TestClocks)
|
||||||
{
|
{
|
||||||
@ -123,26 +123,32 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-12 text-end">
|
||||||
<div class="row col-12 d-flex justify-content-end my-3">
|
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search">
|
||||||
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search"><i class="fa fa-search"></i> Search</button>
|
<i class="fa fa-search"></i> Search
|
||||||
</div>
|
</button>
|
||||||
<hr/>
|
</div>
|
||||||
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
|
</div>
|
||||||
<div class="text-center row d-flex justify-content-center">
|
<hr/>
|
||||||
|
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
|
||||||
|
<div class="text-center row d-flex justify-content-center">
|
||||||
<div id="selectAll" class="d-none col-8">
|
<div id="selectAll" class="d-none col-8">
|
||||||
All @Model.Items.Count subscriptions on this page are selected.<br/>
|
All @Model.Items.Count subscriptions on this page are selected.<br/>
|
||||||
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
|
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
|
||||||
<span id="selectedAllConfirmation" class="d-none text-muted">✔ All subscriptions for this search are selected.</span><br/>
|
<span id="selectedAllConfirmation" class="d-none text-body-secondary">
|
||||||
<div class="alert alert-warning" role="alert">Please be aware that bulk operations may take several minutes to complete.</div>
|
<i class="fa fa-check"></i> All subscriptions for this search are selected.
|
||||||
|
</span>
|
||||||
|
<div class="alert alert-warning mt-2" role="alert">
|
||||||
|
Please be aware that bulk operations may take several minutes to complete.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
</div>
|
||||||
<table class="table table-striped table-hover">
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
<div class="form-check form-check-inline">
|
<div class="form-check">
|
||||||
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
|
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@ -191,7 +197,7 @@
|
|||||||
@{
|
@{
|
||||||
var i2 = i;
|
var i2 = i;
|
||||||
}
|
}
|
||||||
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
|
<input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -214,9 +220,9 @@
|
|||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<nav class="d-inline-flex">
|
<nav class="d-inline-flex align-items-center">
|
||||||
<ul class="pagination">
|
<ul class="pagination mb-0">
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
|
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
|
||||||
{
|
{
|
||||||
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
|
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
|
||||||
@ -257,25 +263,15 @@
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<span id="bulkActions" class="d-none ml-2">
|
<span id="bulkActions" class="d-none ms-3">
|
||||||
<span class="d-inline-flex">
|
<span class="d-inline-flex gap-2">
|
||||||
<button
|
<button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary mr-1"
|
|
||||||
name="action"
|
|
||||||
asp-for="Action"
|
|
||||||
value="@StripeSubscriptionsAction.Export">
|
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
|
||||||
type="submit"
|
|
||||||
class="btn btn-danger"
|
|
||||||
name="action"
|
|
||||||
asp-for="Action"
|
|
||||||
value="@StripeSubscriptionsAction.BulkCancel">
|
|
||||||
Bulk Cancel
|
Bulk Cancel
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
</form>
|
</form>
|
||||||
|
@ -27,20 +27,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" enctype="multipart/form-data" asp-action="TaxRateUpload">
|
<form method="post" enctype="multipart/form-data" asp-action="TaxRateUpload">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<input type="file" name="file" />
|
<input type="file" class="form-control" name="file" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<input type="submit" value="Upload" class="btn btn-primary mb-2" />
|
<input type="submit" value="Upload" class="btn btn-primary" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<hr/>
|
<hr class="my-4">
|
||||||
<h2>View & Manage Tax Rates</h2>
|
<h2>View & Manage Tax Rates</h2>
|
||||||
<a class="btn btn-primary mb-2" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a>
|
<a class="btn btn-primary mb-3" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-hover">
|
<table class="table table-striped table-hover align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 190px;">Id</th>
|
<th style="width: 190px;">Id</th>
|
||||||
@ -97,7 +97,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav>
|
<nav aria-label="Tax rates pagination">
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
@if(Model.PreviousPage.HasValue)
|
@if(Model.PreviousPage.HasValue)
|
||||||
{
|
{
|
||||||
|
@ -115,8 +115,8 @@
|
|||||||
<h2>Premium</h2>
|
<h2>Premium</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="MaxStorageGb"></label>
|
<label asp-for="MaxStorageGb" class="form-label"></label>
|
||||||
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPremium)'>
|
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPremium)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -131,14 +131,14 @@
|
|||||||
<h2>Licensing</h2>
|
<h2>Licensing</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="LicenseKey"></label>
|
<label asp-for="LicenseKey" class="form-label"></label>
|
||||||
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
|
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="PremiumExpirationDate"></label>
|
<label asp-for="PremiumExpirationDate" class="form-label"></label>
|
||||||
<input type="datetime-local" class="form-control" asp-for="PremiumExpirationDate" readonly='@(!canEditLicensing)'>
|
<input type="datetime-local" class="form-control" asp-for="PremiumExpirationDate" readonly='@(!canEditLicensing)'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -149,44 +149,38 @@
|
|||||||
<h2>Billing</h2>
|
<h2>Billing</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<div class="form-group">
|
<label asp-for="Gateway" class="form-label"></label>
|
||||||
<label asp-for="Gateway"></label>
|
<select class="form-select" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
|
||||||
<select class="form-control" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
|
|
||||||
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||||
<option value="">--</option>
|
<option value="">--</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="GatewayCustomerId"></label>
|
<label asp-for="GatewayCustomerId" class="form-label"></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
|
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
|
||||||
@if (canLaunchGateway)
|
@if (canLaunchGateway)
|
||||||
{
|
{
|
||||||
<div class="input-group-append">
|
|
||||||
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
||||||
<i class="fa fa-external-link"></i>
|
<i class="fa fa-external-link"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-group">
|
<div class="mb-3">
|
||||||
<label asp-for="GatewaySubscriptionId"></label>
|
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
|
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
|
||||||
@if (canLaunchGateway)
|
@if (canLaunchGateway)
|
||||||
{
|
{
|
||||||
<div class="input-group-append">
|
|
||||||
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
||||||
<i class="fa fa-external-link"></i>
|
<i class="fa fa-external-link"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -196,10 +190,10 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="d-flex mt-4">
|
<div class="d-flex mt-4">
|
||||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||||
<div class="ml-auto d-flex">
|
<div class="ms-auto d-flex">
|
||||||
@if (canUpgradePremium)
|
@if (canUpgradePremium)
|
||||||
{
|
{
|
||||||
<button class="btn btn-secondary mr-2" type="button" id="upgrade-premium">
|
<button class="btn btn-secondary me-2" type="button" id="upgrade-premium">
|
||||||
Upgrade Premium
|
Upgrade Premium
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@ -5,10 +5,16 @@
|
|||||||
|
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
|
|
||||||
<form class="form-inline mb-2" method="get">
|
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
|
||||||
<label class="sr-only" asp-for="Email">Email</label>
|
<div class="col-12">
|
||||||
<input type="text" class="form-control mb-2 mr-2" placeholder="Email" asp-for="Email" name="email">
|
<label class="visually-hidden" asp-for="Email">Email</label>
|
||||||
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
|
<input type="text" class="form-control" placeholder="Email" asp-for="Email" name="email">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary" title="Search">
|
||||||
|
<i class="fa fa-search"></i> Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -49,7 +55,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-star-o fa-lg fa-fw text-muted" title="Not Premium"></i>
|
<i class="fa fa-star-o fa-lg fa-fw text-body-secondary" title="Not Premium"></i>
|
||||||
}
|
}
|
||||||
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
|
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
|
||||||
{
|
{
|
||||||
@ -59,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
|
<i class="fa fa-plus-square-o fa-lg fa-fw text-body-secondary"
|
||||||
title="No Additional Storage">
|
title="No Additional Storage">
|
||||||
</i>
|
</i>
|
||||||
}
|
}
|
||||||
@ -69,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
|
<i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Email Not Verified"></i>
|
||||||
}
|
}
|
||||||
@if (user.TwoFactorEnabled)
|
@if (user.TwoFactorEnabled)
|
||||||
{
|
{
|
||||||
@ -77,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
<i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
35
src/Admin/package-lock.json
generated
35
src/Admin/package-lock.json
generated
@ -9,10 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "4.6.2",
|
"bootstrap": "5.3.3",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"jquery": "3.7.1",
|
"jquery": "3.7.1",
|
||||||
"popper.js": "1.16.1",
|
|
||||||
"toastr": "2.1.4"
|
"toastr": "2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -385,6 +384,17 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
@ -703,9 +713,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "4.6.2",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||||
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
|
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -716,10 +726,8 @@
|
|||||||
"url": "https://opencollective.com/bootstrap"
|
"url": "https://opencollective.com/bootstrap"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"jquery": "1.9.1 - 3",
|
"@popperjs/core": "^2.11.8"
|
||||||
"popper.js": "^1.16.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@ -1578,17 +1586,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/popper.js": {
|
|
||||||
"version": "1.16.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
|
||||||
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
|
|
||||||
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/popperjs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.47",
|
"version": "8.4.47",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
||||||
|
@ -8,10 +8,9 @@
|
|||||||
"build": "webpack"
|
"build": "webpack"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "4.6.2",
|
"bootstrap": "5.3.3",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"jquery": "3.7.1",
|
"jquery": "3.7.1",
|
||||||
"popper.js": "1.16.1",
|
|
||||||
"toastr": "2.1.4"
|
"toastr": "2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -13,8 +13,6 @@ module.exports = {
|
|||||||
entry: {
|
entry: {
|
||||||
site: [
|
site: [
|
||||||
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
||||||
|
|
||||||
"popper.js",
|
|
||||||
"bootstrap",
|
"bootstrap",
|
||||||
"jquery",
|
"jquery",
|
||||||
"font-awesome/css/font-awesome.css",
|
"font-awesome/css/font-awesome.css",
|
||||||
|
@ -2,15 +2,16 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Response;
|
using Bit.Api.AdminConsole.Models.Response;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -89,11 +90,34 @@ public class GroupsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId)
|
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroups(Guid orgId)
|
||||||
{
|
{
|
||||||
var authorized =
|
var authResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||||
(await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded;
|
if (!authResult.Succeeded)
|
||||||
if (!authorized)
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails))
|
||||||
|
{
|
||||||
|
var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
||||||
|
var responses = groups.Select(g => new GroupDetailsResponseModel(g, []));
|
||||||
|
return new ListResponseModel<GroupDetailsResponseModel>(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDetails = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId);
|
||||||
|
var detailResponses = groupDetails.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2));
|
||||||
|
return new ListResponseModel<GroupDetailsResponseModel>(detailResponses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("details")]
|
||||||
|
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroupDetails(Guid orgId)
|
||||||
|
{
|
||||||
|
var authResult = _featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails)
|
||||||
|
? await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAllDetails)
|
||||||
|
: await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||||
|
|
||||||
|
if (!authResult.Succeeded)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
|
|||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
|
@ -252,6 +252,12 @@ public class OrganizationsController : Controller
|
|||||||
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
|
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.");
|
||||||
|
}
|
||||||
|
|
||||||
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
|
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,9 +289,7 @@ public class OrganizationsController : Controller
|
|||||||
throw new BadRequestException(string.Empty, "User verification failed.");
|
throw new BadRequestException(string.Empty, "User verification failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
if (organization.IsValidClient())
|
||||||
|
|
||||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
|
||||||
{
|
{
|
||||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
@ -316,8 +320,7 @@ public class OrganizationsController : Controller
|
|||||||
throw new BadRequestException("Invalid token.");
|
throw new BadRequestException("Invalid token.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
if (organization.IsValidClient())
|
||||||
if (consolidatedBillingEnabled && organization.IsValidClient())
|
|
||||||
{
|
{
|
||||||
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
if (provider.IsBillable())
|
if (provider.IsBillable())
|
||||||
@ -351,7 +354,7 @@ public class OrganizationsController : Controller
|
|||||||
{
|
{
|
||||||
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
|
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
|
||||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||||
if (plan.ProductTier != ProductTierType.Enterprise)
|
if (plan.ProductTier is not ProductTierType.Enterprise and not ProductTierType.Teams)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request;
|
using Bit.Api.AdminConsole.Models.Request;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
@ -16,7 +20,6 @@ using Bit.Core.Utilities;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
|
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
@ -32,6 +35,8 @@ public class PoliciesController : Controller
|
|||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly IDataProtector _organizationServiceDataProtector;
|
private readonly IDataProtector _organizationServiceDataProtector;
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||||
|
|
||||||
public PoliciesController(
|
public PoliciesController(
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
@ -41,7 +46,9 @@ public class PoliciesController : Controller
|
|||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
IDataProtectionProvider dataProtectionProvider,
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||||
{
|
{
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
@ -53,10 +60,12 @@ public class PoliciesController : Controller
|
|||||||
"OrganizationServiceDataProtector");
|
"OrganizationServiceDataProtector");
|
||||||
|
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||||
|
_featureService = featureService;
|
||||||
|
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{type}")]
|
[HttpGet("{type}")]
|
||||||
public async Task<PolicyResponseModel> Get(Guid orgId, int type)
|
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
|
||||||
{
|
{
|
||||||
if (!await _currentContext.ManagePolicies(orgId))
|
if (!await _currentContext.ManagePolicies(orgId))
|
||||||
{
|
{
|
||||||
@ -65,10 +74,15 @@ public class PoliciesController : Controller
|
|||||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
||||||
if (policy == null)
|
if (policy == null)
|
||||||
{
|
{
|
||||||
return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false });
|
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PolicyResponseModel(policy);
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg)
|
||||||
|
{
|
||||||
|
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PolicyDetailResponseModel(policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
@ -81,8 +95,8 @@ public class PoliciesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
|
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
|
||||||
var responses = policies.Select(p => new PolicyResponseModel(p));
|
|
||||||
return new ListResponseModel<PolicyResponseModel>(responses);
|
return new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||||
|
|
||||||
|
public static class PolicyDetailResponses
|
||||||
|
{
|
||||||
|
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
|
||||||
|
{
|
||||||
|
if (policy.Type is not PolicyType.SingleOrg)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
|
||||||
|
public class PolicyDetailResponseModel : PolicyResponseModel
|
||||||
|
{
|
||||||
|
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
|
||||||
|
{
|
||||||
|
CanToggleState = canToggleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether the Policy can be enabled/disabled
|
||||||
|
/// </summary>
|
||||||
|
public bool CanToggleState { get; set; } = true;
|
||||||
|
}
|
@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities;
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.Models.Api.Response;
|
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
|
||||||
public class PolicyResponseModel : ResponseModel
|
public class PolicyResponseModel : ResponseModel
|
||||||
{
|
{
|
@ -213,7 +213,7 @@ public class MembersController : Controller
|
|||||||
{
|
{
|
||||||
return new NotFoundResult();
|
return new NotFoundResult();
|
||||||
}
|
}
|
||||||
await _updateOrganizationUserGroupsCommand.UpdateUserGroupsAsync(existingUser, model.GroupIds, null);
|
await _updateOrganizationUserGroupsCommand.UpdateUserGroupsAsync(existingUser, model.GroupIds);
|
||||||
return new OkResult();
|
return new OkResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,14 +41,13 @@ public class PoliciesController : Controller
|
|||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||||
public async Task<IActionResult> Get(PolicyType type)
|
public async Task<IActionResult> Get(PolicyType type)
|
||||||
{
|
{
|
||||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(
|
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(_currentContext.OrganizationId.Value, type);
|
||||||
_currentContext.OrganizationId.Value, type);
|
|
||||||
if (policy == null)
|
if (policy == null)
|
||||||
{
|
{
|
||||||
return new NotFoundResult();
|
return new NotFoundResult();
|
||||||
}
|
}
|
||||||
var response = new PolicyResponseModel(policy);
|
|
||||||
return new JsonResult(response);
|
return new JsonResult(new PolicyResponseModel(policy));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -62,9 +61,8 @@ public class PoliciesController : Controller
|
|||||||
public async Task<IActionResult> List()
|
public async Task<IActionResult> List()
|
||||||
{
|
{
|
||||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
|
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
|
||||||
var policyResponses = policies.Select(p => new PolicyResponseModel(p));
|
|
||||||
var response = new ListResponseModel<PolicyResponseModel>(policyResponses);
|
return new JsonResult(new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p))));
|
||||||
return new JsonResult(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -18,7 +18,6 @@ public abstract class MemberBaseModel
|
|||||||
|
|
||||||
Type = user.Type;
|
Type = user.Type;
|
||||||
ExternalId = user.ExternalId;
|
ExternalId = user.ExternalId;
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
|
||||||
|
|
||||||
if (Type == OrganizationUserType.Custom)
|
if (Type == OrganizationUserType.Custom)
|
||||||
{
|
{
|
||||||
@ -35,7 +34,6 @@ public abstract class MemberBaseModel
|
|||||||
|
|
||||||
Type = user.Type;
|
Type = user.Type;
|
||||||
ExternalId = user.ExternalId;
|
ExternalId = user.ExternalId;
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
|
||||||
|
|
||||||
if (Type == OrganizationUserType.Custom)
|
if (Type == OrganizationUserType.Custom)
|
||||||
{
|
{
|
||||||
@ -55,11 +53,7 @@ public abstract class MemberBaseModel
|
|||||||
/// <example>external_id_123456</example>
|
/// <example>external_id_123456</example>
|
||||||
[StringLength(300)]
|
[StringLength(300)]
|
||||||
public string ExternalId { get; set; }
|
public string ExternalId { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public bool ResetPasswordEnrolled { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
||||||
/// default to false.
|
/// default to false.
|
||||||
|
@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
Email = user.Email;
|
Email = user.Email;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
|
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
|
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
|
||||||
@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
TwoFactorEnabled = twoFactorEnabled;
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
|
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
/// The associated collections that this member can access.
|
/// The associated collections that this member can access.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
|
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public bool ResetPasswordEnrolled { get; }
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
||||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Api.Auth.Models.Request;
|
using Bit.Api.Auth.Models.Request;
|
||||||
using Bit.Api.Auth.Models.Response;
|
using Bit.Api.Auth.Models.Response;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Vault.Models.Response;
|
using Bit.Api.Vault.Models.Response;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
|
||||||
using Bit.Core.Auth.Services;
|
using Bit.Core.Auth.Services;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
@ -4,15 +4,14 @@ using Bit.Api.Auth.Models.Response.TwoFactor;
|
|||||||
using Bit.Api.Models.Request;
|
using Bit.Api.Models.Request;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
using Bit.Core.Auth.Utilities;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
|
||||||
using Bit.Core.Tokens;
|
using Bit.Core.Tokens;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Fido2NetLib;
|
using Fido2NetLib;
|
||||||
@ -29,11 +28,10 @@ public class TwoFactorController : Controller
|
|||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
private readonly IOrganizationService _organizationService;
|
private readonly IOrganizationService _organizationService;
|
||||||
private readonly GlobalSettings _globalSettings;
|
|
||||||
private readonly UserManager<User> _userManager;
|
private readonly UserManager<User> _userManager;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
|
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IDuoUniversalTokenService _duoUniversalTokenService;
|
||||||
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
|
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
|
||||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
|
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
|
||||||
|
|
||||||
@ -41,22 +39,20 @@ public class TwoFactorController : Controller
|
|||||||
IUserService userService,
|
IUserService userService,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IOrganizationService organizationService,
|
IOrganizationService organizationService,
|
||||||
GlobalSettings globalSettings,
|
|
||||||
UserManager<User> userManager,
|
UserManager<User> userManager,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IVerifyAuthRequestCommand verifyAuthRequestCommand,
|
IVerifyAuthRequestCommand verifyAuthRequestCommand,
|
||||||
IFeatureService featureService,
|
IDuoUniversalTokenService duoUniversalConfigService,
|
||||||
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
|
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
|
||||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector)
|
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector)
|
||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationService = organizationService;
|
_organizationService = organizationService;
|
||||||
_globalSettings = globalSettings;
|
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_verifyAuthRequestCommand = verifyAuthRequestCommand;
|
_verifyAuthRequestCommand = verifyAuthRequestCommand;
|
||||||
_featureService = featureService;
|
_duoUniversalTokenService = duoUniversalConfigService;
|
||||||
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
|
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
|
||||||
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
|
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
|
||||||
}
|
}
|
||||||
@ -184,21 +180,7 @@ public class TwoFactorController : Controller
|
|||||||
public async Task<TwoFactorDuoResponseModel> PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model)
|
public async Task<TwoFactorDuoResponseModel> PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model)
|
||||||
{
|
{
|
||||||
var user = await CheckAsync(model, true);
|
var user = await CheckAsync(model, true);
|
||||||
try
|
if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
|
||||||
{
|
|
||||||
// for backwards compatibility - will be removed with PM-8107
|
|
||||||
DuoApi duoApi = null;
|
|
||||||
if (model.ClientId != null && model.ClientSecret != null)
|
|
||||||
{
|
|
||||||
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
|
|
||||||
}
|
|
||||||
await duoApi.JSONApiCall("GET", "/auth/v2/check");
|
|
||||||
}
|
|
||||||
catch (DuoException)
|
|
||||||
{
|
{
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
|
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
|
||||||
@ -241,21 +223,7 @@ public class TwoFactorController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException();
|
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException();
|
||||||
try
|
if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
|
||||||
{
|
|
||||||
// for backwards compatibility - will be removed with PM-8107
|
|
||||||
DuoApi duoApi = null;
|
|
||||||
if (model.ClientId != null && model.ClientSecret != null)
|
|
||||||
{
|
|
||||||
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
|
|
||||||
}
|
|
||||||
await duoApi.JSONApiCall("GET", "/auth/v2/check");
|
|
||||||
}
|
|
||||||
catch (DuoException)
|
|
||||||
{
|
{
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
|
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
using Bit.Api.Auth.Models.Request.Accounts;
|
using Bit.Api.Auth.Models.Request.Accounts;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
using Bit.Core.Auth.Models;
|
using Bit.Core.Auth.Models;
|
||||||
using Bit.Core.Auth.Utilities;
|
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Fido2NetLib;
|
using Fido2NetLib;
|
||||||
|
|
||||||
@ -43,21 +43,16 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques
|
|||||||
public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject
|
public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject
|
||||||
{
|
{
|
||||||
/*
|
/*
|
||||||
To support both v2 and v4 we need to remove the required annotation from the properties.
|
String lengths based on Duo's documentation
|
||||||
todo - the required annotation will be added back in PM-8107.
|
https://github.com/duosecurity/duo_universal_csharp/blob/main/DuoUniversal/Client.cs
|
||||||
*/
|
*/
|
||||||
[StringLength(50)]
|
|
||||||
public string ClientId { get; set; }
|
|
||||||
[StringLength(50)]
|
|
||||||
public string ClientSecret { get; set; }
|
|
||||||
//todo - will remove SKey and IKey with PM-8107
|
|
||||||
[StringLength(50)]
|
|
||||||
public string IntegrationKey { get; set; }
|
|
||||||
//todo - will remove SKey and IKey with PM-8107
|
|
||||||
[StringLength(50)]
|
|
||||||
public string SecretKey { get; set; }
|
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(50)]
|
[StringLength(20, MinimumLength = 20, ErrorMessage = "Client Id must be exactly 20 characters.")]
|
||||||
|
public string ClientId { get; set; }
|
||||||
|
[Required]
|
||||||
|
[StringLength(40, MinimumLength = 40, ErrorMessage = "Client Secret must be exactly 40 characters.")]
|
||||||
|
public string ClientSecret { get; set; }
|
||||||
|
[Required]
|
||||||
public string Host { get; set; }
|
public string Host { get; set; }
|
||||||
|
|
||||||
public User ToUser(User existingUser)
|
public User ToUser(User existingUser)
|
||||||
@ -65,22 +60,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
|
|||||||
var providers = existingUser.GetTwoFactorProviders();
|
var providers = existingUser.GetTwoFactorProviders();
|
||||||
if (providers == null)
|
if (providers == null)
|
||||||
{
|
{
|
||||||
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
providers = [];
|
||||||
}
|
}
|
||||||
else if (providers.ContainsKey(TwoFactorProviderType.Duo))
|
else if (providers.ContainsKey(TwoFactorProviderType.Duo))
|
||||||
{
|
{
|
||||||
providers.Remove(TwoFactorProviderType.Duo);
|
providers.Remove(TwoFactorProviderType.Duo);
|
||||||
}
|
}
|
||||||
|
|
||||||
Temporary_SyncDuoParams();
|
|
||||||
|
|
||||||
providers.Add(TwoFactorProviderType.Duo, new TwoFactorProvider
|
providers.Add(TwoFactorProviderType.Duo, new TwoFactorProvider
|
||||||
{
|
{
|
||||||
MetaData = new Dictionary<string, object>
|
MetaData = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
//todo - will remove SKey and IKey with PM-8107
|
|
||||||
["SKey"] = SecretKey,
|
|
||||||
["IKey"] = IntegrationKey,
|
|
||||||
["ClientSecret"] = ClientSecret,
|
["ClientSecret"] = ClientSecret,
|
||||||
["ClientId"] = ClientId,
|
["ClientId"] = ClientId,
|
||||||
["Host"] = Host
|
["Host"] = Host
|
||||||
@ -96,22 +86,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
|
|||||||
var providers = existingOrg.GetTwoFactorProviders();
|
var providers = existingOrg.GetTwoFactorProviders();
|
||||||
if (providers == null)
|
if (providers == null)
|
||||||
{
|
{
|
||||||
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
providers = [];
|
||||||
}
|
}
|
||||||
else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo))
|
else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo))
|
||||||
{
|
{
|
||||||
providers.Remove(TwoFactorProviderType.OrganizationDuo);
|
providers.Remove(TwoFactorProviderType.OrganizationDuo);
|
||||||
}
|
}
|
||||||
|
|
||||||
Temporary_SyncDuoParams();
|
|
||||||
|
|
||||||
providers.Add(TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider
|
providers.Add(TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider
|
||||||
{
|
{
|
||||||
MetaData = new Dictionary<string, object>
|
MetaData = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
//todo - will remove SKey and IKey with PM-8107
|
|
||||||
["SKey"] = SecretKey,
|
|
||||||
["IKey"] = IntegrationKey,
|
|
||||||
["ClientSecret"] = ClientSecret,
|
["ClientSecret"] = ClientSecret,
|
||||||
["ClientId"] = ClientId,
|
["ClientId"] = ClientId,
|
||||||
["Host"] = Host
|
["Host"] = Host
|
||||||
@ -124,34 +109,22 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
|
|||||||
|
|
||||||
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
{
|
{
|
||||||
if (!DuoApi.ValidHost(Host))
|
var results = new List<ValidationResult>();
|
||||||
|
if (string.IsNullOrWhiteSpace(ClientId))
|
||||||
{
|
{
|
||||||
yield return new ValidationResult("Host is invalid.", [nameof(Host)]);
|
results.Add(new ValidationResult("ClientId is required.", [nameof(ClientId)]));
|
||||||
}
|
|
||||||
if (string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId) &&
|
|
||||||
string.IsNullOrWhiteSpace(SecretKey) && string.IsNullOrWhiteSpace(IntegrationKey))
|
|
||||||
{
|
|
||||||
yield return new ValidationResult("Neither v2 or v4 values are valid.", [nameof(IntegrationKey), nameof(SecretKey), nameof(ClientSecret), nameof(ClientId)]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
if (string.IsNullOrWhiteSpace(ClientSecret))
|
||||||
use this method to ensure that both v2 params and v4 params are in sync
|
|
||||||
todo will be removed in pm-8107
|
|
||||||
*/
|
|
||||||
private void Temporary_SyncDuoParams()
|
|
||||||
{
|
{
|
||||||
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
|
results.Add(new ValidationResult("ClientSecret is required.", [nameof(ClientSecret)]));
|
||||||
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
|
|
||||||
{
|
|
||||||
SecretKey = ClientSecret;
|
|
||||||
IntegrationKey = ClientId;
|
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Host) || !DuoUniversalTokenService.ValidDuoHost(Host))
|
||||||
{
|
{
|
||||||
ClientSecret = SecretKey;
|
results.Add(new ValidationResult("Host is invalid.", [nameof(Host)]));
|
||||||
ClientId = IntegrationKey;
|
|
||||||
}
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,37 +13,26 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
|||||||
public TwoFactorDuoResponseModel(User user)
|
public TwoFactorDuoResponseModel(User user)
|
||||||
: base(ResponseObj)
|
: base(ResponseObj)
|
||||||
{
|
{
|
||||||
if (user == null)
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(user));
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||||
Build(provider);
|
Build(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TwoFactorDuoResponseModel(Organization org)
|
public TwoFactorDuoResponseModel(Organization organization)
|
||||||
: base(ResponseObj)
|
: base(ResponseObj)
|
||||||
{
|
{
|
||||||
if (org == null)
|
ArgumentNullException.ThrowIfNull(organization);
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(org));
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = org.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
||||||
Build(provider);
|
Build(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
public string Host { get; set; }
|
public string Host { get; set; }
|
||||||
//TODO - will remove SecretKey with PM-8107
|
|
||||||
public string SecretKey { get; set; }
|
|
||||||
//TODO - will remove IntegrationKey with PM-8107
|
|
||||||
public string IntegrationKey { get; set; }
|
|
||||||
public string ClientSecret { get; set; }
|
public string ClientSecret { get; set; }
|
||||||
public string ClientId { get; set; }
|
public string ClientId { get; set; }
|
||||||
|
|
||||||
// updated build to assist in the EDD migration for the Duo 2FA provider
|
|
||||||
private void Build(TwoFactorProvider provider)
|
private void Build(TwoFactorProvider provider)
|
||||||
{
|
{
|
||||||
if (provider?.MetaData != null && provider.MetaData.Count > 0)
|
if (provider?.MetaData != null && provider.MetaData.Count > 0)
|
||||||
@ -54,36 +43,13 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
|||||||
{
|
{
|
||||||
Host = (string)host;
|
Host = (string)host;
|
||||||
}
|
}
|
||||||
|
|
||||||
//todo - will remove SKey and IKey with PM-8107
|
|
||||||
// check Skey and IKey first if they exist
|
|
||||||
if (provider.MetaData.TryGetValue("SKey", out var sKey))
|
|
||||||
{
|
|
||||||
ClientSecret = MaskKey((string)sKey);
|
|
||||||
SecretKey = MaskKey((string)sKey);
|
|
||||||
}
|
|
||||||
if (provider.MetaData.TryGetValue("IKey", out var iKey))
|
|
||||||
{
|
|
||||||
IntegrationKey = (string)iKey;
|
|
||||||
ClientId = (string)iKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
|
|
||||||
if (provider.MetaData.TryGetValue("ClientSecret", out var clientSecret))
|
if (provider.MetaData.TryGetValue("ClientSecret", out var clientSecret))
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace((string)clientSecret))
|
ClientSecret = MaskSecret((string)clientSecret);
|
||||||
{
|
|
||||||
ClientSecret = MaskKey((string)clientSecret);
|
|
||||||
SecretKey = MaskKey((string)clientSecret);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (provider.MetaData.TryGetValue("ClientId", out var clientId))
|
if (provider.MetaData.TryGetValue("ClientId", out var clientId))
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace((string)clientId))
|
|
||||||
{
|
{
|
||||||
ClientId = (string)clientId;
|
ClientId = (string)clientId;
|
||||||
IntegrationKey = (string)clientId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -92,30 +58,7 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
private static string MaskSecret(string key)
|
||||||
use this method to ensure that both v2 params and v4 params are in sync
|
|
||||||
todo will be removed in pm-8107
|
|
||||||
*/
|
|
||||||
private void Temporary_SyncDuoParams()
|
|
||||||
{
|
|
||||||
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
|
|
||||||
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
|
|
||||||
{
|
|
||||||
SecretKey = ClientSecret;
|
|
||||||
IntegrationKey = ClientId;
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
|
|
||||||
{
|
|
||||||
ClientSecret = SecretKey;
|
|
||||||
ClientId = IntegrationKey;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new InvalidDataException("Invalid Duo parameters.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string MaskKey(string key)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(key) || key.Length <= 6)
|
if (string.IsNullOrWhiteSpace(key) || key.Length <= 6)
|
||||||
{
|
{
|
||||||
|
@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
|
|||||||
new ErrorResponseModel(message),
|
new ErrorResponseModel(message),
|
||||||
statusCode: StatusCodes.Status500InternalServerError);
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
|
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||||
TypedResults.Json(
|
TypedResults.Json(
|
||||||
new ErrorResponseModel("Unauthorized."),
|
new ErrorResponseModel(message),
|
||||||
statusCode: StatusCodes.Status401Unauthorized);
|
statusCode: StatusCodes.Status401Unauthorized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using Bit.Core;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -9,7 +8,6 @@ namespace Bit.Api.Billing.Controllers;
|
|||||||
|
|
||||||
public abstract class BaseProviderController(
|
public abstract class BaseProviderController(
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IFeatureService featureService,
|
|
||||||
ILogger<BaseProviderController> logger,
|
ILogger<BaseProviderController> logger,
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
IUserService userService) : BaseBillingController
|
IUserService userService) : BaseBillingController
|
||||||
@ -26,15 +24,6 @@ public abstract class BaseProviderController(
|
|||||||
Guid providerId,
|
Guid providerId,
|
||||||
Func<Guid, bool> checkAuthorization)
|
Func<Guid, bool> checkAuthorization)
|
||||||
{
|
{
|
||||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) while feature flag is disabled",
|
|
||||||
providerId);
|
|
||||||
|
|
||||||
return (null, Error.NotFound());
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||||
|
|
||||||
if (provider == null)
|
if (provider == null)
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
using Bit.Api.Models.Request.Organizations;
|
using Bit.Api.Models.Request.Organizations;
|
||||||
using Bit.Api.Models.Response.Organizations;
|
using Bit.Api.Models.Response.Organizations;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
@ -31,6 +34,8 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
|
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
|
private readonly IPolicyRepository _policyRepository;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public OrganizationSponsorshipsController(
|
public OrganizationSponsorshipsController(
|
||||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||||
@ -45,7 +50,9 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
IRemoveSponsorshipCommand removeSponsorshipCommand,
|
IRemoveSponsorshipCommand removeSponsorshipCommand,
|
||||||
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
|
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
ICurrentContext currentContext)
|
ICurrentContext currentContext,
|
||||||
|
IPolicyRepository policyRepository,
|
||||||
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_organizationSponsorshipRepository = organizationSponsorshipRepository;
|
_organizationSponsorshipRepository = organizationSponsorshipRepository;
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
@ -60,6 +67,8 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
_syncSponsorshipsCommand = syncSponsorshipsCommand;
|
_syncSponsorshipsCommand = syncSponsorshipsCommand;
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
|
_policyRepository = policyRepository;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
@ -94,9 +103,20 @@ public class OrganizationSponsorshipsController : Controller
|
|||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
[HttpPost("validate-token")]
|
[HttpPost("validate-token")]
|
||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
public async Task<bool> PreValidateSponsorshipToken([FromQuery] string sponsorshipToken)
|
public async Task<PreValidateSponsorshipResponseModel> PreValidateSponsorshipToken([FromQuery] string sponsorshipToken)
|
||||||
{
|
{
|
||||||
return (await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email)).valid;
|
var isFreeFamilyPolicyEnabled = false;
|
||||||
|
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
|
||||||
|
if (isValid && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship) && sponsorship.SponsoringOrganizationId.HasValue)
|
||||||
|
{
|
||||||
|
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
|
||||||
|
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||||
|
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
|
@ -19,14 +19,13 @@ namespace Bit.Api.Billing.Controllers;
|
|||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class ProviderBillingController(
|
public class ProviderBillingController(
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IFeatureService featureService,
|
|
||||||
ILogger<BaseProviderController> logger,
|
ILogger<BaseProviderController> logger,
|
||||||
IProviderBillingService providerBillingService,
|
IProviderBillingService providerBillingService,
|
||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
|
||||||
{
|
{
|
||||||
[HttpGet("invoices")]
|
[HttpGet("invoices")]
|
||||||
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
|
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
|
||||||
|
@ -14,14 +14,13 @@ namespace Bit.Api.Billing.Controllers;
|
|||||||
[Route("providers/{providerId:guid}/clients")]
|
[Route("providers/{providerId:guid}/clients")]
|
||||||
public class ProviderClientsController(
|
public class ProviderClientsController(
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IFeatureService featureService,
|
|
||||||
ILogger<BaseProviderController> logger,
|
ILogger<BaseProviderController> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IProviderBillingService providerBillingService,
|
IProviderBillingService providerBillingService,
|
||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
IProviderService providerService,
|
IProviderService providerService,
|
||||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
|
||||||
{
|
{
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IResult> CreateAsync(
|
public async Task<IResult> CreateAsync(
|
||||||
@ -102,15 +101,27 @@ public class ProviderClientsController(
|
|||||||
|
|
||||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||||
|
|
||||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
|
||||||
{
|
{
|
||||||
await providerBillingService.AssignSeatsToClientOrganization(
|
return Error.ServerError();
|
||||||
provider,
|
|
||||||
clientOrganization,
|
|
||||||
requestBody.AssignedSeats);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
|
||||||
|
|
||||||
|
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
|
||||||
|
provider,
|
||||||
|
clientOrganization.PlanType,
|
||||||
|
seatAdjustment);
|
||||||
|
|
||||||
|
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
|
||||||
|
{
|
||||||
|
return Error.Unauthorized("Service users cannot purchase additional seats.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
|
||||||
|
|
||||||
clientOrganization.Name = requestBody.Name;
|
clientOrganization.Name = requestBody.Name;
|
||||||
|
clientOrganization.Seats = requestBody.AssignedSeats;
|
||||||
|
|
||||||
await organizationRepository.ReplaceAsync(clientOrganization);
|
await organizationRepository.ReplaceAsync(clientOrganization);
|
||||||
|
|
||||||
|
@ -6,12 +6,14 @@ public record OrganizationMetadataResponse(
|
|||||||
bool IsEligibleForSelfHost,
|
bool IsEligibleForSelfHost,
|
||||||
bool IsManaged,
|
bool IsManaged,
|
||||||
bool IsOnSecretsManagerStandalone,
|
bool IsOnSecretsManagerStandalone,
|
||||||
bool IsSubscriptionUnpaid)
|
bool IsSubscriptionUnpaid,
|
||||||
|
bool HasSubscription)
|
||||||
{
|
{
|
||||||
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
|
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
|
||||||
=> new(
|
=> new(
|
||||||
metadata.IsEligibleForSelfHost,
|
metadata.IsEligibleForSelfHost,
|
||||||
metadata.IsManaged,
|
metadata.IsManaged,
|
||||||
metadata.IsOnSecretsManagerStandalone,
|
metadata.IsOnSecretsManagerStandalone,
|
||||||
metadata.IsSubscriptionUnpaid);
|
metadata.IsSubscriptionUnpaid,
|
||||||
|
metadata.HasSubscription);
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,6 @@ using Microsoft.OpenApi.Models;
|
|||||||
using Bit.SharedWeb.Utilities;
|
using Bit.SharedWeb.Utilities;
|
||||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Bit.Core.Auth.Identity;
|
|
||||||
using Bit.Core.Auth.UserFeatures;
|
using Bit.Core.Auth.UserFeatures;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
@ -32,6 +31,8 @@ using Bit.Core.Tools.Entities;
|
|||||||
using Bit.Core.Vault.Entities;
|
using Bit.Core.Vault.Entities;
|
||||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.Auth.Identity.TokenProviders;
|
||||||
|
using Bit.Core.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
|
||||||
#if !OSS
|
#if !OSS
|
||||||
@ -176,6 +177,7 @@ public class Startup
|
|||||||
services.AddOrganizationSubscriptionServices();
|
services.AddOrganizationSubscriptionServices();
|
||||||
services.AddCoreLocalizationServices();
|
services.AddCoreLocalizationServices();
|
||||||
services.AddBillingOperations();
|
services.AddBillingOperations();
|
||||||
|
services.AddReportingServices();
|
||||||
|
|
||||||
// Authorization Handlers
|
// Authorization Handlers
|
||||||
services.AddAuthorizationHandlers();
|
services.AddAuthorizationHandlers();
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
using Bit.Api.Tools.Models.Response;
|
using Bit.Api.Tools.Models;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Api.Tools.Models.Response;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Tools.Entities;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Tools.Models.Data;
|
||||||
using Bit.Core.Vault.Queries;
|
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||||
|
using Bit.Core.Tools.Requests;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers;
|
|||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class ReportsController : Controller
|
public class ReportsController : Controller
|
||||||
{
|
{
|
||||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
|
||||||
private readonly IGroupRepository _groupRepository;
|
|
||||||
private readonly ICollectionRepository _collectionRepository;
|
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
|
||||||
|
|
||||||
public ReportsController(
|
public ReportsController(
|
||||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
|
||||||
IGroupRepository groupRepository,
|
|
||||||
ICollectionRepository collectionRepository,
|
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
|
||||||
IApplicationCacheService applicationCacheService,
|
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
|
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
|
||||||
_groupRepository = groupRepository;
|
|
||||||
_collectionRepository = collectionRepository;
|
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_organizationCiphersQuery = organizationCiphersQuery;
|
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
|
||||||
_applicationCacheService = applicationCacheService;
|
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Organization member information containing a list of cipher ids
|
||||||
|
/// assigned
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgId">Organzation Id</param>
|
||||||
|
/// <returns>IEnumerable of MemberCipherDetailsResponseModel</returns>
|
||||||
|
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||||
|
[HttpGet("member-cipher-details/{orgId}")]
|
||||||
|
public async Task<IEnumerable<MemberCipherDetailsResponseModel>> GetMemberCipherDetails(Guid orgId)
|
||||||
|
{
|
||||||
|
// Using the AccessReports permission here until new permissions
|
||||||
|
// are needed for more control over reports
|
||||||
|
if (!await _currentContext.AccessReports(orgId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||||
|
|
||||||
|
var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access details for an organization member. Includes the member information,
|
||||||
|
/// group collection assignment, and item counts
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgId">Organization Id</param>
|
||||||
|
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
|
||||||
|
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||||
[HttpGet("member-access/{orgId}")]
|
[HttpGet("member-access/{orgId}")]
|
||||||
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||||
{
|
{
|
||||||
@ -52,26 +74,91 @@ public class ReportsController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
|
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||||
new OrganizationUserUserDetailsQueryRequest
|
|
||||||
|
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contains the organization member info, the cipher ids associated with the member,
|
||||||
|
/// and details on their collections, groups, and permissions
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param>
|
||||||
|
/// <returns>IEnumerable of MemberAccessCipherDetails</returns>
|
||||||
|
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
var memberCipherDetails =
|
||||||
IncludeCollections = true,
|
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
|
||||||
IncludeGroups = true
|
return memberCipherDetails;
|
||||||
});
|
}
|
||||||
|
|
||||||
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
/// <summary>
|
||||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
|
/// Get the password health report applications for an organization
|
||||||
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId);
|
/// </summary>
|
||||||
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId);
|
/// <param name="orgId">A valid Organization Id</param>
|
||||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
/// <returns>An Enumerable of PasswordHealthReportApplication </returns>
|
||||||
|
/// <exception cref="NotFoundException">If the user lacks access</exception>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
[HttpGet("password-health-report-applications/{orgId}")]
|
||||||
|
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
|
||||||
|
{
|
||||||
|
if (!await _currentContext.AccessReports(orgId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
var reports = MemberAccessReportResponseModel.CreateReport(
|
return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
|
||||||
orgGroups,
|
}
|
||||||
orgCollectionsWithAccess,
|
|
||||||
orgItems,
|
/// <summary>
|
||||||
organizationUsersTwoFactorEnabled,
|
/// Adds a new record into PasswordHealthReportApplication
|
||||||
orgAbility);
|
/// </summary>
|
||||||
return reports;
|
/// <param name="request">A single instance of PasswordHealthReportApplication Model</param>
|
||||||
|
/// <returns>A single instance of PasswordHealthReportApplication</returns>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
/// <exception cref="NotFoundException">If the user lacks access</exception>
|
||||||
|
[HttpPost("password-health-report-application")]
|
||||||
|
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplication(
|
||||||
|
[FromBody] PasswordHealthReportApplicationModel request)
|
||||||
|
{
|
||||||
|
if (!await _currentContext.AccessReports(request.OrganizationId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandRequest = new AddPasswordHealthReportApplicationRequest
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Url = request.Url
|
||||||
|
};
|
||||||
|
|
||||||
|
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds multiple records into PasswordHealthReportApplication
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">A enumerable of PasswordHealthReportApplicationModel</param>
|
||||||
|
/// <returns>An Enumerable of PasswordHealthReportApplication</returns>
|
||||||
|
/// <exception cref="NotFoundException">If user does not have access to the OrganizationId</exception>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
[HttpPost("password-health-report-applications")]
|
||||||
|
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
|
||||||
|
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> request)
|
||||||
|
{
|
||||||
|
if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Url = request.Url
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequests);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Api.Tools.Models;
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplicationModel
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
@ -1,30 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.Tools.Models.Data;
|
||||||
using Bit.Core.Entities;
|
|
||||||
using Bit.Core.Models.Data;
|
|
||||||
using Bit.Core.Models.Data.Organizations;
|
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
|
||||||
using Bit.Core.Vault.Models.Data;
|
|
||||||
|
|
||||||
namespace Bit.Api.Tools.Models.Response;
|
namespace Bit.Api.Tools.Models.Response;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Member access details. The individual item for the detailed member access
|
|
||||||
/// report. A collection can be assigned directly to a user without a group or
|
|
||||||
/// the user can be assigned to a collection through a group. Group level permissions
|
|
||||||
/// can override collection level permissions.
|
|
||||||
/// </summary>
|
|
||||||
public class MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
public Guid? CollectionId { get; set; }
|
|
||||||
public Guid? GroupId { get; set; }
|
|
||||||
public string GroupName { get; set; }
|
|
||||||
public string CollectionName { get; set; }
|
|
||||||
public int ItemCount { get; set; }
|
|
||||||
public bool? ReadOnly { get; set; }
|
|
||||||
public bool? HidePasswords { get; set; }
|
|
||||||
public bool? Manage { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains the collections and group collections a user has access to including
|
/// Contains the collections and group collections a user has access to including
|
||||||
/// the permission level for the collection and group collection.
|
/// the permission level for the collection and group collection.
|
||||||
@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
|
|||||||
public int TotalItemCount { get; set; }
|
public int TotalItemCount { get; set; }
|
||||||
public Guid? UserGuid { get; set; }
|
public Guid? UserGuid { get; set; }
|
||||||
public bool UsesKeyConnector { get; set; }
|
public bool UsesKeyConnector { get; set; }
|
||||||
public IEnumerable<MemberAccessReportAccessDetails> AccessDetails { get; set; }
|
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||||
/// Generates a report for all members of an organization. Containing summary information
|
|
||||||
/// such as item, collection, and group counts. As well as detailed information on the
|
|
||||||
/// user and group collections along with their permissions
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="orgGroups">Organization groups collection</param>
|
|
||||||
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
|
||||||
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
|
|
||||||
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
|
||||||
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
|
||||||
/// <returns>List of the MemberAccessReportResponseModel</returns>;
|
|
||||||
public static IEnumerable<MemberAccessReportResponseModel> CreateReport(
|
|
||||||
ICollection<Group> orgGroups,
|
|
||||||
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
|
||||||
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
|
||||||
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
|
||||||
OrganizationAbility orgAbility)
|
|
||||||
{
|
{
|
||||||
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
|
this.UserName = memberAccessCipherDetails.UserName;
|
||||||
// Create a dictionary to lookup the group names later.
|
this.Email = memberAccessCipherDetails.Email;
|
||||||
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
|
||||||
|
this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
|
||||||
// Get collections grouped and into a dictionary for counts
|
this.GroupsCount = memberAccessCipherDetails.GroupsCount;
|
||||||
var collectionItems = orgItems
|
this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
|
||||||
.SelectMany(x => x.CollectionIds,
|
this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
|
||||||
(x, b) => new { CipherId = x.Id, CollectionId = b })
|
this.UserGuid = memberAccessCipherDetails.UserGuid;
|
||||||
.GroupBy(y => y.CollectionId,
|
this.AccessDetails = memberAccessCipherDetails.AccessDetails;
|
||||||
(key, g) => new { CollectionId = key, Ciphers = g });
|
|
||||||
var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count());
|
|
||||||
|
|
||||||
|
|
||||||
// Loop through the org users and populate report and access data
|
|
||||||
var memberAccessReport = new List<MemberAccessReportResponseModel>();
|
|
||||||
foreach (var user in orgUsers)
|
|
||||||
{
|
|
||||||
// Take the collections/groups and create the access details items
|
|
||||||
var groupAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
var userCollectionAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
foreach (var tCollect in orgCollectionsWithAccess)
|
|
||||||
{
|
|
||||||
var itemCounts = collectionItemCounts.TryGetValue(tCollect.Item1.Id, out var itemCount) ? itemCount : 0;
|
|
||||||
if (tCollect.Item2.Groups.Count() > 0)
|
|
||||||
{
|
|
||||||
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
|
|
||||||
new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
CollectionId = tCollect.Item1.Id,
|
|
||||||
CollectionName = tCollect.Item1.Name,
|
|
||||||
GroupId = x.Id,
|
|
||||||
GroupName = groupNameDictionary[x.Id],
|
|
||||||
ReadOnly = x.ReadOnly,
|
|
||||||
HidePasswords = x.HidePasswords,
|
|
||||||
Manage = x.Manage,
|
|
||||||
ItemCount = itemCounts,
|
|
||||||
});
|
|
||||||
groupAccessDetails.AddRange(groupDetails);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All collections assigned to users and their permissions
|
|
||||||
if (tCollect.Item2.Users.Count() > 0)
|
|
||||||
{
|
|
||||||
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
|
|
||||||
new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
CollectionId = tCollect.Item1.Id,
|
|
||||||
CollectionName = tCollect.Item1.Name,
|
|
||||||
ReadOnly = x.ReadOnly,
|
|
||||||
HidePasswords = x.HidePasswords,
|
|
||||||
Manage = x.Manage,
|
|
||||||
ItemCount = itemCounts,
|
|
||||||
});
|
|
||||||
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var report = new MemberAccessReportResponseModel
|
|
||||||
{
|
|
||||||
UserName = user.Name,
|
|
||||||
Email = user.Email,
|
|
||||||
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
|
||||||
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
|
|
||||||
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
|
||||||
UserGuid = user.Id,
|
|
||||||
UsesKeyConnector = user.UsesKeyConnector
|
|
||||||
};
|
|
||||||
|
|
||||||
var userAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
if (user.Groups.Any())
|
|
||||||
{
|
|
||||||
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
|
|
||||||
userAccessDetails.AddRange(userGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
// There can be edge cases where groups don't have a collection
|
|
||||||
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
|
||||||
if (groupsWithoutCollections.Count() > 0)
|
|
||||||
{
|
|
||||||
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
GroupId = x,
|
|
||||||
GroupName = groupNameDictionary[x],
|
|
||||||
ItemCount = 0
|
|
||||||
});
|
|
||||||
userAccessDetails.AddRange(emptyGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.Collections.Any())
|
|
||||||
{
|
|
||||||
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
|
|
||||||
userAccessDetails.AddRange(userCollections);
|
|
||||||
}
|
|
||||||
report.AccessDetails = userAccessDetails;
|
|
||||||
|
|
||||||
report.TotalItemCount = collectionItems
|
|
||||||
.Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId))
|
|
||||||
.SelectMany(x => x.Ciphers)
|
|
||||||
.GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault())
|
|
||||||
.Count();
|
|
||||||
|
|
||||||
// Distinct items only
|
|
||||||
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
|
||||||
report.CollectionsCount = distinctItems.Count();
|
|
||||||
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
|
||||||
memberAccessReport.Add(report);
|
|
||||||
}
|
|
||||||
return memberAccessReport;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Api.Tools.Models.Response;
|
||||||
|
|
||||||
|
public class MemberCipherDetailsResponseModel
|
||||||
|
{
|
||||||
|
public string UserName { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public bool UsesKeyConnector { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A distinct list of the cipher ids associated with
|
||||||
|
/// the organization member
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<string> CipherIds { get; set; }
|
||||||
|
|
||||||
|
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||||
|
{
|
||||||
|
this.UserName = memberAccessCipherDetails.UserName;
|
||||||
|
this.Email = memberAccessCipherDetails.Email;
|
||||||
|
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
|
||||||
|
this.CipherIds = memberAccessCipherDetails.CipherIds;
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
#nullable enable
|
|
||||||
using Bit.Core.Context;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
|
|
||||||
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles authorization logic for Group operations.
|
|
||||||
/// This uses new logic implemented in the Flexible Collections initiative.
|
|
||||||
/// </summary>
|
|
||||||
public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequirement>
|
|
||||||
{
|
|
||||||
private readonly ICurrentContext _currentContext;
|
|
||||||
|
|
||||||
public GroupAuthorizationHandler(ICurrentContext currentContext)
|
|
||||||
{
|
|
||||||
_currentContext = currentContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
|
||||||
GroupOperationRequirement requirement)
|
|
||||||
{
|
|
||||||
// Acting user is not authenticated, fail
|
|
||||||
if (!_currentContext.UserId.HasValue)
|
|
||||||
{
|
|
||||||
context.Fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requirement.OrganizationId == default)
|
|
||||||
{
|
|
||||||
context.Fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var org = _currentContext.GetOrganization(requirement.OrganizationId);
|
|
||||||
|
|
||||||
switch (requirement)
|
|
||||||
{
|
|
||||||
case not null when requirement.Name == nameof(GroupOperations.ReadAll):
|
|
||||||
await CanReadAllAsync(context, requirement, org);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
|
|
||||||
CurrentContextOrganization? org)
|
|
||||||
{
|
|
||||||
// All users of an organization can read all groups belonging to the organization for collection access management
|
|
||||||
if (org is not null)
|
|
||||||
{
|
|
||||||
context.Succeed(requirement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow provider users to read all groups if they are a provider for the target organization
|
|
||||||
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
|
|
||||||
{
|
|
||||||
context.Succeed(requirement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
|
||||||
|
|
||||||
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
|
|
||||||
|
|
||||||
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
|
||||||
{
|
|
||||||
public Guid OrganizationId { get; init; }
|
|
||||||
|
|
||||||
public GroupOperationRequirement(string name, Guid organizationId)
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
OrganizationId = organizationId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class GroupOperations
|
|
||||||
{
|
|
||||||
public static GroupOperationRequirement ReadAll(Guid organizationId)
|
|
||||||
{
|
|
||||||
return new GroupOperationRequirement(nameof(ReadAll), organizationId);
|
|
||||||
}
|
|
||||||
}
|
|
@ -99,7 +99,10 @@ public class CiphersController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp);
|
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
|
||||||
|
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||||
|
|
||||||
|
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/full-details")]
|
[HttpGet("{id}/full-details")]
|
||||||
@ -600,10 +603,10 @@ public class CiphersController : Controller
|
|||||||
|
|
||||||
[HttpPut("{id}/collections-admin")]
|
[HttpPut("{id}/collections-admin")]
|
||||||
[HttpPost("{id}/collections-admin")]
|
[HttpPost("{id}/collections-admin")]
|
||||||
public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
||||||
{
|
{
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
|
||||||
|
|
||||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||||
@ -621,6 +624,11 @@ public class CiphersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
|
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
|
||||||
|
|
||||||
|
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
|
||||||
|
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||||
|
|
||||||
|
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("bulk-collections")]
|
[HttpPost("bulk-collections")]
|
||||||
|
@ -112,7 +112,7 @@ public class SyncController : Controller
|
|||||||
|
|
||||||
private ICollection<CipherDetails> FilterSSHKeys(ICollection<CipherDetails> ciphers)
|
private ICollection<CipherDetails> FilterSSHKeys(ICollection<CipherDetails> ciphers)
|
||||||
{
|
{
|
||||||
if (_currentContext.ClientVersion >= _sshKeyCipherMinimumVersion)
|
if (_currentContext.ClientVersion >= _sshKeyCipherMinimumVersion || _featureService.IsEnabled(FeatureFlagKeys.SSHVersionCheckQAOverride))
|
||||||
{
|
{
|
||||||
return ciphers;
|
return ciphers;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
using Bit.Api.Models.Response;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Tools.Models.Response;
|
using Bit.Api.Tools.Models.Response;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
|
||||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
using Bit.Billing.Constants;
|
using Bit.Billing.Constants;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
@ -42,36 +41,26 @@ public class ProviderEventService(
|
|||||||
case HandledStripeWebhook.InvoiceCreated:
|
case HandledStripeWebhook.InvoiceCreated:
|
||||||
{
|
{
|
||||||
var clients =
|
var clients =
|
||||||
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId))
|
await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId);
|
||||||
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
|
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
||||||
|
|
||||||
var enterpriseProviderPlan =
|
var invoiceItems = new List<ProviderInvoiceItem>();
|
||||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
var teamsProviderPlan =
|
foreach (var client in clients)
|
||||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
|
|
||||||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
|
||||||
{
|
{
|
||||||
logger.LogError("Provider {ProviderID} is missing or has misconfigured provider plans", parsedProviderId);
|
if (client.Status != OrganizationStatusType.Managed)
|
||||||
|
{
|
||||||
throw new Exception("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
var plan = StaticStore.Plans.Single(x => x.Name == client.Plan && providerPlans.Any(y => y.PlanType == x.Type));
|
||||||
|
|
||||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||||
|
|
||||||
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||||
|
|
||||||
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
invoiceItems.Add(new ProviderInvoiceItem
|
||||||
|
|
||||||
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
|
|
||||||
{
|
{
|
||||||
ProviderId = parsedProviderId,
|
ProviderId = parsedProviderId,
|
||||||
InvoiceId = invoice.Id,
|
InvoiceId = invoice.Id,
|
||||||
@ -81,58 +70,36 @@ public class ProviderEventService(
|
|||||||
PlanName = client.Plan,
|
PlanName = client.Plan,
|
||||||
AssignedSeats = client.Seats ?? 0,
|
AssignedSeats = client.Seats ?? 0,
|
||||||
UsedSeats = client.OccupiedSeats ?? 0,
|
UsedSeats = client.OccupiedSeats ?? 0,
|
||||||
Total = client.Plan == enterprisePlan.Name
|
Total = (client.Seats ?? 0) * discountedSeatPrice
|
||||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
});
|
||||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
}
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
|
foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0))
|
||||||
{
|
{
|
||||||
var enterpriseClientSeats = invoiceItems
|
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||||
.Where(item => item.PlanName == enterprisePlan.Name)
|
|
||||||
|
var clientSeats = invoiceItems
|
||||||
|
.Where(item => item.PlanName == plan.Name)
|
||||||
.Sum(item => item.AssignedSeats);
|
.Sum(item => item.AssignedSeats);
|
||||||
|
|
||||||
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
|
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
|
||||||
|
|
||||||
|
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||||
|
|
||||||
|
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||||
|
|
||||||
if (unassignedEnterpriseSeats > 0)
|
|
||||||
{
|
|
||||||
invoiceItems.Add(new ProviderInvoiceItem
|
invoiceItems.Add(new ProviderInvoiceItem
|
||||||
{
|
{
|
||||||
ProviderId = parsedProviderId,
|
ProviderId = parsedProviderId,
|
||||||
InvoiceId = invoice.Id,
|
InvoiceId = invoice.Id,
|
||||||
InvoiceNumber = invoice.Number,
|
InvoiceNumber = invoice.Number,
|
||||||
ClientName = "Unassigned seats",
|
ClientName = "Unassigned seats",
|
||||||
PlanName = enterprisePlan.Name,
|
PlanName = plan.Name,
|
||||||
AssignedSeats = unassignedEnterpriseSeats,
|
AssignedSeats = unassignedSeats,
|
||||||
UsedSeats = 0,
|
UsedSeats = 0,
|
||||||
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice
|
Total = unassignedSeats * discountedSeatPrice
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (teamsProviderPlan.PurchasedSeats is null or 0)
|
|
||||||
{
|
|
||||||
var teamsClientSeats = invoiceItems
|
|
||||||
.Where(item => item.PlanName == teamsPlan.Name)
|
|
||||||
.Sum(item => item.AssignedSeats);
|
|
||||||
|
|
||||||
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
|
|
||||||
|
|
||||||
if (unassignedTeamsSeats > 0)
|
|
||||||
{
|
|
||||||
invoiceItems.Add(new ProviderInvoiceItem
|
|
||||||
{
|
|
||||||
ProviderId = parsedProviderId,
|
|
||||||
InvoiceId = invoice.Id,
|
|
||||||
InvoiceNumber = invoice.Number,
|
|
||||||
ClientName = "Unassigned seats",
|
|
||||||
PlanName = teamsPlan.Name,
|
|
||||||
AssignedSeats = unassignedTeamsSeats,
|
|
||||||
UsedSeats = 0,
|
|
||||||
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
||||||
required autofocus>
|
required autofocus>
|
||||||
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
||||||
<small class="form-text text-muted">We'll email you a secure login link.</small>
|
<small class="form-text text-body-secondary">We'll email you a secure login link.</small>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-block" type="submit">Continue</button>
|
<button class="btn btn-primary btn-block" type="submit">Continue</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -15,6 +15,7 @@ public enum PolicyType : byte
|
|||||||
DisablePersonalVaultExport = 10,
|
DisablePersonalVaultExport = 10,
|
||||||
ActivateAutofill = 11,
|
ActivateAutofill = 11,
|
||||||
AutomaticAppLogIn = 12,
|
AutomaticAppLogIn = 12,
|
||||||
|
FreeFamiliesSponsorshipPolicy = 13
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class PolicyTypeExtensions
|
public static class PolicyTypeExtensions
|
||||||
@ -40,6 +41,7 @@ public static class PolicyTypeExtensions
|
|||||||
PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
|
PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
|
||||||
PolicyType.ActivateAutofill => "Active auto-fill",
|
PolicyType.ActivateAutofill => "Active auto-fill",
|
||||||
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications",
|
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications",
|
||||||
|
PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
|
|
||||||
|
public class GroupAuthorizationHandler(ICurrentContext currentContext)
|
||||||
|
: AuthorizationHandler<GroupOperationRequirement, OrganizationScope>
|
||||||
|
{
|
||||||
|
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||||
|
GroupOperationRequirement requirement, OrganizationScope organizationScope)
|
||||||
|
{
|
||||||
|
var authorized = requirement switch
|
||||||
|
{
|
||||||
|
not null when requirement.Name == nameof(GroupOperations.ReadAll) =>
|
||||||
|
await CanReadAllAsync(organizationScope),
|
||||||
|
not null when requirement.Name == nameof(GroupOperations.ReadAllDetails) =>
|
||||||
|
await CanViewGroupDetailsAsync(organizationScope),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requirement is not null && authorized)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanReadAllAsync(OrganizationScope organizationScope) =>
|
||||||
|
currentContext.GetOrganization(organizationScope) is not null
|
||||||
|
|| await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||||
|
|
||||||
|
private async Task<bool> CanViewGroupDetailsAsync(OrganizationScope organizationScope) =>
|
||||||
|
currentContext.GetOrganization(organizationScope) is
|
||||||
|
{ Type: OrganizationUserType.Owner } or
|
||||||
|
{ Type: OrganizationUserType.Admin } or
|
||||||
|
{
|
||||||
|
Permissions: { ManageGroups: true } or
|
||||||
|
{ ManageUsers: true }
|
||||||
|
} ||
|
||||||
|
await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
|
|
||||||
|
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
||||||
|
{
|
||||||
|
public GroupOperationRequirement(string name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GroupOperations
|
||||||
|
{
|
||||||
|
public static readonly GroupOperationRequirement ReadAll = new(nameof(ReadAll));
|
||||||
|
public static readonly GroupOperationRequirement ReadAllDetails = new(nameof(ReadAllDetails));
|
||||||
|
}
|
@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
private readonly IGlobalSettings _globalSettings;
|
private readonly IGlobalSettings _globalSettings;
|
||||||
private readonly IPolicyService _policyService;
|
private readonly IPolicyService _policyService;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IOrganizationService _organizationService;
|
|
||||||
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
||||||
|
|
||||||
public VerifyOrganizationDomainCommand(
|
public VerifyOrganizationDomainCommand(
|
||||||
@ -30,7 +29,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
IPolicyService policyService,
|
IPolicyService policyService,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IOrganizationService organizationService,
|
|
||||||
ILogger<VerifyOrganizationDomainCommand> logger)
|
ILogger<VerifyOrganizationDomainCommand> logger)
|
||||||
{
|
{
|
||||||
_organizationDomainRepository = organizationDomainRepository;
|
_organizationDomainRepository = organizationDomainRepository;
|
||||||
@ -39,7 +37,6 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_organizationService = organizationService;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
using Bit.Core.Context;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Context;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
@ -7,14 +7,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authoriza
|
|||||||
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
||||||
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
||||||
{
|
{
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
|
||||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(
|
public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext)
|
||||||
IApplicationCacheService applicationCacheService,
|
|
||||||
ICurrentContext currentContext)
|
|
||||||
{
|
{
|
||||||
_applicationCacheService = applicationCacheService;
|
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,5 +4,5 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interface
|
|||||||
|
|
||||||
public interface IUpdateOrganizationUserGroupsCommand
|
public interface IUpdateOrganizationUserGroupsCommand
|
||||||
{
|
{
|
||||||
Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds, Guid? loggedInUserId);
|
Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds);
|
||||||
}
|
}
|
||||||
|
@ -9,25 +9,18 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
|||||||
public class UpdateOrganizationUserGroupsCommand : IUpdateOrganizationUserGroupsCommand
|
public class UpdateOrganizationUserGroupsCommand : IUpdateOrganizationUserGroupsCommand
|
||||||
{
|
{
|
||||||
private readonly IEventService _eventService;
|
private readonly IEventService _eventService;
|
||||||
private readonly IOrganizationService _organizationService;
|
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
|
||||||
public UpdateOrganizationUserGroupsCommand(
|
public UpdateOrganizationUserGroupsCommand(
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IOrganizationService organizationService,
|
|
||||||
IOrganizationUserRepository organizationUserRepository)
|
IOrganizationUserRepository organizationUserRepository)
|
||||||
{
|
{
|
||||||
_eventService = eventService;
|
_eventService = eventService;
|
||||||
_organizationService = organizationService;
|
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds, Guid? loggedInUserId)
|
public async Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds)
|
||||||
{
|
{
|
||||||
if (loggedInUserId.HasValue)
|
|
||||||
{
|
|
||||||
await _organizationService.ValidateOrganizationUserUpdatePermissions(organizationUser.OrganizationId, organizationUser.Type, null, organizationUser.GetPermissions());
|
|
||||||
}
|
|
||||||
await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupIds);
|
await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupIds);
|
||||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups);
|
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups);
|
||||||
}
|
}
|
||||||
|
@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand
|
|||||||
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
||||||
{
|
{
|
||||||
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
||||||
.Where(requiredPolicyType =>
|
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||||
savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (missingRequiredPolicyTypes.Count != 0)
|
if (missingRequiredPolicyTypes.Count != 0)
|
||||||
|
@ -18,5 +18,6 @@ public static class PolicyServiceCollectionExtensions
|
|||||||
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
|
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
|
||||||
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
|
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
|
||||||
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
|
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
|
||||||
|
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||||
|
|
||||||
|
public class FreeFamiliesForEnterprisePolicyValidator(
|
||||||
|
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||||
|
IMailService mailService,
|
||||||
|
IOrganizationRepository organizationRepository)
|
||||||
|
: IPolicyValidator
|
||||||
|
{
|
||||||
|
public PolicyType Type => PolicyType.FreeFamiliesSponsorshipPolicy;
|
||||||
|
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||||
|
|
||||||
|
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||||
|
{
|
||||||
|
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })
|
||||||
|
{
|
||||||
|
await NotifiesUserWithApplicablePoliciesAsync(policyUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NotifiesUserWithApplicablePoliciesAsync(PolicyUpdate policy)
|
||||||
|
{
|
||||||
|
var organizationSponsorships = (await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(policy.OrganizationId))
|
||||||
|
.Where(p => p.SponsoredOrganizationId is not null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(policy.OrganizationId);
|
||||||
|
var organizationName = organization?.Name;
|
||||||
|
|
||||||
|
foreach (var org in organizationSponsorships)
|
||||||
|
{
|
||||||
|
var offerAcceptanceDate = org.ValidUntil!.Value.AddDays(-7).ToString("MM/dd/yyyy");
|
||||||
|
await mailService.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(org.FriendlyName, offerAcceptanceDate,
|
||||||
|
org.SponsoredOrganizationId.ToString(), organizationName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
|
||||||
|
}
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
private readonly IOrganizationRepository _organizationRepository;
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||||
|
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||||
|
|
||||||
public SingleOrgPolicyValidator(
|
public SingleOrgPolicyValidator(
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
ISsoConfigRepository ssoConfigRepository,
|
ISsoConfigRepository ssoConfigRepository,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
|
IFeatureService featureService,
|
||||||
|
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||||
|
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||||
{
|
{
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_ssoConfigRepository = ssoConfigRepository;
|
_ssoConfigRepository = ssoConfigRepository;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
|
_featureService = featureService;
|
||||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||||
|
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||||
@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
if (policyUpdate is not { Enabled: true })
|
if (policyUpdate is not { Enabled: true })
|
||||||
{
|
{
|
||||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
|
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
|
||||||
return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
|
||||||
|
var validateDecryptionErrorMessage = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(validateDecryptionErrorMessage))
|
||||||
|
{
|
||||||
|
return validateDecryptionErrorMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
|
||||||
|
{
|
||||||
|
return "The Single organization policy is required for organizations that have enabled domain verification.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A typed wrapper for an organization Guid. This is used for authorization checks
|
/// A typed wrapper for an organization Guid. This is used for authorization checks
|
@ -15,6 +15,7 @@ using Bit.Core.Auth.Models.Business.Tokenables;
|
|||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Models.Sales;
|
using Bit.Core.Billing.Models.Sales;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -444,13 +445,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
|
|
||||||
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup)
|
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup)
|
||||||
{
|
{
|
||||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
|
||||||
|
|
||||||
if (!consolidatedBillingEnabled)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"{nameof(SignupClientAsync)} is only for use within Consolidated Billing");
|
|
||||||
}
|
|
||||||
|
|
||||||
var plan = StaticStore.GetPlan(signup.Plan);
|
var plan = StaticStore.GetPlan(signup.Plan);
|
||||||
|
|
||||||
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
|
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
|
||||||
@ -1443,10 +1437,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
|
|
||||||
if (provider is { Enabled: true })
|
if (provider is { Enabled: true })
|
||||||
{
|
{
|
||||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
if (provider.IsBillable())
|
||||||
|
|
||||||
if (consolidatedBillingEnabled && provider.Type == ProviderType.Msp &&
|
|
||||||
provider.Status == ProviderStatusType.Billable)
|
|
||||||
{
|
{
|
||||||
return (false, "Seat limit has been reached. Please contact your provider to add more seats.");
|
return (false, "Seat limit has been reached. Please contact your provider to add more seats.");
|
||||||
}
|
}
|
||||||
|
@ -289,7 +289,7 @@ public class PolicyService : IPolicyService
|
|||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
|
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Organization has verified domains.");
|
throw new BadRequestException("The Single organization policy is required for organizations that have enabled domain verification.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,86 +0,0 @@
|
|||||||
using Bit.Core.Auth.Enums;
|
|
||||||
using Bit.Core.Auth.Models;
|
|
||||||
using Bit.Core.Auth.Utilities.Duo;
|
|
||||||
using Bit.Core.Entities;
|
|
||||||
using Bit.Core.Services;
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace Bit.Core.Auth.Identity;
|
|
||||||
|
|
||||||
public class DuoWebTokenProvider : IUserTwoFactorTokenProvider<User>
|
|
||||||
{
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
|
||||||
private readonly GlobalSettings _globalSettings;
|
|
||||||
|
|
||||||
public DuoWebTokenProvider(
|
|
||||||
IServiceProvider serviceProvider,
|
|
||||||
GlobalSettings globalSettings)
|
|
||||||
{
|
|
||||||
_serviceProvider = serviceProvider;
|
|
||||||
_globalSettings = globalSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
|
||||||
{
|
|
||||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
|
||||||
if (!(await userService.CanAccessPremium(user)))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
|
||||||
if (!HasProperMetaData(provider))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
|
||||||
{
|
|
||||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
|
||||||
if (!(await userService.CanAccessPremium(user)))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
|
||||||
if (!HasProperMetaData(provider))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var signatureRequest = DuoWeb.SignRequest((string)provider.MetaData["IKey"],
|
|
||||||
(string)provider.MetaData["SKey"], _globalSettings.Duo.AKey, user.Email);
|
|
||||||
return signatureRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
|
|
||||||
{
|
|
||||||
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
|
||||||
if (!(await userService.CanAccessPremium(user)))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
|
||||||
if (!HasProperMetaData(provider))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = DuoWeb.VerifyResponse((string)provider.MetaData["IKey"], (string)provider.MetaData["SKey"],
|
|
||||||
_globalSettings.Duo.AKey, token);
|
|
||||||
|
|
||||||
return response == user.Email;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
|
||||||
{
|
|
||||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
|
|
||||||
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,76 +0,0 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
|
||||||
using Bit.Core.Auth.Enums;
|
|
||||||
using Bit.Core.Auth.Models;
|
|
||||||
using Bit.Core.Auth.Utilities.Duo;
|
|
||||||
using Bit.Core.Entities;
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
|
|
||||||
namespace Bit.Core.Auth.Identity;
|
|
||||||
|
|
||||||
public interface IOrganizationDuoWebTokenProvider : IOrganizationTwoFactorTokenProvider { }
|
|
||||||
|
|
||||||
public class OrganizationDuoWebTokenProvider : IOrganizationDuoWebTokenProvider
|
|
||||||
{
|
|
||||||
private readonly GlobalSettings _globalSettings;
|
|
||||||
|
|
||||||
public OrganizationDuoWebTokenProvider(GlobalSettings globalSettings)
|
|
||||||
{
|
|
||||||
_globalSettings = globalSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
|
|
||||||
{
|
|
||||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
|
||||||
{
|
|
||||||
return Task.FromResult(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
|
||||||
var canGenerate = organization.TwoFactorProviderIsEnabled(TwoFactorProviderType.OrganizationDuo)
|
|
||||||
&& HasProperMetaData(provider);
|
|
||||||
return Task.FromResult(canGenerate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<string> GenerateAsync(Organization organization, User user)
|
|
||||||
{
|
|
||||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
|
||||||
{
|
|
||||||
return Task.FromResult<string>(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
|
||||||
if (!HasProperMetaData(provider))
|
|
||||||
{
|
|
||||||
return Task.FromResult<string>(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var signatureRequest = DuoWeb.SignRequest(provider.MetaData["IKey"].ToString(),
|
|
||||||
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, user.Email);
|
|
||||||
return Task.FromResult(signatureRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> ValidateAsync(string token, Organization organization, User user)
|
|
||||||
{
|
|
||||||
if (organization == null || !organization.Enabled || !organization.Use2fa)
|
|
||||||
{
|
|
||||||
return Task.FromResult(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
|
|
||||||
if (!HasProperMetaData(provider))
|
|
||||||
{
|
|
||||||
return Task.FromResult(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = DuoWeb.VerifyResponse(provider.MetaData["IKey"].ToString(),
|
|
||||||
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, token);
|
|
||||||
|
|
||||||
return Task.FromResult(response == user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
|
||||||
{
|
|
||||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
|
|
||||||
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,172 +0,0 @@
|
|||||||
using Bit.Core.Auth.Models;
|
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
|
||||||
using Bit.Core.Context;
|
|
||||||
using Bit.Core.Entities;
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
using Bit.Core.Tokens;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Duo = DuoUniversal;
|
|
||||||
|
|
||||||
namespace Bit.Core.Auth.Identity;
|
|
||||||
|
|
||||||
/*
|
|
||||||
PM-5156 addresses tech debt
|
|
||||||
Interface to allow for DI, will end up being removed as part of the removal of the old Duo SDK v2 flows.
|
|
||||||
This service is to support SDK v4 flows for Duo. At some time in the future we will need
|
|
||||||
to combine this service with the DuoWebTokenProvider and OrganizationDuoWebTokenProvider to support SDK v4.
|
|
||||||
*/
|
|
||||||
public interface ITemporaryDuoWebV4SDKService
|
|
||||||
{
|
|
||||||
Task<string> GenerateAsync(TwoFactorProvider provider, User user);
|
|
||||||
Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService
|
|
||||||
{
|
|
||||||
private readonly ICurrentContext _currentContext;
|
|
||||||
private readonly GlobalSettings _globalSettings;
|
|
||||||
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory;
|
|
||||||
private readonly ILogger<TemporaryDuoWebV4SDKService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor for the DuoUniversalPromptService. Used to supplement v2 implementation of Duo with v4 SDK
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="currentContext">used to fetch initiating Client</param>
|
|
||||||
/// <param name="globalSettings">used to fetch vault URL for Redirect URL</param>
|
|
||||||
public TemporaryDuoWebV4SDKService(
|
|
||||||
ICurrentContext currentContext,
|
|
||||||
GlobalSettings globalSettings,
|
|
||||||
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
|
||||||
ILogger<TemporaryDuoWebV4SDKService> logger)
|
|
||||||
{
|
|
||||||
_currentContext = currentContext;
|
|
||||||
_globalSettings = globalSettings;
|
|
||||||
_tokenDataFactory = tokenDataFactory;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provider agnostic (either Duo or OrganizationDuo) method to generate a Duo Auth URL
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="provider">Either Duo or OrganizationDuo</param>
|
|
||||||
/// <param name="user">self</param>
|
|
||||||
/// <returns>AuthUrl for DUO SDK v4</returns>
|
|
||||||
public async Task<string> GenerateAsync(TwoFactorProvider provider, User user)
|
|
||||||
{
|
|
||||||
if (!HasProperMetaData(provider))
|
|
||||||
{
|
|
||||||
if (!HasProperMetaData_SDKV2(provider))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var duoClient = await BuildDuoClientAsync(provider);
|
|
||||||
if (duoClient == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var state = _tokenDataFactory.Protect(new DuoUserStateTokenable(user));
|
|
||||||
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
|
|
||||||
|
|
||||||
return authUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates Duo SDK v4 response
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="token">response form Duo</param>
|
|
||||||
/// <param name="provider">TwoFactorProviderType Duo or OrganizationDuo</param>
|
|
||||||
/// <param name="user">self</param>
|
|
||||||
/// <returns>true or false depending on result of verification</returns>
|
|
||||||
public async Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user)
|
|
||||||
{
|
|
||||||
if (!HasProperMetaData(provider))
|
|
||||||
{
|
|
||||||
if (!HasProperMetaData_SDKV2(provider))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var duoClient = await BuildDuoClientAsync(provider);
|
|
||||||
if (duoClient == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parts = token.Split("|");
|
|
||||||
var authCode = parts[0];
|
|
||||||
var state = parts[1];
|
|
||||||
|
|
||||||
_tokenDataFactory.TryUnprotect(state, out var tokenable);
|
|
||||||
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
|
|
||||||
// their authCode with a victims credentials
|
|
||||||
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
|
|
||||||
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
|
|
||||||
return res.AuthResult.Result == "allow";
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool HasProperMetaData(TwoFactorProvider provider)
|
|
||||||
{
|
|
||||||
return provider?.MetaData != null && provider.MetaData.ContainsKey("ClientId") &&
|
|
||||||
provider.MetaData.ContainsKey("ClientSecret") && provider.MetaData.ContainsKey("Host");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the metadata for SDK V2 is present.
|
|
||||||
/// Transitional method to support Duo during v4 database rename
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="provider">The TwoFactorProvider object to check.</param>
|
|
||||||
/// <returns>True if the provider has the proper metadata; otherwise, false.</returns>
|
|
||||||
private bool HasProperMetaData_SDKV2(TwoFactorProvider provider)
|
|
||||||
{
|
|
||||||
if (provider?.MetaData != null &&
|
|
||||||
provider.MetaData.TryGetValue("IKey", out var iKey) &&
|
|
||||||
provider.MetaData.TryGetValue("SKey", out var sKey) &&
|
|
||||||
provider.MetaData.ContainsKey("Host"))
|
|
||||||
{
|
|
||||||
provider.MetaData.Add("ClientId", iKey);
|
|
||||||
provider.MetaData.Add("ClientSecret", sKey);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
|
|
||||||
/// <returns>Duo.Client object or null</returns>
|
|
||||||
private async Task<Duo.Client> BuildDuoClientAsync(TwoFactorProvider provider)
|
|
||||||
{
|
|
||||||
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
|
|
||||||
// to redirect back to the initiating client
|
|
||||||
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
|
|
||||||
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
|
|
||||||
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
|
|
||||||
|
|
||||||
var client = new Duo.ClientBuilder(
|
|
||||||
(string)provider.MetaData["ClientId"],
|
|
||||||
(string)provider.MetaData["ClientSecret"],
|
|
||||||
(string)provider.MetaData["Host"],
|
|
||||||
redirectUri).Build();
|
|
||||||
|
|
||||||
if (!await client.DoHealthCheck(true))
|
|
||||||
{
|
|
||||||
_logger.LogError("Unable to connect to Duo. Health check failed.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,7 +6,7 @@ using Microsoft.Extensions.Caching.Distributed;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using OtpNet;
|
using OtpNet;
|
||||||
|
|
||||||
namespace Bit.Core.Auth.Identity;
|
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||||
|
|
||||||
public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider<User>
|
public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||||
{
|
{
|
@ -0,0 +1,102 @@
|
|||||||
|
using Bit.Core.Auth.Enums;
|
||||||
|
using Bit.Core.Auth.Models;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Duo = DuoUniversal;
|
||||||
|
|
||||||
|
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||||
|
|
||||||
|
public class DuoUniversalTokenProvider(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||||
|
IDuoUniversalTokenService duoUniversalTokenService) : IUserTwoFactorTokenProvider<User>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// We need the IServiceProvider to resolve the IUserService. There is a complex dependency dance
|
||||||
|
/// occurring between IUserService, which extends the UserManager<User>, and the usage of the
|
||||||
|
/// UserManager<User> within this class. Trying to resolve the IUserService using the DI pipeline
|
||||||
|
/// will not allow the server to start and it will hang and give no helpful indication as to the problem.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IServiceProvider _serviceProvider = serviceProvider;
|
||||||
|
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory = tokenDataFactory;
|
||||||
|
private readonly IDuoUniversalTokenService _duoUniversalTokenService = duoUniversalTokenService;
|
||||||
|
|
||||||
|
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
|
||||||
|
{
|
||||||
|
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||||
|
var provider = await GetDuoTwoFactorProvider(user, userService);
|
||||||
|
if (provider == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
|
||||||
|
{
|
||||||
|
var duoClient = await GetDuoClientAsync(user);
|
||||||
|
if (duoClient == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _duoUniversalTokenService.GenerateAuthUrl(duoClient, _tokenDataFactory, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
|
||||||
|
{
|
||||||
|
var duoClient = await GetDuoClientAsync(user);
|
||||||
|
if (duoClient == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await _duoUniversalTokenService.RequestDuoValidationAsync(duoClient, _tokenDataFactory, user, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the Duo Two Factor Provider for the user if they have access to Duo
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">Active User</param>
|
||||||
|
/// <returns>null or Duo TwoFactorProvider</returns>
|
||||||
|
private async Task<TwoFactorProvider> GetDuoTwoFactorProvider(User user, IUserService userService)
|
||||||
|
{
|
||||||
|
if (!await userService.CanAccessPremium(user))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
|
||||||
|
if (!_duoUniversalTokenService.HasProperDuoMetadata(provider))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the User to fetch a valid TwoFactorProvider and use it to create a Duo.Client
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">active user</param>
|
||||||
|
/// <returns>null or Duo TwoFactorProvider</returns>
|
||||||
|
private async Task<Duo.Client> GetDuoClientAsync(User user)
|
||||||
|
{
|
||||||
|
var userService = _serviceProvider.GetRequiredService<IUserService>();
|
||||||
|
var provider = await GetDuoTwoFactorProvider(user, userService);
|
||||||
|
if (provider == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var duoClient = await _duoUniversalTokenService.BuildDuoTwoFactorClientAsync(provider);
|
||||||
|
if (duoClient == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return duoClient;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,177 @@
|
|||||||
|
using Bit.Core.Auth.Models;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Duo = DuoUniversal;
|
||||||
|
|
||||||
|
namespace Bit.Core.Auth.Identity.TokenProviders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OrganizationDuo and Duo TwoFactorProviderTypes both use the same flows so both of those Token Providers will
|
||||||
|
/// have this class injected to utilize these methods
|
||||||
|
/// </summary>
|
||||||
|
public interface IDuoUniversalTokenService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates the Duo Auth URL for the user to be redirected to Duo for 2FA. This
|
||||||
|
/// Auth URL also lets the Duo Service know where to redirect the user back to after
|
||||||
|
/// the 2FA process is complete.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="duoClient">A not null valid Duo.Client</param>
|
||||||
|
/// <param name="tokenDataFactory">This service creates the state token for added security</param>
|
||||||
|
/// <param name="user">currently active user</param>
|
||||||
|
/// <returns>a URL in string format</returns>
|
||||||
|
string GenerateAuthUrl(
|
||||||
|
Duo.Client duoClient,
|
||||||
|
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||||
|
User user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes the request to Duo to validate the authCode and state token
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="duoClient">A not null valid Duo.Client</param>
|
||||||
|
/// <param name="tokenDataFactory">Factory for decrypting the state</param>
|
||||||
|
/// <param name="user">self</param>
|
||||||
|
/// <param name="token">token received from the client</param>
|
||||||
|
/// <returns>boolean based on result from Duo</returns>
|
||||||
|
Task<bool> RequestDuoValidationAsync(
|
||||||
|
Duo.Client duoClient,
|
||||||
|
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||||
|
User user,
|
||||||
|
string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Duo.Client object for use with Duo SDK v4. This method is to validate a Duo configuration
|
||||||
|
/// when adding or updating the configuration. This method makes a web request to Duo to verify the configuration.
|
||||||
|
/// Throws exception if configuration is invalid.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clientSecret">Duo client Secret</param>
|
||||||
|
/// <param name="clientId">Duo client Id</param>
|
||||||
|
/// <param name="host">Duo host</param>
|
||||||
|
/// <returns>Boolean</returns>
|
||||||
|
Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks provider for the correct Duo metadata: ClientId, ClientSecret, and Host. Does no validation on the data.
|
||||||
|
/// it is assumed to be correct. The only way to have the data written to the Database is after verification
|
||||||
|
/// occurs.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">Host being checked for proper data</param>
|
||||||
|
/// <returns>true if all three are present; false if one is missing or the host is incorrect</returns>
|
||||||
|
bool HasProperDuoMetadata(TwoFactorProvider provider);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation.
|
||||||
|
/// This method is made public so that it is easier to test. If the method was private then there would not be an
|
||||||
|
/// easy way to mock the response. Since this makes a web request it is difficult to mock.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
|
||||||
|
/// <returns>Duo.Client object or null</returns>
|
||||||
|
Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DuoUniversalTokenService(
|
||||||
|
ICurrentContext currentContext,
|
||||||
|
GlobalSettings globalSettings) : IDuoUniversalTokenService
|
||||||
|
{
|
||||||
|
private readonly ICurrentContext _currentContext = currentContext;
|
||||||
|
private readonly GlobalSettings _globalSettings = globalSettings;
|
||||||
|
|
||||||
|
public string GenerateAuthUrl(
|
||||||
|
Duo.Client duoClient,
|
||||||
|
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||||
|
User user)
|
||||||
|
{
|
||||||
|
var state = tokenDataFactory.Protect(new DuoUserStateTokenable(user));
|
||||||
|
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
|
||||||
|
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RequestDuoValidationAsync(
|
||||||
|
Duo.Client duoClient,
|
||||||
|
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
|
||||||
|
User user,
|
||||||
|
string token)
|
||||||
|
{
|
||||||
|
var parts = token.Split("|");
|
||||||
|
var authCode = parts[0];
|
||||||
|
var state = parts[1];
|
||||||
|
tokenDataFactory.TryUnprotect(state, out var tokenable);
|
||||||
|
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
|
||||||
|
// their authCode with a victims credentials
|
||||||
|
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
|
||||||
|
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
|
||||||
|
return res.AuthResult.Result == "allow";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host)
|
||||||
|
{
|
||||||
|
// Do some simple checks to ensure data integrity
|
||||||
|
if (!ValidDuoHost(host) ||
|
||||||
|
string.IsNullOrWhiteSpace(clientSecret) ||
|
||||||
|
string.IsNullOrWhiteSpace(clientId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The AuthURI is not important for this health check so we pass in a non-empty string
|
||||||
|
var client = new Duo.ClientBuilder(clientId, clientSecret, host, "non-empty").Build();
|
||||||
|
|
||||||
|
// This could throw an exception, the false flag will allow the exception to bubble up
|
||||||
|
return await client.DoHealthCheck(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasProperDuoMetadata(TwoFactorProvider provider)
|
||||||
|
{
|
||||||
|
return provider?.MetaData != null &&
|
||||||
|
provider.MetaData.ContainsKey("ClientId") &&
|
||||||
|
provider.MetaData.ContainsKey("ClientSecret") &&
|
||||||
|
provider.MetaData.ContainsKey("Host") &&
|
||||||
|
ValidDuoHost((string)provider.MetaData["Host"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the host string to make sure it meets Duo's Guidelines before attempting to create a Duo.Client.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="host">string representing the Duo Host</param>
|
||||||
|
/// <returns>true if the host is valid false otherwise</returns>
|
||||||
|
public static bool ValidDuoHost(string host)
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
|
||||||
|
uri.Host.StartsWith("api-") &&
|
||||||
|
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider)
|
||||||
|
{
|
||||||
|
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
|
||||||
|
// to redirect back to the initiating client
|
||||||
|
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
|
||||||
|
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
|
||||||
|
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
|
||||||
|
|
||||||
|
var client = new Duo.ClientBuilder(
|
||||||
|
(string)provider.MetaData["ClientId"],
|
||||||
|
(string)provider.MetaData["ClientSecret"],
|
||||||
|
(string)provider.MetaData["Host"],
|
||||||
|
redirectUri).Build();
|
||||||
|
|
||||||
|
if (!await client.DoHealthCheck(false))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user