mirror of
https://github.com/bitwarden/browser
synced 2026-02-12 06:23:38 +00:00
Merge branch 'ac/pm-13755/refactor-dialog-to-separate-edit-and-invite-flow' into PM-13755-revoked-members-counted-as-seat
This commit is contained in:
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -85,9 +85,13 @@ apps/web/src/app/shared @bitwarden/team-platform-dev
|
||||
apps/web/src/translation-constants.ts @bitwarden/team-platform-dev
|
||||
# Workflows
|
||||
.github/workflows/brew-bump-desktop.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build-browser-target.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build-browser.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build-cli-target.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build-cli.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build-desktop-target.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build-desktop.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build-web-target.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build-web.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/chromatic.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/lint.yml @bitwarden/team-platform-dev
|
||||
|
||||
39
.github/workflows/build-browser-target.yml
vendored
Normal file
39
.github/workflows/build-browser-target.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Build Browser on PR Target
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
paths:
|
||||
- 'apps/browser/**'
|
||||
- 'libs/**'
|
||||
- '*'
|
||||
- '!*.md'
|
||||
- '!*.txt'
|
||||
workflow_call:
|
||||
inputs: {}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sdk_branch:
|
||||
description: "Custom SDK branch"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
run-workflow:
|
||||
name: Run Build Browser on PR Target
|
||||
needs: check-run
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
uses: ./.github/workflows/build-browser.yml
|
||||
secrets: inherit
|
||||
|
||||
18
.github/workflows/build-browser.yml
vendored
18
.github/workflows/build-browser.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Build Browser
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
@@ -38,19 +38,14 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
outputs:
|
||||
repo_url: ${{ steps.gen_vars.outputs.repo_url }}
|
||||
adj_build_number: ${{ steps.gen_vars.outputs.adj_build_number }}
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -74,6 +69,14 @@ jobs:
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check secrets
|
||||
id: check-secrets
|
||||
env:
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
run: |
|
||||
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
|
||||
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
locales-test:
|
||||
name: Locales Test
|
||||
@@ -281,6 +284,7 @@ jobs:
|
||||
needs:
|
||||
- setup
|
||||
- locales-test
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
env:
|
||||
_BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
|
||||
39
.github/workflows/build-cli-target.yml
vendored
Normal file
39
.github/workflows/build-cli-target.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Build CLI on PR Target
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
paths:
|
||||
- 'apps/cli/**'
|
||||
- 'libs/**'
|
||||
- '*'
|
||||
- '!*.md'
|
||||
- '!*.txt'
|
||||
- '.github/workflows/build-cli.yml'
|
||||
- 'bitwarden_license/bit-cli/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sdk_branch:
|
||||
description: "Custom SDK branch"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
run-workflow:
|
||||
name: Run Build CLI on PR Target
|
||||
needs: check-run
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
uses: ./.github/workflows/build-cli.yml
|
||||
secrets: inherit
|
||||
|
||||
27
.github/workflows/build-cli.yml
vendored
27
.github/workflows/build-cli.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Build CLI
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
@@ -27,6 +27,8 @@ on:
|
||||
- '!*.txt'
|
||||
- '.github/workflows/build-cli.yml'
|
||||
- 'bitwarden_license/bit-cli/**'
|
||||
workflow_call:
|
||||
inputs: {}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sdk_branch:
|
||||
@@ -39,18 +41,13 @@ defaults:
|
||||
working-directory: apps/cli
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
outputs:
|
||||
package_version: ${{ steps.retrieve-package-version.outputs.package_version }}
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -71,6 +68,14 @@ jobs:
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check secrets
|
||||
id: check-secrets
|
||||
env:
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
run: |
|
||||
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
|
||||
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
|
||||
|
||||
cli:
|
||||
name: CLI ${{ matrix.os.base }} - ${{ matrix.license_type.readable }}
|
||||
strategy:
|
||||
@@ -117,7 +122,7 @@ jobs:
|
||||
working-directory: ./
|
||||
|
||||
- name: Download SDK Artifacts
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -130,7 +135,7 @@ jobs:
|
||||
if_no_artifact_found: fail
|
||||
|
||||
- name: Override SDK
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
working-directory: ./
|
||||
run: |
|
||||
ls -l ../
|
||||
@@ -272,7 +277,7 @@ jobs:
|
||||
working-directory: ./
|
||||
|
||||
- name: Download SDK Artifacts
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -285,7 +290,7 @@ jobs:
|
||||
if_no_artifact_found: fail
|
||||
|
||||
- name: Override SDK
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
working-directory: ./
|
||||
run: |
|
||||
ls -l ../
|
||||
|
||||
38
.github/workflows/build-desktop-target.yml
vendored
Normal file
38
.github/workflows/build-desktop-target.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Build Desktop on PR Target
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- 'libs/**'
|
||||
- '*'
|
||||
- '!*.md'
|
||||
- '!*.txt'
|
||||
- '.github/workflows/build-desktop.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sdk_branch:
|
||||
description: "Custom SDK branch"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
run-workflow:
|
||||
name: Run Build Desktop on PR Target
|
||||
needs: check-run
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
uses: ./.github/workflows/build-desktop.yml
|
||||
secrets: inherit
|
||||
|
||||
68
.github/workflows/build-desktop.yml
vendored
68
.github/workflows/build-desktop.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Build Desktop
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
@@ -25,6 +25,8 @@ on:
|
||||
- '!*.md'
|
||||
- '!*.txt'
|
||||
- '.github/workflows/build-desktop.yml'
|
||||
workflow_call:
|
||||
inputs: {}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sdk_branch:
|
||||
@@ -37,15 +39,9 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
electron-verify:
|
||||
name: Verify Electron Version
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -67,8 +63,6 @@ jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
outputs:
|
||||
package_version: ${{ steps.retrieve-version.outputs.package_version }}
|
||||
release_channel: ${{ steps.release-channel.outputs.channel }}
|
||||
@@ -76,6 +70,7 @@ jobs:
|
||||
rc_branch_exists: ${{ steps.branch-check.outputs.rc_branch_exists }}
|
||||
hotfix_branch_exists: ${{ steps.branch-check.outputs.hotfix_branch_exists }}
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/desktop
|
||||
@@ -138,6 +133,14 @@ jobs:
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check secrets
|
||||
id: check-secrets
|
||||
env:
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
run: |
|
||||
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
|
||||
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
|
||||
|
||||
linux:
|
||||
name: Linux Build
|
||||
# Note, before updating the ubuntu version of the workflow, ensure the snap base image
|
||||
@@ -333,12 +336,14 @@ jobs:
|
||||
rustup show
|
||||
|
||||
- name: Login to Azure
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
@@ -353,7 +358,7 @@ jobs:
|
||||
working-directory: ./
|
||||
|
||||
- name: Download SDK Artifacts
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -366,7 +371,7 @@ jobs:
|
||||
if_no_artifact_found: fail
|
||||
|
||||
- name: Override SDK
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
working-directory: ./
|
||||
run: |
|
||||
ls -l ../
|
||||
@@ -386,7 +391,17 @@ jobs:
|
||||
working-directory: apps/desktop/desktop_native
|
||||
run: node build.js cross-platform
|
||||
|
||||
- name: Build & Sign (dev)
|
||||
- name: Build
|
||||
run: |
|
||||
npm run build
|
||||
|
||||
- name: Pack
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'false' }}
|
||||
run: |
|
||||
npm run pack:win
|
||||
|
||||
- name: Pack & Sign (dev)
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
env:
|
||||
ELECTRON_BUILDER_SIGN: 1
|
||||
SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }}
|
||||
@@ -395,10 +410,10 @@ jobs:
|
||||
SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }}
|
||||
SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }}
|
||||
run: |
|
||||
npm run build
|
||||
npm run pack:win
|
||||
|
||||
- name: Rename appx files for store
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
run: |
|
||||
Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx" `
|
||||
-Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx"
|
||||
@@ -408,6 +423,7 @@ jobs:
|
||||
-Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx"
|
||||
|
||||
- name: Package for Chocolatey
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
run: |
|
||||
Copy-Item -Path ./stores/chocolatey -Destination ./dist/chocolatey -Recurse
|
||||
Copy-Item -Path ./dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe `
|
||||
@@ -419,6 +435,7 @@ jobs:
|
||||
choco pack ./dist/chocolatey/bitwarden.nuspec --version "$env:_PACKAGE_VERSION" --out ./dist/chocolatey
|
||||
|
||||
- name: Fix NSIS artifact names for auto-updater
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
run: |
|
||||
Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z `
|
||||
-NewName bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
|
||||
@@ -435,6 +452,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload installer exe artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe
|
||||
@@ -442,6 +460,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload appx ia32 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx
|
||||
@@ -449,6 +468,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload store appx ia32 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx
|
||||
@@ -456,6 +476,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload NSIS ia32 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
|
||||
@@ -463,6 +484,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload appx x64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx
|
||||
@@ -470,6 +492,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload store appx x64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx
|
||||
@@ -477,6 +500,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload NSIS x64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
|
||||
@@ -484,6 +508,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload appx ARM64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx
|
||||
@@ -491,6 +516,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload store appx ARM64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx
|
||||
@@ -498,6 +524,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload NSIS ARM64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
|
||||
@@ -505,6 +532,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload nupkg artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg
|
||||
@@ -512,6 +540,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload auto-update artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: ${{ needs.setup.outputs.release_channel }}.yml
|
||||
@@ -574,11 +603,13 @@ jobs:
|
||||
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
|
||||
|
||||
- name: Login to Azure
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Download Provisioning Profiles secrets
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: profiles
|
||||
@@ -591,6 +622,7 @@ jobs:
|
||||
--output none
|
||||
|
||||
- name: Get certificates
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
run: |
|
||||
mkdir -p $HOME/certificates
|
||||
|
||||
@@ -613,6 +645,7 @@ jobs:
|
||||
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
|
||||
|
||||
- name: Set up keychain
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
@@ -642,6 +675,7 @@ jobs:
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain
|
||||
|
||||
- name: Set up provisioning profiles
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
run: |
|
||||
cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \
|
||||
$GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile
|
||||
@@ -661,7 +695,7 @@ jobs:
|
||||
working-directory: ./
|
||||
|
||||
- name: Download SDK Artifacts
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -674,7 +708,7 @@ jobs:
|
||||
if_no_artifact_found: fail
|
||||
|
||||
- name: Override SDK
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
working-directory: ./
|
||||
run: |
|
||||
ls -l ../
|
||||
@@ -701,6 +735,7 @@ jobs:
|
||||
browser-build:
|
||||
name: Browser Build
|
||||
needs: setup
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: ./.github/workflows/build-browser.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -708,6 +743,7 @@ jobs:
|
||||
macos-package-github:
|
||||
name: MacOS Package GitHub Release Assets
|
||||
runs-on: macos-13
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
needs:
|
||||
- browser-build
|
||||
- macos-build
|
||||
@@ -949,6 +985,7 @@ jobs:
|
||||
macos-package-mas:
|
||||
name: MacOS Package Prod Release Asset
|
||||
runs-on: macos-13
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
needs:
|
||||
- browser-build
|
||||
- macos-build
|
||||
@@ -1216,6 +1253,7 @@ jobs:
|
||||
macos-package-dev:
|
||||
name: MacOS Package Dev Release Asset
|
||||
runs-on: macos-13
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
needs:
|
||||
- browser-build
|
||||
- macos-build
|
||||
|
||||
41
.github/workflows/build-web-target.yml
vendored
Normal file
41
.github/workflows/build-web-target.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Build Web on PR Target
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
paths:
|
||||
- 'apps/web/**'
|
||||
- 'libs/**'
|
||||
- '*'
|
||||
- '!*.md'
|
||||
- '!*.txt'
|
||||
- '.github/workflows/build-web.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
custom_tag_extension:
|
||||
description: "Custom image tag extension"
|
||||
required: false
|
||||
sdk_branch:
|
||||
description: "Custom SDK branch"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
run-workflow:
|
||||
name: Run Build Web on PR Target
|
||||
needs: check-run
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
uses: ./.github/workflows/build-web.yml
|
||||
secrets: inherit
|
||||
|
||||
28
.github/workflows/build-web.yml
vendored
28
.github/workflows/build-web.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Build Web
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
@@ -27,6 +27,8 @@ on:
|
||||
- '.github/workflows/build-web.yml'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_call:
|
||||
inputs: {}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
custom_tag_extension:
|
||||
@@ -41,18 +43,13 @@ env:
|
||||
_AZ_REGISTRY: bitwardenprod.azurecr.io
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.value }}
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -70,6 +67,14 @@ jobs:
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check secrets
|
||||
id: check-secrets
|
||||
env:
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
run: |
|
||||
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
|
||||
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
|
||||
|
||||
build-artifacts:
|
||||
name: Build artifacts
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -128,7 +133,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Download SDK Artifacts
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -141,7 +146,7 @@ jobs:
|
||||
if_no_artifact_found: fail
|
||||
|
||||
- name: Override SDK
|
||||
if: ${{ inputs.sdk_branch != '' }}
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
working-directory: ./
|
||||
run: |
|
||||
ls -l ../
|
||||
@@ -210,19 +215,23 @@ jobs:
|
||||
|
||||
########## ACRs ##########
|
||||
- name: Login to Prod Azure
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
- name: Log into Prod container registry
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
run: az acr login -n bitwardenprod
|
||||
|
||||
- name: Login to Azure - CI Subscription
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve github PAT secrets
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
id: retrieve-secret-pat
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
@@ -270,6 +279,7 @@ jobs:
|
||||
run: echo "name=$_AZ_REGISTRY/${PROJECT_NAME}:${IMAGE_TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Docker image
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
|
||||
with:
|
||||
context: apps/web
|
||||
|
||||
10
.github/workflows/lint.yml
vendored
10
.github/workflows/lint.yml
vendored
@@ -1,12 +1,20 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
paths-ignore:
|
||||
- '.github/workflows/**'
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'rc'
|
||||
- 'hotfix-rc-*'
|
||||
paths-ignore:
|
||||
- '.github/workflows/**'
|
||||
workflow_dispatch:
|
||||
inputs: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Environment,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
|
||||
import { ExtensionSsoComponentService } from "./extension-sso-component.service";
|
||||
|
||||
describe("ExtensionSsoComponentService", () => {
|
||||
let service: ExtensionSsoComponentService;
|
||||
const baseUrl = "https://vault.bitwarden.com";
|
||||
|
||||
let syncService: MockProxy<SyncService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
beforeEach(() => {
|
||||
syncService = mock<SyncService>();
|
||||
authService = mock<AuthService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
i18nService = mock<I18nService>();
|
||||
logService = mock<LogService>();
|
||||
environmentService.environment$ = new BehaviorSubject<Environment>({
|
||||
getWebVaultUrl: () => baseUrl,
|
||||
} as Environment);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: SyncService, useValue: syncService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: EnvironmentService, useValue: environmentService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: LogService, useValue: logService },
|
||||
ExtensionSsoComponentService,
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ExtensionSsoComponentService);
|
||||
|
||||
jest.spyOn(BrowserApi, "reloadOpenWindows").mockImplementation();
|
||||
});
|
||||
|
||||
it("creates the service", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("closeWindow", () => {
|
||||
it("closes window", async () => {
|
||||
const windowSpy = jest.spyOn(window, "close").mockImplementation();
|
||||
|
||||
await service.closeWindow?.();
|
||||
|
||||
expect(windowSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { DefaultSsoComponentService, SsoComponentService } from "@bitwarden/auth/angular";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
/**
|
||||
* This service is used to handle the SSO login process for the browser extension.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExtensionSsoComponentService
|
||||
extends DefaultSsoComponentService
|
||||
implements SsoComponentService
|
||||
{
|
||||
constructor(
|
||||
protected syncService: SyncService,
|
||||
protected authService: AuthService,
|
||||
protected environmentService: EnvironmentService,
|
||||
protected i18nService: I18nService,
|
||||
protected logService: LogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the popup window after a successful login.
|
||||
*/
|
||||
async closeWindow() {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,9 @@ import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
@Component({
|
||||
selector: "app-sso",
|
||||
templateUrl: "sso.component.html",
|
||||
templateUrl: "sso-v1.component.html",
|
||||
})
|
||||
export class SsoComponent extends BaseSsoComponent {
|
||||
export class SsoComponentV1 extends BaseSsoComponent {
|
||||
constructor(
|
||||
ssoLoginService: SsoLoginServiceAbstraction,
|
||||
loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
VaultIcon,
|
||||
LoginDecryptionOptionsComponent,
|
||||
DevicesIcon,
|
||||
SsoComponent,
|
||||
TwoFactorTimeoutIcon,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
@@ -62,7 +63,7 @@ import { RemovePasswordComponent } from "../auth/popup/remove-password.component
|
||||
import { SetPasswordComponent } from "../auth/popup/set-password.component";
|
||||
import { AccountSecurityComponent as AccountSecurityV1Component } from "../auth/popup/settings/account-security-v1.component";
|
||||
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
|
||||
import { SsoComponent } from "../auth/popup/sso.component";
|
||||
import { SsoComponentV1 } from "../auth/popup/sso-v1.component";
|
||||
import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component";
|
||||
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
|
||||
import { TwoFactorComponent } from "../auth/popup/two-factor.component";
|
||||
@@ -230,12 +231,40 @@ const routes: Routes = [
|
||||
canActivate: [unauthGuardFn(unauthRouteOverrides)],
|
||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
path: "sso",
|
||||
component: SsoComponent,
|
||||
canActivate: [unauthGuardFn(unauthRouteOverrides)],
|
||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||
},
|
||||
...unauthUiRefreshSwap(
|
||||
SsoComponentV1,
|
||||
ExtensionAnonLayoutWrapperComponent,
|
||||
{
|
||||
path: "sso",
|
||||
canActivate: [unauthGuardFn(unauthRouteOverrides)],
|
||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
path: "sso",
|
||||
canActivate: [unauthGuardFn(unauthRouteOverrides)],
|
||||
data: {
|
||||
pageIcon: VaultIcon,
|
||||
pageTitle: {
|
||||
key: "enterpriseSingleSignOn",
|
||||
},
|
||||
pageSubtitle: {
|
||||
key: "singleSignOnEnterOrgIdentifierText",
|
||||
},
|
||||
elevation: 1,
|
||||
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
|
||||
children: [
|
||||
{ path: "", component: SsoComponent },
|
||||
{
|
||||
path: "",
|
||||
component: EnvironmentSelectorComponent,
|
||||
outlet: "environment-selector",
|
||||
data: {
|
||||
overlayPosition: ExtensionDefaultOverlayPosition,
|
||||
} satisfies EnvironmentSelectorRouteData,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
{
|
||||
path: "set-password",
|
||||
component: SetPasswordComponent,
|
||||
|
||||
@@ -33,7 +33,7 @@ import { SetPasswordComponent } from "../auth/popup/set-password.component";
|
||||
import { AccountSecurityComponent as AccountSecurityComponentV1 } from "../auth/popup/settings/account-security-v1.component";
|
||||
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
|
||||
import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component";
|
||||
import { SsoComponent } from "../auth/popup/sso.component";
|
||||
import { SsoComponentV1 } from "../auth/popup/sso-v1.component";
|
||||
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
|
||||
import { TwoFactorComponent } from "../auth/popup/two-factor.component";
|
||||
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
|
||||
@@ -177,7 +177,7 @@ import "../platform/popup/locales";
|
||||
SettingsComponent,
|
||||
VaultSettingsComponent,
|
||||
ShareComponent,
|
||||
SsoComponent,
|
||||
SsoComponentV1,
|
||||
SyncComponent,
|
||||
TabsComponent,
|
||||
TabsV2Component,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AnonLayoutWrapperDataService,
|
||||
LoginComponentService,
|
||||
LockComponentService,
|
||||
SsoComponentService,
|
||||
LoginDecryptionOptionsService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
@@ -119,6 +120,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service";
|
||||
import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service";
|
||||
import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service";
|
||||
import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service";
|
||||
import { ExtensionLoginDecryptionOptionsService } from "../../auth/popup/login-decryption-options/extension-login-decryption-options.service";
|
||||
import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service";
|
||||
import AutofillService from "../../autofill/services/autofill.service";
|
||||
@@ -597,6 +599,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useExisting: PopupCompactModeService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SsoComponentService,
|
||||
useClass: ExtensionSsoComponentService,
|
||||
deps: [SyncService, AuthService, EnvironmentService, I18nServiceAbstraction, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginDecryptionOptionsService,
|
||||
useClass: ExtensionLoginDecryptionOptionsService,
|
||||
|
||||
@@ -65,7 +65,9 @@ impl BitwardenDesktopAgent {
|
||||
"[SSH Agent Native Module] Could not remove existing socket file: {}",
|
||||
e
|
||||
);
|
||||
return;
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match UnixListener::bind(sockname) {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"**/node_modules/@bitwarden/desktop-napi/index.js",
|
||||
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
|
||||
],
|
||||
"electronVersion": "32.1.1",
|
||||
"electronVersion": "33.2.1",
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2024.12.0",
|
||||
"version": "2024.12.1",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
VaultIcon,
|
||||
LoginDecryptionOptionsComponent,
|
||||
DevicesIcon,
|
||||
SsoComponent,
|
||||
TwoFactorTimeoutIcon,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
@@ -51,7 +52,7 @@ import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-req
|
||||
import { RegisterComponent } from "../auth/register.component";
|
||||
import { RemovePasswordComponent } from "../auth/remove-password.component";
|
||||
import { SetPasswordComponent } from "../auth/set-password.component";
|
||||
import { SsoComponent } from "../auth/sso.component";
|
||||
import { SsoComponentV1 } from "../auth/sso-v1.component";
|
||||
import { TwoFactorAuthComponent } from "../auth/two-factor-auth.component";
|
||||
import { TwoFactorComponent } from "../auth/two-factor.component";
|
||||
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
|
||||
@@ -122,7 +123,33 @@ const routes: Routes = [
|
||||
},
|
||||
{ path: "accessibility-cookie", component: AccessibilityCookieComponent },
|
||||
{ path: "set-password", component: SetPasswordComponent },
|
||||
{ path: "sso", component: SsoComponent },
|
||||
...unauthUiRefreshSwap(
|
||||
SsoComponentV1,
|
||||
AnonLayoutWrapperComponent,
|
||||
{
|
||||
path: "sso",
|
||||
},
|
||||
{
|
||||
path: "sso",
|
||||
data: {
|
||||
pageIcon: VaultIcon,
|
||||
pageTitle: {
|
||||
key: "enterpriseSingleSignOn",
|
||||
},
|
||||
pageSubtitle: {
|
||||
key: "singleSignOnEnterOrgIdentifierText",
|
||||
},
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
children: [
|
||||
{ path: "", component: SsoComponent },
|
||||
{
|
||||
path: "",
|
||||
component: EnvironmentSelectorComponent,
|
||||
outlet: "environment-selector",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
{
|
||||
path: "send",
|
||||
component: SendComponent,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { LoginModule } from "../auth/login/login.module";
|
||||
import { RegisterComponent } from "../auth/register.component";
|
||||
import { RemovePasswordComponent } from "../auth/remove-password.component";
|
||||
import { SetPasswordComponent } from "../auth/set-password.component";
|
||||
import { SsoComponent } from "../auth/sso.component";
|
||||
import { SsoComponentV1 } from "../auth/sso-v1.component";
|
||||
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
|
||||
import { TwoFactorComponent } from "../auth/two-factor.component";
|
||||
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
|
||||
@@ -92,7 +92,7 @@ import { SendComponent } from "./tools/send/send.component";
|
||||
SetPasswordComponent,
|
||||
SettingsComponent,
|
||||
ShareComponent,
|
||||
SsoComponent,
|
||||
SsoComponentV1,
|
||||
TwoFactorComponent,
|
||||
TwoFactorOptionsComponent,
|
||||
UpdateTempPasswordComponent,
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
LoginComponentService,
|
||||
SetPasswordJitService,
|
||||
LockComponentService,
|
||||
SsoComponentService,
|
||||
DefaultSsoComponentService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
@@ -361,6 +363,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: LoginEmailService,
|
||||
deps: [AccountService, AuthService, StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SsoComponentService,
|
||||
useClass: DefaultSsoComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginApprovalComponentServiceAbstraction,
|
||||
useClass: DesktopLoginApprovalComponentService,
|
||||
|
||||
@@ -23,9 +23,9 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
|
||||
|
||||
@Component({
|
||||
selector: "app-sso",
|
||||
templateUrl: "sso.component.html",
|
||||
templateUrl: "sso-v1.component.html",
|
||||
})
|
||||
export class SsoComponent extends BaseSsoComponent {
|
||||
export class SsoComponentV1 extends BaseSsoComponent {
|
||||
constructor(
|
||||
ssoLoginService: SsoLoginServiceAbstraction,
|
||||
loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
@@ -64,9 +64,8 @@ export class TrayMain {
|
||||
}
|
||||
|
||||
setupWindowListeners(win: BrowserWindow) {
|
||||
win.on("minimize", async (e: Event) => {
|
||||
win.on("minimize", async () => {
|
||||
if (await firstValueFrom(this.desktopSettingsService.minimizeToTray$)) {
|
||||
e.preventDefault();
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.hideToTray();
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2024.12.0",
|
||||
"version": "2024.12.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2024.12.0",
|
||||
"version": "2024.12.1",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/desktop",
|
||||
"productName": "Bitwarden",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2024.12.0",
|
||||
"version": "2024.12.1",
|
||||
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -287,9 +287,12 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private setFormValidators(organization: Organization) {
|
||||
const _orgSeatLimitReachedValidator = [
|
||||
const emailsControlValidators = [
|
||||
Validators.required,
|
||||
commaSeparatedEmails,
|
||||
inputEmailLimitValidator(organization, (maxEmailsCount: number) =>
|
||||
this.i18nService.t("tooManyEmails", maxEmailsCount),
|
||||
),
|
||||
orgSeatLimitReachedValidator(
|
||||
organization,
|
||||
this.params.allOrganizationUserEmails,
|
||||
@@ -297,17 +300,8 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
),
|
||||
];
|
||||
|
||||
const _inputEmailLimitValidator = [
|
||||
Validators.required,
|
||||
commaSeparatedEmails,
|
||||
inputEmailLimitValidator(organization, (maxEmailsCount: number) =>
|
||||
this.i18nService.t("tooManyEmails", maxEmailsCount),
|
||||
),
|
||||
];
|
||||
|
||||
const emailsControl = this.formGroup.get("emails");
|
||||
emailsControl.setValidators(_orgSeatLimitReachedValidator);
|
||||
emailsControl.setValidators(_inputEmailLimitValidator);
|
||||
emailsControl.setValidators(emailsControlValidators);
|
||||
emailsControl.updateValueAndValidity();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { WebSsoComponentService } from "./web-sso-component.service";
|
||||
|
||||
describe("WebSsoComponentService", () => {
|
||||
let service: WebSsoComponentService;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18nService = mock<I18nService>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [WebSsoComponentService, { provide: I18nService, useValue: i18nService }],
|
||||
});
|
||||
service = TestBed.inject(WebSsoComponentService);
|
||||
});
|
||||
|
||||
it("creates the service", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("setDocumentCookies", () => {
|
||||
it("sets ssoHandOffMessage cookie with translated message", () => {
|
||||
const mockMessage = "Test SSO Message";
|
||||
i18nService.t.mockReturnValue(mockMessage);
|
||||
|
||||
service.setDocumentCookies?.();
|
||||
|
||||
expect(document.cookie).toContain(`ssoHandOffMessage=${mockMessage}`);
|
||||
expect(i18nService.t).toHaveBeenCalledWith("ssoHandOff");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { DefaultSsoComponentService, SsoComponentService } from "@bitwarden/auth/angular";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
/**
|
||||
* This service is used to handle the SSO login process for the web client.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebSsoComponentService
|
||||
extends DefaultSsoComponentService
|
||||
implements SsoComponentService
|
||||
{
|
||||
constructor(private i18nService: I18nService) {
|
||||
super();
|
||||
}
|
||||
|
||||
setDocumentCookies() {
|
||||
document.cookie = `ssoHandOffMessage=${this.i18nService.t("ssoHandOff")};SameSite=strict`;
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,10 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
|
||||
|
||||
@Component({
|
||||
selector: "app-sso",
|
||||
templateUrl: "sso.component.html",
|
||||
templateUrl: "sso-v1.component.html",
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class SsoComponent extends BaseSsoComponent implements OnInit {
|
||||
export class SsoComponentV1 extends BaseSsoComponent implements OnInit {
|
||||
protected formGroup = new FormGroup({
|
||||
identifier: new FormControl(null, [Validators.required]),
|
||||
});
|
||||
@@ -23,12 +23,17 @@
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="orgInfoFormGroup.controls.name.invalid"
|
||||
(click)="conditionallyCreateOrganization()"
|
||||
[loading]="loading && (trialPaymentOptional$ | async)"
|
||||
(click)="orgNameEntrySubmit()"
|
||||
>
|
||||
{{ "next" | i18n }}
|
||||
{{ (trialPaymentOptional$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
|
||||
</button>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="Billing" [subLabel]="billingSubLabel" *ngIf="!isSecretsManagerFree">
|
||||
<app-vertical-step
|
||||
label="Billing"
|
||||
[subLabel]="billingSubLabel"
|
||||
*ngIf="!(trialPaymentOptional$ | async) && !isSecretsManagerFree"
|
||||
>
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[organizationInfo]="{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StepperSelectionEvent } from "@angular/cdk/stepper";
|
||||
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { firstValueFrom, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { PasswordInputResult, RegistrationFinishService } from "@bitwarden/auth/angular";
|
||||
import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common";
|
||||
@@ -12,8 +12,14 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
|
||||
import {
|
||||
OrganizationBillingServiceAbstraction as OrganizationBillingService,
|
||||
OrganizationInformation,
|
||||
PlanInformation,
|
||||
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PlanType, ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
@@ -28,6 +34,10 @@ import { RouterService } from "../../../core/router.service";
|
||||
import { AcceptOrganizationInviteService } from "../../organization-invite/accept-organization.service";
|
||||
import { VerticalStepperComponent } from "../vertical-stepper/vertical-stepper.component";
|
||||
|
||||
export type InitiationPath =
|
||||
| "Password Manager trial from marketing website"
|
||||
| "Secrets Manager trial from marketing website";
|
||||
|
||||
@Component({
|
||||
selector: "app-complete-trial-initiation",
|
||||
templateUrl: "complete-trial-initiation.component.html",
|
||||
@@ -65,6 +75,8 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
email = "";
|
||||
/** Token from the backend associated with the email verification */
|
||||
emailVerificationToken: string;
|
||||
loading = false;
|
||||
productTierValue: number;
|
||||
|
||||
orgInfoFormGroup = this.formBuilder.group({
|
||||
name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }],
|
||||
@@ -74,6 +86,9 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected readonly SubscriptionProduct = SubscriptionProduct;
|
||||
protected readonly ProductType = ProductType;
|
||||
protected trialPaymentOptional$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.TrialPaymentOptional,
|
||||
);
|
||||
|
||||
constructor(
|
||||
protected router: Router,
|
||||
@@ -90,6 +105,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
private registrationFinishService: RegistrationFinishService,
|
||||
private validationService: ValidationService,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -119,6 +135,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
this.product = this.validProducts.includes(product) ? product : ProductType.PasswordManager;
|
||||
|
||||
const productTierParam = parseInt(qParams.productTier) as ProductTierType;
|
||||
this.productTierValue = productTierParam;
|
||||
|
||||
/** Only show the trial stepper for a subset of types */
|
||||
const showPasswordManagerStepper = this.stepperProductTypes.includes(productTierParam);
|
||||
@@ -185,6 +202,16 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async orgNameEntrySubmit(): Promise<void> {
|
||||
const isTrialPaymentOptional = await firstValueFrom(this.trialPaymentOptional$);
|
||||
|
||||
if (isTrialPaymentOptional) {
|
||||
await this.createOrganizationOnTrial();
|
||||
} else {
|
||||
await this.conditionallyCreateOrganization();
|
||||
}
|
||||
}
|
||||
|
||||
/** Update local details from organization created event */
|
||||
createdOrganization(event: OrganizationCreatedEvent) {
|
||||
this.orgId = event.organizationId;
|
||||
@@ -192,11 +219,62 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
|
||||
this.verticalStepper.next();
|
||||
}
|
||||
|
||||
/** create an organization on trial without payment method */
|
||||
async createOrganizationOnTrial() {
|
||||
this.loading = true;
|
||||
let trialInitiationPath: InitiationPath = "Password Manager trial from marketing website";
|
||||
let plan: PlanInformation = {
|
||||
type: this.getPlanType(),
|
||||
passwordManagerSeats: 1,
|
||||
};
|
||||
|
||||
if (this.product === ProductType.SecretsManager) {
|
||||
trialInitiationPath = "Secrets Manager trial from marketing website";
|
||||
plan = {
|
||||
...plan,
|
||||
subscribeToSecretsManager: true,
|
||||
isFromSecretsManagerTrial: true,
|
||||
secretsManagerSeats: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const organization: OrganizationInformation = {
|
||||
name: this.orgInfoFormGroup.value.name,
|
||||
billingEmail: this.orgInfoFormGroup.value.billingEmail,
|
||||
initiationPath: trialInitiationPath,
|
||||
};
|
||||
|
||||
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
|
||||
organization,
|
||||
plan,
|
||||
});
|
||||
|
||||
this.orgId = response?.id;
|
||||
this.billingSubLabel = response.name.toString();
|
||||
this.loading = false;
|
||||
this.verticalStepper.next();
|
||||
}
|
||||
|
||||
/** Move the user to the previous step */
|
||||
previousStep() {
|
||||
this.verticalStepper.previous();
|
||||
}
|
||||
|
||||
getPlanType() {
|
||||
switch (this.productTier) {
|
||||
case ProductTierType.Teams:
|
||||
return PlanType.TeamsAnnually;
|
||||
case ProductTierType.Enterprise:
|
||||
return PlanType.EnterpriseAnnually;
|
||||
case ProductTierType.Families:
|
||||
return PlanType.FamiliesAnnually;
|
||||
case ProductTierType.Free:
|
||||
return PlanType.Free;
|
||||
default:
|
||||
return PlanType.EnterpriseAnnually;
|
||||
}
|
||||
}
|
||||
|
||||
get isSecretsManagerFree() {
|
||||
return this.product === ProductType.SecretsManager && this.productTier === ProductTierType.Free;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
@@ -35,7 +34,6 @@ export class SponsoringOrgRowComponent implements OnInit {
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
@@ -87,14 +85,21 @@ export class SponsoringOrgRowComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
get isSentAwaitingSync() {
|
||||
return this.isSelfHosted && !this.sponsoringOrg.familySponsorshipLastSyncDate;
|
||||
}
|
||||
|
||||
private async doRevokeSponsorship() {
|
||||
const content = this.sponsoringOrg.familySponsorshipValidUntil
|
||||
? this.i18nService.t(
|
||||
"updatedRevokeSponsorshipConfirmationForAcceptedSponsorship",
|
||||
this.sponsoringOrg.familySponsorshipFriendlyName,
|
||||
formatDate(this.sponsoringOrg.familySponsorshipValidUntil, "MM/dd/yyyy", this.locale),
|
||||
)
|
||||
: this.i18nService.t(
|
||||
"updatedRevokeSponsorshipConfirmationForSentSponsorship",
|
||||
this.sponsoringOrg.familySponsorshipFriendlyName,
|
||||
);
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: `${this.i18nService.t("remove")} ${this.sponsoringOrg.familySponsorshipFriendlyName}?`,
|
||||
content: { key: "revokeSponsorshipConfirmation" },
|
||||
title: `${this.i18nService.t("removeSponsorship")}?`,
|
||||
content,
|
||||
acceptButtonText: { key: "remove" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
LoginComponentService,
|
||||
LockComponentService,
|
||||
SetPasswordJitService,
|
||||
SsoComponentService,
|
||||
LoginDecryptionOptionsService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
WebLockComponentService,
|
||||
WebLoginDecryptionOptionsService,
|
||||
} from "../auth";
|
||||
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
|
||||
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
|
||||
import { HtmlStorageService } from "../core/html-storage.service";
|
||||
import { I18nService } from "../core/i18n.service";
|
||||
@@ -301,6 +303,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: LoginEmailService,
|
||||
deps: [AccountService, AuthService, StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SsoComponentService,
|
||||
useClass: WebSsoComponentService,
|
||||
deps: [I18nServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginDecryptionOptionsService,
|
||||
useClass: WebLoginDecryptionOptionsService,
|
||||
|
||||
@@ -29,11 +29,13 @@ import {
|
||||
LockIcon,
|
||||
TwoFactorTimeoutIcon,
|
||||
UserLockIcon,
|
||||
SsoKeyIcon,
|
||||
LoginViaAuthRequestComponent,
|
||||
DevicesIcon,
|
||||
RegistrationUserAddIcon,
|
||||
RegistrationLockAltIcon,
|
||||
RegistrationExpiredLinkIcon,
|
||||
SsoComponent,
|
||||
VaultIcon,
|
||||
LoginDecryptionOptionsComponent,
|
||||
} from "@bitwarden/auth/angular";
|
||||
@@ -62,7 +64,7 @@ import { AccountComponent } from "./auth/settings/account/account.component";
|
||||
import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component";
|
||||
import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component";
|
||||
import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module";
|
||||
import { SsoComponent } from "./auth/sso.component";
|
||||
import { SsoComponentV1 } from "./auth/sso-v1.component";
|
||||
import { CompleteTrialInitiationComponent } from "./auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component";
|
||||
import { freeTrialTextResolver } from "./auth/trial-initiation/complete-trial-initiation/resolver/free-trial-text.resolver";
|
||||
import { TrialInitiationComponent } from "./auth/trial-initiation/trial-initiation.component";
|
||||
@@ -430,27 +432,57 @@ const routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "sso",
|
||||
canActivate: [unauthGuardFn()],
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "enterpriseSingleSignOn",
|
||||
},
|
||||
titleId: "enterpriseSingleSignOn",
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: SsoComponent,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: EnvironmentSelectorComponent,
|
||||
outlet: "environment-selector",
|
||||
},
|
||||
],
|
||||
},
|
||||
...unauthUiRefreshSwap(
|
||||
SsoComponentV1,
|
||||
SsoComponent,
|
||||
{
|
||||
path: "sso",
|
||||
canActivate: [unauthGuardFn()],
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "enterpriseSingleSignOn",
|
||||
},
|
||||
titleId: "enterpriseSingleSignOn",
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: SsoComponentV1,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: EnvironmentSelectorComponent,
|
||||
outlet: "environment-selector",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "sso",
|
||||
canActivate: [unauthGuardFn()],
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "singleSignOn",
|
||||
},
|
||||
titleId: "enterpriseSingleSignOn",
|
||||
pageSubtitle: {
|
||||
key: "singleSignOnEnterOrgIdentifierText",
|
||||
},
|
||||
titleAreaMaxWidth: "md",
|
||||
pageIcon: SsoKeyIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: SsoComponent,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: EnvironmentSelectorComponent,
|
||||
outlet: "environment-selector",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
{
|
||||
path: "login",
|
||||
canActivate: [unauthGuardFn()],
|
||||
|
||||
@@ -50,7 +50,7 @@ import { TwoFactorSetupYubiKeyComponent } from "../auth/settings/two-factor/two-
|
||||
import { TwoFactorSetupComponent } from "../auth/settings/two-factor/two-factor-setup.component";
|
||||
import { TwoFactorVerifyComponent } from "../auth/settings/two-factor/two-factor-verify.component";
|
||||
import { UserVerificationModule } from "../auth/shared/components/user-verification";
|
||||
import { SsoComponent } from "../auth/sso.component";
|
||||
import { SsoComponentV1 } from "../auth/sso-v1.component";
|
||||
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
|
||||
import { TwoFactorComponent } from "../auth/two-factor.component";
|
||||
import { UpdatePasswordComponent } from "../auth/update-password.component";
|
||||
@@ -158,7 +158,7 @@ import { SharedModule } from "./shared.module";
|
||||
SetPasswordComponent,
|
||||
SponsoredFamiliesComponent,
|
||||
SponsoringOrgRowComponent,
|
||||
SsoComponent,
|
||||
SsoComponentV1,
|
||||
TwoFactorSetupAuthenticatorComponent,
|
||||
TwoFactorComponent,
|
||||
TwoFactorSetupDuoComponent,
|
||||
@@ -225,7 +225,7 @@ import { SharedModule } from "./shared.module";
|
||||
SetPasswordComponent,
|
||||
SponsoredFamiliesComponent,
|
||||
SponsoringOrgRowComponent,
|
||||
SsoComponent,
|
||||
SsoComponentV1,
|
||||
TwoFactorSetupAuthenticatorComponent,
|
||||
TwoFactorComponent,
|
||||
TwoFactorSetupDuoComponent,
|
||||
|
||||
@@ -4739,6 +4739,12 @@
|
||||
"ssoLogInWithOrgIdentifier": {
|
||||
"message": "Log in using your organization's single sign-on portal. Please enter your organization's SSO identifier to begin."
|
||||
},
|
||||
"singleSignOnEnterOrgIdentifier": {
|
||||
"message": "Enter your organization's SSO identifier to begin"
|
||||
},
|
||||
"singleSignOnEnterOrgIdentifierText": {
|
||||
"message": "To log in with your SSO provider, enter your organization's SSO identifier to begin. You may need to enter this SSO identifier when you log in from a new device."
|
||||
},
|
||||
"enterpriseSingleSignOn": {
|
||||
"message": "Enterprise single sign-on"
|
||||
},
|
||||
@@ -6156,9 +6162,6 @@
|
||||
"emailSent": {
|
||||
"message": "Email sent"
|
||||
},
|
||||
"revokeSponsorshipConfirmation": {
|
||||
"message": "After removing this account, the Families plan sponsorship will expire at the end of the billing period. You will not be able to redeem a new sponsorship offer until the existing one expires. Are you sure you want to continue?"
|
||||
},
|
||||
"removeSponsorshipSuccess": {
|
||||
"message": "Sponsorship removed"
|
||||
},
|
||||
@@ -9959,5 +9962,27 @@
|
||||
"example": "bitwarden.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedRevokeSponsorshipConfirmationForSentSponsorship": {
|
||||
"message": "If you remove $EMAIL$, the sponsorship for this Family plan cannot be redeemed. Are you sure you want to continue?",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "sponsored@organization.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedRevokeSponsorshipConfirmationForAcceptedSponsorship": {
|
||||
"message": "If you remove $EMAIL$, the sponsorship for this Family plan will end and the saved payment method will be charged $40 + applicable tax on $DATE$. You will not be able to redeem a new sponsorship until $DATE$. Are you sure you want to continue?",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "sponsored@organization.com"
|
||||
},
|
||||
"date": {
|
||||
"content": "$2",
|
||||
"example": "12/10/2024"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { BadgeVariant } from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* All applications report summary. The total members,
|
||||
* total at risk members, application, and at risk application
|
||||
* counts. Aggregated from all calculated applications
|
||||
*/
|
||||
export type ApplicationHealthReportSummary = {
|
||||
totalMemberCount: number;
|
||||
totalAtRiskMemberCount: number;
|
||||
totalApplicationCount: number;
|
||||
totalAtRiskApplicationCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* All applications report detail. Application is the cipher
|
||||
* uri. Has the at risk, password, and member information
|
||||
*/
|
||||
export type ApplicationHealthReportDetail = {
|
||||
applicationName: string;
|
||||
passwordCount: number;
|
||||
atRiskPasswordCount: number;
|
||||
memberCount: number;
|
||||
|
||||
memberDetails: MemberDetailsFlat[];
|
||||
atRiskMemberDetails: MemberDetailsFlat[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Breaks the cipher health info out by uri and passes
|
||||
* along the password health and member info
|
||||
*/
|
||||
export type CipherHealthReportUriDetail = {
|
||||
cipherId: string;
|
||||
reusedPasswordCount: number;
|
||||
weakPasswordDetail: WeakPasswordDetail;
|
||||
exposedPasswordDetail: ExposedPasswordDetail;
|
||||
cipherMembers: MemberDetailsFlat[];
|
||||
trimmedUri: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Associates a cipher with it's essential information.
|
||||
* Gets the password health details, cipher members, and
|
||||
* the trimmed uris for the cipher
|
||||
*/
|
||||
export type CipherHealthReportDetail = CipherView & {
|
||||
reusedPasswordCount: number;
|
||||
weakPasswordDetail: WeakPasswordDetail;
|
||||
exposedPasswordDetail: ExposedPasswordDetail;
|
||||
cipherMembers: MemberDetailsFlat[];
|
||||
trimmedUris: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Weak password details containing the score
|
||||
* and the score type for the label and badge
|
||||
*/
|
||||
export type WeakPasswordDetail = {
|
||||
score: number;
|
||||
detailValue: WeakPasswordScore;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* Weak password details containing the badge and
|
||||
* the label for the password score
|
||||
*/
|
||||
export type WeakPasswordScore = {
|
||||
label: string;
|
||||
badgeVariant: BadgeVariant;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* How many times a password has been exposed
|
||||
*/
|
||||
export type ExposedPasswordDetail = {
|
||||
exposedXTimes: number;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* Flattened member details that associates an
|
||||
* organization member to a cipher
|
||||
*/
|
||||
export type MemberDetailsFlat = {
|
||||
userName: string;
|
||||
email: string;
|
||||
cipherId: string;
|
||||
};
|
||||
@@ -1,10 +1,18 @@
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
|
||||
const createLoginUriView = (uri: string): LoginUriView => {
|
||||
const view = new LoginUriView();
|
||||
view.uri = uri;
|
||||
return view;
|
||||
};
|
||||
|
||||
export const mockCiphers: any[] = [
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Cannot Be Edited",
|
||||
name: "Weak Password Cipher",
|
||||
notes: null,
|
||||
isDeleted: false,
|
||||
type: 1,
|
||||
@@ -14,10 +22,11 @@ export const mockCiphers: any[] = [
|
||||
password: "123",
|
||||
hasUris: true,
|
||||
uris: [
|
||||
{ uri: "www.google.com" },
|
||||
{ uri: "accounts.google.com" },
|
||||
{ uri: "https://www.google.com" },
|
||||
{ uri: "https://www.google.com/login" },
|
||||
createLoginUriView("101domain.com"),
|
||||
createLoginUriView("www.google.com"),
|
||||
createLoginUriView("accounts.google.com"),
|
||||
createLoginUriView("https://www.google.com"),
|
||||
createLoginUriView("https://www.google.com/login"),
|
||||
],
|
||||
},
|
||||
edit: false,
|
||||
@@ -31,23 +40,18 @@ export const mockCiphers: any[] = [
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 2",
|
||||
name: "Strong Password Cipher",
|
||||
notes: null,
|
||||
isDeleted: false,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
password: "123",
|
||||
password: "Password!123",
|
||||
hasUris: true,
|
||||
uris: [
|
||||
{
|
||||
uri: "http://nothing.com",
|
||||
},
|
||||
],
|
||||
uris: [createLoginUriView("http://example.com")],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
@@ -60,22 +64,18 @@ export const mockCiphers: any[] = [
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 3",
|
||||
name: "Strong password Cipher",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
password: "123",
|
||||
hasUris: true,
|
||||
uris: [
|
||||
{
|
||||
uri: "http://example.com",
|
||||
},
|
||||
],
|
||||
password: "Password!1234",
|
||||
uris: [createLoginUriView("101domain.com")],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
@@ -91,14 +91,15 @@ export const mockCiphers: any[] = [
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 4",
|
||||
name: "Strong password Cipher",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
hasUris: true,
|
||||
uris: [{ uri: "101domain.com" }],
|
||||
password: "Password!123",
|
||||
uris: [createLoginUriView("example.com")],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
@@ -114,14 +115,39 @@ export const mockCiphers: any[] = [
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 5",
|
||||
name: "Exposed password Cipher",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
hasUris: true,
|
||||
uris: [{ uri: "123formbuilder.com" }],
|
||||
password: "123",
|
||||
uris: [createLoginUriView("123formbuilder.com"), createLoginUriView("www.google.com")],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
collectionIds: [],
|
||||
revisionDate: "2023-08-03T17:40:59.793Z",
|
||||
creationDate: "2023-08-03T17:40:59.793Z",
|
||||
deletedDate: null,
|
||||
reprompt: 0,
|
||||
localData: null,
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001227tt1",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Secure Co Login",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
hasUris: true,
|
||||
password: "4gRyhhOX2Og2p0",
|
||||
uris: [createLoginUriView("SecureCo.com")],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./member-cipher-details-api.service";
|
||||
export * from "./password-health.service";
|
||||
export * from "./risk-insights-report.service";
|
||||
|
||||
@@ -69,6 +69,12 @@ export const mockMemberCipherDetails: any = [
|
||||
"cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
],
|
||||
},
|
||||
{
|
||||
userName: "Mister Secure",
|
||||
email: "mister.secure@secureco.com",
|
||||
usesKeyConnector: true,
|
||||
cipherIds: ["cbea34a8-bde4-46ad-9d19-b05001227tt1"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("Member Cipher Details API Service", () => {
|
||||
@@ -91,7 +97,7 @@ describe("Member Cipher Details API Service", () => {
|
||||
const orgId = "1234";
|
||||
const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result).toHaveLength(7);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/reports/member-cipher-details/" + orgId,
|
||||
|
||||
@@ -3,18 +3,15 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { mockCiphers } from "./ciphers.mock";
|
||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
|
||||
import { PasswordHealthService } from "./password-health.service";
|
||||
|
||||
// FIXME: Remove password-health report service after PR-15498 completion
|
||||
describe("PasswordHealthService", () => {
|
||||
let service: PasswordHealthService;
|
||||
let cipherService: CipherService;
|
||||
let memberCipherDetailsApiService: MemberCipherDetailsApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -51,8 +48,6 @@ describe("PasswordHealthService", () => {
|
||||
});
|
||||
|
||||
service = TestBed.inject(PasswordHealthService);
|
||||
cipherService = TestBed.inject(CipherService);
|
||||
memberCipherDetailsApiService = TestBed.inject(MemberCipherDetailsApiService);
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
@@ -67,83 +62,4 @@ describe("PasswordHealthService", () => {
|
||||
expect(service.exposedPasswordMap.size).toBe(0);
|
||||
expect(service.totalMembersMap.size).toBe(0);
|
||||
});
|
||||
|
||||
describe("generateReport", () => {
|
||||
beforeEach(async () => {
|
||||
await service.generateReport();
|
||||
});
|
||||
|
||||
it("should fetch all ciphers for the organization", () => {
|
||||
expect(cipherService.getAllFromApiForOrganization).toHaveBeenCalledWith("org1");
|
||||
});
|
||||
|
||||
it("should fetch member cipher details", () => {
|
||||
expect(memberCipherDetailsApiService.getMemberCipherDetails).toHaveBeenCalledWith("org1");
|
||||
});
|
||||
|
||||
it("should populate reportCiphers with ciphers that have issues", () => {
|
||||
expect(service.reportCiphers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should detect weak passwords", () => {
|
||||
expect(service.passwordStrengthMap.size).toBeGreaterThan(0);
|
||||
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toEqual([
|
||||
"veryWeak",
|
||||
"danger",
|
||||
]);
|
||||
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([
|
||||
"veryWeak",
|
||||
"danger",
|
||||
]);
|
||||
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([
|
||||
"veryWeak",
|
||||
"danger",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should detect reused passwords", () => {
|
||||
expect(service.passwordUseMap.get("123")).toBe(3);
|
||||
});
|
||||
|
||||
it("should detect exposed passwords", () => {
|
||||
expect(service.exposedPasswordMap.size).toBeGreaterThan(0);
|
||||
expect(service.exposedPasswordMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(100);
|
||||
});
|
||||
|
||||
it("should calculate total members per cipher", () => {
|
||||
expect(service.totalMembersMap.size).toBeGreaterThan(0);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(2);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toBe(4);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toBe(5);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm5")).toBe(4);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm7")).toBe(1);
|
||||
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228xy4")).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWeakPassword", () => {
|
||||
it("should add weak passwords to passwordStrengthMap", () => {
|
||||
const weakCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView;
|
||||
service.findWeakPassword(weakCipher);
|
||||
expect(service.passwordStrengthMap.get(weakCipher.id)).toEqual(["veryWeak", "danger"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findReusedPassword", () => {
|
||||
it("should detect password reuse", () => {
|
||||
mockCiphers.forEach((cipher) => {
|
||||
service.findReusedPassword(cipher as CipherView);
|
||||
});
|
||||
const reuseCounts = Array.from(service.passwordUseMap.values()).filter((count) => count > 1);
|
||||
expect(reuseCounts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findExposedPassword", () => {
|
||||
it("should add exposed passwords to exposedPasswordMap", async () => {
|
||||
const exposedCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView;
|
||||
await service.findExposedPassword(exposedCipher);
|
||||
expect(service.exposedPasswordMap.get(exposedCipher.id)).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { mockCiphers } from "./ciphers.mock";
|
||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
|
||||
import { RiskInsightsReportService } from "./risk-insights-report.service";
|
||||
|
||||
describe("RiskInsightsReportService", () => {
|
||||
let service: RiskInsightsReportService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
RiskInsightsReportService,
|
||||
{
|
||||
provide: PasswordStrengthServiceAbstraction,
|
||||
useValue: {
|
||||
getPasswordStrength: (password: string) => {
|
||||
const score = password.length < 4 ? 1 : 4;
|
||||
return { score };
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AuditService,
|
||||
useValue: {
|
||||
passwordLeaked: (password: string) => Promise.resolve(password === "123" ? 100 : 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CipherService,
|
||||
useValue: {
|
||||
getAllFromApiForOrganization: jest.fn().mockResolvedValue(mockCiphers),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MemberCipherDetailsApiService,
|
||||
useValue: {
|
||||
getMemberCipherDetails: jest.fn().mockResolvedValue(mockMemberCipherDetails),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(RiskInsightsReportService);
|
||||
});
|
||||
|
||||
it("should generate the raw data report correctly", async () => {
|
||||
const result = await firstValueFrom(service.generateRawDataReport$("orgId"));
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
|
||||
let testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001228ab1");
|
||||
expect(testCaseResults).toHaveLength(1);
|
||||
let testCase = testCaseResults[0];
|
||||
expect(testCase).toBeTruthy();
|
||||
expect(testCase.cipherMembers).toHaveLength(2);
|
||||
expect(testCase.trimmedUris).toHaveLength(3);
|
||||
expect(testCase.weakPasswordDetail).toBeTruthy();
|
||||
expect(testCase.exposedPasswordDetail).toBeTruthy();
|
||||
expect(testCase.reusedPasswordCount).toEqual(2);
|
||||
|
||||
testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001227tt1");
|
||||
expect(testCaseResults).toHaveLength(1);
|
||||
testCase = testCaseResults[0];
|
||||
expect(testCase).toBeTruthy();
|
||||
expect(testCase.cipherMembers).toHaveLength(1);
|
||||
expect(testCase.trimmedUris).toHaveLength(1);
|
||||
expect(testCase.weakPasswordDetail).toBeFalsy();
|
||||
expect(testCase.exposedPasswordDetail).toBeFalsy();
|
||||
expect(testCase.reusedPasswordCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should generate the raw data + uri report correctly", async () => {
|
||||
const result = await firstValueFrom(service.generateRawDataUriReport$("orgId"));
|
||||
|
||||
expect(result).toHaveLength(9);
|
||||
|
||||
// Two ciphers that have google.com as their uri. There should be 2 results
|
||||
const googleResults = result.filter((x) => x.trimmedUri === "google.com");
|
||||
expect(googleResults).toHaveLength(2);
|
||||
|
||||
// Verify the details for one of the googles matches the password health info
|
||||
// expected
|
||||
const firstGoogle = googleResults.filter(
|
||||
(x) => x.cipherId === "cbea34a8-bde4-46ad-9d19-b05001228ab1" && x.trimmedUri === "google.com",
|
||||
)[0];
|
||||
expect(firstGoogle.weakPasswordDetail).toBeTruthy();
|
||||
expect(firstGoogle.exposedPasswordDetail).toBeTruthy();
|
||||
expect(firstGoogle.reusedPasswordCount).toEqual(2);
|
||||
});
|
||||
|
||||
it("should generate applications health report data correctly", async () => {
|
||||
const result = await firstValueFrom(service.generateApplicationsReport$("orgId"));
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
|
||||
// Two ciphers have google.com associated with them. The first cipher
|
||||
// has 2 members and the second has 4. However, the 2 members in the first
|
||||
// cipher are also associated with the second. The total amount of members
|
||||
// should be 4 not 6
|
||||
const googleTestResults = result.filter((x) => x.applicationName === "google.com");
|
||||
expect(googleTestResults).toHaveLength(1);
|
||||
const googleTest = googleTestResults[0];
|
||||
expect(googleTest.memberCount).toEqual(4);
|
||||
|
||||
// Both ciphers have at risk passwords
|
||||
expect(googleTest.passwordCount).toEqual(2);
|
||||
|
||||
// All members are at risk since both ciphers are at risk
|
||||
expect(googleTest.atRiskMemberDetails).toHaveLength(4);
|
||||
expect(googleTest.atRiskPasswordCount).toEqual(2);
|
||||
|
||||
// There are 2 ciphers associated with 101domain.com
|
||||
const domain101TestResults = result.filter((x) => x.applicationName === "101domain.com");
|
||||
expect(domain101TestResults).toHaveLength(1);
|
||||
const domain101Test = domain101TestResults[0];
|
||||
expect(domain101Test.passwordCount).toEqual(2);
|
||||
|
||||
// The first cipher is at risk. The second cipher is not at risk
|
||||
expect(domain101Test.atRiskPasswordCount).toEqual(1);
|
||||
|
||||
// The first cipher has 2 members. The second cipher the second
|
||||
// cipher has 4. One of the members in the first cipher is associated
|
||||
// with the second. So there should be 5 members total.
|
||||
expect(domain101Test.memberCount).toEqual(5);
|
||||
|
||||
// The first cipher is at risk. The total at risk members is 2 and
|
||||
// at risk password count is 1.
|
||||
expect(domain101Test.atRiskMemberDetails).toHaveLength(2);
|
||||
expect(domain101Test.atRiskPasswordCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should generate applications summary data correctly", async () => {
|
||||
const reportResult = await firstValueFrom(service.generateApplicationsReport$("orgId"));
|
||||
const reportSummary = service.generateApplicationsSummary(reportResult);
|
||||
|
||||
expect(reportSummary.totalMemberCount).toEqual(7);
|
||||
expect(reportSummary.totalAtRiskMemberCount).toEqual(6);
|
||||
expect(reportSummary.totalApplicationCount).toEqual(6);
|
||||
expect(reportSummary.totalAtRiskApplicationCount).toEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { Injectable } from "@angular/core";
|
||||
import { concatMap, first, from, map, Observable, zip } from "rxjs";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
ApplicationHealthReportSummary,
|
||||
CipherHealthReportDetail,
|
||||
CipherHealthReportUriDetail,
|
||||
ExposedPasswordDetail,
|
||||
MemberDetailsFlat,
|
||||
WeakPasswordDetail,
|
||||
WeakPasswordScore,
|
||||
} from "../models/password-health";
|
||||
|
||||
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
|
||||
|
||||
@Injectable()
|
||||
export class RiskInsightsReportService {
|
||||
constructor(
|
||||
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||
private auditService: AuditService,
|
||||
private cipherService: CipherService,
|
||||
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Report data from raw cipher health data.
|
||||
* Can be used in the Raw Data diagnostic tab (just exclude the members in the view)
|
||||
* and can be used in the raw data + members tab when including the members in the view
|
||||
* @param organizationId
|
||||
* @returns Cipher health report data with members and trimmed uris
|
||||
*/
|
||||
generateRawDataReport$(organizationId: string): Observable<CipherHealthReportDetail[]> {
|
||||
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
|
||||
const memberCiphers$ = from(
|
||||
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
|
||||
);
|
||||
|
||||
const results$ = zip(allCiphers$, memberCiphers$).pipe(
|
||||
map(([allCiphers, memberCiphers]) => {
|
||||
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
|
||||
dtl.cipherIds.map((c) => this.getMemberDetailsFlat(dtl.userName, dtl.email, c)),
|
||||
);
|
||||
return [allCiphers, details] as const;
|
||||
}),
|
||||
concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)),
|
||||
first(),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report data for raw cipher health broken out into the uris
|
||||
* Can be used in the raw data + members + uri diagnostic report
|
||||
* @param organizationId Id of the organization
|
||||
* @returns Cipher health report data flattened to the uris
|
||||
*/
|
||||
generateRawDataUriReport$(organizationId: string): Observable<CipherHealthReportUriDetail[]> {
|
||||
const cipherHealthDetails$ = this.generateRawDataReport$(organizationId);
|
||||
const results$ = cipherHealthDetails$.pipe(
|
||||
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
|
||||
first(),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report data for the aggregation of uris to like uris and getting password/member counts,
|
||||
* members, and at risk statuses.
|
||||
* @param organizationId Id of the organization
|
||||
* @returns The all applications health report data
|
||||
*/
|
||||
generateApplicationsReport$(organizationId: string): Observable<ApplicationHealthReportDetail[]> {
|
||||
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
|
||||
const results$ = cipherHealthUriReport$.pipe(
|
||||
map((uriDetails) => this.getApplicationHealthReport(uriDetails)),
|
||||
first(),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the summary from the application health report. Returns total members and applications as well
|
||||
* as the total at risk members and at risk applications
|
||||
* @param reports The previously calculated application health report data
|
||||
* @returns A summary object containing report totals
|
||||
*/
|
||||
generateApplicationsSummary(
|
||||
reports: ApplicationHealthReportDetail[],
|
||||
): ApplicationHealthReportSummary {
|
||||
const totalMembers = reports.flatMap((x) => x.memberDetails);
|
||||
const uniqueMembers = this.getUniqueMembers(totalMembers);
|
||||
|
||||
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
|
||||
const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers);
|
||||
|
||||
return {
|
||||
totalMemberCount: uniqueMembers.length,
|
||||
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
|
||||
totalApplicationCount: reports.length,
|
||||
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates the members with the ciphers they have access to. Calculates the password health.
|
||||
* Finds the trimmed uris.
|
||||
* @param ciphers Org ciphers
|
||||
* @param memberDetails Org members
|
||||
* @returns Cipher password health data with trimmed uris and associated members
|
||||
*/
|
||||
private async getCipherDetails(
|
||||
ciphers: CipherView[],
|
||||
memberDetails: MemberDetailsFlat[],
|
||||
): Promise<CipherHealthReportDetail[]> {
|
||||
const cipherHealthReports: CipherHealthReportDetail[] = [];
|
||||
const passwordUseMap = new Map<string, number>();
|
||||
for (const cipher of ciphers) {
|
||||
if (this.validateCipher(cipher)) {
|
||||
const weakPassword = this.findWeakPassword(cipher);
|
||||
// Looping over all ciphers needs to happen first to determine reused passwords over all ciphers.
|
||||
// Store in the set and evaluate later
|
||||
if (passwordUseMap.has(cipher.login.password)) {
|
||||
passwordUseMap.set(
|
||||
cipher.login.password,
|
||||
(passwordUseMap.get(cipher.login.password) || 0) + 1,
|
||||
);
|
||||
} else {
|
||||
passwordUseMap.set(cipher.login.password, 1);
|
||||
}
|
||||
|
||||
const exposedPassword = await this.findExposedPassword(cipher);
|
||||
|
||||
// Get the cipher members
|
||||
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
|
||||
|
||||
// Trim uris to host name and create the cipher health report
|
||||
const cipherTrimmedUris = this.getTrimmedCipherUris(cipher);
|
||||
const cipherHealth = {
|
||||
...cipher,
|
||||
weakPasswordDetail: weakPassword,
|
||||
exposedPasswordDetail: exposedPassword,
|
||||
cipherMembers: cipherMembers,
|
||||
trimmedUris: cipherTrimmedUris,
|
||||
} as CipherHealthReportDetail;
|
||||
|
||||
cipherHealthReports.push(cipherHealth);
|
||||
}
|
||||
}
|
||||
|
||||
// loop for reused passwords
|
||||
cipherHealthReports.forEach((detail) => {
|
||||
detail.reusedPasswordCount = passwordUseMap.get(detail.login.password) ?? 0;
|
||||
});
|
||||
return cipherHealthReports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the cipher to trimmed uris. Used for the raw data + uri
|
||||
* @param cipherHealthReport Cipher health report with uris and members
|
||||
* @returns Flattened cipher health details to uri
|
||||
*/
|
||||
private getCipherUriDetails(
|
||||
cipherHealthReport: CipherHealthReportDetail[],
|
||||
): CipherHealthReportUriDetail[] {
|
||||
return cipherHealthReport.flatMap((rpt) =>
|
||||
rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
|
||||
* If the item is new, create and add the object with the flattened details
|
||||
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
|
||||
* @returns Application health reports
|
||||
*/
|
||||
private getApplicationHealthReport(
|
||||
cipherHealthUriReport: CipherHealthReportUriDetail[],
|
||||
): ApplicationHealthReportDetail[] {
|
||||
const appReports: ApplicationHealthReportDetail[] = [];
|
||||
cipherHealthUriReport.forEach((uri) => {
|
||||
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
|
||||
|
||||
let atRisk: boolean = false;
|
||||
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
|
||||
atRisk = true;
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
appReports.push(this.getApplicationReportDetail(uri, atRisk));
|
||||
} else {
|
||||
appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]);
|
||||
}
|
||||
});
|
||||
return appReports;
|
||||
}
|
||||
|
||||
private async findExposedPassword(cipher: CipherView): Promise<ExposedPasswordDetail> {
|
||||
const exposedCount = await this.auditService.passwordLeaked(cipher.login.password);
|
||||
if (exposedCount > 0) {
|
||||
const exposedDetail = { exposedXTimes: exposedCount } as ExposedPasswordDetail;
|
||||
return exposedDetail;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private findWeakPassword(cipher: CipherView): WeakPasswordDetail {
|
||||
const hasUserName = this.isUserNameNotEmpty(cipher);
|
||||
let userInput: string[] = [];
|
||||
if (hasUserName) {
|
||||
const atPosition = cipher.login.username.indexOf("@");
|
||||
if (atPosition > -1) {
|
||||
userInput = userInput
|
||||
.concat(
|
||||
cipher.login.username
|
||||
.substring(0, atPosition)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[^A-Za-z0-9]/),
|
||||
)
|
||||
.filter((i) => i.length >= 3);
|
||||
} else {
|
||||
userInput = cipher.login.username
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[^A-Za-z0-9]/)
|
||||
.filter((i) => i.length >= 3);
|
||||
}
|
||||
}
|
||||
const { score } = this.passwordStrengthService.getPasswordStrength(
|
||||
cipher.login.password,
|
||||
null,
|
||||
userInput.length > 0 ? userInput : null,
|
||||
);
|
||||
|
||||
if (score != null && score <= 2) {
|
||||
const scoreValue = this.weakPasswordScore(score);
|
||||
const weakPasswordDetail = { score: score, detailValue: scoreValue } as WeakPasswordDetail;
|
||||
return weakPasswordDetail;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private weakPasswordScore(score: number): WeakPasswordScore {
|
||||
switch (score) {
|
||||
case 4:
|
||||
return { label: "strong", badgeVariant: "success" };
|
||||
case 3:
|
||||
return { label: "good", badgeVariant: "primary" };
|
||||
case 2:
|
||||
return { label: "weak", badgeVariant: "warning" };
|
||||
default:
|
||||
return { label: "veryWeak", badgeVariant: "danger" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the new application health report detail object with the details from the cipher health report uri detail object
|
||||
* update or create the at risk values if the item is at risk.
|
||||
* @param newUriDetail New cipher uri detail
|
||||
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
|
||||
* @param existingUriDetail The previously processed Uri item
|
||||
* @returns The new or updated application health report detail
|
||||
*/
|
||||
private getApplicationReportDetail(
|
||||
newUriDetail: CipherHealthReportUriDetail,
|
||||
isAtRisk: boolean,
|
||||
existingUriDetail?: ApplicationHealthReportDetail,
|
||||
): ApplicationHealthReportDetail {
|
||||
const reportDetail = {
|
||||
applicationName: existingUriDetail
|
||||
? existingUriDetail.applicationName
|
||||
: newUriDetail.trimmedUri,
|
||||
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
|
||||
memberDetails: existingUriDetail
|
||||
? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
|
||||
: newUriDetail.cipherMembers,
|
||||
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
|
||||
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
|
||||
} as ApplicationHealthReportDetail;
|
||||
|
||||
if (isAtRisk) {
|
||||
(reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1),
|
||||
(reportDetail.atRiskMemberDetails = this.getUniqueMembers(
|
||||
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
|
||||
));
|
||||
}
|
||||
|
||||
reportDetail.memberCount = reportDetail.memberDetails.length;
|
||||
|
||||
return reportDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a distinct array of members from a combined list. Input list may contain
|
||||
* duplicate members.
|
||||
* @param orgMembers Input list of members
|
||||
* @returns Distinct array of members
|
||||
*/
|
||||
private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
|
||||
const existingEmails = new Set<string>();
|
||||
const distinctUsers = orgMembers.filter((member) => {
|
||||
if (existingEmails.has(member.email)) {
|
||||
return false;
|
||||
}
|
||||
existingEmails.add(member.email);
|
||||
return true;
|
||||
});
|
||||
return distinctUsers;
|
||||
}
|
||||
|
||||
private getFlattenedCipherDetails(
|
||||
detail: CipherHealthReportDetail,
|
||||
uri: string,
|
||||
): CipherHealthReportUriDetail {
|
||||
return {
|
||||
cipherId: detail.id,
|
||||
reusedPasswordCount: detail.reusedPasswordCount,
|
||||
weakPasswordDetail: detail.weakPasswordDetail,
|
||||
exposedPasswordDetail: detail.exposedPasswordDetail,
|
||||
cipherMembers: detail.cipherMembers,
|
||||
trimmedUri: uri,
|
||||
};
|
||||
}
|
||||
|
||||
private getMemberDetailsFlat(
|
||||
userName: string,
|
||||
email: string,
|
||||
cipherId: string,
|
||||
): MemberDetailsFlat {
|
||||
return {
|
||||
userName: userName,
|
||||
email: email,
|
||||
cipherId: cipherId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim the cipher uris down to get the password health application.
|
||||
* The uri should only exist once after being trimmed. No duplication.
|
||||
* Example:
|
||||
* - Untrimmed Uris: https://gmail.com, gmail.com/login
|
||||
* - Both would trim to gmail.com
|
||||
* - The cipher trimmed uri list should only return on instance in the list
|
||||
* @param cipher
|
||||
* @returns distinct list of trimmed cipher uris
|
||||
*/
|
||||
private getTrimmedCipherUris(cipher: CipherView): string[] {
|
||||
const cipherUris: string[] = [];
|
||||
const uris = cipher.login?.uris ?? [];
|
||||
uris.map((u: { uri: string }) => {
|
||||
const uri = Utils.getHostname(u.uri).replace("www.", "");
|
||||
if (!cipherUris.includes(uri)) {
|
||||
cipherUris.push(uri);
|
||||
}
|
||||
});
|
||||
return cipherUris;
|
||||
}
|
||||
|
||||
private isUserNameNotEmpty(c: CipherView): boolean {
|
||||
return !Utils.isNullOrWhitespace(c.login.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the cipher is a login item, has a password
|
||||
* is not deleted, and the user can view the password
|
||||
* @param c the input cipher
|
||||
*/
|
||||
private validateCipher(c: CipherView): boolean {
|
||||
const { type, login, isDeleted, viewPassword } = c;
|
||||
if (
|
||||
type !== CipherType.Login ||
|
||||
login.password == null ||
|
||||
login.password === "" ||
|
||||
isDeleted ||
|
||||
!viewPassword
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,8 @@ const DisallowedPlanTypes = [
|
||||
],
|
||||
})
|
||||
export class vNextClientsComponent {
|
||||
providerId: string;
|
||||
addableOrganizations: Organization[];
|
||||
providerId: string = "";
|
||||
addableOrganizations: Organization[] = [];
|
||||
loading = true;
|
||||
manageOrganizations = false;
|
||||
showAddExisting = false;
|
||||
@@ -79,8 +79,8 @@ export class vNextClientsComponent {
|
||||
this.searchControl.setValue(queryParams.search);
|
||||
});
|
||||
|
||||
this.activatedRoute.parent.params
|
||||
.pipe(
|
||||
this.activatedRoute.parent?.params
|
||||
?.pipe(
|
||||
switchMap((params) => {
|
||||
this.providerId = params.providerId;
|
||||
return this.providerService.get$(this.providerId).pipe(
|
||||
@@ -125,7 +125,7 @@ export class vNextClientsComponent {
|
||||
await this.webProviderService.detachOrganization(this.providerId, organization.id);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("detachedOrganization", organization.organizationName),
|
||||
});
|
||||
await this.load();
|
||||
|
||||
@@ -51,15 +51,15 @@ import { vNextNoClientsComponent } from "./vnext-no-clients.component";
|
||||
],
|
||||
})
|
||||
export class vNextManageClientsComponent {
|
||||
providerId: string;
|
||||
provider: Provider;
|
||||
providerId: string = "";
|
||||
provider: Provider | undefined;
|
||||
loading = true;
|
||||
isProviderAdmin = false;
|
||||
dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> =
|
||||
new TableDataSource();
|
||||
|
||||
protected searchControl = new FormControl("", { nonNullable: true });
|
||||
protected plans: PlanResponse[];
|
||||
protected plans: PlanResponse[] = [];
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
@@ -76,8 +76,8 @@ export class vNextManageClientsComponent {
|
||||
this.searchControl.setValue(queryParams.search);
|
||||
});
|
||||
|
||||
this.activatedRoute.parent.params
|
||||
.pipe(
|
||||
this.activatedRoute.parent?.params
|
||||
?.pipe(
|
||||
switchMap((params) => {
|
||||
this.providerId = params.providerId;
|
||||
return this.providerService.get$(this.providerId).pipe(
|
||||
@@ -110,12 +110,12 @@ export class vNextManageClientsComponent {
|
||||
async load() {
|
||||
this.provider = await firstValueFrom(this.providerService.get$(this.providerId));
|
||||
|
||||
this.isProviderAdmin = this.provider.type === ProviderUserType.ProviderAdmin;
|
||||
this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin;
|
||||
|
||||
const clients = (await this.billingApiService.getProviderClientOrganizations(this.providerId))
|
||||
.data;
|
||||
|
||||
clients.forEach((client) => (client.plan = client.plan.replace(" (Monthly)", "")));
|
||||
clients.forEach((client) => (client.plan = client.plan?.replace(" (Monthly)", "")));
|
||||
|
||||
this.dataSource.data = clients;
|
||||
|
||||
@@ -146,7 +146,7 @@ export class vNextManageClientsComponent {
|
||||
organization: {
|
||||
id: organization.id,
|
||||
name: organization.organizationName,
|
||||
seats: organization.seats,
|
||||
seats: organization.seats ? organization.seats : 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export class vNextManageClientsComponent {
|
||||
const dialogRef = openManageClientSubscriptionDialog(this.dialogService, {
|
||||
data: {
|
||||
organization,
|
||||
provider: this.provider,
|
||||
provider: this.provider!,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -190,7 +190,7 @@ export class vNextManageClientsComponent {
|
||||
await this.webProviderService.detachOrganization(this.providerId, organization.id);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("detachedOrganization", organization.organizationName),
|
||||
});
|
||||
await this.load();
|
||||
|
||||
@@ -15,6 +15,7 @@ import { componentRouteSwap } from "../../utils/component-route-swap";
|
||||
* @param defaultComponent - The current non-refreshed component to render.
|
||||
* @param refreshedComponent - The new refreshed component to render.
|
||||
* @param options - The shared route options to apply to both components.
|
||||
* @param altOptions - The alt route options to apply to the alt component. If not provided, the base options will be used.
|
||||
*/
|
||||
export function unauthUiRefreshSwap(
|
||||
defaultComponent: Type<any>,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[icon]="pageIcon"
|
||||
[showReadonlyHostname]="showReadonlyHostname"
|
||||
[maxWidth]="maxWidth"
|
||||
[titleAreaMaxWidth]="titleAreaMaxWidth"
|
||||
>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet slot="secondary" name="secondary"></router-outlet>
|
||||
|
||||
@@ -35,6 +35,10 @@ export interface AnonLayoutWrapperData {
|
||||
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
|
||||
*/
|
||||
maxWidth?: "md" | "3xl";
|
||||
/**
|
||||
* Optional flag to set the max-width of the title area. Defaults to null if not provided.
|
||||
*/
|
||||
titleAreaMaxWidth?: "md";
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -50,6 +54,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
protected pageIcon: Icon;
|
||||
protected showReadonlyHostname: boolean;
|
||||
protected maxWidth: "md" | "3xl";
|
||||
protected titleAreaMaxWidth: "md";
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
@@ -100,6 +105,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
|
||||
this.maxWidth = firstChildRouteData["maxWidth"];
|
||||
this.titleAreaMaxWidth = firstChildRouteData["titleAreaMaxWidth"];
|
||||
}
|
||||
|
||||
private listenForServiceDataChanges() {
|
||||
@@ -157,6 +163,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
this.pageIcon = null;
|
||||
this.showReadonlyHostname = null;
|
||||
this.maxWidth = null;
|
||||
this.titleAreaMaxWidth = null;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
<bit-icon [icon]="logo"></bit-icon>
|
||||
</a>
|
||||
|
||||
<div class="tw-text-center tw-mb-6">
|
||||
<div
|
||||
class="tw-text-center tw-mb-6"
|
||||
[ngClass]="{ 'tw-max-w-md tw-mx-auto': titleAreaMaxWidth === 'md' }"
|
||||
>
|
||||
<div class="tw-mx-auto tw-max-w-28 sm:tw-max-w-32">
|
||||
<bit-icon [icon]="icon"></bit-icon>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,13 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
@Input() hideLogo: boolean = false;
|
||||
@Input() hideFooter: boolean = false;
|
||||
|
||||
/**
|
||||
* Max width of the title area content
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
@Input() titleAreaMaxWidth?: "md";
|
||||
|
||||
/**
|
||||
* Max width of the layout content
|
||||
*
|
||||
@@ -60,6 +67,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
|
||||
async ngOnInit() {
|
||||
this.maxWidth = this.maxWidth ?? "md";
|
||||
this.titleAreaMaxWidth = this.titleAreaMaxWidth ?? null;
|
||||
this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname();
|
||||
this.version = await this.platformUtilsService.getApplicationVersion();
|
||||
|
||||
|
||||
@@ -190,3 +190,22 @@ export const HideFooter: Story = {
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithTitleAreaMaxWidth: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
title: "This is a very long long title to demonstrate titleAreaMaxWidth set to 'md'",
|
||||
subtitle:
|
||||
"This is a very long subtitle that demonstrates how the max width container handles longer text content with the titleAreaMaxWidth input set to 'md'. Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?",
|
||||
},
|
||||
template: `
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" [titleAreaMaxWidth]="'md'">
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -10,4 +10,5 @@ export * from "./vault.icon";
|
||||
export * from "./registration-user-add.icon";
|
||||
export * from "./registration-lock-alt.icon";
|
||||
export * from "./registration-expired-link.icon";
|
||||
export * from "./sso-key.icon";
|
||||
export * from "./two-factor-timeout.icon";
|
||||
|
||||
10
libs/auth/src/angular/icons/sso-key.icon.ts
Normal file
10
libs/auth/src/angular/icons/sso-key.icon.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
|
||||
export const SsoKeyIcon = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100">
|
||||
<path class="tw-fill-art-primary" d="M28.573 23.888c6.488-8.225 16.56-13.51 27.87-13.51 15.454 0 28.595 9.864 33.446 23.62a23.969 23.969 0 0 1 2.844-.168c13.083 0 23.689 10.58 23.689 23.629 0 13.049-10.606 23.628-23.69 23.628H56.445v-2.393h36.289c11.757 0 21.289-9.507 21.289-21.236 0-11.728-9.532-21.235-21.29-21.235-1.182 0-2.34.096-3.469.28l-1.022.168-.315-.984c-4.26-13.293-16.746-22.915-31.482-22.915-10.712 0-20.234 5.083-26.274 12.968l-.31.404-.506.059C16.22 27.718 6.022 38.852 6.022 52.36c0 9.427 4.965 17.696 12.429 22.348l-.954 2.226C9.179 71.897 3.622 62.776 3.622 52.36c0-14.563 10.865-26.595 24.951-28.472Z"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M110.626 64.594a.598.598 0 0 1-.393-.75 18.278 18.278 0 0 0-1.008-13.552 18.326 18.326 0 0 0-7.607-8.025.597.597 0 0 1-.233-.814.6.6 0 0 1 .816-.232 19.52 19.52 0 0 1 8.103 8.548 19.474 19.474 0 0 1 1.074 14.434.6.6 0 0 1-.752.39ZM33.17 30.116c-13.02 0-23.574 10.524-23.574 23.506a.6.6 0 0 1-1.2 0c0-13.642 11.092-24.702 24.773-24.702a.6.6 0 1 1 0 1.196Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M81.528 43.563a1.198 1.198 0 0 0-1.058-1.306l-11.08-1.273c-.32-.037-.641.055-.892.256L46.665 58.748c-6.998-3.579-15.75-3.07-22.226 2.123-8.84 7.088-10.054 19.941-2.89 28.787 7.168 8.847 20.1 10.352 28.888 3.306 6.524-5.232 8.897-13.726 6.742-21.358l3.146-2.523c.25-.2.41-.493.441-.812l.509-5.09 5.29.609c.32.036.64-.056.89-.257.251-.2.41-.493.442-.812l.144-1.439 1.169.135c.317.036.635-.055.885-.252s.41-.485.446-.8l.612-5.372 5.764.663c.32.036.641-.056.892-.257l2.366-1.897c.25-.201.41-.494.441-.812l.912-9.127ZM68.289 58.904l-1.186-.136c-.32-.037-.64.056-.891.257-.25.2-.41.493-.441.812l-.144 1.438-5.29-.607c-.32-.037-.64.055-.891.256-.25.201-.41.494-.441.812l-.58 5.8-3.384 2.713a1.191 1.191 0 0 0-.389 1.3c2.266 6.94.211 14.794-5.724 19.553-7.726 6.196-19.152 4.902-25.508-2.944-6.357-7.848-5.248-19.19 2.527-25.425 5.886-4.72 13.952-5.061 20.26-1.516.43.241.962.198 1.345-.11l22.063-17.691 9.405 1.08-.745 7.458-1.583 1.27-6.46-.743a1.205 1.205 0 0 0-.885.251c-.25.198-.41.486-.446.801l-.612 5.371Z" clip-rule="evenodd"/>
|
||||
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M35.251 78.67c2.144 2.647 1.721 6.453-.864 8.526-2.587 2.074-6.414 1.676-8.558-.97-2.137-2.638-1.78-6.405.865-8.525 2.653-2.127 6.468-1.61 8.557.97Zm-2.373 6.665c1.543-1.237 1.823-3.535.503-5.164-1.291-1.595-3.602-1.873-5.179-.609-1.584 1.271-1.829 3.528-.503 5.165 1.32 1.629 3.637 1.845 5.18.608Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
`;
|
||||
@@ -64,6 +64,11 @@ export * from "./lock/lock-component.service";
|
||||
// vault timeout
|
||||
export * from "./vault-timeout-input/vault-timeout-input.component";
|
||||
|
||||
// sso
|
||||
export * from "./sso/sso.component";
|
||||
export * from "./sso/sso-component.service";
|
||||
export * from "./sso/default-sso-component.service";
|
||||
|
||||
// self hosted environment configuration dialog
|
||||
export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component";
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { SsoComponentService } from "./sso-component.service";
|
||||
|
||||
export class DefaultSsoComponentService implements SsoComponentService {}
|
||||
20
libs/auth/src/angular/sso/sso-component.service.ts
Normal file
20
libs/auth/src/angular/sso/sso-component.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
|
||||
export type SsoClientType = ClientType.Web | ClientType.Browser | ClientType.Desktop;
|
||||
|
||||
/**
|
||||
* Abstract class for SSO component services.
|
||||
*/
|
||||
export abstract class SsoComponentService {
|
||||
/**
|
||||
* Sets the cookies for the SSO component service.
|
||||
* Used to pass translation messages to the SSO connector page (apps/web/src/connectors/sso.ts) during the SSO handoff process.
|
||||
* See implementation in WebSsoComponentService for example usage.
|
||||
*/
|
||||
setDocumentCookies?(): void;
|
||||
|
||||
/**
|
||||
* Closes the window.
|
||||
*/
|
||||
closeWindow?(): Promise<void>;
|
||||
}
|
||||
18
libs/auth/src/angular/sso/sso.component.html
Normal file
18
libs/auth/src/angular/sso/sso.component.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-container">
|
||||
<div *ngIf="loggingIn">
|
||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
{{ "loading" | i18n }}
|
||||
</div>
|
||||
<div *ngIf="!loggingIn">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "ssoIdentifier" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="identifier" appAutofocus />
|
||||
</bit-form-field>
|
||||
<hr />
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" [block]="true">
|
||||
{{ "continue" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
591
libs/auth/src/angular/sso/sso.component.ts
Normal file
591
libs/auth/src/angular/sso/sso.component.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
SsoLoginCredentials,
|
||||
TrustedDeviceUserDecryptionOption,
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction";
|
||||
import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response";
|
||||
import { VerifiedOrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { SsoClientType, SsoComponentService } from "./sso-component.service";
|
||||
|
||||
interface QueryParams {
|
||||
code?: string;
|
||||
state?: string;
|
||||
redirectUri?: string;
|
||||
clientId?: string;
|
||||
codeChallenge?: string;
|
||||
identifier?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component handles the SSO flow.
|
||||
*/
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "sso.component.html",
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CheckboxModule,
|
||||
CommonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
export class SsoComponent implements OnInit {
|
||||
protected formGroup = new FormGroup({
|
||||
identifier: new FormControl<string | null>(null, [Validators.required]),
|
||||
});
|
||||
|
||||
protected redirectUri: string | undefined;
|
||||
protected loggingIn = false;
|
||||
protected identifier: string | undefined;
|
||||
protected state: string | undefined;
|
||||
protected codeChallenge: string | undefined;
|
||||
protected clientId: SsoClientType | undefined;
|
||||
|
||||
formPromise: Promise<AuthResult> | undefined;
|
||||
initiateSsoFormPromise: Promise<SsoPreValidateResponse> | undefined;
|
||||
|
||||
get identifierFormControl() {
|
||||
return this.formGroup.controls.identifier;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private router: Router,
|
||||
private i18nService: I18nService,
|
||||
private route: ActivatedRoute,
|
||||
private orgDomainApiService: OrgDomainApiServiceAbstraction,
|
||||
private validationService: ValidationService,
|
||||
private configService: ConfigService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private apiService: ApiService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private environmentService: EnvironmentService,
|
||||
private passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private toastService: ToastService,
|
||||
private ssoComponentService: SsoComponentService,
|
||||
private syncService: SyncService,
|
||||
) {
|
||||
environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
|
||||
this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html";
|
||||
});
|
||||
|
||||
const clientType = this.platformUtilsService.getClientType();
|
||||
if (this.isValidSsoClientType(clientType)) {
|
||||
this.clientId = clientType as SsoClientType;
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const qParams: QueryParams = await firstValueFrom(this.route.queryParams);
|
||||
|
||||
// This if statement will pass on the second portion of the SSO flow
|
||||
// where the user has already authenticated with the identity provider
|
||||
if (this.hasCodeOrStateParams(qParams)) {
|
||||
await this.handleCodeAndStateParams(qParams);
|
||||
return;
|
||||
}
|
||||
|
||||
// This if statement will pass on the first portion of the SSO flow
|
||||
if (this.hasRequiredSsoParams(qParams)) {
|
||||
this.setRequiredSsoVariables(qParams);
|
||||
return;
|
||||
}
|
||||
|
||||
if (qParams.identifier != null) {
|
||||
// SSO Org Identifier in query params takes precedence over claimed domains
|
||||
this.identifierFormControl.setValue(qParams.identifier);
|
||||
this.loggingIn = true;
|
||||
await this.submit();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initializeIdentifierFromEmailOrStorage(qParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the required SSO variables from the query params
|
||||
* @param qParams - The query params
|
||||
*/
|
||||
private setRequiredSsoVariables(qParams: QueryParams): void {
|
||||
this.redirectUri = qParams.redirectUri ?? "";
|
||||
this.state = qParams.state ?? "";
|
||||
this.codeChallenge = qParams.codeChallenge ?? "";
|
||||
const clientId = qParams.clientId ?? "";
|
||||
if (this.isValidSsoClientType(clientId)) {
|
||||
this.clientId = clientId;
|
||||
} else {
|
||||
throw new Error(`Invalid SSO client type: ${qParams.clientId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is a valid SSO client type
|
||||
* @param value - The value to check
|
||||
* @returns True if the value is a valid SSO client type, otherwise false
|
||||
*/
|
||||
private isValidSsoClientType(value: string): value is SsoClientType {
|
||||
return [ClientType.Web, ClientType.Browser, ClientType.Desktop].includes(value as ClientType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the query params have the required SSO params
|
||||
* @param qParams - The query params
|
||||
* @returns True if the query params have the required SSO params, false otherwise
|
||||
*/
|
||||
private hasRequiredSsoParams(qParams: QueryParams): boolean {
|
||||
return (
|
||||
qParams.clientId != null &&
|
||||
qParams.redirectUri != null &&
|
||||
qParams.state != null &&
|
||||
qParams.codeChallenge != null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the code and state params
|
||||
* @param qParams - The query params
|
||||
*/
|
||||
private async handleCodeAndStateParams(qParams: QueryParams): Promise<void> {
|
||||
const codeVerifier = await this.ssoLoginService.getCodeVerifier();
|
||||
const state = await this.ssoLoginService.getSsoState();
|
||||
await this.ssoLoginService.setCodeVerifier("");
|
||||
await this.ssoLoginService.setSsoState("");
|
||||
|
||||
if (qParams.redirectUri != null) {
|
||||
this.redirectUri = qParams.redirectUri;
|
||||
}
|
||||
|
||||
if (
|
||||
qParams.code != null &&
|
||||
codeVerifier != null &&
|
||||
state != null &&
|
||||
this.checkState(state, qParams.state ?? "")
|
||||
) {
|
||||
const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state ?? "");
|
||||
await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the query params have a code or state
|
||||
* @param qParams - The query params
|
||||
* @returns True if the query params have a code or state, false otherwise
|
||||
*/
|
||||
private hasCodeOrStateParams(qParams: QueryParams): boolean {
|
||||
return qParams.code != null && qParams.state != null;
|
||||
}
|
||||
|
||||
private handleGetClaimedDomainByEmailError(error: unknown): void {
|
||||
if (error instanceof ErrorResponse) {
|
||||
const errorResponse: ErrorResponse = error as ErrorResponse;
|
||||
switch (errorResponse.statusCode) {
|
||||
case HttpStatusCode.NotFound:
|
||||
//this is a valid case for a domain not found
|
||||
return;
|
||||
|
||||
default:
|
||||
this.validationService.showError(errorResponse);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoSubmit = (await firstValueFrom(this.route.queryParams)).identifier != null;
|
||||
|
||||
this.identifier = this.identifierFormControl.value ?? "";
|
||||
await this.ssoLoginService.setOrganizationSsoIdentifier(this.identifier);
|
||||
this.ssoComponentService.setDocumentCookies?.();
|
||||
try {
|
||||
await this.submitSso();
|
||||
} catch (error) {
|
||||
if (autoSubmit) {
|
||||
await this.router.navigate(["/login"]);
|
||||
} else {
|
||||
this.validationService.showError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private async submitSso(returnUri?: string, includeUserIdentifier?: boolean) {
|
||||
if (this.identifier == null || this.identifier === "") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("ssoValidationFailed"),
|
||||
message: this.i18nService.t("ssoIdentifierRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.clientId == null) {
|
||||
throw new Error("Client ID is required");
|
||||
}
|
||||
|
||||
this.initiateSsoFormPromise = this.apiService.preValidateSso(this.identifier);
|
||||
const response = await this.initiateSsoFormPromise;
|
||||
|
||||
const authorizeUrl = await this.buildAuthorizeUrl(
|
||||
returnUri,
|
||||
includeUserIdentifier,
|
||||
response.token,
|
||||
);
|
||||
this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
|
||||
}
|
||||
|
||||
private async buildAuthorizeUrl(
|
||||
returnUri?: string,
|
||||
includeUserIdentifier?: boolean,
|
||||
token?: string,
|
||||
): Promise<string> {
|
||||
let codeChallenge = this.codeChallenge;
|
||||
let state = this.state;
|
||||
|
||||
const passwordOptions = {
|
||||
type: "password" as const,
|
||||
length: 64,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
};
|
||||
|
||||
if (codeChallenge == null) {
|
||||
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
|
||||
codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
|
||||
await this.ssoLoginService.setCodeVerifier(codeVerifier);
|
||||
}
|
||||
|
||||
if (state == null) {
|
||||
state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||
if (returnUri) {
|
||||
state += `_returnUri='${returnUri}'`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add Organization Identifier to state
|
||||
state += `_identifier=${this.identifier}`;
|
||||
|
||||
// Save state (regardless of new or existing)
|
||||
await this.ssoLoginService.setSsoState(state);
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
|
||||
let authorizeUrl =
|
||||
env.getIdentityUrl() +
|
||||
"/connect/authorize?" +
|
||||
"client_id=" +
|
||||
this.clientId +
|
||||
"&redirect_uri=" +
|
||||
encodeURIComponent(this.redirectUri ?? "") +
|
||||
"&" +
|
||||
"response_type=code&scope=api offline_access&" +
|
||||
"state=" +
|
||||
state +
|
||||
"&code_challenge=" +
|
||||
codeChallenge +
|
||||
"&" +
|
||||
"code_challenge_method=S256&response_mode=query&" +
|
||||
"domain_hint=" +
|
||||
encodeURIComponent(this.identifier ?? "") +
|
||||
"&ssoToken=" +
|
||||
encodeURIComponent(token ?? "");
|
||||
|
||||
if (includeUserIdentifier) {
|
||||
const userIdentifier = await this.apiService.getSsoUserIdentifier();
|
||||
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
|
||||
}
|
||||
|
||||
return authorizeUrl;
|
||||
}
|
||||
|
||||
private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise<void> {
|
||||
this.loggingIn = true;
|
||||
try {
|
||||
const email = await this.ssoLoginService.getSsoEmail();
|
||||
const redirectUri = this.redirectUri ?? "";
|
||||
const credentials = new SsoLoginCredentials(
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
orgSsoIdentifier,
|
||||
email,
|
||||
);
|
||||
this.formPromise = this.loginStrategyService.logIn(credentials);
|
||||
const authResult = await this.formPromise;
|
||||
|
||||
if (authResult.requiresTwoFactor) {
|
||||
return await this.handleTwoFactorRequired(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
// Everything after the 2FA check is considered a successful login
|
||||
// Just have to figure out where to send the user
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
|
||||
// - TDE login decryption options component
|
||||
// - Browser SSO on extension open
|
||||
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
|
||||
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier);
|
||||
|
||||
// Users enrolled in admin acct recovery can be forced to set a new password after
|
||||
// having the admin set a temp password for them (affects TDE & standard users)
|
||||
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
|
||||
// Weak password is not a valid scenario here b/c we cannot have evaluated a MP yet
|
||||
return await this.handleForcePasswordReset(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
// must come after 2fa check since user decryption options aren't available if 2fa is required
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
|
||||
const tdeEnabled = userDecryptionOpts.trustedDeviceOption
|
||||
? await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption)
|
||||
: false;
|
||||
|
||||
if (tdeEnabled) {
|
||||
return await this.handleTrustedDeviceEncryptionEnabled(userDecryptionOpts);
|
||||
}
|
||||
|
||||
// In the standard, non TDE case, a user must set password if they don't
|
||||
// have one and they aren't using key connector.
|
||||
// Note: TDE & Key connector are mutually exclusive org config options.
|
||||
const requireSetPassword =
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.keyConnectorOption === undefined;
|
||||
|
||||
if (requireSetPassword || authResult.resetMasterPassword) {
|
||||
// Change implies going no password -> password in this case
|
||||
return await this.handleChangePasswordRequired(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
// Standard SSO login success case
|
||||
return await this.handleSuccessfulLogin();
|
||||
} catch (e) {
|
||||
await this.handleLoginError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async isTrustedDeviceEncEnabled(
|
||||
trustedDeviceOption: TrustedDeviceUserDecryptionOption,
|
||||
): Promise<boolean> {
|
||||
return trustedDeviceOption !== undefined;
|
||||
}
|
||||
|
||||
private async handleTwoFactorRequired(orgIdentifier: string) {
|
||||
await this.router.navigate(["2fa"], {
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
sso: "true",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTrustedDeviceEncryptionEnabled(
|
||||
userDecryptionOpts: UserDecryptionOptions,
|
||||
): Promise<void> {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tde offboarding takes precedence
|
||||
if (
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.trustedDeviceOption?.isTdeOffboarding
|
||||
) {
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.TdeOffboarding,
|
||||
userId,
|
||||
);
|
||||
} else if (
|
||||
// If user doesn't have a MP, but has reset password permission, they must set a MP
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.trustedDeviceOption?.hasManageResetPasswordPermission
|
||||
) {
|
||||
// Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device)
|
||||
// Note: we cannot directly navigate in this scenario as we are in a pre-decryption state, and
|
||||
// if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key.
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.ssoComponentService?.closeWindow) {
|
||||
await this.ssoComponentService.closeWindow();
|
||||
} else {
|
||||
await this.router.navigate(["login-initiated"]);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleChangePasswordRequired(orgIdentifier: string) {
|
||||
const emailVerification = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.EmailVerification,
|
||||
);
|
||||
|
||||
let route = "set-password";
|
||||
if (emailVerification) {
|
||||
route = "set-password-jit";
|
||||
}
|
||||
|
||||
await this.router.navigate([route], {
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleForcePasswordReset(orgIdentifier: string) {
|
||||
await this.router.navigate(["update-temp-password"], {
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSuccessfulLogin() {
|
||||
await this.router.navigate(["lock"]);
|
||||
}
|
||||
|
||||
private async handleLoginError(e: unknown) {
|
||||
this.logService.error(e);
|
||||
|
||||
// TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here
|
||||
if (e instanceof Error && e.message === "Key Connector error") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("ssoKeyConnectorError"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getOrgIdentifierFromState(state: string): string {
|
||||
if (state === null || state === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const stateSplit = state.split("_identifier=");
|
||||
return stateSplit.length > 1 ? stateSplit[1] : "";
|
||||
}
|
||||
|
||||
private checkState(state: string, checkState: string): boolean {
|
||||
if (state === null || state === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (checkState === null || checkState === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stateSplit = state.split("_identifier=");
|
||||
const checkStateSplit = checkState.split("_identifier=");
|
||||
return stateSplit[0] === checkStateSplit[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to initialize the SSO identifier from email or storage.
|
||||
* Note: this flow is written for web but both browser and desktop
|
||||
* redirect here on SSO button click.
|
||||
* @param qParams - The query params
|
||||
*/
|
||||
private async initializeIdentifierFromEmailOrStorage(qParams: QueryParams): Promise<void> {
|
||||
// Check if email matches any claimed domains
|
||||
if (qParams.email) {
|
||||
// show loading spinner
|
||||
this.loggingIn = true;
|
||||
try {
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.VerifiedSsoDomainEndpoint)) {
|
||||
const response: ListResponse<VerifiedOrganizationDomainSsoDetailsResponse> =
|
||||
await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(qParams.email);
|
||||
|
||||
if (response.data.length > 0) {
|
||||
this.identifierFormControl.setValue(response.data[0].organizationIdentifier);
|
||||
await this.submit();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const response: OrganizationDomainSsoDetailsResponse =
|
||||
await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email);
|
||||
|
||||
if (response?.ssoAvailable && response?.verifiedDate) {
|
||||
this.identifierFormControl.setValue(response.organizationIdentifier);
|
||||
await this.submit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleGetClaimedDomainByEmailError(error);
|
||||
}
|
||||
|
||||
this.loggingIn = false;
|
||||
}
|
||||
|
||||
// Fallback to state svc if domain is unclaimed
|
||||
const storedIdentifier = await this.ssoLoginService.getOrganizationSsoIdentifier();
|
||||
if (storedIdentifier != null) {
|
||||
this.identifierFormControl.setValue(storedIdentifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -132,7 +132,7 @@
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"electron": "32.1.1",
|
||||
"electron": "33.2.1",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-log": "5.2.4",
|
||||
"electron-reload": "2.0.0-alpha.1",
|
||||
@@ -230,7 +230,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2024.12.0",
|
||||
"version": "2024.12.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
@@ -15745,9 +15745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "32.1.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-32.1.1.tgz",
|
||||
"integrity": "sha512-NlWvG6kXOJbZbELmzP3oV7u50I3NHYbCeh+AkUQ9vGyP7b74cFMx9HdTzejODeztW1jhr3SjIBbUZzZ45zflfQ==",
|
||||
"version": "33.2.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-33.2.1.tgz",
|
||||
"integrity": "sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -15986,9 +15986,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "20.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz",
|
||||
"integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==",
|
||||
"version": "20.17.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.8.tgz",
|
||||
"integrity": "sha512-ahz2g6/oqbKalW9sPv6L2iRbhLnojxjYWspAqhjvqSWBgGebEJT5GvRmk0QXPj3sbC6rU0GTQjPLQkmR8CObvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"electron": "32.1.1",
|
||||
"electron": "33.2.1",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-log": "5.2.4",
|
||||
"electron-reload": "2.0.0-alpha.1",
|
||||
|
||||
Reference in New Issue
Block a user