diff --git a/.github/workflows/auto-branch-updater.yml b/.github/workflows/auto-branch-updater.yml index 90376c99560..e2f181680d9 100644 --- a/.github/workflows/auto-branch-updater.yml +++ b/.github/workflows/auto-branch-updater.yml @@ -29,7 +29,7 @@ jobs: run: echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: 'eu-web-${{ steps.setup.outputs.branch }}' fetch-depth: 0 diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index a8660bad182..22c1680a41d 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -43,7 +43,7 @@ jobs: node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Get Package Version id: gen_vars @@ -73,7 +73,7 @@ jobs: working-directory: apps/browser steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Testing locales - extName length run: | @@ -111,10 +111,10 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -173,63 +173,63 @@ jobs: working-directory: browser-source/apps/browser - name: Upload Opera artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: dist-opera-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/dist-opera.zip if-no-files-found: error - name: Upload Opera MV3 artifact (DO NOT USE FOR PROD) - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: DO-NOT-USE-FOR-PROD-dist-opera-MV3-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/dist-opera-mv3.zip if-no-files-found: error - name: Upload Chrome MV3 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: dist-chrome-MV3-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/dist-chrome-mv3.zip if-no-files-found: error - name: Upload Chrome MV3 Beta artifact (DO NOT USE FOR PROD) - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: DO-NOT-USE-FOR-PROD-dist-chrome-MV3-beta-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/dist-chrome-mv3-beta.zip if-no-files-found: error - name: Upload Firefox artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: dist-firefox-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/dist-firefox.zip if-no-files-found: error - name: Upload Firefox MV3 artifact (DO NOT USE FOR PROD) - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: DO-NOT-USE-FOR-PROD-dist-firefox-MV3-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/dist-firefox-mv3.zip if-no-files-found: error - name: Upload Edge artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: dist-edge-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/dist-edge.zip if-no-files-found: error - name: Upload Edge MV3 artifact (DO NOT USE FOR PROD) - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: DO-NOT-USE-FOR-PROD-dist-edge-MV3-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/dist-edge-mv3.zip if-no-files-found: error - name: Upload browser source - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: browser-source-${{ env._BUILD_NUMBER }}.zip path: browser-source.zip @@ -237,7 +237,7 @@ jobs: - name: Upload coverage artifact if: false - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: coverage-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/coverage/coverage-${{ env._BUILD_NUMBER }}.zip @@ -254,10 +254,10 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -352,7 +352,7 @@ jobs: ls -la - name: Upload Safari artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: dist-safari-${{ env._BUILD_NUMBER }}.zip path: apps/browser/dist/dist-safari.zip @@ -367,7 +367,7 @@ jobs: - build-safari steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -423,7 +423,7 @@ jobs: secrets: "devops-alerts-slack-webhook-url" - name: Notify Slack on failure - uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0 + uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() env: SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 1f1b9936bf6..fd864cf99a5 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -43,7 +43,7 @@ jobs: node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Get Package Version id: retrieve-package-version @@ -84,7 +84,7 @@ jobs: _WIN_PKG_VERSION: 3.5 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Unix Vars run: | @@ -93,7 +93,7 @@ jobs: awk '{print tolower($0)}')" >> $GITHUB_ENV - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -130,14 +130,14 @@ jobs: matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}-sha256-${{ env._PACKAGE_VERSION }}.txt - name: Upload unix zip asset - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}-${{ env._PACKAGE_VERSION }}.zip if-no-files-found: error - name: Upload unix checksum asset - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}-sha256-${{ env._PACKAGE_VERSION }}.txt path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}-sha256-${{ env._PACKAGE_VERSION }}.txt @@ -162,7 +162,7 @@ jobs: _WIN_PKG_VERSION: 3.5 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Windows builder run: | @@ -171,7 +171,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -269,14 +269,14 @@ jobs: -t sha256 | Out-File -Encoding ASCII ./dist/bw${{ matrix.license_type.artifact_prefix }}-windows-sha256-${env:_PACKAGE_VERSION}.txt - name: Upload windows zip asset - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip if-no-files-found: error - name: Upload windows checksum asset - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bw${{ matrix.license_type.artifact_prefix }}-windows-sha256-${{ env._PACKAGE_VERSION }}.txt path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-windows-sha256-${{ env._PACKAGE_VERSION }}.txt @@ -284,18 +284,21 @@ jobs: - name: Upload Chocolatey asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg path: apps/cli/dist/chocolatey/bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg if-no-files-found: error + + - name: Zip NPM Build Artifact + run: Get-ChildItem -Path .\build | Compress-Archive -DestinationPath .\bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip - name: Upload NPM Build Directory asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip - path: apps/cli/build + path: apps/cli/bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip if-no-files-found: error snap: @@ -309,7 +312,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Print environment run: | @@ -319,7 +322,7 @@ jobs: echo "BW Package Version: $_PACKAGE_VERSION" - name: Get bw linux cli - uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: bw-linux-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/snap @@ -332,7 +335,7 @@ jobs: ls -alth - name: Build snap - uses: snapcore/action-build@2096990827aa966f773676c8a53793c723b6b40f # v1.2.0 + uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1.3.0 with: path: apps/cli/dist/snap @@ -361,14 +364,14 @@ jobs: run: sudo snap remove bw - name: Upload snap asset - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bw_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/cli/dist/snap/bw_${{ env._PACKAGE_VERSION }}_amd64.snap if-no-files-found: error - name: Upload snap checksum asset - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bw-snap-sha256-${{ env._PACKAGE_VERSION }}.txt path: apps/cli/dist/snap/bw-snap-sha256-${{ env._PACKAGE_VERSION }}.txt @@ -405,7 +408,7 @@ jobs: secrets: "devops-alerts-slack-webhook-url" - name: Notify Slack on failure - uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0 + uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() env: SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c933ea304c6..54560cf15dd 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -38,7 +38,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Verify run: | @@ -67,7 +67,7 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Get Package Version id: retrieve-version @@ -140,10 +140,10 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -169,7 +169,7 @@ jobs: working-directory: ./ - name: Cache Native Module - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 id: cache with: path: | @@ -193,42 +193,42 @@ jobs: run: npm run dist:lin - name: Upload .deb artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb if-no-files-found: error - name: Upload .rpm artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm if-no-files-found: error - name: Upload .freebsd artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd if-no-files-found: error - name: Upload .snap artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap if-no-files-found: error - name: Upload .AppImage artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ needs.setup.outputs.release_channel }}-linux.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml @@ -249,10 +249,10 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -298,7 +298,7 @@ jobs: working-directory: ./ - name: Cache Native Module - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 id: cache with: path: apps/desktop/desktop_native/napi/*.node @@ -351,91 +351,91 @@ jobs: -NewName bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - name: Upload portable exe artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe if-no-files-found: error - name: Upload installer exe artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe if-no-files-found: error - name: Upload appx ia32 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx if-no-files-found: error - name: Upload store appx ia32 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx if-no-files-found: error - name: Upload NSIS ia32 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z if-no-files-found: error - name: Upload appx x64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx if-no-files-found: error - name: Upload store appx x64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx if-no-files-found: error - name: Upload NSIS x64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z if-no-files-found: error - name: Upload appx ARM64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx if-no-files-found: error - name: Upload store appx ARM64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx if-no-files-found: error - name: Upload NSIS ARM64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z if-no-files-found: error - name: Upload nupkg artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ needs.setup.outputs.release_channel }}.yml path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml @@ -455,10 +455,10 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -481,14 +481,14 @@ jobs: - name: Cache Build id: build-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Cache Safari id: safari-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -581,7 +581,7 @@ jobs: working-directory: ./ - name: Cache Native Module - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 id: cache with: path: apps/desktop/desktop_native/napi/*.node @@ -619,10 +619,10 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -645,14 +645,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -745,7 +745,7 @@ jobs: working-directory: ./ - name: Cache Native Module - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 id: cache with: path: apps/desktop/desktop_native/napi/*.node @@ -761,7 +761,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -792,28 +792,28 @@ jobs: run: npm run pack:mac - name: Upload .zip artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip if-no-files-found: error - name: Upload .dmg artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg if-no-files-found: error - name: Upload .dmg blockmap artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ needs.setup.outputs.release_channel }}-mac.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml @@ -836,10 +836,10 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -862,14 +862,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -962,7 +962,7 @@ jobs: working-directory: ./ - name: Cache Native Module - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 id: cache with: path: apps/desktop/desktop_native/napi/*.node @@ -978,7 +978,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1009,7 +1009,7 @@ jobs: run: npm run pack:mac:mas - name: Upload .pkg artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg @@ -1044,10 +1044,10 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1065,14 +1065,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1165,7 +1165,7 @@ jobs: working-directory: ./ - name: Cache Native Module - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 id: cache with: path: apps/desktop/desktop_native/napi/*.node @@ -1181,7 +1181,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1215,7 +1215,7 @@ jobs: zip -r Bitwarden-${{ env._PACKAGE_VERSION }}-masdev-universal.zip Bitwarden.app - name: Upload masdev artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-masdev-universal.zip path: apps/desktop/dist/mas-dev-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-masdev-universal.zip @@ -1233,7 +1233,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -1294,7 +1294,7 @@ jobs: secrets: "devops-alerts-slack-webhook-url" - name: Notify Slack on failure - uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0 + uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() env: SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 46e65e8924b..6e0f6a3eb89 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -45,7 +45,7 @@ jobs: node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Get GitHub sha as version id: version @@ -91,10 +91,10 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -130,7 +130,7 @@ jobs: run: zip -r web-${{ env._VERSION }}-${{ matrix.name }}.zip build - name: Upload ${{ matrix.name }} artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: web-${{ env._VERSION }}-${{ matrix.name }}.zip path: apps/web/web-${{ env._VERSION }}-${{ matrix.name }}.zip @@ -157,7 +157,7 @@ jobs: _VERSION: ${{ needs.setup.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Check Branch to Publish env: @@ -194,7 +194,7 @@ jobs: secrets: "github-pat-bitwarden-devops-bot-repo-scope" - name: Download ${{ matrix.artifact_name }} artifact - uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip path: apps/web @@ -255,7 +255,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -345,7 +345,7 @@ jobs: secrets: "devops-alerts-slack-webhook-url" - name: Notify Slack on failure - uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0 + uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() env: SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 6a02c2d1245..c8dd3e77838 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -38,13 +38,13 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} - name: Cache NPM id: npm-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: "~/.npm" key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }} diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 1f5df5a66c0..527dedb5a86 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -23,7 +23,7 @@ jobs: crowdin_project_id: "308189" steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2d881a4c304..bb495a5a26d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Lint filenames (no capital characters) run: | @@ -48,7 +48,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 09b6b53e584..6581e260900 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -92,7 +92,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -129,7 +129,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -169,7 +169,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -183,8 +183,11 @@ jobs: keyvault: "bitwarden-ci" secrets: "npm-api-key" - - name: Download artifacts - run: wget https://github.com/bitwarden/clients/releases/download/cli-v${{ env._PKG_VERSION }}/bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip + - name: Download and set up artifact + run: | + mkdir -p build + wget https://github.com/bitwarden/clients/releases/download/cli-v${{ env._PKG_VERSION }}/bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip + unzip bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip -d build - name: Setup NPM run: | diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index c03697fc802..d12072c7e6d 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -184,7 +184,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag-name }} steps: - name: Checkout Repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -228,7 +228,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag-name }} steps: - name: Checkout Repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Print Environment run: | diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index b7ea8498593..4409da93560 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -27,7 +27,7 @@ jobs: tag_version: ${{ steps.version.outputs.tag }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Branch check if: ${{ inputs.publish_type != 'Dry Run' }} @@ -67,7 +67,7 @@ jobs: echo "Github Release Option: $_RELEASE_OPTION" - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 ########## ACR ########## - name: Login to Azure - PROD Subscription diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index 3feaff8cede..2811b23af9b 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -27,7 +27,7 @@ jobs: release-version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -56,7 +56,7 @@ jobs: needs: setup steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Testing locales - extName length run: | diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index ddcdb4e904f..cd450b2cd79 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -27,7 +27,7 @@ jobs: release-version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Branch check if: ${{ inputs.release_type != 'Dry Run' }} diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 74db61563e1..3f8bc45d51d 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -24,7 +24,7 @@ jobs: node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Branch check run: | @@ -125,12 +125,12 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ needs.setup.outputs.branch-name }} - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -159,42 +159,42 @@ jobs: run: npm run dist:lin - name: Upload .deb artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb if-no-files-found: error - name: Upload .rpm artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm if-no-files-found: error - name: Upload .freebsd artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd if-no-files-found: error - name: Upload .snap artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap if-no-files-found: error - name: Upload .AppImage artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ needs.setup.outputs.release-channel }}-linux.yml path: apps/desktop/dist/${{ needs.setup.outputs.release-channel }}-linux.yml @@ -215,12 +215,12 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ needs.setup.outputs.branch-name }} - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -300,91 +300,91 @@ jobs: -NewName bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - name: Upload portable exe artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe if-no-files-found: error - name: Upload installer exe artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe if-no-files-found: error - name: Upload appx ia32 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx if-no-files-found: error - name: Upload store appx ia32 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx if-no-files-found: error - name: Upload NSIS ia32 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z if-no-files-found: error - name: Upload appx x64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx if-no-files-found: error - name: Upload store appx x64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx if-no-files-found: error - name: Upload NSIS x64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z if-no-files-found: error - name: Upload appx ARM64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx if-no-files-found: error - name: Upload store appx ARM64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx if-no-files-found: error - name: Upload NSIS ARM64 artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z if-no-files-found: error - name: Upload nupkg artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ needs.setup.outputs.release-channel }}.yml path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release-channel }}.yml @@ -404,12 +404,12 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ needs.setup.outputs.branch-name }} - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -427,14 +427,14 @@ jobs: - name: Cache Build id: build-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Cache Safari id: safari-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -538,12 +538,12 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ needs.setup.outputs.branch-name }} - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -561,14 +561,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -708,28 +708,28 @@ jobs: run: npm run pack:mac - name: Upload .zip artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip if-no-files-found: error - name: Upload .dmg artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg if-no-files-found: error - name: Upload .dmg blockmap artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ needs.setup.outputs.release-channel }}-mac.yml path: apps/desktop/dist/${{ needs.setup.outputs.release-channel }}-mac.yml @@ -751,12 +751,12 @@ jobs: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ needs.setup.outputs.branch-name }} - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -774,14 +774,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -916,7 +916,7 @@ jobs: APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - name: Upload .pkg artifact - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg @@ -958,7 +958,7 @@ jobs: aws-electron-bucket-name" - name: Download all artifacts - uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: path: apps/desktop/artifacts @@ -1011,7 +1011,7 @@ jobs: - release steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup git config run: | diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 2fe7cb2b7a4..5b75460ef92 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -27,7 +27,7 @@ jobs: release-channel: ${{ steps.release-channel.outputs.channel }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index 596341459cd..982e3867585 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -24,7 +24,7 @@ jobs: tag_version: ${{ steps.version.outputs.tag }} steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 212795d3a2b..d90e009bf36 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -27,12 +27,12 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ github.event.pull_request.head.sha }} - name: Scan with Checkmarx - uses: checkmarx/ast-github-action@749fec53e0db0f6404a97e2e0807c3e80e3583a7 # v2.0.23 + uses: checkmarx/ast-github-action@1fe318de2993222574e6249750ba9000a4e2a6cd # 2.0.33 env: INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" with: @@ -47,7 +47,7 @@ jobs: --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub - uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 + uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5 with: sarif_file: cx_result.sarif @@ -61,13 +61,13 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} - name: Scan with SonarCloud - uses: sonarsource/sonarcloud-github-action@49e6cd3b187936a73b8280d59ffd9da69df63ec9 # v2.1.1 + uses: sonarsource/sonarcloud-github-action@e44258b109568baa0df60ed515909fc6c72cba92 # v2.3.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52928e9a040..5b4cd52ac8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Get Node Version id: retrieve-node-version @@ -51,7 +51,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -86,7 +86,7 @@ jobs: fail-on-error: true - name: Upload coverage to codecov.io - uses: codecov/codecov-action@54bcd8715eee62d40e33596ef5e8f0f48dbbccab # v4.1.0 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 if: ${{ needs.check-test-secrets.outputs.available == 'true' }} env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -121,7 +121,7 @@ jobs: sudo apt-get install -y gnome-keyring dbus-x11 - name: Check out repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Build working-directory: ./apps/desktop/desktop_native diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 4bf502da21c..fc30996e850 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -58,7 +58,7 @@ jobs: fi - name: Checkout Branch - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: main @@ -526,7 +526,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout Branch - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: main diff --git a/apps/browser/package.json b/apps/browser/package.json index 07fb9deb26f..db743b509bf 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.8.0", + "version": "2024.8.1", "scripts": { "build": "cross-env MANIFEST_VERSION=3 webpack", "build:mv2": "webpack", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 1e8fd03ade3..cd2cc912930 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1113,7 +1113,7 @@ "message": "Thank you for supporting Bitwarden." }, "premiumFeatures": { - "message": "Upgrade to premium and receive:" + "message": "Upgrade to Premium and receive:" }, "premiumPrice": { "message": "All for just $PRICE$ /year!", @@ -2083,6 +2083,12 @@ "biometricsNotUnlockedDesc": { "message": "Please unlock this user in the desktop application and try again." }, + "biometricsNotAvailableTitle": { + "message": "Biometric unlock unavailable" + }, + "biometricsNotAvailableDesc": { + "message": "Biometric unlock is currently unavailable. Please try again later." + }, "biometricsFailedTitle": { "message": "Biometrics failed" }, diff --git a/apps/browser/src/auth/popup/lock.component.html b/apps/browser/src/auth/popup/lock.component.html index 5ea839470be..ccc743d86d4 100644 --- a/apps/browser/src/auth/popup/lock.component.html +++ b/apps/browser/src/auth/popup/lock.component.html @@ -89,7 +89,7 @@

- {{ biometricError }} + {{ biometricError }}

{{ "awaitDesktop" | i18n }}

