1
0
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:
Jimmy Vo
2024-12-12 15:41:47 -05:00
64 changed files with 2132 additions and 260 deletions

4
.github/CODEOWNERS vendored
View File

@@ -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

View 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

View File

@@ -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
View 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

View File

@@ -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 ../

View 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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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: {}

View File

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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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"

View File

@@ -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",

View File

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

View File

@@ -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");
});
});
});

View File

@@ -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`;
}
}

View File

@@ -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]),
});

View File

@@ -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]="{

View File

@@ -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;
}

View File

@@ -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",
});

View File

@@ -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,

View File

@@ -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()],

View File

@@ -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,

View File

@@ -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"
}
}
}
}

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -1,2 +1,3 @@
export * from "./member-cipher-details-api.service";
export * from "./password-health.service";
export * from "./risk-insights-report.service";

View File

@@ -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,

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -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>,

View File

@@ -4,6 +4,7 @@
[icon]="pageIcon"
[showReadonlyHostname]="showReadonlyHostname"
[maxWidth]="maxWidth"
[titleAreaMaxWidth]="titleAreaMaxWidth"
>
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>

View File

@@ -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() {

View File

@@ -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>

View File

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

View File

@@ -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>
`,
}),
};

View File

@@ -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";

View 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>
`;

View File

@@ -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";

View File

@@ -0,0 +1,3 @@
import { SsoComponentService } from "./sso-component.service";
export class DefaultSsoComponentService implements SsoComponentService {}

View 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>;
}

View 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>

View 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
View File

@@ -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": {

View File

@@ -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",