diff --git a/apps/browser/src/auth/popup/lock.component.ts b/apps/browser/src/auth/popup/lock.component.ts index 2819d6a21fe..a6da98fe996 100644 --- a/apps/browser/src/auth/popup/lock.component.ts +++ b/apps/browser/src/auth/popup/lock.component.ts @@ -24,6 +24,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService } from "@bitwarden/components"; @@ -67,6 +68,7 @@ export class LockComponent extends BaseLockComponent implements OnInit { pinService: PinServiceAbstraction, private routerService: BrowserRouterService, biometricStateService: BiometricStateService, + biometricsService: BiometricsService, accountService: AccountService, kdfConfigService: KdfConfigService, syncService: SyncService, @@ -93,6 +95,7 @@ export class LockComponent extends BaseLockComponent implements OnInit { userVerificationService, pinService, biometricStateService, + biometricsService, accountService, authService, kdfConfigService, @@ -129,22 +132,35 @@ export class LockComponent extends BaseLockComponent implements OnInit { this.isInitialLockScreen && (await this.authService.getAuthStatus()) === AuthenticationStatus.Locked ) { - await this.unlockBiometric(); + await this.unlockBiometric(true); } }, 100); } - override async unlockBiometric(): Promise { + override async unlockBiometric(automaticPrompt: boolean = false): Promise { if (!this.biometricLock) { return; } - this.pendingBiometric = true; this.biometricError = null; let success; try { - success = await super.unlockBiometric(); + const available = await super.isBiometricUnlockAvailable(); + if (!available) { + if (!automaticPrompt) { + await this.dialogService.openSimpleDialog({ + type: "warning", + title: { key: "biometricsNotAvailableTitle" }, + content: { key: "biometricsNotAvailableDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + }); + } + } else { + this.pendingBiometric = true; + success = await super.unlockBiometric(); + } } catch (e) { const error = BiometricErrors[e?.message as BiometricErrorTypes]; diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 076c03801aa..7bced79a0a8 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -33,6 +33,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { VaultTimeout, VaultTimeoutOption, @@ -94,6 +95,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { private dialogService: DialogService, private changeDetectorRef: ChangeDetectorRef, private biometricStateService: BiometricStateService, + private biometricsService: BiometricsService, ) { this.accountSwitcherEnabled = enableAccountSwitching(); } @@ -165,7 +167,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { }; this.form.patchValue(initialValues, { emitEvent: false }); - this.supportsBiometric = await this.platformUtilsService.supportsBiometric(); + this.supportsBiometric = await this.biometricsService.supportsBiometric(); this.showChangeMasterPass = await this.userVerificationService.hasMasterPassword(); this.form.controls.vaultTimeout.valueChanges @@ -405,7 +407,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { const biometricsPromise = async () => { try { - const result = await this.platformUtilsService.authenticateBiometric(); + const result = await this.biometricsService.authenticateBiometric(); // prevent duplicate dialog biometricsResponseReceived = true; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4081e0e0cd9..3944f2d8afc 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -97,6 +97,7 @@ import { BiometricStateService, DefaultBiometricStateService, } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency creation @@ -228,6 +229,7 @@ import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender import { OffscreenDocumentService } from "../platform/offscreen-document/abstractions/offscreen-document"; import { DefaultOffscreenDocumentService } from "../platform/offscreen-document/offscreen-document.service"; import { BrowserTaskSchedulerService } from "../platform/services/abstractions/browser-task-scheduler.service"; +import { BackgroundBrowserBiometricsService } from "../platform/services/background-browser-biometrics.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; @@ -343,6 +345,7 @@ export default class MainBackground { organizationVaultExportService: OrganizationVaultExportServiceAbstraction; vaultSettingsService: VaultSettingsServiceAbstraction; biometricStateService: BiometricStateService; + biometricsService: BiometricsService; stateEventRunnerService: StateEventRunnerService; ssoLoginService: SsoLoginServiceAbstraction; billingAccountProfileStateService: BillingAccountProfileStateService; @@ -429,7 +432,6 @@ export default class MainBackground { this.platformUtilsService = new BackgroundPlatformUtilsService( this.messagingService, (clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs), - async () => this.biometricUnlock(), self, this.offscreenDocumentService, ); @@ -577,6 +579,7 @@ export default class MainBackground { ); this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService( + messageListener, this.globalStateProvider, ); @@ -610,6 +613,8 @@ export default class MainBackground { this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); + this.biometricsService = new BackgroundBrowserBiometricsService(this.nativeMessagingBackground); + this.kdfConfigService = new KdfConfigService(this.stateProvider); this.pinService = new PinService( @@ -636,6 +641,7 @@ export default class MainBackground { this.accountService, this.stateProvider, this.biometricStateService, + this.biometricsService, this.kdfConfigService, ); @@ -1507,17 +1513,6 @@ export default class MainBackground { } } - async biometricUnlock(): Promise { - if (this.nativeMessagingBackground == null) { - return false; - } - - const responsePromise = this.nativeMessagingBackground.getResponse(); - await this.nativeMessagingBackground.send({ command: "biometricUnlock" }); - const response = await responsePromise; - return response.response === "unlocked"; - } - private async fullSync(override = false) { const syncInternal = 6 * 60 * 60 * 1000; // 6 hours const lastSync = await this.syncService.getLastSync(); diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 777af9538b0..613fe777efb 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -285,7 +285,9 @@ export class NativeMessagingBackground { switch (message.command) { case "biometricUnlock": { if ( - ["not enabled", "not supported", "not unlocked", "canceled"].includes(message.response) + ["not available", "not enabled", "not supported", "not unlocked", "canceled"].includes( + message.response, + ) ) { this.rejecter(message.response); return; @@ -352,6 +354,10 @@ export class NativeMessagingBackground { } break; } + case "biometricUnlockAvailable": { + this.resolver(message); + break; + } default: this.logService.error("NativeMessage, got unknown command: " + message.command); break; diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index b667936581f..44e395659b3 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -68,6 +68,7 @@ export default class RuntimeBackground { ) => { const messagesWithResponse = [ "biometricUnlock", + "biometricUnlockAvailable", "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag", "getInlineMenuFieldQualificationFeatureFlag", ]; @@ -179,7 +180,11 @@ export default class RuntimeBackground { } break; case "biometricUnlock": { - const result = await this.main.biometricUnlock(); + const result = await this.main.biometricsService.authenticateBiometric(); + return result; + } + case "biometricUnlockAvailable": { + const result = await this.main.biometricsService.isBiometricUnlockAvailable(); return result; } case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": { diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.html b/apps/browser/src/billing/popup/settings/premium-v2.component.html index 7b781eafdb4..f578de8ae7a 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.html +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.html @@ -10,7 +10,7 @@
-
    +
    • {{ "ppremiumSignUpStorage" | i18n }}
    • diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 84d17eb2345..b13a98e7a46 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.8.0", + "version": "2024.8.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index c6de26b8192..c3420e1c6a5 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.8.0", + "version": "2024.8.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/models/biometricErrors.ts b/apps/browser/src/models/biometricErrors.ts index 570c776f563..42d9c679d34 100644 --- a/apps/browser/src/models/biometricErrors.ts +++ b/apps/browser/src/models/biometricErrors.ts @@ -11,7 +11,8 @@ export type BiometricErrorTypes = | "not unlocked" | "invalidateEncryption" | "userkey wrong" - | "wrongUserId"; + | "wrongUserId" + | "not available"; export const BiometricErrors: Record = { startDesktop: { @@ -46,4 +47,8 @@ export const BiometricErrors: Record = { title: "biometricsWrongUserTitle", description: "biometricsWrongUserDesc", }, + "not available": { + title: "biometricsNotAvailableTitle", + description: "biometricsNotAvailableDesc", + }, }; diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts new file mode 100644 index 00000000000..819a2aa3e46 --- /dev/null +++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts @@ -0,0 +1,136 @@ +import { + DestroyRef, + effect, + inject, + Injectable, + Injector, + signal, + WritableSignal, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormGroup } from "@angular/forms"; +import { NavigationEnd, Router } from "@angular/router"; +import { filter, firstValueFrom, skip } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { + FormCacheOptions, + SignalCacheOptions, + ViewCacheService, +} from "@bitwarden/angular/platform/abstractions/view-cache.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { MessageSender } from "@bitwarden/common/platform/messaging"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; + +import { + ClEAR_VIEW_CACHE_COMMAND, + POPUP_VIEW_CACHE_KEY, + SAVE_VIEW_CACHE_COMMAND, +} from "../../services/popup-view-cache-background.service"; + +/** + * Popup implementation of {@link ViewCacheService}. + * + * Persists user changes between popup open and close + */ +@Injectable({ + providedIn: "root", +}) +export class PopupViewCacheService implements ViewCacheService { + private configService = inject(ConfigService); + private globalStateProvider = inject(GlobalStateProvider); + private messageSender = inject(MessageSender); + private router = inject(Router); + + private featureEnabled: boolean; + + private _cache: Record; + private get cache(): Record { + if (!this._cache) { + throw new Error("Dirty View Cache not initialized"); + } + return this._cache; + } + + /** + * Initialize the service. This should only be called once. + */ + async init() { + this.featureEnabled = await this.configService.getFeatureFlag(FeatureFlag.PersistPopupView); + const initialState = this.featureEnabled + ? await firstValueFrom(this.globalStateProvider.get(POPUP_VIEW_CACHE_KEY).state$) + : {}; + this._cache = Object.freeze(initialState ?? {}); + + this.router.events + .pipe( + filter((e) => e instanceof NavigationEnd), + /** Skip the first navigation triggered by `popupRouterCacheGuard` */ + skip(1), + ) + .subscribe(() => this.clearState()); + } + + /** + * @see {@link ViewCacheService.signal} + */ + signal(options: SignalCacheOptions): WritableSignal { + const { + deserializer = (v: Jsonify): T => v as T, + key, + injector = inject(Injector), + initialValue, + } = options; + const cachedValue = this.cache[key] ? deserializer(JSON.parse(this.cache[key])) : initialValue; + const _signal = signal(cachedValue); + + effect( + () => { + this.updateState(key, JSON.stringify(_signal())); + }, + { injector }, + ); + + return _signal; + } + + /** + * @see {@link ViewCacheService.formGroup} + */ + formGroup(options: FormCacheOptions): TFormGroup { + const { control, injector } = options; + + const _signal = this.signal({ + ...options, + initialValue: control.getRawValue(), + }); + + const value = _signal(); + if (value !== undefined && JSON.stringify(value) !== JSON.stringify(control.getRawValue())) { + control.setValue(value); + control.markAsDirty(); + } + + control.valueChanges.pipe(takeUntilDestroyed(injector?.get(DestroyRef))).subscribe(() => { + _signal.set(control.getRawValue()); + }); + + return control; + } + + private updateState(key: string, value: string) { + if (!this.featureEnabled) { + return; + } + + this.messageSender.send(SAVE_VIEW_CACHE_COMMAND, { + key, + value, + }); + } + + private clearState() { + this.messageSender.send(ClEAR_VIEW_CACHE_COMMAND, {}); + } +} diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts new file mode 100644 index 00000000000..fbe94bece8c --- /dev/null +++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts @@ -0,0 +1,224 @@ +import { Component, inject, Injector } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { FormControl, FormGroup } from "@angular/forms"; +import { Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockProxy, mock } from "jest-mock-extended"; + +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { MessageSender } from "@bitwarden/common/platform/messaging"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; +import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/common/spec"; + +import { + ClEAR_VIEW_CACHE_COMMAND, + POPUP_VIEW_CACHE_KEY, + SAVE_VIEW_CACHE_COMMAND, +} from "../../services/popup-view-cache-background.service"; + +import { PopupViewCacheService } from "./popup-view-cache.service"; + +@Component({ template: "" }) +export class EmptyComponent {} + +@Component({ template: "" }) +export class TestComponent { + private viewCacheService = inject(PopupViewCacheService); + + formGroup = this.viewCacheService.formGroup({ + key: "test-form-cache", + control: new FormGroup({ + name: new FormControl("initial name"), + }), + }); + + signal = this.viewCacheService.signal({ + key: "test-signal", + initialValue: "initial signal", + }); +} + +describe("popup view cache", () => { + const configServiceMock = mock(); + let testBed: TestBed; + let service: PopupViewCacheService; + let fakeGlobalState: FakeGlobalState>; + let messageSenderMock: MockProxy; + let router: Router; + + const initServiceWithState = async (state: Record) => { + await fakeGlobalState.update(() => state); + await service.init(); + }; + + beforeEach(async () => { + jest.spyOn(configServiceMock, "getFeatureFlag").mockResolvedValue(true); + messageSenderMock = mock(); + + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + fakeGlobalState = fakeGlobalStateProvider.getFake(POPUP_VIEW_CACHE_KEY); + + testBed = TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { path: "a", component: EmptyComponent }, + { path: "b", component: EmptyComponent }, + ]), + ], + providers: [ + { provide: GlobalStateProvider, useValue: fakeGlobalStateProvider }, + { provide: MessageSender, useValue: messageSenderMock }, + { provide: ConfigService, useValue: configServiceMock }, + ], + }); + + await testBed.compileComponents(); + + router = testBed.inject(Router); + service = testBed.inject(PopupViewCacheService); + }); + + it("should initialize signal when ran within an injection context", async () => { + await initServiceWithState({}); + + const signal = TestBed.runInInjectionContext(() => + service.signal({ + key: "foo-123", + initialValue: "foo", + }), + ); + + expect(signal()).toBe("foo"); + }); + + it("should initialize signal when provided an injector", async () => { + await initServiceWithState({}); + + const injector = TestBed.inject(Injector); + + const signal = service.signal({ + key: "foo-123", + initialValue: "foo", + injector, + }); + + expect(signal()).toBe("foo"); + }); + + it("should initialize signal from state", async () => { + await initServiceWithState({ "foo-123": JSON.stringify("bar") }); + + const injector = TestBed.inject(Injector); + + const signal = service.signal({ + key: "foo-123", + initialValue: "foo", + injector, + }); + + expect(signal()).toBe("bar"); + }); + + it("should initialize form from state", async () => { + await initServiceWithState({ "test-form-cache": JSON.stringify({ name: "baz" }) }); + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentRef.instance; + expect(component.formGroup.value.name).toBe("baz"); + expect(component.formGroup.dirty).toBe(true); + }); + + it("should not modify form when empty", async () => { + await initServiceWithState({}); + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentRef.instance; + expect(component.formGroup.value.name).toBe("initial name"); + expect(component.formGroup.dirty).toBe(false); + }); + + it("should utilize deserializer", async () => { + await initServiceWithState({ "foo-123": JSON.stringify("bar") }); + + const injector = TestBed.inject(Injector); + + const signal = service.signal({ + key: "foo-123", + initialValue: "foo", + injector, + deserializer: (jsonValue) => "test", + }); + + expect(signal()).toBe("test"); + }); + + it("should not utilize deserializer when empty", async () => { + await initServiceWithState({}); + + const injector = TestBed.inject(Injector); + + const signal = service.signal({ + key: "foo-123", + initialValue: "foo", + injector, + deserializer: (jsonValue) => "test", + }); + + expect(signal()).toBe("foo"); + }); + + it("should send signal updates to message sender", async () => { + await initServiceWithState({}); + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentRef.instance; + component.signal.set("Foobar"); + fixture.detectChanges(); + + expect(messageSenderMock.send).toHaveBeenCalledWith(SAVE_VIEW_CACHE_COMMAND, { + key: "test-signal", + value: JSON.stringify("Foobar"), + }); + }); + + it("should send form updates to message sender", async () => { + await initServiceWithState({}); + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentRef.instance; + component.formGroup.controls.name.setValue("Foobar"); + fixture.detectChanges(); + + expect(messageSenderMock.send).toHaveBeenCalledWith(SAVE_VIEW_CACHE_COMMAND, { + key: "test-form-cache", + value: JSON.stringify({ name: "Foobar" }), + }); + }); + + it("should clear on 2nd navigation", async () => { + await initServiceWithState({}); + + await router.navigate(["a"]); + expect(messageSenderMock.send).toHaveBeenCalledTimes(0); + + await router.navigate(["b"]); + expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, {}); + }); + + it("should ignore cached values when feature flag is off", async () => { + jest.spyOn(configServiceMock, "getFeatureFlag").mockResolvedValue(false); + + await initServiceWithState({ "foo-123": JSON.stringify("bar") }); + + const injector = TestBed.inject(Injector); + + const signal = service.signal({ + key: "foo-123", + initialValue: "foo", + injector, + }); + + // The cached state is ignored + expect(signal()).toBe("foo"); + }); +}); diff --git a/apps/browser/src/platform/services/background-browser-biometrics.service.ts b/apps/browser/src/platform/services/background-browser-biometrics.service.ts new file mode 100644 index 00000000000..41ae15972cd --- /dev/null +++ b/apps/browser/src/platform/services/background-browser-biometrics.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@angular/core"; + +import { NativeMessagingBackground } from "../../background/nativeMessaging.background"; + +import { BrowserBiometricsService } from "./browser-biometrics.service"; + +@Injectable() +export class BackgroundBrowserBiometricsService extends BrowserBiometricsService { + constructor(private nativeMessagingBackground: NativeMessagingBackground) { + super(); + } + + async authenticateBiometric(): Promise { + const responsePromise = this.nativeMessagingBackground.getResponse(); + await this.nativeMessagingBackground.send({ command: "biometricUnlock" }); + const response = await responsePromise; + return response.response === "unlocked"; + } + + async isBiometricUnlockAvailable(): Promise { + const responsePromise = this.nativeMessagingBackground.getResponse(); + await this.nativeMessagingBackground.send({ command: "biometricUnlockAvailable" }); + const response = await responsePromise; + return response.response === "available"; + } + + async biometricsNeedsSetup(): Promise { + return false; + } + + async biometricsSupportsAutoSetup(): Promise { + return false; + } + + async biometricsSetup(): Promise {} +} diff --git a/apps/browser/src/platform/services/browser-biometrics.service.ts b/apps/browser/src/platform/services/browser-biometrics.service.ts new file mode 100644 index 00000000000..84734fb4927 --- /dev/null +++ b/apps/browser/src/platform/services/browser-biometrics.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@angular/core"; + +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; + +import { BrowserApi } from "../browser/browser-api"; + +@Injectable() +export abstract class BrowserBiometricsService extends BiometricsService { + async supportsBiometric() { + const platformInfo = await BrowserApi.getPlatformInfo(); + if (platformInfo.os === "mac" || platformInfo.os === "win" || platformInfo.os === "linux") { + return true; + } + return false; + } + + abstract authenticateBiometric(): Promise; + abstract isBiometricUnlockAvailable(): Promise; +} diff --git a/apps/browser/src/platform/services/browser-crypto.service.ts b/apps/browser/src/platform/services/browser-crypto.service.ts index 1242d520213..1d61fb4c8ed 100644 --- a/apps/browser/src/platform/services/browser-crypto.service.ts +++ b/apps/browser/src/platform/services/browser-crypto.service.ts @@ -11,6 +11,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; import { USER_KEY } from "@bitwarden/common/platform/services/key-state/user-key.state"; @@ -31,6 +32,7 @@ export class BrowserCryptoService extends CryptoService { accountService: AccountService, stateProvider: StateProvider, private biometricStateService: BiometricStateService, + private biometricsService: BiometricsService, kdfConfigService: KdfConfigService, ) { super( @@ -68,7 +70,7 @@ export class BrowserCryptoService extends CryptoService { userId?: UserId, ): Promise { if (keySuffix === KeySuffixOptions.Biometric) { - const biometricsResult = await this.platformUtilService.authenticateBiometric(); + const biometricsResult = await this.biometricsService.authenticateBiometric(); if (!biometricsResult) { return null; diff --git a/apps/browser/src/platform/services/foreground-browser-biometrics.ts b/apps/browser/src/platform/services/foreground-browser-biometrics.ts new file mode 100644 index 00000000000..ee55de20108 --- /dev/null +++ b/apps/browser/src/platform/services/foreground-browser-biometrics.ts @@ -0,0 +1,34 @@ +import { BrowserApi } from "../browser/browser-api"; + +import { BrowserBiometricsService } from "./browser-biometrics.service"; + +export class ForegroundBrowserBiometricsService extends BrowserBiometricsService { + async authenticateBiometric(): Promise { + const response = await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>("biometricUnlock"); + if (!response.result) { + throw response.error; + } + return response.result; + } + + async isBiometricUnlockAvailable(): Promise { + const response = await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>("biometricUnlockAvailable"); + return response.result && response.result === true; + } + + async biometricsNeedsSetup(): Promise { + return false; + } + + async biometricsSupportsAutoSetup(): Promise { + return false; + } + + async biometricsSetup(): Promise {} +} diff --git a/apps/browser/src/platform/services/platform-utils/background-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/background-platform-utils.service.ts index ec26d6aa29b..da6a8faf3e8 100644 --- a/apps/browser/src/platform/services/platform-utils/background-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/background-platform-utils.service.ts @@ -8,11 +8,10 @@ export class BackgroundPlatformUtilsService extends BrowserPlatformUtilsService constructor( private messagingService: MessagingService, clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, - biometricCallback: () => Promise, win: Window & typeof globalThis, offscreenDocumentService: OffscreenDocumentService, ) { - super(clipboardWriteCallback, biometricCallback, win, offscreenDocumentService); + super(clipboardWriteCallback, win, offscreenDocumentService); } override showToast( diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts index c86c9158019..762380071b7 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts @@ -16,7 +16,7 @@ class TestBrowserPlatformUtilsService extends BrowserPlatformUtilsService { win: Window & typeof globalThis, offscreenDocumentService: OffscreenDocumentService, ) { - super(clipboardSpy, null, win, offscreenDocumentService); + super(clipboardSpy, win, offscreenDocumentService); } showToast( diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index 8428a74d430..b47488bdd7d 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -15,7 +15,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic constructor( private clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, - private biometricCallback: () => Promise, private globalContext: Window | ServiceWorkerGlobalScope, private offscreenDocumentService: OffscreenDocumentService, ) {} @@ -276,30 +275,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return await BrowserClipboardService.read(windowContext); } - async supportsBiometric() { - const platformInfo = await BrowserApi.getPlatformInfo(); - if (platformInfo.os === "mac" || platformInfo.os === "win" || platformInfo.os === "linux") { - return true; - } - return false; - } - - async biometricsNeedsSetup(): Promise { - return false; - } - - async biometricsSupportsAutoSetup(): Promise { - return false; - } - - async biometricsSetup(): Promise { - return; - } - - authenticateBiometric() { - return this.biometricCallback(); - } - supportsSecureStorage(): boolean { return false; } diff --git a/apps/browser/src/platform/services/platform-utils/foreground-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/foreground-platform-utils.service.ts index f775f049e78..5b4b7288d19 100644 --- a/apps/browser/src/platform/services/platform-utils/foreground-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/foreground-platform-utils.service.ts @@ -8,11 +8,10 @@ export class ForegroundPlatformUtilsService extends BrowserPlatformUtilsService constructor( private toastService: ToastService, clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, - biometricCallback: () => Promise, win: Window & typeof globalThis, offscreenDocumentService: OffscreenDocumentService, ) { - super(clipboardWriteCallback, biometricCallback, win, offscreenDocumentService); + super(clipboardWriteCallback, win, offscreenDocumentService); } override showToast( diff --git a/apps/browser/src/platform/services/popup-view-cache-background.service.ts b/apps/browser/src/platform/services/popup-view-cache-background.service.ts index 09099b08c8c..c2713f70a16 100644 --- a/apps/browser/src/platform/services/popup-view-cache-background.service.ts +++ b/apps/browser/src/platform/services/popup-view-cache-background.service.ts @@ -1,5 +1,6 @@ -import { switchMap, merge, delay, filter, map } from "rxjs"; +import { switchMap, merge, delay, filter, concatMap, map } from "rxjs"; +import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging"; import { POPUP_VIEW_MEMORY, KeyDefinition, @@ -11,6 +12,15 @@ import { fromChromeEvent } from "../browser/from-chrome-event"; const popupClosedPortName = "new_popup"; +/** We cannot use `UserKeyDefinition` because we must be able to store state when there is no active user. */ +export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record( + POPUP_VIEW_MEMORY, + "popup-view-cache", + { + deserializer: (jsonValue) => jsonValue, + }, +); + export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition( POPUP_VIEW_MEMORY, "popup-route-history", @@ -19,12 +29,35 @@ export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition( }, ); +export const SAVE_VIEW_CACHE_COMMAND = new CommandDefinition<{ + key: string; + value: string; +}>("save-view-cache"); + +export const ClEAR_VIEW_CACHE_COMMAND = new CommandDefinition("clear-view-cache"); + export class PopupViewCacheBackgroundService { + private popupViewCacheState = this.globalStateProvider.get(POPUP_VIEW_CACHE_KEY); private popupRouteHistoryState = this.globalStateProvider.get(POPUP_ROUTE_HISTORY_KEY); - constructor(private globalStateProvider: GlobalStateProvider) {} + constructor( + private messageListener: MessageListener, + private globalStateProvider: GlobalStateProvider, + ) {} startObservingTabChanges() { + this.messageListener + .messages$(SAVE_VIEW_CACHE_COMMAND) + .pipe( + concatMap(async ({ key, value }) => + this.popupViewCacheState.update((state) => ({ + ...state, + [key]: value, + })), + ), + ) + .subscribe(); + merge( // on tab changed, excluding extension tabs fromChromeEvent(chrome.tabs.onActivated).pipe( @@ -45,6 +78,7 @@ export class PopupViewCacheBackgroundService { async clearState() { return Promise.all([ + this.popupViewCacheState.update(() => ({}), { shouldUpdate: this.objNotEmpty }), this.popupRouteHistoryState.update(() => [], { shouldUpdate: this.objNotEmpty }), ]); } diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 27fc868a9bb..65fed721380 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, inject } from "@angular/core"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs"; @@ -21,6 +21,7 @@ import { } from "@bitwarden/components"; import { BrowserApi } from "../platform/browser/browser-api"; +import { PopupViewCacheService } from "../platform/popup/view-cache/popup-view-cache.service"; import { initPopupClosedListener } from "../platform/services/popup-view-cache-background.service"; import { BrowserSendStateService } from "../tools/popup/services/browser-send-state.service"; import { VaultBrowserStateService } from "../vault/services/vault-browser-state.service"; @@ -37,6 +38,8 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
`, }) export class AppComponent implements OnInit, OnDestroy { + private viewCacheService = inject(PopupViewCacheService); + private lastActivity: Date; private activeUserId: UserId; private recordActivitySubject = new Subject(); @@ -64,6 +67,7 @@ export class AppComponent implements OnInit, OnDestroy { async ngOnInit() { initPopupClosedListener(); + await this.viewCacheService.init(); // Component states must not persist between closing and reopening the popup, otherwise they become dead objects // Clear them aggressively to make sure this doesn't occur diff --git a/apps/browser/src/popup/scss/misc.scss b/apps/browser/src/popup/scss/misc.scss index 134bac917d3..57bd3e010c8 100644 --- a/apps/browser/src/popup/scss/misc.scss +++ b/apps/browser/src/popup/scss/misc.scss @@ -287,102 +287,6 @@ app-vault-icon, cursor: move; } -.callout { - padding: 10px; - margin: 10px; - border: 1px solid #000000; - border-left-width: 5px; - border-radius: 3px; - @include themify($themes) { - border-color: themed("calloutBorderColor"); - background-color: themed("calloutBackgroundColor"); - } - - .callout-heading { - margin-top: 0; - } - - h3.callout-heading { - font-weight: bold; - text-transform: uppercase; - } - - &.callout-primary { - @include themify($themes) { - border-left-color: themed("primaryColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("primaryColor"); - } - } - } - - &.callout-info { - @include themify($themes) { - border-left-color: themed("infoColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("infoColor"); - } - } - } - - &.callout-danger { - @include themify($themes) { - border-left-color: themed("dangerColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("dangerColor"); - } - } - } - - &.callout-success { - @include themify($themes) { - border-left-color: themed("successColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("successColor"); - } - } - } - - &.callout-warning { - @include themify($themes) { - border-left-color: themed("warningColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("warningColor"); - } - } - } - - &.clickable { - &:hover, - &:focus, - &.active { - @include themify($themes) { - background-color: themed("boxBackgroundHoverColor"); - } - } - } - - .enforced-policy-options ul { - padding-left: 30px; - margin: 0; - } -} - input[type="password"]::-ms-reveal { display: none; } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7c5e49e741b..0349d1a694a 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,6 +1,7 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { Subject, merge, of } from "rxjs"; +import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { @@ -62,6 +63,7 @@ import { ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; @@ -102,11 +104,13 @@ import { OffscreenDocumentService } from "../../platform/offscreen-document/abst import { DefaultOffscreenDocumentService } from "../../platform/offscreen-document/offscreen-document.service"; import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service"; +import { PopupViewCacheService } from "../../platform/popup/view-cache/popup-view-cache.service"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; import { BrowserCryptoService } from "../../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; +import { ForegroundBrowserBiometricsService } from "../../platform/services/foreground-browser-biometrics"; import I18nService from "../../platform/services/i18n.service"; import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service"; import { ForegroundTaskSchedulerService } from "../../platform/services/task-scheduler/foreground-task-scheduler.service"; @@ -215,6 +219,7 @@ const safeProviders: SafeProvider[] = [ accountService: AccountServiceAbstraction, stateProvider: StateProvider, biometricStateService: BiometricStateService, + biometricsService: BiometricsService, kdfConfigService: KdfConfigService, ) => { const cryptoService = new BrowserCryptoService( @@ -229,6 +234,7 @@ const safeProviders: SafeProvider[] = [ accountService, stateProvider, biometricStateService, + biometricsService, kdfConfigService, ); new ContainerService(cryptoService, encryptService).attachToGlobal(self); @@ -246,6 +252,7 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, StateProvider, BiometricStateService, + BiometricsService, KdfConfigService, ], }), @@ -270,22 +277,19 @@ const safeProviders: SafeProvider[] = [ (clipboardValue: string, clearMs: number) => { void BrowserApi.sendMessage("clearClipboard", { clipboardValue, clearMs }); }, - async () => { - const response = await BrowserApi.sendMessageWithResponse<{ - result: boolean; - error: string; - }>("biometricUnlock"); - if (!response.result) { - throw response.error; - } - return response.result; - }, window, offscreenDocumentService, ); }, deps: [ToastService, OffscreenDocumentService], }), + safeProvider({ + provide: BiometricsService, + useFactory: () => { + return new ForegroundBrowserBiometricsService(); + }, + deps: [], + }), safeProvider({ provide: SyncService, useFactory: getBgService("syncService"), @@ -305,6 +309,11 @@ const safeProviders: SafeProvider[] = [ provide: AutofillServiceAbstraction, useExisting: AutofillService, }), + safeProvider({ + provide: ViewCacheService, + useExisting: PopupViewCacheService, + deps: [], + }), safeProvider({ provide: AutofillService, deps: [ diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index 95288f6b411..77a50ea35d9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -1,14 +1,22 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { ActivatedRoute, Router } from "@angular/router"; -import { mock } from "jest-mock-extended"; +import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { CipherFormConfig, CipherFormConfigService, CipherFormMode } from "@bitwarden/vault"; +import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; +import { + CipherFormConfig, + CipherFormConfigService, + CipherFormMode, + OptionalInitialValues, +} from "@bitwarden/vault"; import { BrowserFido2UserInterfaceSession } from "../../../../../autofill/fido2/services/browser-fido2-user-interface.service"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; @@ -25,6 +33,8 @@ jest.mock("qrcode-parser", () => {}); describe("AddEditV2Component", () => { let component: AddEditV2Component; let fixture: ComponentFixture; + let addEditCipherInfo$: BehaviorSubject; + let cipherServiceMock: MockProxy; const buildConfigResponse = { originalCipher: {} } as CipherFormConfig; const buildConfig = jest.fn((mode: CipherFormMode) => @@ -41,6 +51,10 @@ describe("AddEditV2Component", () => { navigate.mockClear(); back.mockClear(); + addEditCipherInfo$ = new BehaviorSubject(null); + cipherServiceMock = mock(); + cipherServiceMock.addEditCipherInfo$ = addEditCipherInfo$.asObservable(); + await TestBed.configureTestingModule({ imports: [AddEditV2Component], providers: [ @@ -51,6 +65,7 @@ describe("AddEditV2Component", () => { { provide: Router, useValue: { navigate } }, { provide: ActivatedRoute, useValue: { queryParams: queryParams$ } }, { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: CipherService, useValue: cipherServiceMock }, ], }) .overrideProvider(CipherFormConfigService, { @@ -107,6 +122,72 @@ describe("AddEditV2Component", () => { }); }); + describe("addEditCipherInfo initialization", () => { + it("populates config.initialValues with `addEditCipherInfo` values", fakeAsync(() => { + const addEditCipherInfo = { + cipher: { + name: "test", + folderId: "folder1", + organizationId: "org1", + type: CipherType.Login, + login: { + password: "password", + username: "username", + uris: [{ uri: "https://example.com" }], + }, + }, + collectionIds: ["col1", "col2"], + } as AddEditCipherInfo; + addEditCipherInfo$.next(addEditCipherInfo); + queryParams$.next({}); + + tick(); + + expect(component.config.initialValues).toEqual({ + name: "test", + folderId: "folder1", + organizationId: "org1", + password: "password", + username: "username", + loginUri: "https://example.com", + collectionIds: ["col1", "col2"], + } as OptionalInitialValues); + })); + + it("populates config.initialValues.username when `addEditCipherInfo` is an Identity", fakeAsync(() => { + addEditCipherInfo$.next({ + cipher: { type: CipherType.Identity, identity: { username: "identity-username" } }, + } as AddEditCipherInfo); + queryParams$.next({}); + + tick(); + + expect(component.config.initialValues.username).toBe("identity-username"); + })); + + it("overrides query params with `addEditCipherInfo` values", fakeAsync(() => { + addEditCipherInfo$.next({ + cipher: { name: "AddEditCipherName" }, + } as AddEditCipherInfo); + queryParams$.next({ + name: "QueryParamName", + }); + + tick(); + + expect(component.config.initialValues.name).toBe("AddEditCipherName"); + })); + + it("clears `addEditCipherInfo` after initialization", fakeAsync(() => { + addEditCipherInfo$.next({ cipher: { name: "test" } } as AddEditCipherInfo); + queryParams$.next({}); + + tick(); + + expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledTimes(1); + })); + }); + describe("onCipherSaved", () => { it("disables warning when in popout", async () => { jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValueOnce(true); diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index b830ae75048..b1e95afb535 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -8,8 +8,10 @@ import { firstValueFrom, map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +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 { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/components"; import { CipherFormConfig, @@ -18,6 +20,7 @@ import { CipherFormMode, CipherFormModule, DefaultCipherFormConfigService, + OptionalInitialValues, TotpCaptureService, } from "@bitwarden/vault"; @@ -156,6 +159,7 @@ export class AddEditV2Component implements OnInit { private popupCloseWarningService: PopupCloseWarningService, private popupRouterCacheService: PopupRouterCacheService, private router: Router, + private cipherService: CipherService, ) { this.subscribeToParams(); } @@ -255,7 +259,21 @@ export class AddEditV2Component implements OnInit { config.mode = "partial-edit"; } - this.setInitialValuesFromParams(params, config); + config.initialValues = this.setInitialValuesFromParams(params); + + // The browser notification bar and overlay use addEditCipherInfo$ to pass modified cipher details to the form + // Attempt to fetch them here and overwrite the initialValues if present + const cachedCipherInfo = await firstValueFrom(this.cipherService.addEditCipherInfo$); + + if (cachedCipherInfo != null) { + // Cached cipher info has priority over queryParams + config.initialValues = { + ...config.initialValues, + ...mapAddEditCipherInfoToInitialValues(cachedCipherInfo), + }; + // Be sure to clear the "cached" cipher info, so it doesn't get used again + await this.cipherService.setAddEditCipherInfo(null); + } return config; }), @@ -266,26 +284,27 @@ export class AddEditV2Component implements OnInit { }); } - setInitialValuesFromParams(params: QueryParams, config: CipherFormConfig) { - config.initialValues = {}; + setInitialValuesFromParams(params: QueryParams) { + const initialValues = {} as OptionalInitialValues; if (params.folderId) { - config.initialValues.folderId = params.folderId; + initialValues.folderId = params.folderId; } if (params.organizationId) { - config.initialValues.organizationId = params.organizationId; + initialValues.organizationId = params.organizationId; } if (params.collectionId) { - config.initialValues.collectionIds = [params.collectionId]; + initialValues.collectionIds = [params.collectionId]; } if (params.uri) { - config.initialValues.loginUri = params.uri; + initialValues.loginUri = params.uri; } if (params.username) { - config.initialValues.username = params.username; + initialValues.username = params.username; } if (params.name) { - config.initialValues.name = params.name; + initialValues.name = params.name; } + return initialValues; } setHeader(mode: CipherFormMode, type: CipherType) { @@ -303,3 +322,63 @@ export class AddEditV2Component implements OnInit { } } } + +/** + * Helper to map the old AddEditCipherInfo to the new OptionalInitialValues type used by the CipherForm + * @param cipherInfo + */ +const mapAddEditCipherInfoToInitialValues = ( + cipherInfo: AddEditCipherInfo | null, +): OptionalInitialValues => { + const initialValues: OptionalInitialValues = {}; + + if (cipherInfo == null) { + return initialValues; + } + + if (cipherInfo.collectionIds != null) { + initialValues.collectionIds = cipherInfo.collectionIds as CollectionId[]; + } + + if (cipherInfo.cipher == null) { + return initialValues; + } + + const cipher = cipherInfo.cipher; + + if (cipher.folderId != null) { + initialValues.folderId = cipher.folderId; + } + + if (cipher.organizationId != null) { + initialValues.organizationId = cipher.organizationId as OrganizationId; + } + + if (cipher.name != null) { + initialValues.name = cipher.name; + } + + if (cipher.type === CipherType.Login) { + const login = cipher.login; + + if (login != null) { + if (login.uris != null && login.uris.length > 0) { + initialValues.loginUri = login.uris[0].uri; + } + + if (login.username != null) { + initialValues.username = login.username; + } + + if (login.password != null) { + initialValues.password = login.password; + } + } + } + + if (cipher.type === CipherType.Identity && cipher.identity?.username != null) { + initialValues.username = cipher.identity.username; + } + + return initialValues; +}; diff --git a/apps/cli/package.json b/apps/cli/package.json index 00c810e0cc4..9a0b5df3468 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/cli", "description": "A secure and free password manager for all of your devices.", - "version": "2024.8.0", + "version": "2024.8.2", "keywords": [ "bitwarden", "password", diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 3b67f955406..9a69bcc3c0a 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -342,7 +342,7 @@ export class LoginCommand { } } - return await this.handleSuccessResponse(); + return await this.handleSuccessResponse(response); } catch (e) { return Response.error(e); } @@ -353,8 +353,8 @@ export class LoginCommand { process.env.BW_SESSION = Utils.fromBufferToB64(key); } - private async handleSuccessResponse(): Promise { - const usesKeyConnector = await this.keyConnectorService.getUsesKeyConnector(); + private async handleSuccessResponse(response: AuthResult): Promise { + const usesKeyConnector = await this.keyConnectorService.getUsesKeyConnector(response.userId); if ( (this.options.sso != null || this.options.apikey != null) && diff --git a/apps/cli/src/auth/commands/unlock.command.ts b/apps/cli/src/auth/commands/unlock.command.ts index f4486ff9667..bebaa946040 100644 --- a/apps/cli/src/auth/commands/unlock.command.ts +++ b/apps/cli/src/auth/commands/unlock.command.ts @@ -73,6 +73,7 @@ export class UnlockCommand { if (await this.keyConnectorService.getConvertAccountRequired()) { const convertToKeyConnectorCommand = new ConvertToKeyConnectorCommand( + userId, this.keyConnectorService, this.environmentService, this.syncService, diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index f308bdc2deb..e4340b68e22 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -116,20 +116,30 @@ export abstract class BaseProgram { } } + /** + * Exist if no user is authenticated + * @returns the userId of the active account + */ protected async exitIfNotAuthed() { - const authed = await this.serviceContainer.stateService.getIsAuthenticated(); - if (!authed) { - this.processResponse(Response.error("You are not logged in."), true); + const fail = () => this.processResponse(Response.error("You are not logged in."), true); + const userId = (await firstValueFrom(this.serviceContainer.accountService.activeAccount$))?.id; + if (!userId) { + fail(); } + const authed = await this.serviceContainer.stateService.getIsAuthenticated({ userId }); + if (!authed) { + fail(); + } + return userId; } protected async exitIfLocked() { - await this.exitIfNotAuthed(); + const userId = await this.exitIfNotAuthed(); if (await this.serviceContainer.cryptoService.hasUserKey()) { return; } else if (process.env.BW_NOINTERACTION !== "true") { // must unlock - if (await this.serviceContainer.keyConnectorService.getUsesKeyConnector()) { + if (await this.serviceContainer.keyConnectorService.getUsesKeyConnector(userId)) { const response = Response.error( "Your vault is locked. You must unlock your vault using your session key.\n" + "If you do not have your session key, you can get a new one by logging out and logging in again.", diff --git a/apps/cli/src/commands/convert-to-key-connector.command.ts b/apps/cli/src/commands/convert-to-key-connector.command.ts index 654606dc062..0dbdbb43250 100644 --- a/apps/cli/src/commands/convert-to-key-connector.command.ts +++ b/apps/cli/src/commands/convert-to-key-connector.command.ts @@ -7,6 +7,7 @@ import { EnvironmentService, Region, } from "@bitwarden/common/platform/abstractions/environment.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Response } from "../models/response"; @@ -14,6 +15,7 @@ import { MessageResponse } from "../models/response/message.response"; export class ConvertToKeyConnectorCommand { constructor( + private readonly userId: UserId, private keyConnectorService: KeyConnectorService, private environmentService: EnvironmentService, private syncService: SyncService, @@ -68,7 +70,7 @@ export class ConvertToKeyConnectorCommand { } await this.keyConnectorService.removeConvertAccountRequired(); - await this.keyConnectorService.setUsesKeyConnector(true); + await this.keyConnectorService.setUsesKeyConnector(true, this.userId); // Update environment URL - required for api key login const env = await firstValueFrom(this.environmentService.environment$); diff --git a/apps/cli/src/platform/services/cli-platform-utils.service.ts b/apps/cli/src/platform/services/cli-platform-utils.service.ts index 2a39510fda8..24bceec389c 100644 --- a/apps/cli/src/platform/services/cli-platform-utils.service.ts +++ b/apps/cli/src/platform/services/cli-platform-utils.service.ts @@ -131,26 +131,6 @@ export class CliPlatformUtilsService implements PlatformUtilsService { throw new Error("Not implemented."); } - supportsBiometric(): Promise { - return Promise.resolve(false); - } - - authenticateBiometric(): Promise { - return Promise.resolve(false); - } - - biometricsNeedsSetup(): Promise { - return Promise.resolve(false); - } - - biometricsSupportsAutoSetup(): Promise { - return Promise.resolve(false); - } - - biometricsSetup(): Promise { - return Promise.resolve(); - } - supportsSecureStorage(): boolean { return false; } diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 51c4b39e988..6ecdb249315 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -206,9 +206,9 @@ export class Program extends BaseProgram { writeLn("", true); }) .action(async (cmd) => { - await this.exitIfNotAuthed(); + const userId = await this.exitIfNotAuthed(); - if (await this.serviceContainer.keyConnectorService.getUsesKeyConnector()) { + if (await this.serviceContainer.keyConnectorService.getUsesKeyConnector(userId)) { const logoutCommand = new LogoutCommand( this.serviceContainer.authService, this.serviceContainer.i18nService, diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index aded8cc3914..b6572587faa 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -25,7 +25,7 @@ "**/node_modules/argon2/package.json", "**/node_modules/argon2/build/Release/argon2.node" ], - "electronVersion": "31.4.0", + "electronVersion": "32.0.1", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0056673a5a0..1ca7de0e135 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2024.8.1", + "version": "2024.8.2", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index d85447398a4..ebcae98a88a 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -20,6 +20,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { KeySuffixOptions, ThemeType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; @@ -133,6 +134,7 @@ export class SettingsComponent implements OnInit, OnDestroy { private userVerificationService: UserVerificationServiceAbstraction, private desktopSettingsService: DesktopSettingsService, private biometricStateService: BiometricStateService, + private biometricsService: BiometricsService, private desktopAutofillSettingsService: DesktopAutofillSettingsService, private pinService: PinServiceAbstraction, private logService: LogService, @@ -287,7 +289,7 @@ export class SettingsComponent implements OnInit, OnDestroy { // Non-form values this.showMinToTray = this.platformUtilsService.getDevice() !== DeviceType.LinuxDesktop; this.showAlwaysShowDock = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; - this.supportsBiometric = await this.platformUtilsService.supportsBiometric(); + this.supportsBiometric = await this.biometricsService.supportsBiometric(); this.previousVaultTimeout = this.form.value.vaultTimeout; this.refreshTimeoutSettings$ @@ -466,13 +468,12 @@ export class SettingsComponent implements OnInit, OnDestroy { return; } - const needsSetup = await this.platformUtilsService.biometricsNeedsSetup(); - const supportsBiometricAutoSetup = - await this.platformUtilsService.biometricsSupportsAutoSetup(); + const needsSetup = await this.biometricsService.biometricsNeedsSetup(); + const supportsBiometricAutoSetup = await this.biometricsService.biometricsSupportsAutoSetup(); if (needsSetup) { if (supportsBiometricAutoSetup) { - await this.platformUtilsService.biometricsSetup(); + await this.biometricsService.biometricsSetup(); } else { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "biometricsManualSetupTitle" }, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 85bfbc09f63..be110be138b 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -56,6 +56,7 @@ import { StateService as StateServiceAbstraction } from "@bitwarden/common/platf import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; @@ -72,6 +73,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; +import { ElectronBiometricsService } from "../../platform/services/electron-biometrics.service"; import { ElectronCryptoService } from "../../platform/services/electron-crypto.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; import { @@ -104,6 +106,11 @@ const RELOAD_CALLBACK = new SafeInjectionToken<() => any>("RELOAD_CALLBACK"); */ const safeProviders: SafeProvider[] = [ safeProvider(InitService), + safeProvider({ + provide: BiometricsService, + useClass: ElectronBiometricsService, + deps: [], + }), safeProvider(NativeMessagingService), safeProvider(SearchBarService), safeProvider(DialogService), diff --git a/apps/desktop/src/auth/lock.component.spec.ts b/apps/desktop/src/auth/lock.component.spec.ts index c46b791b1b6..c5b5b7acf00 100644 --- a/apps/desktop/src/auth/lock.component.spec.ts +++ b/apps/desktop/src/auth/lock.component.spec.ts @@ -28,6 +28,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService as AbstractBiometricService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; @@ -35,6 +36,8 @@ import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService } from "@bitwarden/components"; +import { BiometricsService } from "src/platform/main/biometric"; + import { LockComponent } from "./lock.component"; // ipc mock global @@ -53,6 +56,7 @@ describe("LockComponent", () => { let fixture: ComponentFixture; let stateServiceMock: MockProxy; let biometricStateService: MockProxy; + let biometricsService: MockProxy; let messagingServiceMock: MockProxy; let broadcasterServiceMock: MockProxy; let platformUtilsServiceMock: MockProxy; @@ -163,6 +167,10 @@ describe("LockComponent", () => { provide: BiometricStateService, useValue: biometricStateService, }, + { + provide: AbstractBiometricService, + useValue: biometricsService, + }, { provide: AccountService, useValue: accountService, diff --git a/apps/desktop/src/auth/lock.component.ts b/apps/desktop/src/auth/lock.component.ts index e6c2f7d11d6..55cc79e0a68 100644 --- a/apps/desktop/src/auth/lock.component.ts +++ b/apps/desktop/src/auth/lock.component.ts @@ -25,6 +25,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService } from "@bitwarden/components"; @@ -66,6 +67,7 @@ export class LockComponent extends BaseLockComponent implements OnInit, OnDestro userVerificationService: UserVerificationService, pinService: PinServiceAbstraction, biometricStateService: BiometricStateService, + biometricsService: BiometricsService, accountService: AccountService, authService: AuthService, kdfConfigService: KdfConfigService, @@ -93,6 +95,7 @@ export class LockComponent extends BaseLockComponent implements OnInit, OnDestro userVerificationService, pinService, biometricStateService, + biometricsService, accountService, authService, kdfConfigService, @@ -139,7 +142,7 @@ export class LockComponent extends BaseLockComponent implements OnInit, OnDestro // start background listener until destroyed on interval this.timerId = setInterval(async () => { - this.supportsBiometric = await this.platformUtilsService.supportsBiometric(); + this.supportsBiometric = await this.biometricsService.supportsBiometric(); this.biometricReady = await this.canUseBiometric(); }, 1000); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b77cc722691..86d07440a73 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -32,7 +32,7 @@ import { PowerMonitorMain } from "./main/power-monitor.main"; import { TrayMain } from "./main/tray.main"; import { UpdaterMain } from "./main/updater.main"; import { WindowMain } from "./main/window.main"; -import { BiometricsService, BiometricsServiceAbstraction } from "./platform/main/biometric/index"; +import { BiometricsService, DesktopBiometricsService } from "./platform/main/biometric/index"; import { ClipboardMain } from "./platform/main/clipboard.main"; import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener"; import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service"; @@ -64,7 +64,7 @@ export class Main { menuMain: MenuMain; powerMonitorMain: PowerMonitorMain; trayMain: TrayMain; - biometricsService: BiometricsServiceAbstraction; + biometricsService: DesktopBiometricsService; nativeMessagingMain: NativeMessagingMain; clipboardMain: ClipboardMain; desktopAutofillSettingsService: DesktopAutofillSettingsService; diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index ba7b14a054c..e7496714416 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.8.1", + "version": "2024.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.8.1", + "version": "2024.8.2", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native", diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 2987d5ec746..577683e0a86 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2024.8.1", + "version": "2024.8.2", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/platform/main/biometric/biometric.darwin.main.ts b/apps/desktop/src/platform/main/biometric/biometric.darwin.main.ts index 838968f1909..0f26cc78fbf 100644 --- a/apps/desktop/src/platform/main/biometric/biometric.darwin.main.ts +++ b/apps/desktop/src/platform/main/biometric/biometric.darwin.main.ts @@ -3,7 +3,7 @@ import { systemPreferences } from "electron"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { passwords } from "@bitwarden/desktop-napi"; -import { OsBiometricService } from "./biometrics.service.abstraction"; +import { OsBiometricService } from "./desktop.biometrics.service"; export default class BiometricDarwinMain implements OsBiometricService { constructor(private i18nservice: I18nService) {} diff --git a/apps/desktop/src/platform/main/biometric/biometric.noop.main.ts b/apps/desktop/src/platform/main/biometric/biometric.noop.main.ts index 3dfba76432b..57a86942e8c 100644 --- a/apps/desktop/src/platform/main/biometric/biometric.noop.main.ts +++ b/apps/desktop/src/platform/main/biometric/biometric.noop.main.ts @@ -1,4 +1,4 @@ -import { OsBiometricService } from "./biometrics.service.abstraction"; +import { OsBiometricService } from "./desktop.biometrics.service"; export default class NoopBiometricsService implements OsBiometricService { constructor() {} diff --git a/apps/desktop/src/platform/main/biometric/biometric.unix.main.ts b/apps/desktop/src/platform/main/biometric/biometric.unix.main.ts index e2428d9d129..c748276a6ef 100644 --- a/apps/desktop/src/platform/main/biometric/biometric.unix.main.ts +++ b/apps/desktop/src/platform/main/biometric/biometric.unix.main.ts @@ -7,7 +7,7 @@ import { biometrics, passwords } from "@bitwarden/desktop-napi"; import { WindowMain } from "../../../main/window.main"; import { isFlatpak, isLinux, isSnapStore } from "../../../utils"; -import { OsBiometricService } from "./biometrics.service.abstraction"; +import { OsBiometricService } from "./desktop.biometrics.service"; const polkitPolicy = ` { return { diff --git a/apps/desktop/src/platform/main/biometric/biometrics.service.ts b/apps/desktop/src/platform/main/biometric/biometrics.service.ts index 686007c7b5f..e432939c877 100644 --- a/apps/desktop/src/platform/main/biometric/biometrics.service.ts +++ b/apps/desktop/src/platform/main/biometric/biometrics.service.ts @@ -6,9 +6,9 @@ import { UserId } from "@bitwarden/common/types/guid"; import { WindowMain } from "../../../main/window.main"; -import { BiometricsServiceAbstraction, OsBiometricService } from "./biometrics.service.abstraction"; +import { DesktopBiometricsService, OsBiometricService } from "./desktop.biometrics.service"; -export class BiometricsService implements BiometricsServiceAbstraction { +export class BiometricsService extends DesktopBiometricsService { private platformSpecificService: OsBiometricService; private clientKeyHalves = new Map(); @@ -20,6 +20,7 @@ export class BiometricsService implements BiometricsServiceAbstraction { private platform: NodeJS.Platform, private biometricStateService: BiometricStateService, ) { + super(); this.loadPlatformSpecificService(this.platform); } @@ -63,19 +64,19 @@ export class BiometricsService implements BiometricsServiceAbstraction { this.platformSpecificService = new NoopBiometricsService(); } - async osSupportsBiometric() { + async supportsBiometric() { return await this.platformSpecificService.osSupportsBiometric(); } - async osBiometricsNeedsSetup() { + async biometricsNeedsSetup() { return await this.platformSpecificService.osBiometricsNeedsSetup(); } - async osBiometricsCanAutoSetup() { + async biometricsSupportsAutoSetup() { return await this.platformSpecificService.osBiometricsCanAutoSetup(); } - async osBiometricsSetup() { + async biometricsSetup() { await this.platformSpecificService.osBiometricsSetup(); } @@ -91,7 +92,7 @@ export class BiometricsService implements BiometricsServiceAbstraction { const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); const clientKeyHalfB64 = this.getClientKeyHalf(service, key); const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64; - return clientKeyHalfSatisfied && (await this.osSupportsBiometric()); + return clientKeyHalfSatisfied && (await this.supportsBiometric()); } async authenticateBiometric(): Promise { @@ -110,6 +111,10 @@ export class BiometricsService implements BiometricsServiceAbstraction { return result; } + async isBiometricUnlockAvailable(): Promise { + return await this.platformSpecificService.osSupportsBiometric(); + } + async getBiometricKey(service: string, storageKey: string): Promise { return await this.interruptProcessReload(async () => { await this.enforceClientKeyHalf(service, storageKey); diff --git a/apps/desktop/src/platform/main/biometric/biometrics.service.abstraction.ts b/apps/desktop/src/platform/main/biometric/desktop.biometrics.service.ts similarity index 84% rename from apps/desktop/src/platform/main/biometric/biometrics.service.abstraction.ts rename to apps/desktop/src/platform/main/biometric/desktop.biometrics.service.ts index 22766b7a312..c8e3a59612a 100644 --- a/apps/desktop/src/platform/main/biometric/biometrics.service.abstraction.ts +++ b/apps/desktop/src/platform/main/biometric/desktop.biometrics.service.ts @@ -1,8 +1,10 @@ -export abstract class BiometricsServiceAbstraction { - abstract osSupportsBiometric(): Promise; - abstract osBiometricsNeedsSetup: () => Promise; - abstract osBiometricsCanAutoSetup: () => Promise; - abstract osBiometricsSetup: () => Promise; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; + +/** + * This service extends the base biometrics service to provide desktop specific functions, + * specifically for the main process. + */ +export abstract class DesktopBiometricsService extends BiometricsService { abstract canAuthBiometric({ service, key, @@ -12,7 +14,6 @@ export abstract class BiometricsServiceAbstraction { key: string; userId: string; }): Promise; - abstract authenticateBiometric(): Promise; abstract getBiometricKey(service: string, key: string): Promise; abstract setBiometricKey(service: string, key: string, value: string): Promise; abstract setEncryptionKeyHalf({ diff --git a/apps/desktop/src/platform/main/biometric/index.ts b/apps/desktop/src/platform/main/biometric/index.ts index f5a594d966f..ad7725d718a 100644 --- a/apps/desktop/src/platform/main/biometric/index.ts +++ b/apps/desktop/src/platform/main/biometric/index.ts @@ -1,2 +1,2 @@ -export * from "./biometrics.service.abstraction"; +export * from "./desktop.biometrics.service"; export * from "./biometrics.service"; diff --git a/apps/desktop/src/platform/main/desktop-credential-storage-listener.ts b/apps/desktop/src/platform/main/desktop-credential-storage-listener.ts index 2f423e75fcf..5f278b23a0a 100644 --- a/apps/desktop/src/platform/main/desktop-credential-storage-listener.ts +++ b/apps/desktop/src/platform/main/desktop-credential-storage-listener.ts @@ -6,14 +6,14 @@ import { passwords } from "@bitwarden/desktop-napi"; import { BiometricMessage, BiometricAction } from "../../types/biometric-message"; -import { BiometricsServiceAbstraction } from "./biometric/index"; +import { DesktopBiometricsService } from "./biometric/index"; const AuthRequiredSuffix = "_biometric"; export class DesktopCredentialStorageListener { constructor( private serviceName: string, - private biometricService: BiometricsServiceAbstraction, + private biometricService: DesktopBiometricsService, private logService: ConsoleLogService, ) {} @@ -77,16 +77,16 @@ export class DesktopCredentialStorageListener { }); break; case BiometricAction.OsSupported: - val = await this.biometricService.osSupportsBiometric(); + val = await this.biometricService.supportsBiometric(); break; case BiometricAction.NeedsSetup: - val = await this.biometricService.osBiometricsNeedsSetup(); + val = await this.biometricService.biometricsNeedsSetup(); break; case BiometricAction.Setup: - await this.biometricService.osBiometricsSetup(); + await this.biometricService.biometricsSetup(); break; case BiometricAction.CanAutoSetup: - val = await this.biometricService.osBiometricsCanAutoSetup(); + val = await this.biometricService.biometricsSupportsAutoSetup(); break; default: } diff --git a/apps/desktop/src/platform/services/electron-biometrics.service.ts b/apps/desktop/src/platform/services/electron-biometrics.service.ts new file mode 100644 index 00000000000..8e1b1f8a5d6 --- /dev/null +++ b/apps/desktop/src/platform/services/electron-biometrics.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from "@angular/core"; + +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; + +/** + * This service implement the base biometrics service to provide desktop specific functions, + * specifically for the renderer process by passing messages to the main process. + */ +@Injectable() +export class ElectronBiometricsService extends BiometricsService { + async supportsBiometric(): Promise { + return await ipc.platform.biometric.osSupported(); + } + + async isBiometricUnlockAvailable(): Promise { + return await ipc.platform.biometric.osSupported(); + } + + /** This method is used to authenticate the user presence _only_. + * It should not be used in the process to retrieve + * biometric keys, which has a separate authentication mechanism. + * For biometric keys, invoke "keytar" with a biometric key suffix */ + async authenticateBiometric(): Promise { + return await ipc.platform.biometric.authenticate(); + } + + async biometricsNeedsSetup(): Promise { + return await ipc.platform.biometric.biometricsNeedsSetup(); + } + + async biometricsSupportsAutoSetup(): Promise { + return await ipc.platform.biometric.biometricsCanAutoSetup(); + } + + async biometricsSetup(): Promise { + return await ipc.platform.biometric.biometricsSetup(); + } +} diff --git a/apps/desktop/src/platform/services/electron-platform-utils.service.ts b/apps/desktop/src/platform/services/electron-platform-utils.service.ts index 30753f09f12..2808b74f097 100644 --- a/apps/desktop/src/platform/services/electron-platform-utils.service.ts +++ b/apps/desktop/src/platform/services/electron-platform-utils.service.ts @@ -131,30 +131,6 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { return ipc.platform.clipboard.read(); } - async supportsBiometric(): Promise { - return await ipc.platform.biometric.osSupported(); - } - - async biometricsNeedsSetup(): Promise { - return await ipc.platform.biometric.biometricsNeedsSetup(); - } - - async biometricsSupportsAutoSetup(): Promise { - return await ipc.platform.biometric.biometricsCanAutoSetup(); - } - - async biometricsSetup(): Promise { - return await ipc.platform.biometric.biometricsSetup(); - } - - /** This method is used to authenticate the user presence _only_. - * It should not be used in the process to retrieve - * biometric keys, which has a separate authentication mechanism. - * For biometric keys, invoke "keytar" with a biometric key suffix */ - async authenticateBiometric(): Promise { - return await ipc.platform.biometric.authenticate(); - } - supportsSecureStorage(): boolean { return ELECTRON_SUPPORTS_SECURE_STORAGE; } diff --git a/apps/desktop/src/scss/misc.scss b/apps/desktop/src/scss/misc.scss index ccc0af8fa4a..75a72640f2b 100644 --- a/apps/desktop/src/scss/misc.scss +++ b/apps/desktop/src/scss/misc.scss @@ -439,92 +439,6 @@ app-root > #loading, cursor: move; } -.callout { - padding: 10px; - margin-bottom: 10px; - border: 1px solid #000000; - border-left-width: 5px; - border-radius: 3px; - @include themify($themes) { - border-color: themed("calloutBorderColor"); - background-color: themed("calloutBackgroundColor"); - } - - .callout-heading { - margin-top: 0; - } - - h3.callout-heading { - font-weight: bold; - text-transform: uppercase; - } - - &.callout-primary { - @include themify($themes) { - border-left-color: themed("primaryColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("primaryColor"); - } - } - } - - &.callout-info { - @include themify($themes) { - border-left-color: themed("infoColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("infoColor"); - } - } - } - - &.callout-danger { - @include themify($themes) { - border-left-color: themed("dangerColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("dangerColor"); - } - } - } - - &.callout-success { - @include themify($themes) { - border-left-color: themed("successColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("successColor"); - } - } - } - - &.callout-warning { - @include themify($themes) { - border-left-color: themed("warningColor"); - } - - .callout-heading { - @include themify($themes) { - color: themed("warningColor"); - } - } - } - - ul { - padding-left: 40px; - margin: 0; - } -} - .password-reprompt { text-align: left; margin-top: 15px; diff --git a/apps/desktop/src/services/native-messaging.service.ts b/apps/desktop/src/services/native-messaging.service.ts index 4137c4e680f..f106d137b76 100644 --- a/apps/desktop/src/services/native-messaging.service.ts +++ b/apps/desktop/src/services/native-messaging.service.ts @@ -8,8 +8,8 @@ import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/c import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; @@ -33,11 +33,11 @@ export class NativeMessagingService { constructor( private cryptoFunctionService: CryptoFunctionService, private cryptoService: CryptoService, - private platformUtilService: PlatformUtilsService, private logService: LogService, private messagingService: MessagingService, private desktopSettingService: DesktopSettingsService, private biometricStateService: BiometricStateService, + private biometricsService: BiometricsService, private nativeMessageHandler: NativeMessageHandlerService, private dialogService: DialogService, private accountService: AccountService, @@ -133,7 +133,14 @@ export class NativeMessagingService { switch (message.command) { case "biometricUnlock": { - if (!(await this.platformUtilService.supportsBiometric())) { + const isTemporarilyDisabled = + (await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) && + !(await this.biometricsService.supportsBiometric()); + if (isTemporarilyDisabled) { + return this.send({ command: "biometricUnlock", response: "not available" }, appId); + } + + if (!(await this.biometricsService.supportsBiometric())) { return this.send({ command: "biometricUnlock", response: "not supported" }, appId); } @@ -198,8 +205,18 @@ export class NativeMessagingService { break; } + case "biometricUnlockAvailable": { + const isAvailable = await this.biometricsService.supportsBiometric(); + return this.send( + { + command: "biometricUnlockAvailable", + response: isAvailable ? "available" : "not available", + }, + appId, + ); + } default: - this.logService.error("NativeMessage, got unknown command."); + this.logService.error("NativeMessage, got unknown command: " + message.command); break; } } diff --git a/apps/web/package.json b/apps/web/package.json index 35ef8056ee5..8d4b130f72b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2024.8.0", + "version": "2024.8.1", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/404.html b/apps/web/src/404.html index 0c11f6680dc..817bfe30985 100644 --- a/apps/web/src/404.html +++ b/apps/web/src/404.html @@ -1,5 +1,5 @@ - + @@ -16,9 +16,9 @@ -
- Bitwarden + Bitwarden +

Sorry, this page isn't available.

diff --git a/apps/web/src/app/auth/login/login.component.ts b/apps/web/src/app/auth/login/login.component.ts index d0a4376556a..145d7666273 100644 --- a/apps/web/src/app/auth/login/login.component.ts +++ b/apps/web/src/app/auth/login/login.component.ts @@ -28,6 +28,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { flagEnabled } from "../../../utils/flags"; @@ -129,7 +130,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { } } - async goAfterLogIn() { + async goAfterLogIn(userId: UserId) { const masterPassword = this.formGroup.value.masterPassword; // Check master password against policy @@ -150,7 +151,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { ) { const policiesData: { [id: string]: PolicyData } = {}; this.policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p))); - await this.policyService.replace(policiesData); + await this.policyService.replace(policiesData, userId); await this.router.navigate(["update-password"]); return; } diff --git a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html index e1f5431c45b..8b5ef867cca 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html @@ -30,6 +30,8 @@ bitIconButton="bwi-clone" bitSuffix type="button" + showToast + [valueLabel]="'billingSyncKey' | i18n" [appCopyClick]="clientSecret" [appA11yTitle]="'copyValue' | i18n" > diff --git a/apps/web/src/app/core/web-platform-utils.service.ts b/apps/web/src/app/core/web-platform-utils.service.ts index dceaaf51d15..dbd0ef593d6 100644 --- a/apps/web/src/app/core/web-platform-utils.service.ts +++ b/apps/web/src/app/core/web-platform-utils.service.ts @@ -186,20 +186,6 @@ export class WebPlatformUtilsService implements PlatformUtilsService { throw new Error("Cannot read from clipboard on web."); } - supportsBiometric() { - return Promise.resolve(false); - } - - authenticateBiometric() { - return Promise.resolve(false); - } - - biometricsNeedsSetup: () => Promise; - biometricsSupportsAutoSetup(): Promise { - throw new Error("Method not implemented."); - } - biometricsSetup: () => Promise; - supportsSecureStorage() { return false; } diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index e1ba6389abf..96de2585532 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -167,7 +167,10 @@ export class PreferencesComponent implements OnInit, OnDestroy { ); return; } - const values = this.form.value; + + // must get raw value b/c the vault timeout action is disabled when a policy is applied + // which removes the timeout action property and value from the normal form.value. + const values = this.form.getRawValue(); const activeAcct = await firstValueFrom(this.accountService.activeAccount$); diff --git a/apps/web/src/images/logo-white.svg b/apps/web/src/images/logo-white.svg new file mode 100644 index 00000000000..d9ffdd8e339 --- /dev/null +++ b/apps/web/src/images/logo-white.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/apps/web/src/index.html b/apps/web/src/index.html index c3a2c03ed97..ce1a955b88c 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -5,7 +5,7 @@ - Bitwarden Web Vault + Bitwarden Web vault @@ -15,16 +15,14 @@ -

-
- -

- -

+
+ Bitwarden +
+
diff --git a/apps/web/src/scss/callouts.scss b/apps/web/src/scss/callouts.scss deleted file mode 100644 index da28d607161..00000000000 --- a/apps/web/src/scss/callouts.scss +++ /dev/null @@ -1,79 +0,0 @@ -.callout { - border-left-width: 5px !important; - border-radius: $card-inner-border-radius; - margin-bottom: $alert-margin-bottom; - padding: $alert-padding-y $alert-padding-x; - @include themify($themes) { - background-color: themed("calloutBackground"); - border: 1px solid themed("borderColor"); - color: themed("calloutColor"); - } - - .callout-heading { - margin-top: 0; - } - - h3.callout-heading { - font-weight: bold; - text-transform: uppercase; - } - - &.callout-primary { - @include themify($themes) { - border-left-color: themed("primary"); - } - .callout-heading { - @include themify($themes) { - color: themed("primary"); - } - } - } - - &.callout-info { - @include themify($themes) { - border-left-color: themed("info"); - } - - .callout-heading { - @include themify($themes) { - color: themed("info"); - } - } - } - - &.callout-danger { - @include themify($themes) { - border-left-color: themed("danger"); - } - - .callout-heading { - @include themify($themes) { - color: themed("danger"); - } - } - } - - &.callout-success { - @include themify($themes) { - border-left-color: themed("success"); - } - - .callout-heading { - @include themify($themes) { - color: themed("success"); - } - } - } - - &.callout-warning { - @include themify($themes) { - border-left-color: themed("warning"); - } - - .callout-heading { - @include themify($themes) { - color: themed("warning"); - } - } - } -} diff --git a/apps/web/src/scss/styles.scss b/apps/web/src/scss/styles.scss index 8fbea200a96..d17181615ca 100644 --- a/apps/web/src/scss/styles.scss +++ b/apps/web/src/scss/styles.scss @@ -45,7 +45,6 @@ @import "./base"; @import "./buttons"; -@import "./callouts"; @import "./cards"; @import "./forms"; @import "./modals"; diff --git a/apps/web/src/scss/tailwind.css b/apps/web/src/scss/tailwind.css index 9c64be63080..1ac7b154011 100644 --- a/apps/web/src/scss/tailwind.css +++ b/apps/web/src/scss/tailwind.css @@ -5,9 +5,14 @@ @import "../../../../libs/components/src/tw-theme.css"; /* - * Duplicated styling from Angular components. + * Web specific global styling. * - * For use in non Angular pages like the 404 and connectors. + * Be mindful of what is added here. Generally use Tailwind classes directly in Angular components. + * + * Some valid scenarios for adding styles here: + * + * - Duplicated styling for CL components used in non Angular pages like connectors and 404. + * - Shared styles like Logo. */ @layer components { .tw-h1 { @@ -24,4 +29,35 @@ @apply tw-bg-transparent tw-border-text-muted hover:tw-bg-text-muted hover:tw-border-text-muted hover:!tw-text-contrast disabled:tw-bg-transparent disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60 disabled:tw-cursor-not-allowed; @apply tw-text-muted !important; } + + /** + * Loading page + */ + body.layout_frontend { + /* We apply the background color here since body classes are dynamically added and removed */ + @apply tw-bg-background-alt !important; + + /* Spinner requires fixed height and width to appear centered */ + .spinner-container { + @apply tw-fixed tw-inset-2/4 -tw-translate-x-1/2 -tw-translate-y-1/2; + + height: 42px; + width: 42px; + } + } + + /** + * Logo, used both in loading and on "frontend" pages. + */ + img.new-logo-themed { + @apply tw-block; + + width: 128px; + } + .theme_light img.new-logo-themed { + content: url("../images/logo.svg"); + } + .theme_dark img.new-logo-themed { + content: url("../images/logo-white.svg"); + } } diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index 50eded416b2..400dcfd8891 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -30,6 +30,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { UserId } from "@bitwarden/common/types/guid"; @@ -84,6 +85,7 @@ export class LockComponent implements OnInit, OnDestroy { protected userVerificationService: UserVerificationService, protected pinService: PinServiceAbstraction, protected biometricStateService: BiometricStateService, + protected biometricsService: BiometricsService, protected accountService: AccountService, protected authService: AuthService, protected kdfConfigService: KdfConfigService, @@ -146,6 +148,13 @@ export class LockComponent implements OnInit, OnDestroy { return !!userKey; } + async isBiometricUnlockAvailable(): Promise { + if (!(await this.biometricsService.supportsBiometric())) { + return false; + } + return this.biometricsService.isBiometricUnlockAvailable(); + } + togglePassword() { this.showPassword = !this.showPassword; const input = document.getElementById(this.pinEnabled ? "pin" : "masterPassword"); @@ -327,7 +336,7 @@ export class LockComponent implements OnInit, OnDestroy { this.masterPasswordEnabled = await this.userVerificationService.hasMasterPassword(); - this.supportsBiometric = await this.platformUtilsService.supportsBiometric(); + this.supportsBiometric = await this.biometricsService.supportsBiometric(); this.biometricLock = (await this.vaultTimeoutSettingsService.isBiometricLockSet()) && ((await this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric)) || diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index 057d67b1527..40880b514aa 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -23,6 +23,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { @@ -39,7 +40,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, showPassword = false; formPromise: Promise; onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLoginNavigate: (userId: UserId) => Promise; onSuccessfulLoginTwoFactorNavigate: () => Promise; onSuccessfulLoginForceResetNavigate: () => Promise; showLoginWithDevice: boolean; @@ -185,7 +186,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, if (this.onSuccessfulLoginNavigate != null) { // 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.onSuccessfulLoginNavigate(); + this.onSuccessfulLoginNavigate(response.userId); } else { this.loginEmailService.clearValues(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.html b/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.html index 7add3f6d35d..f7e57acb7f8 100644 --- a/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.html +++ b/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.html @@ -141,7 +141,7 @@ - + {{ "makeSureEnoughCredit" | i18n }} diff --git a/libs/angular/src/components/callout.component.html b/libs/angular/src/components/callout.component.html index a049d5cb722..7e352fa0ced 100644 --- a/libs/angular/src/components/callout.component.html +++ b/libs/angular/src/components/callout.component.html @@ -1,14 +1,5 @@ -
-

- - {{ title }} -

-
+ +
{{ enforcedPolicyMessage }}
  • @@ -32,4 +23,4 @@
-
+ diff --git a/libs/angular/src/components/callout.component.ts b/libs/angular/src/components/callout.component.ts index c595beec196..2fd0878654d 100644 --- a/libs/angular/src/components/callout.component.ts +++ b/libs/angular/src/components/callout.component.ts @@ -2,16 +2,19 @@ import { Component, Input, OnInit } from "@angular/core"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CalloutTypes } from "@bitwarden/components"; +/** + * @deprecated use the CL's `CalloutComponent` instead + */ @Component({ selector: "app-callout", templateUrl: "callout.component.html", }) -export class CalloutComponent implements OnInit { - @Input() type = "info"; +export class DeprecatedCalloutComponent implements OnInit { + @Input() type: CalloutTypes = "info"; @Input() icon: string; @Input() title: string; - @Input() clickable: boolean; @Input() enforcedPolicyOptions: MasterPasswordPolicyOptions; @Input() enforcedPolicyMessage: string; @Input() useAlertRole = false; @@ -26,34 +29,6 @@ export class CalloutComponent implements OnInit { if (this.enforcedPolicyMessage === undefined) { this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect"); } - - if (this.type === "warning" || this.type === "danger") { - if (this.type === "danger") { - this.calloutStyle = "danger"; - } - if (this.title === undefined) { - this.title = this.i18nService.t("warning"); - } - if (this.icon === undefined) { - this.icon = "bwi-exclamation-triangle"; - } - } else if (this.type === "error") { - this.calloutStyle = "danger"; - if (this.title === undefined) { - this.title = this.i18nService.t("error"); - } - if (this.icon === undefined) { - this.icon = "bwi-error"; - } - } else if (this.type === "tip") { - this.calloutStyle = "success"; - if (this.title === undefined) { - this.title = this.i18nService.t("tip"); - } - if (this.icon === undefined) { - this.icon = "bwi-lightbulb"; - } - } } getPasswordScoreAlertDisplay() { diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index da8a4dd4181..755d52c0e77 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -14,6 +14,7 @@ import { AsyncActionsModule, AutofocusDirective, ButtonModule, + CalloutModule, CheckboxModule, DialogModule, FormFieldModule, @@ -29,7 +30,7 @@ import { } from "@bitwarden/components"; import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component"; -import { CalloutComponent } from "./components/callout.component"; +import { DeprecatedCalloutComponent } from "./components/callout.component"; import { A11yInvalidDirective } from "./directives/a11y-invalid.directive"; import { A11yTitleDirective } from "./directives/a11y-title.directive"; import { ApiActionDirective } from "./directives/api-action.directive"; @@ -72,6 +73,7 @@ import { IconComponent } from "./vault/components/icon.component"; FormFieldModule, SelectModule, ButtonModule, + CalloutModule, CheckboxModule, DialogModule, TypographyModule, @@ -88,7 +90,7 @@ import { IconComponent } from "./vault/components/icon.component"; ApiActionDirective, AutofocusDirective, BoxRowDirective, - CalloutComponent, + DeprecatedCalloutComponent, CopyTextDirective, CreditCardNumberPipe, EllipsisPipe, @@ -125,7 +127,7 @@ import { IconComponent } from "./vault/components/icon.component"; AutofocusDirective, ToastModule, BoxRowDirective, - CalloutComponent, + DeprecatedCalloutComponent, CopyTextDirective, CreditCardNumberPipe, EllipsisPipe, diff --git a/libs/angular/src/platform/abstractions/view-cache.service.ts b/libs/angular/src/platform/abstractions/view-cache.service.ts new file mode 100644 index 00000000000..0ee09afb812 --- /dev/null +++ b/libs/angular/src/platform/abstractions/view-cache.service.ts @@ -0,0 +1,83 @@ +import { Injector, WritableSignal } from "@angular/core"; +import type { FormGroup } from "@angular/forms"; +import type { Jsonify, JsonValue } from "type-fest"; + +type Deserializer = { + /** + * A function to use to safely convert your type from json to your expected type. + * + * @param jsonValue The JSON object representation of your state. + * @returns The fully typed version of your state. + */ + readonly deserializer?: (jsonValue: Jsonify) => T; +}; + +type BaseCacheOptions = { + /** A unique key for saving the cached value to state */ + key: string; + + /** An optional injector. Required if the method is called outside of an injection context. */ + injector?: Injector; +} & (T extends JsonValue ? Deserializer : Required>); + +export type SignalCacheOptions = BaseCacheOptions & { + /** The initial value for the signal. */ + initialValue: T; +}; + +/** Extract the value type from a FormGroup */ +type FormValue = TFormGroup["value"]; + +export type FormCacheOptions = BaseCacheOptions< + FormValue +> & { + control: TFormGroup; +}; + +/** + * Cache for temporary component state + * + * #### Implementations + * - browser extension popup: used to persist UI between popup open and close + * - all other clients: noop + */ +export abstract class ViewCacheService { + /** + * Create a signal from a previously cached value. Whenever the signal is updated, the new value is saved to the cache. + * + * Non browser extension implementations are noop and return a normal signal. + * + * @returns the created signal + * + * @example + * ```ts + * const mySignal = this.viewCacheService.signal({ + * key: "popup-search-text" + * initialValue: "" + * }); + * ``` + */ + abstract signal(options: SignalCacheOptions): WritableSignal; + + /** + * - Initialize a form from a cached value + * - Save form value to cache when it changes + * - The form is marked dirty if the restored value is not `undefined`. + * + * Non browser extension implementations are noop and return the original form group. + * + * @example + * ```ts + * this.loginDetailsForm = this.viewCacheService.formGroup({ + * key: "vault-login-details-form", + * control: this.formBuilder.group({ + * username: [""], + * email: [""], + * }) + * }); + * ``` + **/ + abstract formGroup( + options: FormCacheOptions, + ): TFormGroup; +} diff --git a/libs/angular/src/platform/services/noop-view-cache.service.ts b/libs/angular/src/platform/services/noop-view-cache.service.ts new file mode 100644 index 00000000000..9953e80b3b0 --- /dev/null +++ b/libs/angular/src/platform/services/noop-view-cache.service.ts @@ -0,0 +1,33 @@ +import { Injectable, signal, WritableSignal } from "@angular/core"; +import type { FormGroup } from "@angular/forms"; + +import { + FormCacheOptions, + SignalCacheOptions, + ViewCacheService, +} from "../abstractions/view-cache.service"; + +/** + * The functionality of the {@link ViewCacheService} is only needed in the browser extension popup, + * yet is provided to all clients to make sharing components easier. + * + * Non-extension clients use this noop implementation. + * */ +@Injectable({ + providedIn: "root", +}) +export class NoopViewCacheService implements ViewCacheService { + /** + * Return a normal signal. + */ + signal(options: SignalCacheOptions): WritableSignal { + return signal(options.initialValue); + } + + /** + * Return the original form group. + **/ + formGroup(options: FormCacheOptions): TFormGroup { + return options.control; + } +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 0997fb68635..851e02c8e04 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -268,8 +268,10 @@ import { } from "@bitwarden/vault-export-core"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; +import { ViewCacheService } from "../platform/abstractions/view-cache.service"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; import { LoggingErrorHandler } from "../platform/services/logging-error-handler"; +import { NoopViewCacheService } from "../platform/services/noop-view-cache.service"; import { AngularThemingService } from "../platform/services/theming/angular-theming.service"; import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction"; import { safeProvider, SafeProvider } from "../platform/utils/safe-provider"; @@ -1290,6 +1292,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultRegistrationFinishService, deps: [CryptoServiceAbstraction, AccountApiServiceAbstraction], }), + safeProvider({ + provide: ViewCacheService, + useExisting: NoopViewCacheService, + deps: [], + }), ]; @NgModule({ diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index bd3de51c461..082edf40630 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -2,7 +2,7 @@ class="tw-flex tw-min-h-screen tw-w-full tw-mx-auto tw-flex-col tw-gap-7 tw-bg-background-alt tw-px-8 tw-pb-4 tw-text-main" [ngClass]="{ 'tw-pt-0': decreaseTopPadding, 'tw-pt-8': !decreaseTopPadding }" > - +
diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index 9e9efa12bab..b112e5aa2ab 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -158,7 +158,10 @@ describe("AuthRequestLoginStrategy", () => { decMasterKeyHash, mockUserId, ); - expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + tokenResponse.key, + mockUserId, + ); expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId); expect(deviceTrustService.trustDeviceIfRequired).toHaveBeenCalled(); expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId); @@ -183,7 +186,10 @@ describe("AuthRequestLoginStrategy", () => { expect(masterPasswordService.mock.setMasterKeyHash).not.toHaveBeenCalled(); // setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called - expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + tokenResponse.key, + mockUserId, + ); expect(cryptoService.setUserKey).toHaveBeenCalledWith(decUserKey, mockUserId); expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId); diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index 9998abb30d3..ae0024d2181 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -99,7 +99,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy { const authRequestCredentials = this.cache.value.authRequestCredentials; // User now may or may not have a master password // but set the master key encrypted user key if it exists regardless - await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); + await this.cryptoService.setMasterKeyEncryptedUserKey(response.key, userId); if (authRequestCredentials.decryptedUserKey) { await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey, userId); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 2065f898be6..ff6bf07af7e 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -222,7 +222,11 @@ export abstract class LoginStrategy { ), ); - await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false); + await this.billingAccountProfileStateService.setHasPremium( + accountInformation.premium, + false, + userId, + ); return userId; } diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 6b9cddd99c5..16614497964 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -172,7 +172,10 @@ describe("UserApiLoginStrategy", () => { await apiLogInStrategy.logIn(credentials); - expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + tokenResponse.key, + userId, + ); expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId); }); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index 1faac3f6c75..3b112c79a0f 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -64,7 +64,7 @@ export class UserApiLoginStrategy extends LoginStrategy { response: IdentityTokenResponse, userId: UserId, ): Promise { - await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); + await this.cryptoService.setMasterKeyEncryptedUserKey(response.key, userId); if (response.apiUseKeyConnector) { const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); diff --git a/libs/common/spec/fake-state-provider.ts b/libs/common/spec/fake-state-provider.ts index cd868931f20..666487ecf09 100644 --- a/libs/common/spec/fake-state-provider.ts +++ b/libs/common/spec/fake-state-provider.ts @@ -32,7 +32,7 @@ export class FakeGlobalStateProvider implements GlobalStateProvider { states: Map> = new Map(); get(keyDefinition: KeyDefinition): GlobalState { this.mock.get(keyDefinition); - const cacheKey = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`; + const cacheKey = this.cacheKey(keyDefinition); let result = this.states.get(cacheKey); if (result == null) { @@ -53,94 +53,143 @@ export class FakeGlobalStateProvider implements GlobalStateProvider { return result as GlobalState; } + private cacheKey(keyDefinition: KeyDefinition) { + return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`; + } + getFake(keyDefinition: KeyDefinition): FakeGlobalState { return this.get(keyDefinition) as FakeGlobalState; } - mockFor(keyDefinitionKey: string, initialValue?: T): FakeGlobalState { - if (!this.establishedMocks.has(keyDefinitionKey)) { - this.establishedMocks.set(keyDefinitionKey, new FakeGlobalState(initialValue)); + mockFor(keyDefinition: KeyDefinition, initialValue?: T): FakeGlobalState { + const cacheKey = this.cacheKey(keyDefinition); + if (!this.states.has(cacheKey)) { + this.states.set(cacheKey, new FakeGlobalState(initialValue)); } - return this.establishedMocks.get(keyDefinitionKey) as FakeGlobalState; + return this.states.get(cacheKey) as FakeGlobalState; } } export class FakeSingleUserStateProvider implements SingleUserStateProvider { mock = mock(); - establishedMocks: Map> = new Map(); states: Map> = new Map(); + + constructor( + readonly updateSyncCallback?: ( + key: UserKeyDefinition, + userId: UserId, + newValue: unknown, + ) => Promise, + ) {} + get(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { this.mock.get(userId, userKeyDefinition); - const cacheKey = `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}_${userId}`; + const cacheKey = this.cacheKey(userId, userKeyDefinition); let result = this.states.get(cacheKey); if (result == null) { - let fake: FakeSingleUserState; - // Look for established mock - if (this.establishedMocks.has(userKeyDefinition.key)) { - fake = this.establishedMocks.get(userKeyDefinition.key) as FakeSingleUserState; - } else { - fake = new FakeSingleUserState(userId); - } - fake.keyDefinition = userKeyDefinition; - result = fake; + result = this.buildFakeState(userId, userKeyDefinition); this.states.set(cacheKey, result); } return result as SingleUserState; } - getFake(userId: UserId, userKeyDefinition: UserKeyDefinition): FakeSingleUserState { + getFake( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + { allowInit }: { allowInit: boolean } = { allowInit: true }, + ): FakeSingleUserState { + if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) { + return null; + } + return this.get(userId, userKeyDefinition) as FakeSingleUserState; } - mockFor(userId: UserId, keyDefinitionKey: string, initialValue?: T): FakeSingleUserState { - if (!this.establishedMocks.has(keyDefinitionKey)) { - this.establishedMocks.set(keyDefinitionKey, new FakeSingleUserState(userId, initialValue)); + mockFor( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + initialValue?: T, + ): FakeSingleUserState { + const cacheKey = this.cacheKey(userId, userKeyDefinition); + if (!this.states.has(cacheKey)) { + this.states.set(cacheKey, this.buildFakeState(userId, userKeyDefinition, initialValue)); } - return this.establishedMocks.get(keyDefinitionKey) as FakeSingleUserState; + return this.states.get(cacheKey) as FakeSingleUserState; + } + + private buildFakeState( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + initialValue?: T, + ) { + const state = new FakeSingleUserState(userId, initialValue, async (...args) => { + await this.updateSyncCallback?.(userKeyDefinition, ...args); + }); + state.keyDefinition = userKeyDefinition; + return state; + } + + private cacheKey(userId: UserId, userKeyDefinition: UserKeyDefinition) { + return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`; } } export class FakeActiveUserStateProvider implements ActiveUserStateProvider { activeUserId$: Observable; - establishedMocks: Map> = new Map(); - states: Map> = new Map(); - constructor(public accountService: FakeAccountService) { + constructor( + public accountService: FakeAccountService, + readonly updateSyncCallback?: ( + key: UserKeyDefinition, + userId: UserId, + newValue: unknown, + ) => Promise, + ) { this.activeUserId$ = accountService.activeAccountSubject.asObservable().pipe(map((a) => a?.id)); } get(userKeyDefinition: UserKeyDefinition): ActiveUserState { - const cacheKey = `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`; + const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition); let result = this.states.get(cacheKey); if (result == null) { - // Look for established mock - if (this.establishedMocks.has(userKeyDefinition.key)) { - result = this.establishedMocks.get(userKeyDefinition.key); - } else { - result = new FakeActiveUserState(this.accountService); - } - result.keyDefinition = userKeyDefinition; + result = this.buildFakeState(userKeyDefinition); this.states.set(cacheKey, result); } return result as ActiveUserState; } - getFake(userKeyDefinition: UserKeyDefinition): FakeActiveUserState { + getFake( + userKeyDefinition: UserKeyDefinition, + { allowInit }: { allowInit: boolean } = { allowInit: true }, + ): FakeActiveUserState { + if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) { + return null; + } return this.get(userKeyDefinition) as FakeActiveUserState; } - mockFor(keyDefinitionKey: string, initialValue?: T): FakeActiveUserState { - if (!this.establishedMocks.has(keyDefinitionKey)) { - this.establishedMocks.set( - keyDefinitionKey, - new FakeActiveUserState(this.accountService, initialValue), - ); + mockFor(userKeyDefinition: UserKeyDefinition, initialValue?: T): FakeActiveUserState { + const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition); + if (!this.states.has(cacheKey)) { + this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue)); } - return this.establishedMocks.get(keyDefinitionKey) as FakeActiveUserState; + return this.states.get(cacheKey) as FakeActiveUserState; } + + private buildFakeState(userKeyDefinition: UserKeyDefinition, initialValue?: T) { + const state = new FakeActiveUserState(this.accountService, initialValue, async (...args) => { + await this.updateSyncCallback?.(userKeyDefinition, ...args); + }); + state.keyDefinition = userKeyDefinition; + return state; + } +} + +function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition) { + return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`; } export class FakeStateProvider implements StateProvider { @@ -207,9 +256,35 @@ export class FakeStateProvider implements StateProvider { constructor(public accountService: FakeAccountService) {} + private distributeSingleUserUpdate( + key: UserKeyDefinition, + userId: UserId, + newState: unknown, + ) { + if (this.activeUser.accountService.activeUserId === userId) { + const state = this.activeUser.getFake(key, { allowInit: false }); + state?.nextState(newState, { syncValue: false }); + } + } + + private distributeActiveUserUpdate( + key: UserKeyDefinition, + userId: UserId, + newState: unknown, + ) { + this.singleUser + .getFake(userId, key, { allowInit: false }) + ?.nextState(newState, { syncValue: false }); + } + global: FakeGlobalStateProvider = new FakeGlobalStateProvider(); - singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider(); - activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider(this.accountService); + singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider( + this.distributeSingleUserUpdate.bind(this), + ); + activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider( + this.accountService, + this.distributeActiveUserUpdate.bind(this), + ); derived: FakeDerivedStateProvider = new FakeDerivedStateProvider(); activeUserId$: Observable = this.activeUser.activeUserId$; } diff --git a/libs/common/spec/fake-state.ts b/libs/common/spec/fake-state.ts index 0f2a09d9c1b..2400e470d42 100644 --- a/libs/common/spec/fake-state.ts +++ b/libs/common/spec/fake-state.ts @@ -1,4 +1,4 @@ -import { Observable, ReplaySubject, concatMap, firstValueFrom, map, timeout } from "rxjs"; +import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs"; import { DerivedState, @@ -41,6 +41,10 @@ export class FakeGlobalState implements GlobalState { this.stateSubject.next(initialValue ?? null); } + nextState(state: T) { + this.stateSubject.next(state); + } + async update( configureState: (state: T, dependency: TCombine) => T, options?: StateUpdateOptions, @@ -89,7 +93,10 @@ export class FakeGlobalState implements GlobalState { export class FakeSingleUserState implements SingleUserState { // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject>(1); + stateSubject = new ReplaySubject<{ + syncValue: boolean; + combinedState: CombinedState; + }>(1); state$: Observable; combinedState$: Observable>; @@ -97,15 +104,28 @@ export class FakeSingleUserState implements SingleUserState { constructor( readonly userId: UserId, initialValue?: T, + updateSyncCallback?: (userId: UserId, newValue: T) => Promise, ) { - this.stateSubject.next([userId, initialValue ?? null]); + // Inform the state provider of updates to keep active user states in sync + this.stateSubject + .pipe( + filter((next) => next.syncValue), + concatMap(async ({ combinedState }) => { + await updateSyncCallback?.(...combinedState); + }), + ) + .subscribe(); + this.nextState(initialValue ?? null, { syncValue: initialValue != null }); - this.combinedState$ = this.stateSubject.asObservable(); + this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState)); this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); } - nextState(state: T) { - this.stateSubject.next([this.userId, state]); + nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) { + this.stateSubject.next({ + syncValue, + combinedState: [this.userId, state], + }); } async update( @@ -122,7 +142,7 @@ export class FakeSingleUserState implements SingleUserState { return current; } const newState = configureState(current, combinedDependencies); - this.stateSubject.next([this.userId, newState]); + this.nextState(newState); this.nextMock(newState); return newState; } @@ -146,7 +166,10 @@ export class FakeActiveUserState implements ActiveUserState { [activeMarker]: true; // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject>(1); + stateSubject = new ReplaySubject<{ + syncValue: boolean; + combinedState: CombinedState; + }>(1); state$: Observable; combinedState$: Observable>; @@ -154,10 +177,18 @@ export class FakeActiveUserState implements ActiveUserState { constructor( private accountService: FakeAccountService, initialValue?: T, + updateSyncCallback?: (userId: UserId, newValue: T) => Promise, ) { - this.stateSubject.next([accountService.activeUserId, initialValue ?? null]); + // Inform the state provider of updates to keep single user states in sync + this.stateSubject.pipe( + filter((next) => next.syncValue), + concatMap(async ({ combinedState }) => { + await updateSyncCallback?.(...combinedState); + }), + ); + this.nextState(initialValue ?? null, { syncValue: initialValue != null }); - this.combinedState$ = this.stateSubject.asObservable(); + this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState)); this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); } @@ -165,8 +196,11 @@ export class FakeActiveUserState implements ActiveUserState { return this.accountService.activeUserId; } - nextState(state: T) { - this.stateSubject.next([this.userId, state]); + nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) { + this.stateSubject.next({ + syncValue, + combinedState: [this.userId, state], + }); } async update( @@ -183,7 +217,7 @@ export class FakeActiveUserState implements ActiveUserState { return [this.userId, current]; } const newState = configureState(current, combinedDependencies); - this.stateSubject.next([this.userId, newState]); + this.nextState(newState); this.nextMock([this.userId, newState]); return [this.userId, newState]; } diff --git a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts index 21669f78ad2..1067c242346 100644 --- a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts @@ -77,5 +77,5 @@ export abstract class PolicyService { export abstract class InternalPolicyService extends PolicyService { upsert: (policy: PolicyData) => Promise; - replace: (policies: { [id: string]: PolicyData }) => Promise; + replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise; } diff --git a/libs/common/src/admin-console/services/policy/policy.service.spec.ts b/libs/common/src/admin-console/services/policy/policy.service.spec.ts index 88264d1c3b7..d9802db9e38 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.spec.ts @@ -20,6 +20,7 @@ import { POLICIES, PolicyService } from "../../../admin-console/services/policy/ import { PolicyId, UserId } from "../../../types/guid"; describe("PolicyService", () => { + const userId = "userId" as UserId; let stateProvider: FakeStateProvider; let organizationService: MockProxy; let activeUserState: FakeActiveUserState>; @@ -27,7 +28,7 @@ describe("PolicyService", () => { let policyService: PolicyService; beforeEach(() => { - const accountService = mockAccountServiceWith("userId" as UserId); + const accountService = mockAccountServiceWith(userId); stateProvider = new FakeStateProvider(accountService); organizationService = mock(); @@ -95,9 +96,12 @@ describe("PolicyService", () => { ]), ); - await policyService.replace({ - "2": policyData("2", "test-organization", PolicyType.DisableSend, true), - }); + await policyService.replace( + { + "2": policyData("2", "test-organization", PolicyType.DisableSend, true), + }, + userId, + ); expect(await firstValueFrom(policyService.policies$)).toEqual([ { diff --git a/libs/common/src/admin-console/services/policy/policy.service.ts b/libs/common/src/admin-console/services/policy/policy.service.ts index 2287ef9b4f4..f52d061ad9c 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.ts @@ -219,8 +219,8 @@ export class PolicyService implements InternalPolicyServiceAbstraction { }); } - async replace(policies: { [id: string]: PolicyData }): Promise { - await this.activeUserPolicyState.update(() => policies); + async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise { + await this.stateProvider.setUserState(POLICIES, policies, userId); } /** diff --git a/libs/common/src/auth/abstractions/key-connector.service.ts b/libs/common/src/auth/abstractions/key-connector.service.ts index b1b6727cd1b..26335ced489 100644 --- a/libs/common/src/auth/abstractions/key-connector.service.ts +++ b/libs/common/src/auth/abstractions/key-connector.service.ts @@ -4,17 +4,17 @@ import { IdentityTokenResponse } from "../models/response/identity-token.respons export abstract class KeyConnectorService { setMasterKeyFromUrl: (url: string, userId: UserId) => Promise; - getManagingOrganization: () => Promise; - getUsesKeyConnector: () => Promise; - migrateUser: () => Promise; - userNeedsMigration: () => Promise; + getManagingOrganization: (userId?: UserId) => Promise; + getUsesKeyConnector: (userId: UserId) => Promise; + migrateUser: (userId?: UserId) => Promise; + userNeedsMigration: (userId: UserId) => Promise; convertNewSsoUserToKeyConnector: ( tokenResponse: IdentityTokenResponse, orgId: string, userId: UserId, ) => Promise; - setUsesKeyConnector: (enabled: boolean) => Promise; - setConvertAccountRequired: (status: boolean) => Promise; + setUsesKeyConnector: (enabled: boolean, userId: UserId) => Promise; + setConvertAccountRequired: (status: boolean, userId?: UserId) => Promise; getConvertAccountRequired: () => Promise; - removeConvertAccountRequired: () => Promise; + removeConvertAccountRequired: (userId?: UserId) => Promise; } diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index c86b5f1ee39..9239a0db543 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -148,10 +148,11 @@ export abstract class TokenService { /** * Decodes the access token. - * @param token The access token to decode. + * @param tokenOrUserId The access token to decode or the user id to retrieve the access token for, and then decode. + * If null, the currently active user's token is used. * @returns A promise that resolves with the decoded access token. */ - decodeAccessToken: (token?: string) => Promise; + decodeAccessToken: (tokenOrUserId?: string | UserId) => Promise; /** * Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration @@ -212,9 +213,10 @@ export abstract class TokenService { /** * Gets whether or not the user authenticated via an external mechanism. + * @param userId The optional user id to check for external authN status; if not provided, the active user is used. * @returns A promise that resolves with a boolean representing the user's external authN status. */ - getIsExternal: () => Promise; + getIsExternal: (userId: UserId) => Promise; /** Gets the active or passed in user's security stamp */ getSecurityStamp: (userId?: UserId) => Promise; diff --git a/libs/common/src/auth/services/key-connector.service.spec.ts b/libs/common/src/auth/services/key-connector.service.spec.ts index 0fc0267a533..5d1aff45f60 100644 --- a/libs/common/src/auth/services/key-connector.service.spec.ts +++ b/libs/common/src/auth/services/key-connector.service.spec.ts @@ -78,9 +78,9 @@ describe("KeyConnectorService", () => { const newValue = true; - await keyConnectorService.setUsesKeyConnector(newValue); + await keyConnectorService.setUsesKeyConnector(newValue, mockUserId); - expect(await keyConnectorService.getUsesKeyConnector()).toBe(newValue); + expect(await keyConnectorService.getUsesKeyConnector(mockUserId)).toBe(newValue); }); }); @@ -185,7 +185,7 @@ describe("KeyConnectorService", () => { const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR); state.nextState(false); - const result = await keyConnectorService.userNeedsMigration(); + const result = await keyConnectorService.userNeedsMigration(mockUserId); expect(result).toBe(true); }); @@ -197,7 +197,7 @@ describe("KeyConnectorService", () => { const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR); state.nextState(true); - const result = await keyConnectorService.userNeedsMigration(); + const result = await keyConnectorService.userNeedsMigration(mockUserId); expect(result).toBe(false); }); diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index 8f204e557ed..ad9b7081cdf 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -69,25 +69,25 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { ); } - async setUsesKeyConnector(usesKeyConnector: boolean) { - await this.usesKeyConnectorState.update(() => usesKeyConnector); + async setUsesKeyConnector(usesKeyConnector: boolean, userId: UserId) { + await this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).update(() => usesKeyConnector); } - getUsesKeyConnector(): Promise { - return firstValueFrom(this.usesKeyConnectorState.state$); + getUsesKeyConnector(userId: UserId): Promise { + return firstValueFrom(this.stateProvider.getUserState$(USES_KEY_CONNECTOR, userId)); } - async userNeedsMigration() { - const loggedInUsingSso = await this.tokenService.getIsExternal(); - const requiredByOrganization = (await this.getManagingOrganization()) != null; - const userIsNotUsingKeyConnector = !(await this.getUsesKeyConnector()); + async userNeedsMigration(userId: UserId) { + const loggedInUsingSso = await this.tokenService.getIsExternal(userId); + const requiredByOrganization = (await this.getManagingOrganization(userId)) != null; + const userIsNotUsingKeyConnector = !(await this.getUsesKeyConnector(userId)); return loggedInUsingSso && requiredByOrganization && userIsNotUsingKeyConnector; } - async migrateUser() { - const organization = await this.getManagingOrganization(); - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + async migrateUser(userId?: UserId) { + userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; + const organization = await this.getManagingOrganization(userId); const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); @@ -115,8 +115,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { } } - async getManagingOrganization(): Promise { - const orgs = await this.organizationService.getAll(); + async getManagingOrganization(userId?: UserId): Promise { + const orgs = await this.organizationService.getAll(userId); return orgs.find( (o) => o.keyConnectorEnabled && @@ -178,16 +178,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { await this.apiService.postSetKeyConnectorKey(setPasswordRequest); } - async setConvertAccountRequired(status: boolean) { - await this.convertAccountToKeyConnectorState.update(() => status); + async setConvertAccountRequired(status: boolean, userId?: UserId) { + await this.stateProvider.setUserState(CONVERT_ACCOUNT_TO_KEY_CONNECTOR, status, userId); } getConvertAccountRequired(): Promise { return firstValueFrom(this.convertAccountToKeyConnectorState.state$); } - async removeConvertAccountRequired() { - await this.setConvertAccountRequired(null); + async removeConvertAccountRequired(userId?: UserId) { + await this.setConvertAccountRequired(null, userId); } private handleKeyConnectorError(e: any) { diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index 4be945de5f8..f8882e1b118 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -126,7 +126,7 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); // Act const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); @@ -139,11 +139,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); // Act const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); @@ -156,7 +156,7 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]); + .nextState("encryptedAccessToken"); secureStorageService.get.mockResolvedValue(accessTokenKeyB64); @@ -282,7 +282,7 @@ describe("TokenService", () => { // For testing purposes, let's assume that the access token is already in memory singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); keyGenerationService.createKey.mockResolvedValue(accessTokenKey); @@ -411,9 +411,7 @@ describe("TokenService", () => { it("returns null when no access token is found in memory, disk, or secure storage", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getAccessToken(); @@ -429,18 +427,16 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); // set disk to undefined singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); // Need to have global active id set to the user id if (!userId) { - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); } // Act @@ -459,17 +455,15 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); // Need to have global active id set to the user id if (!userId) { - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); } // Act @@ -498,20 +492,18 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]); + .nextState("encryptedAccessToken"); secureStorageService.get.mockResolvedValue(accessTokenKeyB64); encryptService.decryptToUtf8.mockResolvedValue("decryptedAccessToken"); // Need to have global active id set to the user id if (!userId) { - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); } // Act @@ -534,17 +526,15 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); // Need to have global active id set to the user id if (!userId) { - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); } // No access token key set @@ -564,11 +554,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, encryptedAccessToken]); + .nextState(encryptedAccessToken); // No access token key set @@ -596,11 +586,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, encryptedAccessToken]); + .nextState(encryptedAccessToken); // Mock linux secure storage error const secureStorageError = "Secure storage error"; @@ -655,17 +645,15 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); // Need to have global active id set to the user id if (!userId) { - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); } // Act @@ -688,8 +676,32 @@ describe("TokenService", () => { }); describe("decodeAccessToken", () => { + it("retrieves the requested user's token when the passed in parameter is a Guid", async () => { + // Arrange + tokenService.getAccessToken = jest.fn().mockResolvedValue(accessTokenJwt); + + // Act + const result = await tokenService.decodeAccessToken(userIdFromAccessToken); + + // Assert + expect(result).toEqual(accessTokenDecoded); + expect(tokenService.getAccessToken).toHaveBeenCalledWith(userIdFromAccessToken); + }); + + it("decodes the given token when a string is passed in that is not a Guid", async () => { + // Arrange + tokenService.getAccessToken = jest.fn(); + + // Act + const result = await tokenService.decodeAccessToken(accessTokenJwt); + + // Assert + expect(result).toEqual(accessTokenDecoded); + expect(tokenService.getAccessToken).not.toHaveBeenCalled(); + }); + it("throws an error when no access token is provided or retrievable from state", async () => { - // Access + // Arrange tokenService.getAccessToken = jest.fn().mockResolvedValue(null); // Act @@ -1194,7 +1206,7 @@ describe("TokenService", () => { // Act // note: don't await here because we want to test the error - const result = tokenService.getIsExternal(); + const result = tokenService.getIsExternal(null); // Assert await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); @@ -1210,7 +1222,7 @@ describe("TokenService", () => { .mockResolvedValue(accessTokenDecodedWithoutExternalAmr); // Act - const result = await tokenService.getIsExternal(); + const result = await tokenService.getIsExternal(null); // Assert expect(result).toEqual(false); @@ -1227,11 +1239,22 @@ describe("TokenService", () => { .mockResolvedValue(accessTokenDecodedWithExternalAmr); // Act - const result = await tokenService.getIsExternal(); + const result = await tokenService.getIsExternal(null); // Assert expect(result).toEqual(true); }); + + it("passes the requested userId to decode", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + await tokenService.getIsExternal(userIdFromAccessToken); + + // Assert + expect(tokenService.decodeAccessToken).toHaveBeenCalledWith(userIdFromAccessToken); + }); }); }); }); @@ -1326,11 +1349,11 @@ describe("TokenService", () => { // For testing purposes, let's assume that the token is already in disk and memory singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); // We immediately call to get the refresh token from secure storage after setting it to ensure it was set. secureStorageService.get.mockResolvedValue(refreshToken); @@ -1423,11 +1446,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + .nextState(accessTokenJwt); // Mock linux secure storage error const secureStorageError = "Secure storage error"; @@ -1480,11 +1503,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, encryptedAccessToken]); + .nextState(encryptedAccessToken); secureStorageService.get.mockResolvedValue(accessTokenKeyB64); encryptService.decryptToUtf8.mockRejectedValue(new Error("Decryption error")); @@ -1520,9 +1543,7 @@ describe("TokenService", () => { it("returns null when no refresh token is found in memory, disk, or secure storage", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await (tokenService as any).getRefreshToken(); @@ -1535,16 +1556,14 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getRefreshToken(); @@ -1557,11 +1576,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); // Act const result = await tokenService.getRefreshToken(userIdFromAccessToken); @@ -1575,16 +1594,14 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getRefreshToken(); @@ -1596,11 +1613,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); // Act const result = await tokenService.getRefreshToken(userIdFromAccessToken); @@ -1619,18 +1636,16 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); secureStorageService.get.mockResolvedValue(refreshToken); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getRefreshToken(); @@ -1643,11 +1658,11 @@ describe("TokenService", () => { singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); secureStorageService.get.mockResolvedValue(refreshToken); @@ -1661,11 +1676,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); // Act const result = await tokenService.getRefreshToken(userIdFromAccessToken); @@ -1681,16 +1696,14 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getRefreshToken(); @@ -1719,11 +1732,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); secureStorageService.get.mockResolvedValue(null); @@ -1743,11 +1756,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); const secureStorageSvcMockErrorMsg = "Secure storage retrieval error"; @@ -1792,11 +1805,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, refreshToken]); + .nextState(refreshToken); // Act await (tokenService as any).clearRefreshToken(userIdFromAccessToken); @@ -1833,9 +1846,7 @@ describe("TokenService", () => { it("should throw an error if the vault timeout is missing", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = tokenService.setClientId(clientId, VaultTimeoutAction.Lock, null); @@ -1847,9 +1858,7 @@ describe("TokenService", () => { it("should throw an error if the vault timeout action is missing", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = tokenService.setClientId(clientId, null, VaultTimeoutStringType.Never); @@ -1861,9 +1870,7 @@ describe("TokenService", () => { describe("Memory storage tests", () => { it("sets the client id in memory when there is an active user in global state", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act await tokenService.setClientId(clientId, memoryVaultTimeoutAction, memoryVaultTimeout); @@ -1895,9 +1902,7 @@ describe("TokenService", () => { describe("Disk storage tests", () => { it("sets the client id in disk when there is an active user in global state", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act await tokenService.setClientId(clientId, diskVaultTimeoutAction, diskVaultTimeout); @@ -1935,9 +1940,7 @@ describe("TokenService", () => { it("returns null when no client id is found in memory or disk", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getClientId(); @@ -1950,17 +1953,15 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .stateSubject.next([userIdFromAccessToken, clientId]); + .nextState(clientId); // set disk to undefined singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getClientId(); @@ -1973,12 +1974,12 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .stateSubject.next([userIdFromAccessToken, clientId]); + .nextState(clientId); // set disk to undefined singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); // Act const result = await tokenService.getClientId(userIdFromAccessToken); @@ -1992,16 +1993,14 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .stateSubject.next([userIdFromAccessToken, clientId]); + .nextState(clientId); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getClientId(); @@ -2013,11 +2012,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .stateSubject.next([userIdFromAccessToken, clientId]); + .nextState(clientId); // Act const result = await tokenService.getClientId(userIdFromAccessToken); @@ -2040,11 +2039,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .stateSubject.next([userIdFromAccessToken, clientId]); + .nextState(clientId); singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .stateSubject.next([userIdFromAccessToken, clientId]); + .nextState(clientId); // Act await (tokenService as any).clearClientId(userIdFromAccessToken); @@ -2062,16 +2061,14 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .stateSubject.next([userIdFromAccessToken, clientId]); + .nextState(clientId); singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .stateSubject.next([userIdFromAccessToken, clientId]); + .nextState(clientId); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act await (tokenService as any).clearClientId(); @@ -2106,9 +2103,7 @@ describe("TokenService", () => { it("should throw an error if the vault timeout is missing", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = tokenService.setClientSecret(clientSecret, VaultTimeoutAction.Lock, null); @@ -2120,9 +2115,7 @@ describe("TokenService", () => { it("should throw an error if the vault timeout action is missing", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = tokenService.setClientSecret( @@ -2138,9 +2131,7 @@ describe("TokenService", () => { describe("Memory storage tests", () => { it("sets the client secret in memory when there is an active user in global state", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act await tokenService.setClientSecret( @@ -2176,9 +2167,7 @@ describe("TokenService", () => { describe("Disk storage tests", () => { it("sets the client secret on disk when there is an active user in global state", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act await tokenService.setClientSecret( @@ -2222,9 +2211,7 @@ describe("TokenService", () => { it("returns null when no client secret is found in memory or disk", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getClientSecret(); @@ -2237,17 +2224,15 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .stateSubject.next([userIdFromAccessToken, clientSecret]); + .nextState(clientSecret); // set disk to undefined singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getClientSecret(); @@ -2260,12 +2245,12 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .stateSubject.next([userIdFromAccessToken, clientSecret]); + .nextState(clientSecret); // set disk to undefined singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); // Act const result = await tokenService.getClientSecret(userIdFromAccessToken); @@ -2279,16 +2264,14 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .stateSubject.next([userIdFromAccessToken, clientSecret]); + .nextState(clientSecret); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act const result = await tokenService.getClientSecret(); @@ -2300,11 +2283,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); + .nextState(undefined); singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .stateSubject.next([userIdFromAccessToken, clientSecret]); + .nextState(clientSecret); // Act const result = await tokenService.getClientSecret(userIdFromAccessToken); @@ -2327,11 +2310,11 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .stateSubject.next([userIdFromAccessToken, clientSecret]); + .nextState(clientSecret); singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .stateSubject.next([userIdFromAccessToken, clientSecret]); + .nextState(clientSecret); // Act await (tokenService as any).clearClientSecret(userIdFromAccessToken); @@ -2351,16 +2334,14 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .stateSubject.next([userIdFromAccessToken, clientSecret]); + .nextState(clientSecret); singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .stateSubject.next([userIdFromAccessToken, clientSecret]); + .nextState(clientSecret); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act await (tokenService as any).clearClientSecret(); @@ -2634,7 +2615,7 @@ describe("TokenService", () => { // Arrange const userId = "userId" as UserId; - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).stateSubject.next(userId); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userId); tokenService.clearAccessToken = jest.fn(); (tokenService as any).clearRefreshToken = jest.fn(); @@ -2693,7 +2674,7 @@ describe("TokenService", () => { globalStateProvider .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) - .stateSubject.next(initialTwoFactorTokenRecord); + .nextState(initialTwoFactorTokenRecord); // Act await tokenService.setTwoFactorToken(email, twoFactorToken); @@ -2716,7 +2697,7 @@ describe("TokenService", () => { globalStateProvider .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) - .stateSubject.next(initialTwoFactorTokenRecord); + .nextState(initialTwoFactorTokenRecord); // Act const result = await tokenService.getTwoFactorToken(email); @@ -2734,7 +2715,7 @@ describe("TokenService", () => { globalStateProvider .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) - .stateSubject.next(initialTwoFactorTokenRecord); + .nextState(initialTwoFactorTokenRecord); // Act const result = await tokenService.getTwoFactorToken(email); @@ -2745,9 +2726,7 @@ describe("TokenService", () => { it("returns null when there is no two factor token record", async () => { // Arrange - globalStateProvider - .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) - .stateSubject.next(null); + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextState(null); // Act const result = await tokenService.getTwoFactorToken("testUser"); @@ -2768,7 +2747,7 @@ describe("TokenService", () => { globalStateProvider .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) - .stateSubject.next(initialTwoFactorTokenRecord); + .nextState(initialTwoFactorTokenRecord); // Act await tokenService.clearTwoFactorToken(email); @@ -2808,9 +2787,7 @@ describe("TokenService", () => { it("sets the security stamp in memory when there is an active user in global state", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act await tokenService.setSecurityStamp(mockSecurityStamp); @@ -2843,13 +2820,11 @@ describe("TokenService", () => { it("returns the security stamp from memory when no user id is specified (uses global active user)", async () => { // Arrange - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); singleUserStateProvider .getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY) - .stateSubject.next([userIdFromAccessToken, mockSecurityStamp]); + .nextState(mockSecurityStamp); // Act const result = await tokenService.getSecurityStamp(); @@ -2862,7 +2837,7 @@ describe("TokenService", () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY) - .stateSubject.next([userIdFromAccessToken, mockSecurityStamp]); + .nextState(mockSecurityStamp); // Act const result = await tokenService.getSecurityStamp(userIdFromAccessToken); diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index ef7f23cb05a..c2150bc5c52 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -9,6 +9,7 @@ import { KeyGenerationService } from "../../platform/abstractions/key-generation import { LogService } from "../../platform/abstractions/log.service"; import { AbstractStorageService } from "../../platform/abstractions/storage.service"; import { StorageLocation } from "../../platform/enums"; +import { Utils } from "../../platform/misc/utils"; import { EncString, EncryptedString } from "../../platform/models/domain/enc-string"; import { StorageOptions } from "../../platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -875,8 +876,13 @@ export class TokenService implements TokenServiceAbstraction { // jwthelper methods // ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js - async decodeAccessToken(token?: string): Promise { - token = token ?? (await this.getAccessToken()); + async decodeAccessToken(tokenOrUserId?: string | UserId): Promise { + let token = tokenOrUserId as string; + if (Utils.isGuid(tokenOrUserId)) { + token = await this.getAccessToken(tokenOrUserId as UserId); + } else { + token ??= await this.getAccessToken(); + } if (token == null) { throw new Error("Access token not found."); @@ -1012,10 +1018,10 @@ export class TokenService implements TokenServiceAbstraction { return decoded.iss; } - async getIsExternal(): Promise { + async getIsExternal(userId: UserId): Promise { let decoded: DecodedAccessToken; try { - decoded = await this.decodeAccessToken(); + decoded = await this.decodeAccessToken(userId); } catch (error) { throw new Error("Failed to decode access token: " + error.message); } diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts index 4b36e8d2bfc..7f2e8c31508 100644 --- a/libs/common/src/autofill/services/domain-settings.service.ts +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -15,6 +15,7 @@ import { StateProvider, UserKeyDefinition, } from "../../platform/state"; +import { UserId } from "../../types/guid"; const SHOW_FAVICONS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "showFavicons", { deserializer: (value: boolean) => value ?? true, @@ -44,7 +45,7 @@ export abstract class DomainSettingsService { neverDomains$: Observable; setNeverDomains: (newValue: NeverDomains) => Promise; equivalentDomains$: Observable; - setEquivalentDomains: (newValue: EquivalentDomains) => Promise; + setEquivalentDomains: (newValue: EquivalentDomains, userId: UserId) => Promise; defaultUriMatchStrategy$: Observable; setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise; getUrlEquivalentDomains: (url: string) => Observable>; @@ -87,8 +88,8 @@ export class DefaultDomainSettingsService implements DomainSettingsService { await this.neverDomainsState.update(() => newValue); } - async setEquivalentDomains(newValue: EquivalentDomains): Promise { - await this.equivalentDomainsState.update(() => newValue); + async setEquivalentDomains(newValue: EquivalentDomains, userId: UserId): Promise { + await this.stateProvider.getUser(userId, EQUIVALENT_DOMAINS).update(() => newValue); } async setDefaultUriMatchStrategy(newValue: UriMatchStrategySetting): Promise { diff --git a/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts b/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts index e07dec3cf90..080c61e9ffb 100644 --- a/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts +++ b/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts @@ -1,5 +1,7 @@ import { Observable } from "rxjs"; +import { UserId } from "../../../types/guid"; + export type BillingAccountProfile = { hasPremiumPersonally: boolean; hasPremiumFromAnyOrganization: boolean; @@ -32,5 +34,6 @@ export abstract class BillingAccountProfileStateService { abstract setHasPremium( hasPremiumPersonally: boolean, hasPremiumFromAnyOrganization: boolean, + userId: UserId, ): Promise; } diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts index 7f0f218a239..7e0dee0eedf 100644 --- a/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts +++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts @@ -3,7 +3,6 @@ import { firstValueFrom } from "rxjs"; import { FakeAccountService, mockAccountServiceWith, - FakeActiveUserState, FakeStateProvider, FakeSingleUserState, } from "../../../../spec"; @@ -18,7 +17,6 @@ import { describe("BillingAccountProfileStateService", () => { let stateProvider: FakeStateProvider; let sut: DefaultBillingAccountProfileStateService; - let billingAccountProfileState: FakeActiveUserState; let userBillingAccountProfileState: FakeSingleUserState; let accountService: FakeAccountService; @@ -30,10 +28,6 @@ describe("BillingAccountProfileStateService", () => { sut = new DefaultBillingAccountProfileStateService(stateProvider); - billingAccountProfileState = stateProvider.activeUser.getFake( - BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, - ); - userBillingAccountProfileState = stateProvider.singleUser.getFake( userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, @@ -133,12 +127,11 @@ describe("BillingAccountProfileStateService", () => { describe("setHasPremium", () => { it("should update the active users state when called", async () => { - await sut.setHasPremium(true, false); + await sut.setHasPremium(true, false, userId); - expect(billingAccountProfileState.nextMock).toHaveBeenCalledWith([ - userId, - { hasPremiumPersonally: true, hasPremiumFromAnyOrganization: false }, - ]); + expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false); + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true); + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); }); }); }); diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts index cf05df2f22b..7d256da9714 100644 --- a/libs/common/src/billing/services/account/billing-account-profile-state.service.ts +++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts @@ -6,6 +6,7 @@ import { StateProvider, UserKeyDefinition, } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; import { BillingAccountProfile, BillingAccountProfileStateService, @@ -27,7 +28,7 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP hasPremiumPersonally$: Observable; hasPremiumFromAnySource$: Observable; - constructor(stateProvider: StateProvider) { + constructor(private readonly stateProvider: StateProvider) { this.billingAccountProfileState = stateProvider.getActive( BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, ); @@ -62,8 +63,9 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP async setHasPremium( hasPremiumPersonally: boolean, hasPremiumFromAnyOrganization: boolean, + userId: UserId, ): Promise { - await this.billingAccountProfileState.update((billingAccountProfile) => { + await this.stateProvider.getUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION).update((_) => { return { hasPremiumPersonally: hasPremiumPersonally, hasPremiumFromAnyOrganization: hasPremiumFromAnyOrganization, diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index b9499c8fd59..ed18204e9e0 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -143,7 +143,7 @@ export abstract class CryptoService { * @param userKeyMasterKey The master key encrypted user key to set * @param userId The desired user */ - abstract setMasterKeyEncryptedUserKey(UserKeyMasterKey: string, userId?: string): Promise; + abstract setMasterKeyEncryptedUserKey(UserKeyMasterKey: string, userId: string): Promise; /** * @param password The user's master password that will be used to derive a master key if one isn't found * @param userId The desired user diff --git a/libs/common/src/platform/abstractions/platform-utils.service.ts b/libs/common/src/platform/abstractions/platform-utils.service.ts index 9725435afa4..fa0fc8f2501 100644 --- a/libs/common/src/platform/abstractions/platform-utils.service.ts +++ b/libs/common/src/platform/abstractions/platform-utils.service.ts @@ -43,26 +43,6 @@ export abstract class PlatformUtilsService { abstract isSelfHost(): boolean; abstract copyToClipboard(text: string, options?: ClipboardOptions): void | boolean; abstract readFromClipboard(): Promise; - abstract supportsBiometric(): Promise; - /** - * Determine whether biometrics support requires going through a setup process. - * This is currently only needed on Linux. - * - * @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place) - */ - abstract biometricsNeedsSetup: () => Promise; - /** - * Determine whether biometrics support can be automatically setup, or requires user interaction. - * Auto-setup is prevented by sandboxed environments, such as Snap and Flatpak. - * - * @returns true if biometrics support can be automatically setup, false if it requires user interaction. - */ - abstract biometricsSupportsAutoSetup(): Promise; - /** - * Start automatic biometric setup, which places the required configuration files / changes the required settings. - */ - abstract biometricsSetup: () => Promise; - abstract authenticateBiometric(): Promise; abstract supportsSecureStorage(): boolean; abstract getAutofillKeyboardShortcut(): Promise; } diff --git a/libs/common/src/platform/biometrics/biometric-state.service.spec.ts b/libs/common/src/platform/biometrics/biometric-state.service.spec.ts index 097428e16af..56e9cb164f5 100644 --- a/libs/common/src/platform/biometrics/biometric-state.service.spec.ts +++ b/libs/common/src/platform/biometrics/biometric-state.service.spec.ts @@ -119,7 +119,7 @@ describe("BiometricStateService", () => { describe("getRequirePasswordOnStart", () => { it("returns the requirePasswordOnStart state value", async () => { - stateProvider.singleUser.mockFor(userId, REQUIRE_PASSWORD_ON_START.key, true); + stateProvider.singleUser.mockFor(userId, REQUIRE_PASSWORD_ON_START, true); expect(await sut.getRequirePasswordOnStart(userId)).toBe(true); }); diff --git a/libs/common/src/platform/biometrics/biometric.service.ts b/libs/common/src/platform/biometrics/biometric.service.ts new file mode 100644 index 00000000000..ae65dcd1765 --- /dev/null +++ b/libs/common/src/platform/biometrics/biometric.service.ts @@ -0,0 +1,37 @@ +/** + * The biometrics service is used to provide access to the status of and access to biometric functionality on the platforms. + */ +export abstract class BiometricsService { + /** + * Check if the platform supports biometric authentication. + */ + abstract supportsBiometric(): Promise; + + /** + * Checks whether biometric unlock is currently available at the moment (e.g. if the laptop lid is shut, biometric unlock may not be available) + */ + abstract isBiometricUnlockAvailable(): Promise; + + /** + * Performs biometric authentication + */ + abstract authenticateBiometric(): Promise; + /** + * Determine whether biometrics support requires going through a setup process. + * This is currently only needed on Linux. + * + * @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place) + */ + abstract biometricsNeedsSetup(): Promise; + /** + * Determine whether biometrics support can be automatically setup, or requires user interaction. + * Auto-setup is prevented by sandboxed environments, such as Snap and Flatpak. + * + * @returns true if biometrics support can be automatically setup, false if it requires user interaction. + */ + abstract biometricsSupportsAutoSetup(): Promise; + /** + * Start automatic biometric setup, which places the required configuration files / changes the required settings. + */ + abstract biometricsSetup(): Promise; +} diff --git a/libs/common/src/platform/services/config/config.service.spec.ts b/libs/common/src/platform/services/config/config.service.spec.ts index d7e33473d01..efe75f0882a 100644 --- a/libs/common/src/platform/services/config/config.service.spec.ts +++ b/libs/common/src/platform/services/config/config.service.spec.ts @@ -3,8 +3,8 @@ * @jest-environment ../../libs/shared/test.environment.ts */ -import { mock } from "jest-mock-extended"; -import { Subject, firstValueFrom, of } from "rxjs"; +import { matches, mock } from "jest-mock-extended"; +import { BehaviorSubject, Subject, bufferCount, firstValueFrom, of } from "rxjs"; import { FakeGlobalState, @@ -35,6 +35,7 @@ import { RETRIEVAL_INTERVAL, GLOBAL_SERVER_CONFIGURATIONS, USER_SERVER_CONFIG, + SLOW_EMISSION_GUARD, } from "./default-config.service"; describe("ConfigService", () => { @@ -65,12 +66,14 @@ describe("ConfigService", () => { describe.each([null, userId])("active user: %s", (activeUserId) => { let sut: DefaultConfigService; + const environmentSubject = new BehaviorSubject(environmentFactory(activeApiUrl)); + beforeAll(async () => { await accountService.switchAccount(activeUserId); }); beforeEach(() => { - environmentService.environment$ = of(environmentFactory(activeApiUrl)); + environmentService.environment$ = environmentSubject; sut = new DefaultConfigService( configApiService, environmentService, @@ -129,7 +132,8 @@ describe("ConfigService", () => { await firstValueFrom(sut.serverConfig$); expect(logService.error).toHaveBeenCalledWith( - `Unable to fetch ServerConfig from ${activeApiUrl}: Unable to fetch`, + `Unable to fetch ServerConfig from ${activeApiUrl}`, + matches((e) => e.message === "Unable to fetch"), ); }); }); @@ -138,6 +142,10 @@ describe("ConfigService", () => { const response = serverConfigResponseFactory(); const newConfig = new ServerConfig(new ServerConfigData(response)); + beforeEach(() => { + configApiService.get.mockResolvedValue(response); + }); + it("should be a new config", async () => { expect(newConfig).not.toEqual(activeUserId ? userStored : globalStored[activeApiUrl]); }); @@ -149,8 +157,6 @@ describe("ConfigService", () => { }); it("returns the updated config", async () => { - configApiService.get.mockResolvedValue(response); - const actual = await firstValueFrom(sut.serverConfig$); // This is the time the response is converted to a config @@ -270,6 +276,54 @@ describe("ConfigService", () => { }); }); }); + + describe("slow configuration", () => { + const environmentSubject = new BehaviorSubject(null); + + let sut: DefaultConfigService = null; + + beforeEach(async () => { + const config = serverConfigFactory("existing-data", tooOld); + environmentService.environment$ = environmentSubject; + + globalState.stateSubject.next({ [apiUrl(0)]: config }); + userState.stateSubject.next({ + syncValue: true, + combinedState: [userId, config], + }); + + configApiService.get.mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(serverConfigResponseFactory("slow-response")); + }, SLOW_EMISSION_GUARD + 20); + }); + }); + + sut = new DefaultConfigService( + configApiService, + environmentService, + logService, + stateProvider, + authService, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("emits old configuration when the http call takes a long time", async () => { + environmentSubject.next(environmentFactory(apiUrl(0))); + + const configs = await firstValueFrom(sut.serverConfig$.pipe(bufferCount(2))); + + await jest.runOnlyPendingTimersAsync(); + + expect(configs[0].gitHash).toBe("existing-data"); + expect(configs[1].gitHash).toBe("slow-response"); + }); + }); }); function apiUrl(count: number) { @@ -305,8 +359,9 @@ function serverConfigResponseFactory(hash?: string) { }); } -function environmentFactory(apiUrl: string) { +function environmentFactory(apiUrl: string, isCloud: boolean = true) { return { getApiUrl: () => apiUrl, + isCloud: () => isCloud, } as Environment; } diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts index 16878a72832..74dd5055d4b 100644 --- a/libs/common/src/platform/services/config/default-config.service.ts +++ b/libs/common/src/platform/services/config/default-config.service.ts @@ -24,7 +24,7 @@ import { UserId } from "../../../types/guid"; import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; import { ConfigService } from "../../abstractions/config/config.service"; import { ServerConfig } from "../../abstractions/config/server-config"; -import { EnvironmentService, Region } from "../../abstractions/environment.service"; +import { Environment, EnvironmentService, Region } from "../../abstractions/environment.service"; import { LogService } from "../../abstractions/log.service"; import { devFlagEnabled, devFlagValue } from "../../misc/flags"; import { ServerConfigData } from "../../models/data/server-config.data"; @@ -34,6 +34,8 @@ export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs") ? (devFlagValue("configRetrievalIntervalMs") as number) : 3_600_000; // 1 hour +export const SLOW_EMISSION_GUARD = 800; + export type ApiUrl = string; export const USER_SERVER_CONFIG = new UserKeyDefinition(CONFIG_DISK, "serverConfig", { @@ -64,29 +66,32 @@ export class DefaultConfigService implements ConfigService { private stateProvider: StateProvider, private authService: AuthService, ) { - const apiUrl$ = this.environmentService.environment$.pipe( - map((environment) => environment.getApiUrl()), - ); const userId$ = this.stateProvider.activeUserId$; const authStatus$ = userId$.pipe( switchMap((userId) => (userId == null ? of(null) : this.authService.authStatusFor$(userId))), ); - this.serverConfig$ = combineLatest([userId$, apiUrl$, authStatus$]).pipe( - switchMap(([userId, apiUrl, authStatus]) => { + this.serverConfig$ = combineLatest([ + userId$, + this.environmentService.environment$, + authStatus$, + ]).pipe( + switchMap(([userId, environment, authStatus]) => { if (userId == null || authStatus !== AuthenticationStatus.Unlocked) { - return this.globalConfigFor$(apiUrl).pipe( - map((config) => [config, null, apiUrl] as const), + return this.globalConfigFor$(environment.getApiUrl()).pipe( + map((config) => [config, null, environment] as const), ); } - return this.userConfigFor$(userId).pipe(map((config) => [config, userId, apiUrl] as const)); + return this.userConfigFor$(userId).pipe( + map((config) => [config, userId, environment] as const), + ); }), tap(async (rec) => { - const [existingConfig, userId, apiUrl] = rec; + const [existingConfig, userId, environment] = rec; // Grab new config if older retrieval interval if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) { - await this.renewConfig(existingConfig, userId, apiUrl); + await this.renewConfig(existingConfig, userId, environment); } }), switchMap(([existingConfig]) => { @@ -149,10 +154,20 @@ export class DefaultConfigService implements ConfigService { private async renewConfig( existingConfig: ServerConfig, userId: UserId, - apiUrl: string, + environment: Environment, ): Promise { try { + // Feature flags often have a big impact on user experience, lets ensure we return some value + // somewhat quickly even though it may not be accurate, we won't cancel the HTTP request + // though so that hopefully it can have finished and hydrated a more accurate value. + const handle = setTimeout(() => { + this.logService.info( + "Self-host environment did not respond in time, emitting previous config.", + ); + this.failedFetchFallbackSubject.next(existingConfig); + }, SLOW_EMISSION_GUARD); const response = await this.configApiService.get(userId); + clearTimeout(handle); const newConfig = new ServerConfig(new ServerConfigData(response)); // Update the environment region @@ -167,7 +182,7 @@ export class DefaultConfigService implements ConfigService { if (userId == null) { // update global state with new pulled config await this.stateProvider.getGlobal(GLOBAL_SERVER_CONFIGURATIONS).update((configs) => { - return { ...configs, [apiUrl]: newConfig }; + return { ...configs, [environment.getApiUrl()]: newConfig }; }); } else { // update state with new pulled config @@ -175,9 +190,7 @@ export class DefaultConfigService implements ConfigService { } } catch (e) { // mutate error to be handled by catchError - this.logService.error( - `Unable to fetch ServerConfig from ${apiUrl}: ${(e as Error)?.message}`, - ); + this.logService.error(`Unable to fetch ServerConfig from ${environment.getApiUrl()}`, e); // Emit the existing config this.failedFetchFallbackSubject.next(existingConfig); } diff --git a/libs/common/src/platform/services/crypto.service.spec.ts b/libs/common/src/platform/services/crypto.service.spec.ts index 2386ad13711..dfa244ff2ab 100644 --- a/libs/common/src/platform/services/crypto.service.spec.ts +++ b/libs/common/src/platform/services/crypto.service.spec.ts @@ -365,9 +365,9 @@ describe("cryptoService", () => { const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY); const fakeMasterKey = makeMasterKey ? makeSymmetricCryptoKey(64) : null; masterPasswordService.masterKeySubject.next(fakeMasterKey); - userKeyState.stateSubject.next([mockUserId, null]); + userKeyState.nextState(null); const fakeUserKey = makeUserKey ? makeSymmetricCryptoKey(64) : null; - userKeyState.stateSubject.next([mockUserId, fakeUserKey]); + userKeyState.nextState(fakeUserKey); return [fakeUserKey, fakeMasterKey]; } @@ -384,10 +384,7 @@ describe("cryptoService", () => { const fakeEncryptedUserPrivateKey = makeEncString("1"); - userEncryptedPrivateKeyState.stateSubject.next([ - mockUserId, - fakeEncryptedUserPrivateKey.encryptedString, - ]); + userEncryptedPrivateKeyState.nextState(fakeEncryptedUserPrivateKey.encryptedString); // Decryption of the user private key const fakeDecryptedUserPrivateKey = makeStaticByteArray(10, 1); @@ -423,7 +420,7 @@ describe("cryptoService", () => { mockUserId, USER_ENCRYPTED_PRIVATE_KEY, ); - encryptedUserPrivateKeyState.stateSubject.next([mockUserId, null]); + encryptedUserPrivateKeyState.nextState(null); const userPrivateKey = await firstValueFrom(cryptoService.userPrivateKey$(mockUserId)); expect(userPrivateKey).toBeFalsy(); @@ -463,7 +460,7 @@ describe("cryptoService", () => { function updateKeys(keys: Partial = {}) { if ("userKey" in keys) { const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY); - userKeyState.stateSubject.next([mockUserId, keys.userKey]); + userKeyState.nextState(keys.userKey); } if ("encryptedPrivateKey" in keys) { @@ -471,10 +468,7 @@ describe("cryptoService", () => { mockUserId, USER_ENCRYPTED_PRIVATE_KEY, ); - userEncryptedPrivateKey.stateSubject.next([ - mockUserId, - keys.encryptedPrivateKey.encryptedString, - ]); + userEncryptedPrivateKey.nextState(keys.encryptedPrivateKey.encryptedString); } if ("orgKeys" in keys) { @@ -482,7 +476,7 @@ describe("cryptoService", () => { mockUserId, USER_ENCRYPTED_ORGANIZATION_KEYS, ); - orgKeysState.stateSubject.next([mockUserId, keys.orgKeys]); + orgKeysState.nextState(keys.orgKeys); } if ("providerKeys" in keys) { @@ -490,7 +484,7 @@ describe("cryptoService", () => { mockUserId, USER_ENCRYPTED_PROVIDER_KEYS, ); - providerKeysState.stateSubject.next([mockUserId, keys.providerKeys]); + providerKeysState.nextState(keys.providerKeys); } encryptService.decryptToBytes.mockImplementation((encryptedPrivateKey, userKey) => { diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index 6d99f920825..61830513136 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -225,7 +225,7 @@ export class CryptoService implements CryptoServiceAbstraction { } } - async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: UserId): Promise { + async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId: UserId): Promise { userId ??= await firstValueFrom(this.stateProvider.activeUserId$); await this.masterPasswordService.setMasterKeyEncryptedUserKey( new EncString(userKeyMasterKey), diff --git a/libs/common/src/platform/state/implementations/default-state.provider.spec.ts b/libs/common/src/platform/state/implementations/default-state.provider.spec.ts index 5b8b2d1bfeb..b3190bd532e 100644 --- a/libs/common/src/platform/state/implementations/default-state.provider.spec.ts +++ b/libs/common/src/platform/state/implementations/default-state.provider.spec.ts @@ -143,7 +143,7 @@ describe("DefaultStateProvider", () => { it("should not emit any values until a truthy user id is supplied", async () => { accountService.activeAccountSubject.next(null); const state = singleUserStateProvider.getFake(userId, keyDefinition); - state.stateSubject.next([userId, "value"]); + state.nextState("value"); const emissions = trackEmissions(sut.getUserState$(keyDefinition)); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index e48ab0618c3..322687ce6a6 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -124,12 +124,12 @@ export class DefaultSyncService extends CoreSyncService { const response = await this.apiService.getSync(); await this.syncProfile(response.profile); - await this.syncFolders(response.folders); - await this.syncCollections(response.collections); - await this.syncCiphers(response.ciphers); - await this.syncSends(response.sends); - await this.syncSettings(response.domains); - await this.syncPolicies(response.policies); + await this.syncFolders(response.folders, response.profile.id); + await this.syncCollections(response.collections, response.profile.id); + await this.syncCiphers(response.ciphers, response.profile.id); + await this.syncSends(response.sends, response.profile.id); + await this.syncSettings(response.domains, response.profile.id); + await this.syncPolicies(response.policies, response.profile.id); await this.setLastSync(now, userId); return this.syncCompleted(true); @@ -190,8 +190,9 @@ export class DefaultSyncService extends CoreSyncService { await this.billingAccountProfileStateService.setHasPremium( response.premiumPersonally, response.premiumFromOrganization, + response.id, ); - await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector); + await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector, response.id); await this.setForceSetPasswordReasonIfNeeded(response); @@ -200,17 +201,17 @@ export class DefaultSyncService extends CoreSyncService { providers[p.id] = new ProviderData(p); }); - await this.providerService.save(providers); + await this.providerService.save(providers, response.id); - await this.syncProfileOrganizations(response); + await this.syncProfileOrganizations(response, response.id); - if (await this.keyConnectorService.userNeedsMigration()) { - await this.keyConnectorService.setConvertAccountRequired(true); + if (await this.keyConnectorService.userNeedsMigration(response.id)) { + await this.keyConnectorService.setConvertAccountRequired(true, response.id); this.messageSender.send("convertAccountToKeyConnector"); } else { // 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.keyConnectorService.removeConvertAccountRequired(); + this.keyConnectorService.removeConvertAccountRequired(response.id); } } @@ -261,7 +262,7 @@ export class DefaultSyncService extends CoreSyncService { } } - private async syncProfileOrganizations(response: ProfileResponse) { + private async syncProfileOrganizations(response: ProfileResponse, userId: UserId) { const organizations: { [id: string]: OrganizationData } = {}; response.organizations.forEach((o) => { organizations[o.id] = new OrganizationData(o, { @@ -281,42 +282,42 @@ export class DefaultSyncService extends CoreSyncService { } }); - await this.organizationService.replace(organizations); + await this.organizationService.replace(organizations, userId); } - private async syncFolders(response: FolderResponse[]) { + private async syncFolders(response: FolderResponse[], userId: UserId) { const folders: { [id: string]: FolderData } = {}; response.forEach((f) => { folders[f.id] = new FolderData(f); }); - return await this.folderService.replace(folders); + return await this.folderService.replace(folders, userId); } - private async syncCollections(response: CollectionDetailsResponse[]) { + private async syncCollections(response: CollectionDetailsResponse[], userId: UserId) { const collections: { [id: string]: CollectionData } = {}; response.forEach((c) => { collections[c.id] = new CollectionData(c); }); - return await this.collectionService.replace(collections); + return await this.collectionService.replace(collections, userId); } - private async syncCiphers(response: CipherResponse[]) { + private async syncCiphers(response: CipherResponse[], userId: UserId) { const ciphers: { [id: string]: CipherData } = {}; response.forEach((c) => { ciphers[c.id] = new CipherData(c); }); - return await this.cipherService.replace(ciphers); + return await this.cipherService.replace(ciphers, userId); } - private async syncSends(response: SendResponse[]) { + private async syncSends(response: SendResponse[], userId: UserId) { const sends: { [id: string]: SendData } = {}; response.forEach((s) => { sends[s.id] = new SendData(s); }); - return await this.sendService.replace(sends); + return await this.sendService.replace(sends, userId); } - private async syncSettings(response: DomainsResponse) { + private async syncSettings(response: DomainsResponse, userId: UserId) { let eqDomains: string[][] = []; if (response != null && response.equivalentDomains != null) { eqDomains = eqDomains.concat(response.equivalentDomains); @@ -330,16 +331,16 @@ export class DefaultSyncService extends CoreSyncService { }); } - return this.domainSettingsService.setEquivalentDomains(eqDomains); + return this.domainSettingsService.setEquivalentDomains(eqDomains, userId); } - private async syncPolicies(response: PolicyResponse[]) { + private async syncPolicies(response: PolicyResponse[], userId: UserId) { const policies: { [id: string]: PolicyData } = {}; if (response != null) { response.forEach((p) => { policies[p.id] = new PolicyData(p); }); } - return await this.policyService.replace(policies); + return await this.policyService.replace(policies, userId); } } diff --git a/libs/common/src/tools/send/services/send-state.provider.abstraction.ts b/libs/common/src/tools/send/services/send-state.provider.abstraction.ts index 7a35506b56c..c16d06fb929 100644 --- a/libs/common/src/tools/send/services/send-state.provider.abstraction.ts +++ b/libs/common/src/tools/send/services/send-state.provider.abstraction.ts @@ -1,15 +1,19 @@ import { Observable } from "rxjs"; +import type { Simplify } from "type-fest"; +import { CombinedState } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; import { SendData } from "../models/data/send.data"; import { SendView } from "../models/view/send.view"; +type EncryptedSendState = Simplify>>; export abstract class SendStateProvider { - encryptedState$: Observable>; + encryptedState$: Observable; decryptedState$: Observable; - getEncryptedSends: () => Promise<{ [id: string]: SendData }>; + getEncryptedSends: () => Promise; - setEncryptedSends: (value: { [id: string]: SendData }) => Promise; + setEncryptedSends: (value: { [id: string]: SendData }, userId: UserId) => Promise; getDecryptedSends: () => Promise; diff --git a/libs/common/src/tools/send/services/send-state.provider.spec.ts b/libs/common/src/tools/send/services/send-state.provider.spec.ts index 069e0d80697..abca614d11b 100644 --- a/libs/common/src/tools/send/services/send-state.provider.spec.ts +++ b/libs/common/src/tools/send/services/send-state.provider.spec.ts @@ -27,11 +27,11 @@ describe("Send State Provider", () => { describe("Encrypted Sends", () => { it("should return SendData", async () => { const sendData = { "1": testSendData("1", "Test Send Data") }; - await sendStateProvider.setEncryptedSends(sendData); + await sendStateProvider.setEncryptedSends(sendData, mockUserId); await awaitAsync(); const actual = await sendStateProvider.getEncryptedSends(); - expect(actual).toStrictEqual(sendData); + expect(actual).toStrictEqual([mockUserId, sendData]); }); }); diff --git a/libs/common/src/tools/send/services/send-state.provider.ts b/libs/common/src/tools/send/services/send-state.provider.ts index 1e9397b7a9d..66989a70543 100644 --- a/libs/common/src/tools/send/services/send-state.provider.ts +++ b/libs/common/src/tools/send/services/send-state.provider.ts @@ -1,6 +1,7 @@ import { Observable, firstValueFrom } from "rxjs"; -import { ActiveUserState, StateProvider } from "../../../platform/state"; +import { ActiveUserState, CombinedState, StateProvider } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; import { SendData } from "../models/data/send.data"; import { SendView } from "../models/view/send.view"; @@ -10,7 +11,7 @@ import { SendStateProvider as SendStateProviderAbstraction } from "./send-state. /** State provider for sends */ export class SendStateProvider implements SendStateProviderAbstraction { /** Observable for the encrypted sends for an active user */ - encryptedState$: Observable>; + encryptedState$: Observable>>; /** Observable with the decrypted sends for an active user */ decryptedState$: Observable; @@ -19,20 +20,20 @@ export class SendStateProvider implements SendStateProviderAbstraction { constructor(protected stateProvider: StateProvider) { this.activeUserEncryptedState = this.stateProvider.getActive(SEND_USER_ENCRYPTED); - this.encryptedState$ = this.activeUserEncryptedState.state$; + this.encryptedState$ = this.activeUserEncryptedState.combinedState$; this.activeUserDecryptedState = this.stateProvider.getActive(SEND_USER_DECRYPTED); this.decryptedState$ = this.activeUserDecryptedState.state$; } /** Gets the encrypted sends from state for an active user */ - async getEncryptedSends(): Promise<{ [id: string]: SendData }> { + async getEncryptedSends(): Promise> { return await firstValueFrom(this.encryptedState$); } /** Sets the encrypted send state for an active user */ - async setEncryptedSends(value: { [id: string]: SendData }): Promise { - await this.activeUserEncryptedState.update(() => value); + async setEncryptedSends(value: { [id: string]: SendData }, userId: UserId): Promise { + await this.stateProvider.getUser(userId, SEND_USER_ENCRYPTED).update(() => value); } /** Gets the decrypted sends from state for the active user */ diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index 6033c9c6cb4..4fa927942c1 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -55,6 +55,6 @@ export abstract class SendService implements UserKeyRotationDataProvider Promise; - replace: (sends: { [id: string]: SendData }) => Promise; + replace: (sends: { [id: string]: SendData }, userId: UserId) => Promise; delete: (id: string | string[]) => Promise; } diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 5d04127192f..5743eff481b 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -110,9 +110,12 @@ describe("SendService", () => { const result = await firstValueFrom(singleSendObservable); expect(result).toEqual(testSend("1", "Test Send")); - await sendService.replace({ - "1": testSendData("1", "Test Send Updated"), - }); + await sendService.replace( + { + "1": testSendData("1", "Test Send Updated"), + }, + mockUserId, + ); const result2 = await firstValueFrom(singleSendObservable); expect(result2).toEqual(testSend("1", "Test Send Updated")); @@ -127,10 +130,13 @@ describe("SendService", () => { //it is immediately called when subscribed, we need to reset the value changed = false; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); @@ -138,10 +144,13 @@ describe("SendService", () => { it("reports a change when notes changes on a new send", async () => { const sendDataObject = createSendData() as SendData; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); let changed = false; sendService.get$("1").subscribe(() => { @@ -152,10 +161,13 @@ describe("SendService", () => { //it is immediately called when subscribed, we need to reset the value changed = false; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); @@ -163,10 +175,13 @@ describe("SendService", () => { it("reports a change when Text changes on a new send", async () => { const sendDataObject = createSendData() as SendData; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); let changed = false; sendService.get$("1").subscribe(() => { @@ -177,10 +192,13 @@ describe("SendService", () => { changed = false; sendDataObject.text.text = "new text"; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); @@ -188,10 +206,13 @@ describe("SendService", () => { it("reports a change when Text is set as null on a new send", async () => { const sendDataObject = createSendData() as SendData; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); let changed = false; sendService.get$("1").subscribe(() => { @@ -202,10 +223,13 @@ describe("SendService", () => { changed = false; sendDataObject.text = null; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); @@ -215,10 +239,13 @@ describe("SendService", () => { type: SendType.File, file: new SendFileData(new SendFileApi({ FileName: "name of file" })), }) as SendData; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); sendDataObject.file = new SendFileData(new SendFileApi({ FileName: "updated name of file" })); let changed = false; @@ -229,10 +256,13 @@ describe("SendService", () => { //it is immediately called when subscribed, we need to reset the value changed = false; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(false); }); @@ -240,10 +270,13 @@ describe("SendService", () => { it("reports a change when key changes on a new send", async () => { const sendDataObject = createSendData() as SendData; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); let changed = false; sendService.get$("1").subscribe(() => { @@ -254,10 +287,13 @@ describe("SendService", () => { changed = false; sendDataObject.key = "newKey"; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); @@ -265,10 +301,13 @@ describe("SendService", () => { it("reports a change when revisionDate changes on a new send", async () => { const sendDataObject = createSendData() as SendData; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); let changed = false; sendService.get$("1").subscribe(() => { @@ -279,10 +318,13 @@ describe("SendService", () => { changed = false; sendDataObject.revisionDate = "2025-04-05"; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); @@ -290,10 +332,13 @@ describe("SendService", () => { it("reports a change when a property is set as null on a new send", async () => { const sendDataObject = createSendData() as SendData; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); let changed = false; sendService.get$("1").subscribe(() => { @@ -304,10 +349,13 @@ describe("SendService", () => { changed = false; sendDataObject.name = null; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); @@ -317,10 +365,13 @@ describe("SendService", () => { text: new SendTextData(new SendTextApi({ Text: null })), }) as SendData; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); let changed = false; sendService.get$("1").subscribe(() => { @@ -330,23 +381,29 @@ describe("SendService", () => { //it is immediately called when subscribed, we need to reset the value changed = false; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(false); sendDataObject.text.text = "Asdf"; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); - it("do not reports a change when nothing changes on the observed send", async () => { + it("do not report a change when nothing changes on the observed send", async () => { let changed = false; sendService.get$("1").subscribe(() => { changed = true; @@ -357,10 +414,13 @@ describe("SendService", () => { //it is immediately called when subscribed, we need to reset the value changed = false; - await sendService.replace({ - "1": sendDataObject, - "2": testSendData("3", "Test Send 3"), - }); + await sendService.replace( + { + "1": sendDataObject, + "2": testSendData("3", "Test Send 3"), + }, + mockUserId, + ); expect(changed).toEqual(false); }); @@ -373,9 +433,12 @@ describe("SendService", () => { //it is immediately called when subscribed, we need to reset the value changed = false; - await sendService.replace({ - "2": testSendData("2", "Test Send 2"), - }); + await sendService.replace( + { + "2": testSendData("2", "Test Send 2"), + }, + mockUserId, + ); expect(changed).toEqual(true); }); @@ -426,7 +489,7 @@ describe("SendService", () => { }); it("returns empty array if there are no sends", async () => { - await sendService.replace(null); + await sendService.replace(null, mockUserId); await awaitAsync(); @@ -461,16 +524,11 @@ describe("SendService", () => { }); it("replace", async () => { - await sendService.replace({ "2": testSendData("2", "test 2") }); + await sendService.replace({ "2": testSendData("2", "test 2") }, mockUserId); expect(await firstValueFrom(sendService.sends$)).toEqual([testSend("2", "test 2")]); }); - it("clear", async () => { - await sendService.clear(); - await awaitAsync(); - expect(await firstValueFrom(sendService.sends$)).toEqual([]); - }); describe("Delete", () => { it("Sends count should decrease after delete", async () => { const sendsBeforeDelete = await firstValueFrom(sendService.sends$); @@ -488,7 +546,7 @@ describe("SendService", () => { }); it("Deleting on an empty sends array should not throw", async () => { - sendStateProvider.getEncryptedSends = jest.fn().mockResolvedValue(null); + stateProvider.activeUser.getFake(SEND_USER_ENCRYPTED).nextState(null); await expect(sendService.delete("2")).resolves.not.toThrow(); }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 7048cf5a371..63c07e862ff 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -28,10 +28,10 @@ export class SendService implements InternalSendServiceAbstraction { readonly sendKeyPurpose = "send"; sends$ = this.stateProvider.encryptedState$.pipe( - map((record) => Object.values(record || {}).map((data) => new Send(data))), + map(([, record]) => Object.values(record || {}).map((data) => new Send(data))), ); sendViews$ = this.stateProvider.encryptedState$.pipe( - concatMap((record) => + concatMap(([, record]) => this.decryptSends(Object.values(record || {}).map((data) => new Send(data))), ), ); @@ -167,7 +167,7 @@ export class SendService implements InternalSendServiceAbstraction { } async getFromState(id: string): Promise { - const sends = await this.stateProvider.getEncryptedSends(); + const [, sends] = await this.stateProvider.getEncryptedSends(); // eslint-disable-next-line if (sends == null || !sends.hasOwnProperty(id)) { return null; @@ -177,7 +177,7 @@ export class SendService implements InternalSendServiceAbstraction { } async getAll(): Promise { - const sends = await this.stateProvider.getEncryptedSends(); + const [, sends] = await this.stateProvider.getEncryptedSends(); const response: Send[] = []; for (const id in sends) { // eslint-disable-next-line @@ -214,7 +214,8 @@ export class SendService implements InternalSendServiceAbstraction { } async upsert(send: SendData | SendData[]): Promise { - let sends = await this.stateProvider.getEncryptedSends(); + const [userId, currentSends] = await this.stateProvider.getEncryptedSends(); + let sends = currentSends; if (sends == null) { sends = {}; } @@ -227,16 +228,11 @@ export class SendService implements InternalSendServiceAbstraction { }); } - await this.replace(sends); - } - - async clear(userId?: string): Promise { - await this.stateProvider.setDecryptedSends(null); - await this.stateProvider.setEncryptedSends(null); + await this.replace(sends, userId); } async delete(id: string | string[]): Promise { - const sends = await this.stateProvider.getEncryptedSends(); + const [userId, sends] = await this.stateProvider.getEncryptedSends(); if (sends == null) { return; } @@ -252,11 +248,11 @@ export class SendService implements InternalSendServiceAbstraction { }); } - await this.replace(sends); + await this.replace(sends, userId); } - async replace(sends: { [id: string]: SendData }): Promise { - await this.stateProvider.setEncryptedSends(sends); + async replace(sends: { [id: string]: SendData }, userId: UserId): Promise { + await this.stateProvider.setEncryptedSends(sends, userId); } async getRotatedData( diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index c95ae27f612..061bd5cedb5 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -133,7 +133,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider Promise>; - replace: (ciphers: { [id: string]: CipherData }) => Promise; + replace: (ciphers: { [id: string]: CipherData }, userId: UserId) => Promise; clear: (userId?: string) => Promise; moveManyWithServer: (ids: string[], folderId: string) => Promise; delete: (id: string | string[]) => Promise; diff --git a/libs/common/src/vault/abstractions/collection.service.ts b/libs/common/src/vault/abstractions/collection.service.ts index 0c206139633..084aa3a8084 100644 --- a/libs/common/src/vault/abstractions/collection.service.ts +++ b/libs/common/src/vault/abstractions/collection.service.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { CollectionId } from "../../types/guid"; +import { CollectionId, UserId } from "../../types/guid"; import { CollectionData } from "../models/data/collection.data"; import { Collection } from "../models/domain/collection"; import { TreeNode } from "../models/domain/tree-node"; @@ -22,7 +22,7 @@ export abstract class CollectionService { getAllNested: (collections?: CollectionView[]) => Promise[]>; getNested: (id: string) => Promise>; upsert: (collection: CollectionData | CollectionData[]) => Promise; - replace: (collections: { [id: string]: CollectionData }) => Promise; + replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; clear: (userId?: string) => Promise; delete: (id: string | string[]) => Promise; } diff --git a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts index 71b8089fa6f..3480a8aca03 100644 --- a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts @@ -45,7 +45,7 @@ export abstract class FolderService implements UserKeyRotationDataProvider Promise; - replace: (folders: { [id: string]: FolderData }) => Promise; + replace: (folders: { [id: string]: FolderData }, userId: UserId) => Promise; clear: (userId?: string) => Promise; delete: (id: string | string[]) => Promise; } diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 92676aea97b..cb72d413c8f 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -913,8 +913,8 @@ export class CipherService implements CipherServiceAbstraction { }); } - async replace(ciphers: { [id: string]: CipherData }): Promise { - await this.updateEncryptedCipherState(() => ciphers); + async replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise { + await this.updateEncryptedCipherState(() => ciphers, userId); } /** @@ -924,15 +924,18 @@ export class CipherService implements CipherServiceAbstraction { */ private async updateEncryptedCipherState( update: (current: Record) => Record, + userId: UserId = null, ): Promise> { - const userId = await firstValueFrom(this.stateProvider.activeUserId$); + userId ||= await firstValueFrom(this.stateProvider.activeUserId$); // Store that we should wait for an update to return any ciphers await this.ciphersExpectingUpdate.forceValue(true); await this.clearDecryptedCiphersState(userId); - const [, updatedCiphers] = await this.encryptedCiphersState.update((current) => { - const result = update(current ?? {}); - return result; - }); + const updatedCiphers = await this.stateProvider + .getUser(userId, ENCRYPTED_CIPHERS) + .update((current) => { + const result = update(current ?? {}); + return result; + }); return updatedCiphers; } diff --git a/libs/common/src/vault/services/collection.service.ts b/libs/common/src/vault/services/collection.service.ts index 47063aa29dc..e9ad09a4831 100644 --- a/libs/common/src/vault/services/collection.service.ts +++ b/libs/common/src/vault/services/collection.service.ts @@ -184,8 +184,10 @@ export class CollectionService implements CollectionServiceAbstraction { }); } - async replace(collections: Record): Promise { - await this.encryptedCollectionDataState.update(() => collections); + async replace(collections: Record, userId: UserId): Promise { + await this.stateProvider + .getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY) + .update(() => collections); } async clear(userId?: UserId): Promise { diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index 6f181cf882e..c27ea7646b0 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -120,7 +120,7 @@ describe("Folder Service", () => { }); it("replace", async () => { - await folderService.replace({ "2": folderData("2", "test 2") }); + await folderService.replace({ "2": folderData("2", "test 2") }, mockUserId); expect(await firstValueFrom(folderService.folders$)).toEqual([ { diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index 7de7222edca..0c17d7178b2 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -111,12 +111,12 @@ export class FolderService implements InternalFolderServiceAbstraction { }); } - async replace(folders: { [id: string]: FolderData }): Promise { + async replace(folders: { [id: string]: FolderData }, userId: UserId): Promise { if (!folders) { return; } - await this.encryptedFoldersState.update(() => { + await this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS).update(() => { const newFolders: Record = { ...folders }; return newFolders; }); diff --git a/libs/components/src/callout/callout.component.ts b/libs/components/src/callout/callout.component.ts index f7235534d70..0fa1c130504 100644 --- a/libs/components/src/callout/callout.component.ts +++ b/libs/components/src/callout/callout.component.ts @@ -5,7 +5,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { SharedModule } from "../shared"; import { TypographyModule } from "../typography"; -type CalloutTypes = "success" | "info" | "warning" | "danger"; +export type CalloutTypes = "success" | "info" | "warning" | "danger"; const defaultIcon: Record = { success: "bwi-check", diff --git a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts index 837c9d11ede..25c0e411243 100644 --- a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts +++ b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts @@ -23,6 +23,7 @@ export type OptionalInitialValues = { collectionIds?: CollectionId[]; loginUri?: string; username?: string; + password?: string; name?: string; }; @@ -58,7 +59,8 @@ type BaseCipherFormConfig = { originalCipher?: Cipher; /** - * Optional initial values for the form when creating a new cipher. Useful when creating a cipher in a filtered view. + * Optional initial values for the form when opening the cipher form. + * Useful when creating a new cipher in a filtered view or modifying a cipher with values from another source (e.g. the notification bar in Browser) */ initialValues?: OptionalInitialValues; diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts index 601380f98a1..e4dba11525b 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts @@ -128,6 +128,47 @@ describe("AutofillOptionsComponent", () => { expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(null); }); + it("initializes 'autoFillOptionsForm' with initialValues when editing an existing cipher", () => { + cipherFormContainer.config.initialValues = { loginUri: "https://new-website.com" }; + const existingLogin = new LoginUriView(); + existingLogin.uri = "https://example.com"; + existingLogin.match = UriMatchStrategy.Exact; + + (cipherFormContainer.originalCipherView as CipherView) = new CipherView(); + cipherFormContainer.originalCipherView.login = { + autofillOnPageLoad: true, + uris: [existingLogin], + } as LoginView; + + fixture.detectChanges(); + + expect(component.autofillOptionsForm.value.uris).toEqual([ + { uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }, + { uri: "https://new-website.com", matchDetection: null }, + ]); + expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(true); + }); + + it("initializes 'autoFillOptionsForm' with initialValues without duplicating an existing URI", () => { + cipherFormContainer.config.initialValues = { loginUri: "https://example.com" }; + const existingLogin = new LoginUriView(); + existingLogin.uri = "https://example.com"; + existingLogin.match = UriMatchStrategy.Exact; + + (cipherFormContainer.originalCipherView as CipherView) = new CipherView(); + cipherFormContainer.originalCipherView.login = { + autofillOnPageLoad: true, + uris: [existingLogin], + } as LoginView; + + fixture.detectChanges(); + + expect(component.autofillOptionsForm.value.uris).toEqual([ + { uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }, + ]); + expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(true); + }); + it("initializes 'autoFillOptionsForm' with an empty URI when creating a new cipher", () => { cipherFormContainer.config.initialValues = null; diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts index 80de50c4421..eb5767b534f 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts @@ -143,6 +143,20 @@ export class AutofillOptionsComponent implements OnInit { this.autofillOptionsForm.patchValue({ autofillOnPageLoad: existingLogin.autofillOnPageLoad, }); + + if (this.cipherFormContainer.config.initialValues?.loginUri) { + // Avoid adding the same uri again if it already exists + if ( + existingLogin.uris?.findIndex( + (uri) => uri.uri === this.cipherFormContainer.config.initialValues.loginUri, + ) === -1 + ) { + this.addUri({ + uri: this.cipherFormContainer.config.initialValues.loginUri, + matchDetection: null, + }); + } + } } private initNewCipher() { diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts index 5b65c6da24d..85ace2f0ac0 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts @@ -126,15 +126,22 @@ describe("CipherFormGeneratorComponent", () => { expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeTruthy(); }); - it("should save password options when the password type is updated", async () => { - mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password"); + it("should update the generated value when the password type is updated", fakeAsync(async () => { + mockLegacyPasswordGenerationService.generatePassword + .mockResolvedValueOnce("first-password") + .mockResolvedValueOnce("second-password"); + + component.ngOnChanges(); + tick(); + + expect(component["generatedValue"]).toBe("first-password"); await component["updatePasswordType"]("passphrase"); + tick(); - expect(mockLegacyPasswordGenerationService.saveOptions).toHaveBeenCalledWith({ - type: "passphrase", - }); - }); + expect(component["generatedValue"]).toBe("second-password"); + expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(2); + })); it("should update the password history when a new password is generated", fakeAsync(() => { mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("new-password"); diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts index 2d24194d290..7d93ca20d94 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts @@ -1,7 +1,18 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { firstValueFrom, map, startWith, Subject, Subscription, switchMap, tap } from "rxjs"; +import { + combineLatest, + map, + merge, + shareReplay, + startWith, + Subject, + Subscription, + switchMap, + take, + tap, +} from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -61,18 +72,13 @@ export class CipherFormGeneratorComponent implements OnChanges { protected regenerateButtonTitle: string; protected regenerate$ = new Subject(); + protected passwordTypeSubject$ = new Subject(); /** * The currently generated value displayed to the user. * @protected */ protected generatedValue: string = ""; - /** - * The current password generation options. - * @private - */ - private passwordOptions$ = this.legacyPasswordGenerationService.getOptions$(); - /** * The current username generation options. * @private @@ -80,10 +86,30 @@ export class CipherFormGeneratorComponent implements OnChanges { private usernameOptions$ = this.legacyUsernameGenerationService.getOptions$(); /** - * The current password type specified by the password generation options. + * The current password type selected in the UI. Starts with the saved value from the service. * @protected */ - protected passwordType$ = this.passwordOptions$.pipe(map(([options]) => options.type)); + protected passwordType$ = merge( + this.legacyPasswordGenerationService.getOptions$().pipe( + take(1), + map(([options]) => options.type), + ), + this.passwordTypeSubject$, + ).pipe(shareReplay({ bufferSize: 1, refCount: false })); + + /** + * The current password generation options. + * @private + */ + private passwordOptions$ = combineLatest([ + this.legacyPasswordGenerationService.getOptions$(), + this.passwordType$, + ]).pipe( + map(([[options], type]) => { + options.type = type; + return options; + }), + ); /** * Tracks the regenerate$ subscription @@ -121,7 +147,7 @@ export class CipherFormGeneratorComponent implements OnChanges { .pipe( startWith(null), switchMap(() => this.passwordOptions$), - switchMap(([options]) => this.legacyPasswordGenerationService.generatePassword(options)), + switchMap((options) => this.legacyPasswordGenerationService.generatePassword(options)), tap(async (password) => { await this.legacyPasswordGenerationService.addHistory(password); }), @@ -148,12 +174,10 @@ export class CipherFormGeneratorComponent implements OnChanges { } /** - * Switch the password generation type and save the options (generating a new password automatically). + * Switch the password generation type. * @param value The new password generation type. */ protected updatePasswordType = async (value: GeneratorType) => { - const [currentOptions] = await firstValueFrom(this.passwordOptions$); - currentOptions.type = value; - await this.legacyPasswordGenerationService.saveOptions(currentOptions); + this.passwordTypeSubject$.next(value); }; } diff --git a/libs/vault/src/cipher-form/components/identity/identity.component.ts b/libs/vault/src/cipher-form/components/identity/identity.component.ts index ae712b915b3..d8f938f4ae7 100644 --- a/libs/vault/src/cipher-form/components/identity/identity.component.ts +++ b/libs/vault/src/cipher-form/components/identity/identity.component.ts @@ -127,7 +127,7 @@ export class IdentitySectionComponent implements OnInit { firstName: identity.firstName, middleName: identity.middleName, lastName: identity.lastName, - username: identity.username, + username: this.cipherFormContainer.config.initialValues?.username ?? identity.username, company: identity.company, ssn: identity.ssn, passportNumber: identity.passportNumber, diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index a0a1b4e83f7..d7678aa596a 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -5,6 +5,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @@ -104,6 +105,43 @@ describe("ItemDetailsSectionComponent", () => { expect(updatedCipher.favorite).toBe(true); })); + it("should prioritize initialValues when editing an existing cipher ", fakeAsync(async () => { + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + ]; + component.originalCipherView = { + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col1"], + favorite: true, + } as CipherView; + + component.config.initialValues = { + name: "new-name", + folderId: "new-folder", + organizationId: "bad-org" as OrganizationId, // Should not be set in edit mode + collectionIds: ["col2" as CollectionId], + }; + + await component.ngOnInit(); + tick(); + + expect(cipherFormProvider.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.name).toBe("new-name"); + expect(updatedCipher.organizationId).toBe("org1"); + expect(updatedCipher.folderId).toBe("new-folder"); + expect(updatedCipher.collectionIds).toEqual(["col2"]); + expect(updatedCipher.favorite).toBe(true); + })); + it("should disable organizationId control if ownership change is not allowed", async () => { component.config.allowPersonalOwnership = false; component.config.organizations = [{ id: "org1" } as Organization]; diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 99ecd84cd29..b0716218b59 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -190,9 +190,9 @@ export class ItemDetailsSectionComponent implements OnInit { private async initFromExistingCipher() { this.itemDetailsForm.setValue({ - name: this.originalCipherView.name, - organizationId: this.originalCipherView.organizationId, - folderId: this.originalCipherView.folderId, + name: this.initialValues?.name ?? this.originalCipherView.name, + organizationId: this.originalCipherView.organizationId, // We do not allow changing ownership of an existing cipher. + folderId: this.initialValues?.folderId ?? this.originalCipherView.folderId, collectionIds: [], favorite: this.originalCipherView.favorite, }); @@ -208,7 +208,10 @@ export class ItemDetailsSectionComponent implements OnInit { } } - await this.updateCollectionOptions(this.originalCipherView.collectionIds as CollectionId[]); + await this.updateCollectionOptions( + this.initialValues?.collectionIds ?? + (this.originalCipherView.collectionIds as CollectionId[]), + ); if (this.partialEdit) { this.itemDetailsForm.disable(); diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts index 06f325d0534..f50f8598b94 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts @@ -125,6 +125,29 @@ describe("LoginDetailsSectionComponent", () => { }); }); + it("initializes 'loginDetailsForm' with initialValues that override any original cipher view values", async () => { + (cipherFormContainer.originalCipherView as CipherView) = { + viewPassword: true, + login: { + password: "original-password", + username: "original-username", + totp: "original-totp", + } as LoginView, + } as CipherView; + cipherFormContainer.config.initialValues = { + username: "new-username", + password: "new-password", + }; + + await component.ngOnInit(); + + expect(component.loginDetailsForm.value).toEqual({ + username: "new-username", + password: "new-password", + totp: "original-totp", + }); + }); + describe("viewHiddenFields", () => { beforeEach(() => { (cipherFormContainer.originalCipherView as CipherView) = { diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts index 020c2d18bd8..0186b0820c3 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts @@ -95,6 +95,10 @@ export class LoginDetailsSectionComponent implements OnInit { return true; } + get initialValues() { + return this.cipherFormContainer.config.initialValues; + } + constructor( private cipherFormContainer: CipherFormContainer, private formBuilder: FormBuilder, @@ -139,8 +143,8 @@ export class LoginDetailsSectionComponent implements OnInit { private initFromExistingCipher(existingLogin: LoginView) { this.loginDetailsForm.patchValue({ - username: existingLogin.username, - password: existingLogin.password, + username: this.initialValues?.username ?? existingLogin.username, + password: this.initialValues?.password ?? existingLogin.password, totp: existingLogin.totp, }); @@ -154,8 +158,8 @@ export class LoginDetailsSectionComponent implements OnInit { private async initNewCipher() { this.loginDetailsForm.patchValue({ - username: this.cipherFormContainer.config.initialValues?.username || "", - password: "", + username: this.initialValues?.username || "", + password: this.initialValues?.password || "", }); } diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 10b0a1a07c3..48a89eb307d 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -37,6 +37,7 @@ data-testid="login-password" /> diff --git a/package-lock.json b/package-lock.json index eed0eda0958..941839c6f41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,12 +129,12 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "6.10.0", - "electron": "31.4.0", + "electron": "32.0.1", "electron-builder": "24.13.3", "electron-log": "5.0.1", "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", - "electron-updater": "6.3.3", + "electron-updater": "6.3.4", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-import-resolver-typescript": "3.6.1", @@ -192,11 +192,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2024.8.0" + "version": "2024.8.1" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2024.8.0", + "version": "2024.8.2", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "3.0.2", @@ -232,7 +232,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2024.8.1", + "version": "2024.8.2", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -246,7 +246,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2024.8.0" + "version": "2024.8.1" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -15037,11 +15037,12 @@ } }, "node_modules/electron": { - "version": "31.4.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-31.4.0.tgz", - "integrity": "sha512-YTwKoAA+nrJMlI1TTHnIXLYWoQLKnhbkz0qxZcI7Hadcy0UaFMFs9xzwvH2MnrRpVJy7RKo49kVGuvSdRl8zMA==", + "version": "32.0.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-32.0.1.tgz", + "integrity": "sha512-5Hd5Jaf9niYVR2hZxoRd3gOrcxPOxQV1XPV5WaoSfT9jLJHFadhlKtuSDIk3U6rQZke+aC7GqPPAv55nWFCMsA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", @@ -15189,10 +15190,11 @@ "license": "ISC" }, "node_modules/electron-updater": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.3.tgz", - "integrity": "sha512-Kj1u6kfyxUyatnspvKa6qhGn82rMZfUD03WOvCGJ12PyRss/AC8kkYsN9IrJihKTlN8nRwTjZ1JM2UUXoD0KsA==", + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.4.tgz", + "integrity": "sha512-uZUo7p1Y53G4tl6Cgw07X1yF8Jlz6zhaL7CQJDZ1fVVkOaBfE2cWtx80avwDVi8jHp+I/FWawrMgTAeCCNIfAg==", "dev": true, + "license": "MIT", "dependencies": { "builder-util-runtime": "9.2.5", "fs-extra": "^10.1.0", @@ -15200,7 +15202,7 @@ "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", - "semver": "^7.3.8", + "semver": "^7.6.3", "tiny-typed-emitter": "^2.1.0" } }, @@ -15208,13 +15210,15 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/electron-updater/node_modules/builder-util-runtime": { "version": "9.2.5", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.5.tgz", "integrity": "sha512-HjIDfhvqx/8B3TDN4GbABQcgpewTU4LMRTQPkVpKYV3lsuxEJoIfvg09GyWTNmfVNSUAYf+fbTN//JX4TH20pg==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -15228,6 +15232,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -15242,6 +15247,7 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -15249,6 +15255,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/emitter-component": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz", diff --git a/package.json b/package.json index 99fd8a40499..ce5c3e81df2 100644 --- a/package.json +++ b/package.json @@ -91,12 +91,12 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "6.10.0", - "electron": "31.4.0", + "electron": "32.0.1", "electron-builder": "24.13.3", "electron-log": "5.0.1", "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", - "electron-updater": "6.3.3", + "electron-updater": "6.3.4", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-import-resolver-typescript": "3.6.1",