diff --git a/.github/renovate.json5 b/.github/renovate.json5 index acd181310d6..c4c24799da1 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -157,6 +157,7 @@ "html-webpack-injector", "html-webpack-plugin", "interprocess", + "itertools", "json5", "keytar", "libc", diff --git a/.github/workflows/alert-ddg-files-modified.yml b/.github/workflows/alert-ddg-files-modified.yml index 90c055a97b8..35eb0515c10 100644 --- a/.github/workflows/alert-ddg-files-modified.yml +++ b/.github/workflows/alert-ddg-files-modified.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/auto-branch-updater.yml b/.github/workflows/auto-branch-updater.yml index 02176b3169e..be9cd338e82 100644 --- a/.github/workflows/auto-branch-updater.yml +++ b/.github/workflows/auto-branch-updater.yml @@ -30,7 +30,7 @@ jobs: run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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 ab932c561ba..b5859516eaa 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -55,7 +55,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -94,7 +94,7 @@ jobs: working-directory: apps/browser steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -146,7 +146,7 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -254,7 +254,7 @@ jobs: artifact_name: "dist-opera-MV3" steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -386,7 +386,7 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -542,7 +542,7 @@ jobs: - build-safari steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 964cbc834c5..704a9810b27 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -59,7 +59,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -114,7 +114,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -311,7 +311,7 @@ jobs: _WIN_PKG_VERSION: 3.5 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -520,7 +520,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 5a7703adb78..45297a110a0 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -88,7 +88,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: true @@ -173,24 +173,14 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - - name: Free disk space for build - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /usr/share/swift - sudo rm -rf /usr/local/.ghcup - sudo rm -rf /usr/share/miniconda - sudo rm -rf /usr/share/az_* - sudo rm -rf /usr/local/julia* - sudo rm -rf /usr/lib/mono - sudo rm -rf /usr/lib/heroku - sudo rm -rf /usr/local/aws-cli - sudo rm -rf /usr/local/aws-sam-cli + - name: Free disk space + uses: bitwarden/gh-actions/free-disk-space@main - name: Set up Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -343,7 +333,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -491,7 +481,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -759,7 +749,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1004,7 +994,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1244,7 +1234,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1519,7 +1509,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1860,7 +1850,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 02ab7727c24..7d302fb453b 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -64,7 +64,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -144,7 +144,7 @@ jobs: _VERSION: ${{ needs.setup.outputs.version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -174,7 +174,7 @@ jobs: echo "server_ref=$SERVER_REF" >> "$GITHUB_OUTPUT" - name: Check out Server repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: path: server repository: bitwarden/server @@ -367,7 +367,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 44ea21276e2..c7d80b82baa 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 5475c4dd692..e99034c499a 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -58,7 +58,7 @@ jobs: permission-pull-requests: write # for generating pull requests - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.app-token.outputs.token }} persist-credentials: false diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index b0efeb50823..dff253a8da2 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -22,7 +22,7 @@ jobs: ] steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 persist-credentials: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b46204514b8..3aeb75dcbf6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -95,7 +95,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/locales-lint.yml b/.github/workflows/locales-lint.yml index 8335d6aacad..e431854aea2 100644 --- a/.github/workflows/locales-lint.yml +++ b/.github/workflows/locales-lint.yml @@ -17,11 +17,11 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - name: Checkout base branch repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.base.sha }} path: base diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 0f01aa27899..1e23c31b033 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 8fcd1fe7c98..ef287b0de08 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -103,7 +103,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -151,7 +151,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -203,7 +203,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index 3d512d49559..f013abbbb3b 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -204,7 +204,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout Repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -258,7 +258,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout Repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -315,7 +315,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index fb1de5a1bc5..be0087800f7 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -28,7 +28,7 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -74,7 +74,7 @@ jobs: echo "Github Release Option: $_RELEASE_OPTION" - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index ff5fb669faf..f7e45919308 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -28,7 +28,7 @@ jobs: release_version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -61,7 +61,7 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 08045b8d3c7..3f7b7e326d9 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -29,7 +29,7 @@ jobs: release_version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 2239cb1268f..ec529d7b4d8 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -31,7 +31,7 @@ jobs: release_channel: ${{ steps.release_channel.outputs.channel }} steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index fc0ac340234..f6feb3386a7 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -25,7 +25,7 @@ jobs: tag_version: ${{ steps.version.outputs.tag }} steps: - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 0a343be878c..b2edf0171db 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -105,7 +105,7 @@ jobs: permission-contents: write # for committing and pushing to current branch - name: Check out branch - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.ref }} token: ${{ steps.app-token.outputs.token }} @@ -471,7 +471,7 @@ jobs: permission-contents: write # for creating and pushing new branch - name: Check out target ref - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index 14547b3942f..ecc803ebd5c 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -64,7 +64,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Check out clients repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index c8f4c959c52..6e236f2352c 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -18,7 +18,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3ba6112b7d..e8f062ea345 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -103,7 +103,7 @@ jobs: sudo apt-get install -y gnome-keyring dbus-x11 - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -137,7 +137,7 @@ jobs: runs-on: macos-14 steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -173,7 +173,7 @@ jobs: - rust-coverage steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 65f004149de..d66c48fcf58 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -39,7 +39,7 @@ jobs: permission-contents: write # for committing and pushing to the current branch - name: Check out target ref - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: main token: ${{ steps.app-token.outputs.token }} diff --git a/apps/browser/package.json b/apps/browser/package.json index cf2be624a22..7055aabf4fd 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.12.0", + "version": "2025.12.1", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 3f98313c2b8..d7257bab478 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -2498,7 +2498,7 @@ } }, "topLayerHijackWarning": { - "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + "message": "Bu səhifə Bitwarden təcrübəsinə müdaxilə edir. Bitwarden daxili menyusu, təhlükəsizlik tədbiri olaraq müvəqqəti sıradan çıxarılıb." }, "setMasterPassword": { "message": "Ana parolu ayarla" @@ -4124,7 +4124,7 @@ "message": "Avto-doldurula bilmir" }, "cannotAutofillExactMatch": { - "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + "message": "İlkin uyuşma 'Tam Uyuşur' olaraq ayarlanıb. Hazırkı veb sayt, bu element üçün saxlanılmış giriş məlumatları ilə tam uyuşmur." }, "okay": { "message": "Oldu" diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 2b103555604..95d3f662994 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -6039,5 +6039,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index a699be016eb..c7f7fbcd618 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -1486,7 +1486,7 @@ "message": "选择一个文件" }, "itemsTransferred": { - "message": "项目已传输" + "message": "项目已转移" }, "maxFileSize": { "message": "文件最大为 500 MB。" @@ -3804,7 +3804,7 @@ "description": "Browser extension/addon" }, "desktop": { - "message": "桌面", + "message": "桌面端", "description": "Desktop app" }, "webVault": { @@ -5707,7 +5707,7 @@ "message": "导入现有密码" }, "emptyVaultNudgeBody": { - "message": "使用导入器快速将登录传输到 Bitwarden 而无需手动添加。" + "message": "使用导入器快速将登录转移到 Bitwarden 而无需手动添加。" }, "emptyVaultNudgeButton": { "message": "立即导入" @@ -6014,7 +6014,7 @@ "message": "我该如何管理我的密码库?" }, "transferItemsToOrganizationTitle": { - "message": "传输项目到 $ORGANIZATION$", + "message": "转移项目到 $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -6023,7 +6023,7 @@ } }, "transferItemsToOrganizationContent": { - "message": "出于安全和合规考虑,$ORGANIZATION$ 要求所有项目归组织所有。点击「接受」以传输您的项目的所有权。", + "message": "出于安全和合规考虑,$ORGANIZATION$ 要求所有项目归组织所有。点击「接受」以转移您的项目的所有权。", "placeholders": { "organization": { "content": "$1", @@ -6032,7 +6032,7 @@ } }, "acceptTransfer": { - "message": "接受传输" + "message": "接受转移" }, "declineAndLeave": { "message": "拒绝并退出" diff --git a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts index 257f7e9efd5..61ed7a8ed08 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Message, MessageTypes } from "./message"; const SENDER = "bitwarden-webauthn"; @@ -25,7 +23,7 @@ type Handler = ( * handling aborts and exceptions across separate execution contexts. */ export class Messenger { - private messageEventListener: (event: MessageEvent) => void | null = null; + private messageEventListener: ((event: MessageEvent) => void) | null = null; private onDestroy = new EventTarget(); /** @@ -60,6 +58,12 @@ export class Messenger { this.broadcastChannel.addEventListener(this.messageEventListener); } + private stripMetadata({ SENDER, senderId, ...message }: MessageWithMetadata): Message { + void SENDER; + void senderId; + return message; + } + /** * Sends a request to the content script and returns the response. * AbortController signals will be forwarded to the content script. @@ -74,7 +78,9 @@ export class Messenger { try { const promise = new Promise((resolve) => { - localPort.onmessage = (event: MessageEvent) => resolve(event.data); + localPort.onmessage = (event: MessageEvent) => { + resolve(this.stripMetadata(event.data)); + }; }); const abortListener = () => @@ -129,7 +135,9 @@ export class Messenger { try { const handlerResponse = await this.handler(message, abortController); - port.postMessage({ ...handlerResponse, SENDER }); + if (handlerResponse !== undefined) { + port.postMessage({ ...handlerResponse, SENDER }); + } } catch (error) { port.postMessage({ SENDER, diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts index 64ef7d180ed..ad1241e98d2 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EVENTS } from "@bitwarden/common/autofill/constants"; import { ThemeTypes } from "@bitwarden/common/platform/enums"; @@ -15,14 +13,17 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe private readonly setElementStyles = setElementStyles; private readonly sendExtensionMessage = sendExtensionMessage; private port: chrome.runtime.Port | null = null; - private portKey: string; + private portKey?: string; private readonly extensionOrigin: string; private iframeMutationObserver: MutationObserver; - private iframe: HTMLIFrameElement; - private ariaAlertElement: HTMLDivElement; - private ariaAlertTimeout: number | NodeJS.Timeout; - private delayedCloseTimeout: number | NodeJS.Timeout; - private fadeInTimeout: number | NodeJS.Timeout; + /** + * Initialized in initMenuIframe which makes it safe to assert non null by lifecycle. + */ + private iframe!: HTMLIFrameElement; + private ariaAlertElement?: HTMLDivElement; + private ariaAlertTimeout: number | NodeJS.Timeout | null = null; + private delayedCloseTimeout: number | NodeJS.Timeout | null = null; + private fadeInTimeout: number | NodeJS.Timeout | null = null; private readonly fadeInOpacityTransition = "opacity 125ms ease-out 0s"; private readonly fadeOutOpacityTransition = "opacity 65ms ease-out 0s"; private iframeStyles: Partial = { @@ -50,7 +51,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe }; private foreignMutationsCount = 0; private mutationObserverIterations = 0; - private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; + private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout | null = null; private readonly backgroundPortMessageHandlers: BackgroundPortMessageHandlers = { initAutofillInlineMenuButton: ({ message }) => this.initAutofillInlineMenu(message), initAutofillInlineMenuList: ({ message }) => this.initAutofillInlineMenu(message), @@ -134,7 +135,9 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe this.port.onDisconnect.addListener(this.handlePortDisconnect); this.port.onMessage.addListener(this.handlePortMessage); - this.announceAriaAlert(this.ariaAlert, 2000); + if (this.ariaAlert) { + this.announceAriaAlert(this.ariaAlert, 2000); + } }; /** @@ -155,7 +158,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe this.ariaAlertTimeout = globalThis.setTimeout(async () => { const isFieldFocused = await this.sendExtensionMessage("checkIsFieldCurrentlyFocused"); - if (isFieldFocused || triggeredByUser) { + if ((isFieldFocused || triggeredByUser) && this.ariaAlertElement) { this.shadow.appendChild(this.ariaAlertElement); } this.ariaAlertTimeout = null; @@ -242,7 +245,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe */ private initAutofillInlineMenuList(message: AutofillInlineMenuIframeExtensionMessage) { const { theme } = message; - let borderColor: string; + let borderColor: string | undefined; let verifiedTheme = theme; if (verifiedTheme === ThemeTypes.System) { verifiedTheme = globalThis.matchMedia("(prefers-color-scheme: dark)").matches @@ -274,8 +277,8 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe * * @param position - The position styles to apply to the iframe */ - private updateIframePosition(position: Partial) { - if (!globalThis.document.hasFocus()) { + private updateIframePosition(position?: Partial) { + if (!position || !globalThis.document.hasFocus()) { return; } @@ -295,7 +298,9 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe this.handleFadeInInlineMenuIframe(); } - this.announceAriaAlert(this.ariaAlert, 2000); + if (this.ariaAlert) { + this.announceAriaAlert(this.ariaAlert, 2000); + } } /** @@ -359,8 +364,8 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe * @param customElement - The element to update the styles for * @param styles - The styles to apply to the element */ - private updateElementStyles(customElement: HTMLElement, styles: Partial) { - if (!customElement) { + private updateElementStyles(customElement: HTMLElement, styles?: Partial) { + if (!customElement || !styles) { return; } diff --git a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts index ab3b8144426..5784fd7a73a 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts +++ b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EVENTS } from "@bitwarden/common/autofill/constants"; import { BrowserApi } from "../../../../platform/browser/browser-api"; @@ -84,11 +82,15 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC } const { type, typeData, params } = message.data; + if (!typeData) { + return; + } + if (this.currentNotificationBarType && type !== this.currentNotificationBarType) { this.closeNotificationBar(); } - const initData = { + const initData: NotificationBarIframeInitData = { type: type as NotificationType, isVaultLocked: typeData.isVaultLocked, theme: typeData.theme, @@ -116,7 +118,9 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC const closedByUser = typeof message.data?.closedByUser === "boolean" ? message.data.closedByUser : true; if (message.data?.fadeOutNotification) { - setElementStyles(this.notificationBarIframeElement, { opacity: "0" }, true); + if (this.notificationBarIframeElement) { + setElementStyles(this.notificationBarIframeElement, { opacity: "0" }, true); + } globalThis.setTimeout(() => this.closeNotificationBar(closedByUser), 150); return; } @@ -166,7 +170,9 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC this.createNotificationBarElement(); this.setupInitNotificationBarMessageListener(initData); - globalThis.document.body.appendChild(this.notificationBarRootElement); + if (this.notificationBarRootElement) { + globalThis.document.body.appendChild(this.notificationBarRootElement); + } } } @@ -179,7 +185,7 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC const isNotificationFresh = initData.launchTimestamp && Date.now() - initData.launchTimestamp < 250; - this.currentNotificationBarType = initData.type; + this.currentNotificationBarType = initData.type ?? null; this.notificationBarIframeElement = globalThis.document.createElement("iframe"); this.notificationBarIframeElement.id = "bit-notification-bar-iframe"; const parentOrigin = globalThis.location.origin; @@ -206,11 +212,13 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC * This will animate the notification bar into view. */ private handleNotificationBarIframeOnLoad = () => { - setElementStyles( - this.notificationBarIframeElement, - { transform: "translateX(0)", opacity: "1" }, - true, - ); + if (this.notificationBarIframeElement) { + setElementStyles( + this.notificationBarIframeElement, + { transform: "translateX(0)", opacity: "1" }, + true, + ); + } this.notificationBarIframeElement?.removeEventListener( EVENTS.LOAD, @@ -252,6 +260,7 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC const handleInitNotificationBarMessage = (event: MessageEvent) => { const { source, data } = event; if ( + !this.notificationBarIframeElement?.contentWindow || source !== this.notificationBarIframeElement.contentWindow || data?.command !== "initNotificationBar" ) { @@ -282,13 +291,14 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC return; } - this.notificationBarIframeElement.remove(); + this.notificationBarIframeElement?.remove(); this.notificationBarIframeElement = null; - this.notificationBarElement.remove(); + this.notificationBarElement?.remove(); this.notificationBarElement = null; this.notificationBarShadowRoot = null; - this.notificationBarRootElement.remove(); + + this.notificationBarRootElement?.remove(); this.notificationBarRootElement = null; const removableNotificationTypes = new Set([ @@ -297,7 +307,11 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC NotificationTypes.AtRiskPassword, ] as NotificationType[]); - if (closedByUserAction && removableNotificationTypes.has(this.currentNotificationBarType)) { + if ( + closedByUserAction && + this.currentNotificationBarType && + removableNotificationTypes.has(this.currentNotificationBarType) + ) { void sendExtensionMessage("bgRemoveTabFromNotificationQueue"); } @@ -310,7 +324,7 @@ export class OverlayNotificationsContentService implements OverlayNotificationsC * @param message - The message to send to the notification bar iframe. */ private sendMessageToNotificationBarIframe(message: Record) { - if (this.notificationBarIframeElement) { + if (this.notificationBarIframeElement?.contentWindow) { this.notificationBarIframeElement.contentWindow.postMessage(message, this.extensionOrigin); } } diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index 1153ad58719..085145adb19 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -6,16 +6,18 @@
-
- -
+ @if (showSpotlightNudge$ | async) { +
+ +
+ }

{{ "autofillSuggestionsSectionTitle" | i18n }}

diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index 49be3104dc1..acb2aa7a970 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -611,6 +611,10 @@ export class AutofillComponent implements OnInit { if (this.canOverrideBrowserAutofillSetting) { this.defaultBrowserAutofillDisabled = true; await this.updateDefaultBrowserAutofillDisabled(); + await this.nudgesService.dismissNudge( + NudgeType.AutofillNudge, + await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)), + ); } else { await this.openURI(event, this.disablePasswordManagerURI); } diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 1651f616e03..26add57d1ae 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": "Bitwarden", - "version": "2025.12.0", + "version": "2025.12.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 67399192b64..64d182ebd3d 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": "Bitwarden", - "version": "2025.12.0", + "version": "2025.12.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 739166ff6f8..7a2ded5bb83 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -23,6 +23,8 @@ import { WINDOW, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; +import { AUTOFILL_NUDGE_SERVICE } from "@bitwarden/angular/vault"; +import { SingleNudgeService } from "@bitwarden/angular/vault/services/default-single-nudge.service"; import { LoginComponentService, TwoFactorAuthComponentService, @@ -208,6 +210,7 @@ import { } from "../../platform/system-notifications/browser-system-notification.service"; import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; +import { BrowserAutofillNudgeService } from "../../vault/popup/services/browser-autofill-nudge.service"; import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service"; import { ExtensionAnonLayoutWrapperDataService } from "../components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; @@ -756,6 +759,11 @@ const safeProviders: SafeProvider[] = [ MessagingServiceAbstraction, ], }), + safeProvider({ + provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken, + useClass: BrowserAutofillNudgeService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/browser/src/safari/desktop/Info.plist b/apps/browser/src/safari/desktop/Info.plist index b687d9d2f3a..94542609351 100644 --- a/apps/browser/src/safari/desktop/Info.plist +++ b/apps/browser/src/safari/desktop/Info.plist @@ -25,7 +25,7 @@ LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright - Copyright © 2015-2025 Bitwarden Inc. All rights reserved. + Copyright © 2015-2026 Bitwarden Inc. All rights reserved. NSMainStoryboardFile Main NSPrincipalClass diff --git a/apps/browser/src/safari/safari/Info.plist b/apps/browser/src/safari/safari/Info.plist index 95172846758..68b872610e9 100644 --- a/apps/browser/src/safari/safari/Info.plist +++ b/apps/browser/src/safari/safari/Info.plist @@ -30,7 +30,7 @@ $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler NSHumanReadableCopyright - Copyright © 2015-2025 Bitwarden Inc. All rights reserved. + Copyright © 2015-2026 Bitwarden Inc. All rights reserved. NSHumanReadableDescription A secure and free password manager for all of your devices. SFSafariAppExtensionBundleIdentifiersToReplace diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index 683b7d70ed6..06c89e15f59 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -34,13 +34,11 @@

{{ "autofill" | i18n }}

- 1 + @if (showAutofillBadge$ | async) { + 1 + }
diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts index f51d514289e..4cc3ed0149c 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts @@ -148,31 +148,7 @@ describe("SettingsV2Component", () => { expect(openSpy).toHaveBeenCalledWith(dialogService); }); - it("isBrowserAutofillSettingOverridden$ emits the value from the AutofillBrowserSettingsService", async () => { - pushActiveAccount(); - - mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(true); - - const fixture = TestBed.createComponent(SettingsV2Component); - const component = fixture.componentInstance; - fixture.detectChanges(); - await fixture.whenStable(); - - const value = await firstValueFrom(component["isBrowserAutofillSettingOverridden$"]); - expect(value).toBe(true); - - mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(false); - - const fixture2 = TestBed.createComponent(SettingsV2Component); - const component2 = fixture2.componentInstance; - fixture2.detectChanges(); - await fixture2.whenStable(); - - const value2 = await firstValueFrom(component2["isBrowserAutofillSettingOverridden$"]); - expect(value2).toBe(false); - }); - - it("showAutofillBadge$ emits true when default autofill is NOT disabled and nudge is true", async () => { + it("showAutofillBadge$ emits true when showNudgeBadge is true", async () => { pushActiveAccount(); mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => @@ -184,30 +160,10 @@ describe("SettingsV2Component", () => { fixture.detectChanges(); await fixture.whenStable(); - mockAutofillSettings.defaultBrowserAutofillDisabled$.next(false); - const value = await firstValueFrom(component.showAutofillBadge$); expect(value).toBe(true); }); - it("showAutofillBadge$ emits false when default autofill IS disabled even if nudge is true", async () => { - pushActiveAccount(); - - mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => - of(type === NudgeType.AutofillNudge), - ); - - const fixture = TestBed.createComponent(SettingsV2Component); - const component = fixture.componentInstance; - fixture.detectChanges(); - await fixture.whenStable(); - - mockAutofillSettings.defaultBrowserAutofillDisabled$.next(true); - - const value = await firstValueFrom(component.showAutofillBadge$); - expect(value).toBe(false); - }); - it("dismissBadge dismisses when showVaultBadge$ emits true", async () => { const acct = pushActiveAccount(); diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index 95aeeb2f480..e10d41b9445 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -1,16 +1,7 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { - combineLatest, - filter, - firstValueFrom, - from, - map, - Observable, - shareReplay, - switchMap, -} from "rxjs"; +import { filter, firstValueFrom, Observable, shareReplay, switchMap } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -28,8 +19,6 @@ import { } from "@bitwarden/components"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; -import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service"; -import { BrowserApi } from "../../../platform/browser/browser-api"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -55,12 +44,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co export class SettingsV2Component { NudgeType = NudgeType; - protected isBrowserAutofillSettingOverridden$ = from( - this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden( - BrowserApi.getBrowserClientVendor(window), - ), - ); - private authenticatedAccount$: Observable = this.accountService.activeAccount$.pipe( filter((account): account is Account => account !== null), shareReplay({ bufferSize: 1, refCount: true }), @@ -82,23 +65,13 @@ export class SettingsV2Component { ), ); - showAutofillBadge$: Observable = combineLatest([ - this.autofillBrowserSettingsService.defaultBrowserAutofillDisabled$, - this.authenticatedAccount$, - ]).pipe( - switchMap(([defaultBrowserAutofillDisabled, account]) => - this.nudgesService.showNudgeBadge$(NudgeType.AutofillNudge, account.id).pipe( - map((badgeStatus) => { - return !defaultBrowserAutofillDisabled && badgeStatus; - }), - ), - ), + showAutofillBadge$: Observable = this.authenticatedAccount$.pipe( + switchMap((account) => this.nudgesService.showNudgeBadge$(NudgeType.AutofillNudge, account.id)), ); constructor( private readonly nudgesService: NudgesService, private readonly accountService: AccountService, - private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService, private readonly accountProfileStateService: BillingAccountProfileStateService, private readonly dialogService: DialogService, ) {} diff --git a/apps/browser/src/vault/popup/services/browser-autofill-nudge.service.spec.ts b/apps/browser/src/vault/popup/services/browser-autofill-nudge.service.spec.ts new file mode 100644 index 00000000000..40782760283 --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-autofill-nudge.service.spec.ts @@ -0,0 +1,157 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { NudgeStatus, NudgeType } from "@bitwarden/angular/vault"; +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { BrowserClientVendors } from "@bitwarden/common/autofill/constants"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../../../libs/common/spec"; +import { BrowserApi } from "../../../platform/browser/browser-api"; + +import { BrowserAutofillNudgeService } from "./browser-autofill-nudge.service"; + +describe("BrowserAutofillNudgeService", () => { + let service: BrowserAutofillNudgeService; + let vaultProfileService: MockProxy; + let fakeStateProvider: FakeStateProvider; + + const userId = "test-user-id" as UserId; + const nudgeType = NudgeType.AutofillNudge; + + const notDismissedStatus: NudgeStatus = { + hasBadgeDismissed: false, + hasSpotlightDismissed: false, + }; + + const dismissedStatus: NudgeStatus = { + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }; + + // Set profile creation date to now (new account, within 30 days) + const recentProfileDate = new Date(); + + beforeEach(() => { + vaultProfileService = mock(); + vaultProfileService.getProfileCreationDate.mockResolvedValue(recentProfileDate); + + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + + TestBed.configureTestingModule({ + providers: [ + BrowserAutofillNudgeService, + { + provide: VaultProfileService, + useValue: vaultProfileService, + }, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: LogService, + useValue: mock(), + }, + ], + }); + + service = TestBed.inject(BrowserAutofillNudgeService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("nudgeStatus$", () => { + it("returns parent status when browser client is Unknown", async () => { + jest + .spyOn(BrowserApi, "getBrowserClientVendor") + .mockReturnValue(BrowserClientVendors.Unknown); + jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(true); + + const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId)); + + expect(result).toEqual(notDismissedStatus); + }); + + it("returns parent status when browser autofill is not overridden", async () => { + jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue(BrowserClientVendors.Chrome); + jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(false); + + const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId)); + + expect(result).toEqual(notDismissedStatus); + }); + + it("returns dismissed status when browser autofill is overridden", async () => { + jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue(BrowserClientVendors.Chrome); + jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(true); + + const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId)); + + expect(result).toEqual(dismissedStatus); + }); + + it("preserves parent dismissed status when account is older than 30 days", async () => { + // Set profile creation date to more than 30 days ago + const oldProfileDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + vaultProfileService.getProfileCreationDate.mockResolvedValue(oldProfileDate); + + jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue(BrowserClientVendors.Chrome); + jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(false); + + const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId)); + + expect(result).toEqual(dismissedStatus); + }); + + it("combines parent dismissed and browser autofill overridden status", async () => { + // Set profile creation date to more than 30 days ago (parent dismisses) + const oldProfileDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + vaultProfileService.getProfileCreationDate.mockResolvedValue(oldProfileDate); + + jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue(BrowserClientVendors.Chrome); + jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(true); + + const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId)); + + expect(result).toEqual(dismissedStatus); + }); + + it.each([ + BrowserClientVendors.Chrome, + BrowserClientVendors.Edge, + BrowserClientVendors.Opera, + BrowserClientVendors.Vivaldi, + ])("checks browser autofill settings for %s browser", async (browserVendor) => { + const getBrowserClientVendorSpy = jest + .spyOn(BrowserApi, "getBrowserClientVendor") + .mockReturnValue(browserVendor); + const browserAutofillSettingsOverriddenSpy = jest + .spyOn(BrowserApi, "browserAutofillSettingsOverridden") + .mockResolvedValue(true); + + await firstValueFrom(service.nudgeStatus$(nudgeType, userId)); + + expect(getBrowserClientVendorSpy).toHaveBeenCalledWith(window); + expect(browserAutofillSettingsOverriddenSpy).toHaveBeenCalled(); + }); + + it("does not check browser autofill settings for Unknown browser", async () => { + jest + .spyOn(BrowserApi, "getBrowserClientVendor") + .mockReturnValue(BrowserClientVendors.Unknown); + const browserAutofillSettingsOverriddenSpy = jest + .spyOn(BrowserApi, "browserAutofillSettingsOverridden") + .mockResolvedValue(true); + + await firstValueFrom(service.nudgeStatus$(nudgeType, userId)); + + expect(browserAutofillSettingsOverriddenSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/services/browser-autofill-nudge.service.ts b/apps/browser/src/vault/popup/services/browser-autofill-nudge.service.ts new file mode 100644 index 00000000000..7fe5f527bcb --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-autofill-nudge.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from "@angular/core"; +import { Observable, switchMap } from "rxjs"; + +import { NudgeStatus, NudgeType } from "@bitwarden/angular/vault"; +import { NewAccountNudgeService } from "@bitwarden/angular/vault/services/custom-nudges-services/new-account-nudge.service"; +import { BrowserClientVendors } from "@bitwarden/common/autofill/constants"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; + +/** + * Browser-specific autofill nudge service. + * Extends NewAccountNudgeService (30-day account age check) and adds + * browser autofill setting detection. + * + * Nudge is dismissed if: + * - Account is older than 30 days (inherited from NewAccountNudgeService) + * - Browser's built-in password manager is already disabled via privacy settings + */ +@Injectable() +export class BrowserAutofillNudgeService extends NewAccountNudgeService { + override nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { + return super.nudgeStatus$(nudgeType, userId).pipe( + switchMap(async (status) => { + const browserClient = BrowserApi.getBrowserClientVendor(window); + const browserAutofillOverridden = + browserClient !== BrowserClientVendors.Unknown && + (await BrowserApi.browserAutofillSettingsOverridden()); + + return { + hasBadgeDismissed: status.hasBadgeDismissed || browserAutofillOverridden, + hasSpotlightDismissed: status.hasSpotlightDismissed || browserAutofillOverridden, + }; + }), + ); + } +} diff --git a/apps/cli/package.json b/apps/cli/package.json index ff74664ac76..5174e324586 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": "2025.12.0", + "version": "2025.12.1", "keywords": [ "bitwarden", "password", diff --git a/apps/cli/stores/chocolatey/bitwarden-cli.nuspec b/apps/cli/stores/chocolatey/bitwarden-cli.nuspec index f7f86bc843f..9552ccc282c 100644 --- a/apps/cli/stores/chocolatey/bitwarden-cli.nuspec +++ b/apps/cli/stores/chocolatey/bitwarden-cli.nuspec @@ -10,7 +10,7 @@ Bitwarden Inc. https://bitwarden.com/ https://raw.githubusercontent.com/bitwarden/brand/master/icons/256x256.png - Copyright © 2015-2025 Bitwarden Inc. + Copyright © 2015-2026 Bitwarden Inc. https://github.com/bitwarden/clients/ https://help.bitwarden.com/article/cli/ https://github.com/bitwarden/clients/issues diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 5978659f21e..f5e5cf7ee18 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -329,6 +329,7 @@ name = "autotype" version = "0.0.0" dependencies = [ "anyhow", + "itertools", "mockall", "serial_test", "tracing", @@ -1026,6 +1027,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1617,6 +1624,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 26f791fd660..86eb507a6c1 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -39,6 +39,7 @@ futures = "=0.3.31" hex = "=0.4.3" homedir = "=0.3.6" interprocess = "=2.2.1" +itertools = "=0.14.0" libc = "=0.2.178" linux-keyutils = "=0.2.4" memsec = "=0.7.0" diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index 580df30e72d..6bf3218d98a 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -9,6 +9,7 @@ publish.workspace = true anyhow = { workspace = true } [target.'cfg(windows)'.dependencies] +itertools.workspace = true mockall = "=0.14.0" serial_test = "=3.2.0" tracing.workspace = true diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index c87fea23b60..4b9e65180e6 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -28,6 +28,6 @@ pub fn get_foreground_window_title() -> Result { /// This function returns an `anyhow::Error` if there is any /// issue in typing the input. Detailed reasons will /// vary based on platform implementation. -pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { +pub fn type_input(input: &[u16], keyboard_shortcut: &[String]) -> Result<()> { windowing::type_input(input, keyboard_shortcut) } diff --git a/apps/desktop/desktop_native/autotype/src/linux.rs b/apps/desktop/desktop_native/autotype/src/linux.rs index 9fda0ed9e33..e7b0ee8117e 100644 --- a/apps/desktop/desktop_native/autotype/src/linux.rs +++ b/apps/desktop/desktop_native/autotype/src/linux.rs @@ -2,6 +2,6 @@ pub fn get_foreground_window_title() -> anyhow::Result { todo!("Bitwarden does not yet support Linux autotype"); } -pub fn type_input(_input: Vec, _keyboard_shortcut: Vec) -> anyhow::Result<()> { +pub fn type_input(_input: &[u16], _keyboard_shortcut: &[String]) -> anyhow::Result<()> { todo!("Bitwarden does not yet support Linux autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/macos.rs b/apps/desktop/desktop_native/autotype/src/macos.rs index c6681a3291e..56995a7f810 100644 --- a/apps/desktop/desktop_native/autotype/src/macos.rs +++ b/apps/desktop/desktop_native/autotype/src/macos.rs @@ -2,6 +2,6 @@ pub fn get_foreground_window_title() -> anyhow::Result { todo!("Bitwarden does not yet support macOS autotype"); } -pub fn type_input(_input: Vec, _keyboard_shortcut: Vec) -> anyhow::Result<()> { +pub fn type_input(_input: &[u16], _keyboard_shortcut: &[String]) -> anyhow::Result<()> { todo!("Bitwarden does not yet support macOS autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/windows/mod.rs b/apps/desktop/desktop_native/autotype/src/windows/mod.rs index 3ea63b2b8f4..9cd9bc0cbe5 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/mod.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/mod.rs @@ -1,6 +1,10 @@ use anyhow::Result; +use itertools::Itertools; use tracing::debug; -use windows::Win32::Foundation::{GetLastError, SetLastError, WIN32_ERROR}; +use windows::Win32::{ + Foundation::{GetLastError, SetLastError, WIN32_ERROR}, + UI::Input::KeyboardAndMouse::INPUT, +}; mod type_input; mod window_title; @@ -12,7 +16,7 @@ const WIN32_SUCCESS: WIN32_ERROR = WIN32_ERROR(0); /// win32 errors. #[cfg_attr(test, mockall::automock)] trait ErrorOperations { - /// https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setlasterror + /// fn set_last_error(err: u32) { debug!(err, "Calling SetLastError"); unsafe { @@ -20,7 +24,7 @@ trait ErrorOperations { } } - /// https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-getlasterror + /// fn get_last_error() -> WIN32_ERROR { let last_err = unsafe { GetLastError() }; debug!("GetLastError(): {}", last_err.to_hresult().message()); @@ -36,6 +40,23 @@ pub fn get_foreground_window_title() -> Result { window_title::get_foreground_window_title() } -pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { - type_input::type_input(input, keyboard_shortcut) +/// `KeyboardShortcutInput` is an `INPUT` of one of the valid shortcut keys: +/// - Control +/// - Alt +/// - Super +/// - Shift +/// - \[a-z\]\[A-Z\] +struct KeyboardShortcutInput(INPUT); + +pub fn type_input(input: &[u16], keyboard_shortcut: &[String]) -> Result<()> { + debug!(?keyboard_shortcut, "type_input() called."); + + // convert the raw string input to Windows input and error + // if any key is not a valid keyboard shortcut input + let keyboard_shortcut: Vec = keyboard_shortcut + .iter() + .map(|s| KeyboardShortcutInput::try_from(s.as_str())) + .try_collect()?; + + type_input::type_input(input, &keyboard_shortcut) } diff --git a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs index b2f4c6b82df..b62dd7290d1 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -5,7 +5,15 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{ VIRTUAL_KEY, }; -use super::{ErrorOperations, Win32ErrorOperations}; +use super::{ErrorOperations, KeyboardShortcutInput, Win32ErrorOperations}; + +const SHIFT_KEY_STR: &str = "Shift"; +const CONTROL_KEY_STR: &str = "Control"; +const ALT_KEY_STR: &str = "Alt"; +const LEFT_WINDOWS_KEY_STR: &str = "Super"; + +const IS_VIRTUAL_KEY: bool = true; +const IS_REAL_KEY: bool = false; /// `InputOperations` provides an interface to Window32 API for /// working with inputs. @@ -13,7 +21,7 @@ use super::{ErrorOperations, Win32ErrorOperations}; trait InputOperations { /// Attempts to type the provided input wherever the user's cursor is. /// - /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput + /// fn send_input(inputs: &[INPUT]) -> u32; } @@ -21,8 +29,11 @@ struct Win32InputOperations; impl InputOperations for Win32InputOperations { fn send_input(inputs: &[INPUT]) -> u32 { - const INPUT_STRUCT_SIZE: i32 = std::mem::size_of::() as i32; - let insert_count = unsafe { SendInput(inputs, INPUT_STRUCT_SIZE) }; + const INPUT_STRUCT_SIZE: usize = std::mem::size_of::(); + + let size = i32::try_from(INPUT_STRUCT_SIZE).expect("INPUT size to fit in i32"); + + let insert_count = unsafe { SendInput(inputs, size) }; debug!(insert_count, "SendInput() called."); @@ -33,40 +44,37 @@ impl InputOperations for Win32InputOperations { /// Attempts to type the input text wherever the user's cursor is. /// /// `input` must be a vector of utf-16 encoded characters to insert. -/// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, -/// Shift, letters a - Z +/// `keyboard_shortcut` is a vector of valid shortcut keys. /// -/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -pub(super) fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { +/// +pub(super) fn type_input(input: &[u16], keyboard_shortcut: &[KeyboardShortcutInput]) -> Result<()> { // the length of this vec is always shortcut keys to release + (2x length of input chars) let mut keyboard_inputs: Vec = Vec::with_capacity(keyboard_shortcut.len() + (input.len() * 2)); - debug!(?keyboard_shortcut, "Converting keyboard shortcut to input."); - - // Add key "up" inputs for the shortcut - for key in keyboard_shortcut { - keyboard_inputs.push(convert_shortcut_key_to_up_input(key)?); + // insert the keyboard shortcut + for shortcut in keyboard_shortcut { + keyboard_inputs.push(shortcut.0); } - add_input(&input, &mut keyboard_inputs); + add_input(input, &mut keyboard_inputs); - send_input::(keyboard_inputs) + send_input::(&keyboard_inputs) } // Add key "down" and "up" inputs for the input // (currently in this form: {username}/t{password}) fn add_input(input: &[u16], keyboard_inputs: &mut Vec) { - const TAB_KEY: u8 = 9; + const TAB_KEY: u16 = 9; for i in input { - let next_down_input = if *i == TAB_KEY.into() { - build_virtual_key_input(InputKeyPress::Down, *i as u8) + let next_down_input = if *i == TAB_KEY { + build_virtual_key_input(InputKeyPress::Down, *i) } else { build_unicode_input(InputKeyPress::Down, *i) }; - let next_up_input = if *i == TAB_KEY.into() { - build_virtual_key_input(InputKeyPress::Up, *i as u8) + let next_up_input = if *i == TAB_KEY { + build_virtual_key_input(InputKeyPress::Up, *i) } else { build_unicode_input(InputKeyPress::Up, *i) }; @@ -76,26 +84,27 @@ fn add_input(input: &[u16], keyboard_inputs: &mut Vec) { } } -/// Converts a valid shortcut key to an "up" keyboard input. -/// -/// `input` must be a valid shortcut key: Control, Alt, Super, Shift, letters [a-z][A-Z] -fn convert_shortcut_key_to_up_input(key: String) -> Result { - const SHIFT_KEY: u8 = 0x10; - const SHIFT_KEY_STR: &str = "Shift"; - const CONTROL_KEY: u8 = 0x11; - const CONTROL_KEY_STR: &str = "Control"; - const ALT_KEY: u8 = 0x12; - const ALT_KEY_STR: &str = "Alt"; - const LEFT_WINDOWS_KEY: u8 = 0x5B; - const LEFT_WINDOWS_KEY_STR: &str = "Super"; +impl TryFrom<&str> for KeyboardShortcutInput { + type Error = anyhow::Error; - Ok(match key.as_str() { - SHIFT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, SHIFT_KEY), - CONTROL_KEY_STR => build_virtual_key_input(InputKeyPress::Up, CONTROL_KEY), - ALT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, ALT_KEY), - LEFT_WINDOWS_KEY_STR => build_virtual_key_input(InputKeyPress::Up, LEFT_WINDOWS_KEY), - _ => build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?), - }) + fn try_from(key: &str) -> std::result::Result { + const SHIFT_KEY: u16 = 0x10; + const CONTROL_KEY: u16 = 0x11; + const ALT_KEY: u16 = 0x12; + const LEFT_WINDOWS_KEY: u16 = 0x5B; + + // the modifier keys are using the Up keypress variant because the user has already + // pressed those keys in order to trigger the feature. + let input = match key { + SHIFT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, SHIFT_KEY), + CONTROL_KEY_STR => build_virtual_key_input(InputKeyPress::Up, CONTROL_KEY), + ALT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, ALT_KEY), + LEFT_WINDOWS_KEY_STR => build_virtual_key_input(InputKeyPress::Up, LEFT_WINDOWS_KEY), + _ => build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?), + }; + + Ok(KeyboardShortcutInput(input)) + } } /// Given a letter that is a String, get the utf16 encoded @@ -105,7 +114,7 @@ fn convert_shortcut_key_to_up_input(key: String) -> Result { /// Because we only accept [a-z][A-Z], the decimal u16 /// cast of the letter is safe because the unicode code point /// of these characters fits in a u16. -fn get_alphabetic_hotkey(letter: String) -> Result { +fn get_alphabetic_hotkey(letter: &str) -> Result { if letter.len() != 1 { error!( len = letter.len(), @@ -135,23 +144,28 @@ fn get_alphabetic_hotkey(letter: String) -> Result { } /// An input key can be either pressed (down), or released (up). +#[derive(Copy, Clone)] enum InputKeyPress { Down, Up, } -/// A function for easily building keyboard unicode INPUT structs used in SendInput(). -/// -/// Before modifying this function, make sure you read the SendInput() documentation: -/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -fn build_unicode_input(key_press: InputKeyPress, character: u16) -> INPUT { +/// Before modifying this function, make sure you read the `SendInput()` documentation: +/// +/// +fn build_input(key_press: InputKeyPress, character: u16, is_virtual: bool) -> INPUT { + let (w_vk, w_scan) = if is_virtual { + (VIRTUAL_KEY(character), 0) + } else { + (VIRTUAL_KEY::default(), character) + }; match key_press { InputKeyPress::Down => INPUT { r#type: INPUT_KEYBOARD, Anonymous: INPUT_0 { ki: KEYBDINPUT { - wVk: Default::default(), - wScan: character, + wVk: w_vk, + wScan: w_scan, dwFlags: KEYEVENTF_UNICODE, time: 0, dwExtraInfo: 0, @@ -162,8 +176,8 @@ fn build_unicode_input(key_press: InputKeyPress, character: u16) -> INPUT { r#type: INPUT_KEYBOARD, Anonymous: INPUT_0 { ki: KEYBDINPUT { - wVk: Default::default(), - wScan: character, + wVk: w_vk, + wScan: w_scan, dwFlags: KEYEVENTF_KEYUP | KEYEVENTF_UNICODE, time: 0, dwExtraInfo: 0, @@ -173,53 +187,29 @@ fn build_unicode_input(key_press: InputKeyPress, character: u16) -> INPUT { } } -/// A function for easily building keyboard virtual-key INPUT structs used in SendInput(). -/// -/// Before modifying this function, make sure you read the SendInput() documentation: -/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -/// https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes -fn build_virtual_key_input(key_press: InputKeyPress, virtual_key: u8) -> INPUT { - match key_press { - InputKeyPress::Down => INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: VIRTUAL_KEY(virtual_key as u16), - wScan: Default::default(), - dwFlags: Default::default(), - time: 0, - dwExtraInfo: 0, - }, - }, - }, - InputKeyPress::Up => INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: VIRTUAL_KEY(virtual_key as u16), - wScan: Default::default(), - dwFlags: KEYEVENTF_KEYUP, - time: 0, - dwExtraInfo: 0, - }, - }, - }, - } +/// A function for easily building keyboard unicode `INPUT` structs used in `SendInput()`. +fn build_unicode_input(key_press: InputKeyPress, character: u16) -> INPUT { + build_input(key_press, character, IS_REAL_KEY) } -fn send_input(inputs: Vec) -> Result<()> +/// A function for easily building keyboard virtual-key `INPUT` structs used in `SendInput()`. +fn build_virtual_key_input(key_press: InputKeyPress, character: u16) -> INPUT { + build_input(key_press, character, IS_VIRTUAL_KEY) +} + +fn send_input(inputs: &[INPUT]) -> Result<()> where I: InputOperations, E: ErrorOperations, { - let insert_count = I::send_input(&inputs); + let insert_count = I::send_input(inputs); if insert_count == 0 { let last_err = E::get_last_error().to_hresult().message(); error!(GetLastError = %last_err, "SendInput sent 0 inputs. Input was blocked by another thread."); return Err(anyhow!("SendInput sent 0 inputs. Input was blocked by another thread. GetLastError: {last_err}")); - } else if insert_count != inputs.len() as u32 { + } else if insert_count != u32::try_from(inputs.len()).expect("to convert inputs len to u32") { let last_err = E::get_last_error().to_hresult().message(); error!(sent = %insert_count, expected = inputs.len(), GetLastError = %last_err, "SendInput sent does not match expected." @@ -237,8 +227,9 @@ where mod tests { //! For the mocking of the traits that are static methods, we need to use the `serial_test` //! crate in order to mock those, since the mock expectations set have to be global in - //! absence of a `self`. More info: https://docs.rs/mockall/latest/mockall/#static-methods + //! absence of a `self`. More info: + use itertools::Itertools; use serial_test::serial; use windows::Win32::Foundation::WIN32_ERROR; @@ -249,7 +240,7 @@ mod tests { fn get_alphabetic_hot_key_succeeds() { for c in ('a'..='z').chain('A'..='Z') { let letter = c.to_string(); - let converted = get_alphabetic_hotkey(letter).unwrap(); + let converted = get_alphabetic_hotkey(&letter).unwrap(); assert_eq!(converted, c as u16); } } @@ -258,14 +249,14 @@ mod tests { #[should_panic = "Final keyboard shortcut key should be a single character: foo"] fn get_alphabetic_hot_key_fail_not_single_char() { let letter = String::from("foo"); - get_alphabetic_hotkey(letter).unwrap(); + get_alphabetic_hotkey(&letter).unwrap(); } #[test] #[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '}'"] fn get_alphabetic_hot_key_fail_not_alphabetic() { let letter = String::from("}"); - get_alphabetic_hotkey(letter).unwrap(); + get_alphabetic_hotkey(&letter).unwrap(); } #[test] @@ -275,7 +266,7 @@ mod tests { ctxi.checkpoint(); ctxi.expect().returning(|_| 1); - send_input::(vec![build_unicode_input( + send_input::(&[build_unicode_input( InputKeyPress::Up, 0, )]) @@ -284,6 +275,29 @@ mod tests { drop(ctxi); } + #[test] + #[serial] + fn keyboard_shortcut_conversion_succeeds() { + let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "B"]; + let _: Vec = keyboard_shortcut + .iter() + .map(|s| KeyboardShortcutInput::try_from(*s)) + .try_collect() + .unwrap(); + } + + #[test] + #[serial] + #[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '1'"] + fn keyboard_shortcut_conversion_fails_invalid_key() { + let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "1"]; + let _: Vec = keyboard_shortcut + .iter() + .map(|s| KeyboardShortcutInput::try_from(*s)) + .try_collect() + .unwrap(); + } + #[test] #[serial] #[should_panic( @@ -298,7 +312,7 @@ mod tests { ctxge.checkpoint(); ctxge.expect().returning(|| WIN32_ERROR(1)); - send_input::(vec![build_unicode_input( + send_input::(&[build_unicode_input( InputKeyPress::Up, 0, )]) @@ -320,7 +334,7 @@ mod tests { ctxge.checkpoint(); ctxge.expect().returning(|| WIN32_ERROR(1)); - send_input::(vec![build_unicode_input( + send_input::(&[build_unicode_input( InputKeyPress::Up, 0, )]) diff --git a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs index 4fc0b3bb3ad..12e6501a7c5 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs @@ -11,10 +11,10 @@ use super::{ErrorOperations, Win32ErrorOperations, WIN32_SUCCESS}; #[cfg_attr(test, mockall::automock)] trait WindowHandleOperations { - // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw + // fn get_window_text_length_w(&self) -> Result; - // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw + // fn get_window_text_w(&self, buffer: &mut Vec) -> Result; } @@ -70,7 +70,7 @@ pub(super) fn get_foreground_window_title() -> Result { /// Retrieves the foreground window handle and validates it. fn get_foreground_window_handle() -> Result { - // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow + // let handle = unsafe { GetForegroundWindow() }; debug!("GetForegroundWindow() called."); @@ -87,7 +87,7 @@ fn get_foreground_window_handle() -> Result { /// /// # Errors /// -/// - If the length zero and GetLastError() != 0, return the GetLastError() message. +/// - If the length zero and `GetLastError()` != 0, return the `GetLastError()` message. fn get_window_title_length(window_handle: &H) -> Result where H: WindowHandleOperations, @@ -128,7 +128,7 @@ where /// # Errors /// /// - If the actual window title length (what the win32 API declares was written into the buffer), -/// is length zero and GetLastError() != 0 , return the GetLastError() message. +/// is length zero and `GetLastError()` != 0 , return the `GetLastError()` message. fn get_window_title(window_handle: &H, expected_title_length: usize) -> Result where H: WindowHandleOperations, @@ -140,7 +140,7 @@ where // The upstream will make a contains comparison on what we return, so an empty string // will not result on a match. warn!("Window title length is zero."); - return Ok(String::from("")); + return Ok(String::new()); } let mut buffer: Vec = vec![0; expected_title_length + 1]; // add extra space for the null character @@ -171,7 +171,7 @@ where mod tests { //! For the mocking of the traits that are static methods, we need to use the `serial_test` //! crate in order to mock those, since the mock expectations set have to be global in - //! absence of a `self`. More info: https://docs.rs/mockall/latest/mockall/#static-methods + //! absence of a `self`. More info: use mockall::predicate; use serial_test::serial; diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index fe084349501..588f757631c 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -1241,8 +1241,7 @@ pub mod autotype { input: Vec, keyboard_shortcut: Vec, ) -> napi::Result<(), napi::Status> { - autotype::type_input(input, keyboard_shortcut).map_err(|_| { - napi::Error::from_reason("Autotype Error: failed to type input".to_string()) - }) + autotype::type_input(&input, &keyboard_shortcut) + .map_err(|e| napi::Error::from_reason(format!("Autotype Error: {e}"))) } } diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 630a956560d..0c95c7f01a6 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -5,7 +5,7 @@ "productName": "Bitwarden Beta", "appId": "com.bitwarden.desktop.beta", "buildDependenciesFromSource": true, - "copyright": "Copyright © 2015-2025 Bitwarden Inc.", + "copyright": "Copyright © 2015-2026 Bitwarden Inc.", "directories": { "buildResources": "resources", "output": "dist", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index f979df81fd0..a4e1c44dc5b 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -5,7 +5,7 @@ "productName": "Bitwarden", "appId": "com.bitwarden.desktop", "buildDependenciesFromSource": true, - "copyright": "Copyright © 2015-2025 Bitwarden Inc.", + "copyright": "Copyright © 2015-2026 Bitwarden Inc.", "directories": { "buildResources": "resources", "output": "dist", diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts index 74cddd02495..253444232e5 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts @@ -4,7 +4,9 @@ import { RouterModule } from "@angular/router"; import { mock } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { NavigationModule } from "@bitwarden/components"; +import { GlobalStateProvider } from "@bitwarden/state"; import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; @@ -36,6 +38,8 @@ describe("DesktopLayoutComponent", () => { let component: DesktopLayoutComponent; let fixture: ComponentFixture; + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule], @@ -44,6 +48,10 @@ describe("DesktopLayoutComponent", () => { provide: I18nService, useValue: mock(), }, + { + provide: GlobalStateProvider, + useValue: fakeGlobalStateProvider, + }, ], }) .overrideComponent(DesktopLayoutComponent, { diff --git a/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts b/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts index 4d5c3a90253..9b99dbf09c2 100644 --- a/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts @@ -2,7 +2,9 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { NavigationModule } from "@bitwarden/components"; +import { GlobalStateProvider } from "@bitwarden/state"; import { DesktopSideNavComponent } from "./desktop-side-nav.component"; @@ -24,6 +26,8 @@ describe("DesktopSideNavComponent", () => { let component: DesktopSideNavComponent; let fixture: ComponentFixture; + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [DesktopSideNavComponent, NavigationModule], @@ -32,6 +36,10 @@ describe("DesktopSideNavComponent", () => { provide: I18nService, useValue: mock(), }, + { + provide: GlobalStateProvider, + useValue: fakeGlobalStateProvider, + }, ], }).compileComponents(); diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts index 95ba5c53e36..ab881e5b57b 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts @@ -5,9 +5,11 @@ import { RouterTestingHarness } from "@angular/router/testing"; import { BehaviorSubject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; +import { GlobalStateProvider } from "@bitwarden/state"; import { SendFiltersNavComponent } from "./send-filters-nav.component"; @@ -35,6 +37,8 @@ describe("SendFiltersNavComponent", () => { let filterFormValueSubject: BehaviorSubject<{ sendType: SendType | null }>; let mockSendListFiltersService: Partial; + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + beforeEach(async () => { filterFormValueSubject = new BehaviorSubject<{ sendType: SendType | null }>({ sendType: null, @@ -72,6 +76,10 @@ describe("SendFiltersNavComponent", () => { t: jest.fn((key) => key), }, }, + { + provide: GlobalStateProvider, + useValue: fakeGlobalStateProvider, + }, ], }).compileComponents(); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index d26a46a9efe..9be96a62589 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4388,6 +4388,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 7d1c1648bb6..b80e1cea689 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -709,7 +709,7 @@ "message": "添加附件" }, "itemsTransferred": { - "message": "项目已传输" + "message": "项目已转移" }, "fixEncryption": { "message": "修复加密" @@ -4454,7 +4454,7 @@ "message": "我该如何管理我的密码库?" }, "transferItemsToOrganizationTitle": { - "message": "传输项目到 $ORGANIZATION$", + "message": "转移项目到 $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -4463,7 +4463,7 @@ } }, "transferItemsToOrganizationContent": { - "message": "出于安全和合规考虑,$ORGANIZATION$ 要求所有项目归组织所有。点击「接受」以传输您的项目的所有权。", + "message": "出于安全和合规考虑,$ORGANIZATION$ 要求所有项目归组织所有。点击「接受」以转移您的项目的所有权。", "placeholders": { "organization": { "content": "$1", @@ -4472,7 +4472,7 @@ } }, "acceptTransfer": { - "message": "接受传输" + "message": "接受转移" }, "declineAndLeave": { "message": "拒绝并退出" diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 6c4ebe13f14..730891f6dea 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -565,7 +565,7 @@ export class VaultV2Component } } - if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + if (userCanArchive && !cipher.isDeleted && !cipher.isArchived) { menu.push({ label: this.i18nService.t("archiveVerb"), click: async () => { diff --git a/apps/desktop/stores/chocolatey/bitwarden.nuspec b/apps/desktop/stores/chocolatey/bitwarden.nuspec index 450fa734736..567002d0d8c 100644 --- a/apps/desktop/stores/chocolatey/bitwarden.nuspec +++ b/apps/desktop/stores/chocolatey/bitwarden.nuspec @@ -10,7 +10,7 @@ Bitwarden Inc. https://bitwarden.com/ https://raw.githubusercontent.com/bitwarden/brand/master/icons/256x256.png - Copyright © 2015-2025 Bitwarden Inc. + Copyright © 2015-2026 Bitwarden Inc. https://github.com/bitwarden/clients/ https://bitwarden.com/help/ https://github.com/bitwarden/clients/issues diff --git a/apps/web/package.json b/apps/web/package.json index a5399de920e..b92fc5f736a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2025.12.1", + "version": "2025.12.2", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html index c272a8e5b70..8a538cb961c 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html @@ -16,27 +16,26 @@ FIDO2 WebAuthn logo
  • - - - {{ "webAuthnkeyX" | i18n: (i + 1).toString() }} - - - {{ k.name }} - - - - {{ "webAuthnMigrated" | i18n }} + + + + {{ k.name || ("unnamedKey" | i18n) }} + + + + {{ "webAuthnMigrated" | i18n }} + + + + + - + {{ "remove" | i18n }} - - - - - - {{ "remove" | i18n }}
@@ -60,7 +59,9 @@ type="button" [bitAction]="readKey" buttonType="secondary" - [disabled]="$any(readKeyBtn).loading() || webAuthnListening || !keyIdAvailable" + [disabled]=" + $any(readKeyBtn).loading() || webAuthnListening || !keyIdAvailable || formGroup.invalid + " class="tw-mr-2" #readKeyBtn > diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts index 11ba5955902..57001acc4d2 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, Inject, NgZone } from "@angular/core"; -import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; @@ -99,7 +99,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom toastService, ); this.formGroup = new FormGroup({ - name: new FormControl({ value: "", disabled: false }), + name: new FormControl({ value: "", disabled: false }, Validators.required), }); this.auth(data); } @@ -213,7 +213,22 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom this.webAuthnListening = listening; } + private findNextAvailableKeyId(existingIds: Set): number { + // Search for first gap, bounded by current key count + 1 + for (let i = 1; i <= existingIds.size + 1; i++) { + if (!existingIds.has(i)) { + return i; + } + } + + // This should never be reached due to loop bounds, but TypeScript requires a return + throw new Error("Unable to find next available key ID"); + } + private processResponse(response: TwoFactorWebAuthnResponse) { + if (!response.keys || response.keys.length === 0) { + response.keys = []; + } this.resetWebAuthn(); this.keys = []; this.keyIdAvailable = null; @@ -223,26 +238,37 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom nameControl.setValue(""); } this.keysConfiguredCount = 0; - for (let i = 1; i <= 5; i++) { - if (response.keys != null) { - const key = response.keys.filter((k) => k.id === i); - if (key.length > 0) { - this.keysConfiguredCount++; - this.keys.push({ - id: i, - name: key[0].name, - configured: true, - migrated: key[0].migrated, - removePromise: null, - }); - continue; - } - } - this.keys.push({ id: i, name: "", configured: false, removePromise: null }); - if (this.keyIdAvailable == null) { - this.keyIdAvailable = i; - } + + // Build configured keys + for (const key of response.keys) { + this.keysConfiguredCount++; + this.keys.push({ + id: key.id, + name: key.name, + configured: true, + migrated: key.migrated, + removePromise: null, + }); } + + // [PM-20109]: To accommodate the existing form logic with minimal changes, + // we need to have at least one unconfigured key slot available to the collection. + // Prior to PM-20109, both client and server had hard checks for IDs <= 5. + // While we don't have any technical constraints _at this time_, we should avoid + // unbounded growth of key IDs over time as users add/remove keys; + // this strategy gap-fills key IDs. + const existingIds = new Set(response.keys.map((k) => k.id)); + const nextId = this.findNextAvailableKeyId(existingIds); + + // Add unconfigured slot, which can be used to add a new key + this.keys.push({ + id: nextId, + name: "", + configured: false, + removePromise: null, + }); + this.keyIdAvailable = nextId; + this.enabled = response.enabled; this.onUpdated.emit(this.enabled); } diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html index eb476090963..fcdb3f6ca64 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html @@ -26,7 +26,7 @@ - + {{ "name" | i18n }} diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index 5fa2806d133..92d56c1c7a3 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -31,7 +31,7 @@ - + {{ "name" | i18n }} diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts index a894fce0c41..9ae0600fb2a 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts @@ -132,7 +132,10 @@ describe("CipherStep", () => { userKey: null, encryptedPrivateKey: null, isPrivateKeyCorrupt: false, - ciphers: [{ id: "cipher-1", organizationId: null } as Cipher], + ciphers: [ + { id: "cipher-1", organizationId: null } as Cipher, + { id: "cipher-2", organizationId: null } as Cipher, + ], folders: [], }; @@ -144,14 +147,39 @@ describe("CipherStep", () => { expect(result).toBe(false); }); - it("returns true when there are undecryptable ciphers", async () => { + it("returns true when there are undecryptable ciphers but at least one decryptable cipher", async () => { const userId = "user-id" as UserId; const workingData: RecoveryWorkingData = { userId, userKey: null, encryptedPrivateKey: null, isPrivateKeyCorrupt: false, - ciphers: [{ id: "cipher-1", organizationId: null } as Cipher], + ciphers: [ + { id: "cipher-1", organizationId: null } as Cipher, + { id: "cipher-2", organizationId: null } as Cipher, + ], + folders: [], + }; + + cipherEncryptionService.decrypt.mockRejectedValueOnce(new Error("Decryption failed")); + + await cipherStep.runDiagnostics(workingData, logger); + const result = cipherStep.canRecover(workingData); + + expect(result).toBe(true); + }); + + it("returns false when all ciphers are undecryptable", async () => { + const userId = "user-id" as UserId; + const workingData: RecoveryWorkingData = { + userId, + userKey: null, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [ + { id: "cipher-1", organizationId: null } as Cipher, + { id: "cipher-2", organizationId: null } as Cipher, + ], folders: [], }; @@ -160,7 +188,7 @@ describe("CipherStep", () => { await cipherStep.runDiagnostics(workingData, logger); const result = cipherStep.canRecover(workingData); - expect(result).toBe(true); + expect(result).toBe(false); }); }); diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts index b44e8afc54d..01c2d9bc2a1 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts @@ -10,6 +10,7 @@ export class CipherStep implements RecoveryStep { title = "recoveryStepCipherTitle"; private undecryptableCipherIds: string[] = []; + private decryptableCipherIds: string[] = []; constructor( private apiService: ApiService, @@ -31,18 +32,21 @@ export class CipherStep implements RecoveryStep { for (const cipher of userCiphers) { try { await this.cipherService.decrypt(cipher, workingData.userId); + this.decryptableCipherIds.push(cipher.id); } catch { logger.record(`Cipher ID ${cipher.id} was undecryptable`); this.undecryptableCipherIds.push(cipher.id); } } logger.record(`Found ${this.undecryptableCipherIds.length} undecryptable ciphers`); + logger.record(`Found ${this.decryptableCipherIds.length} decryptable ciphers`); return this.undecryptableCipherIds.length == 0; } canRecover(workingData: RecoveryWorkingData): boolean { - return this.undecryptableCipherIds.length > 0; + // If everything fails to decrypt, it's a deeper issue and we shouldn't offer recovery here. + return this.undecryptableCipherIds.length > 0 && this.decryptableCipherIds.length > 0; } async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts index bc0ae31efba..90e252ce6c3 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts @@ -11,6 +11,7 @@ export class FolderStep implements RecoveryStep { title = "recoveryStepFoldersTitle"; private undecryptableFolderIds: string[] = []; + private decryptableFolderIds: string[] = []; constructor( private folderService: FolderApiServiceAbstraction, @@ -36,18 +37,21 @@ export class FolderStep implements RecoveryStep { folder.name.encryptedString, workingData.userKey.toEncoded(), ); + this.decryptableFolderIds.push(folder.id); } catch { logger.record(`Folder name for folder ID ${folder.id} was undecryptable`); this.undecryptableFolderIds.push(folder.id); } } logger.record(`Found ${this.undecryptableFolderIds.length} undecryptable folders`); + logger.record(`Found ${this.decryptableFolderIds.length} decryptable folders`); return this.undecryptableFolderIds.length == 0; } canRecover(workingData: RecoveryWorkingData): boolean { - return this.undecryptableFolderIds.length > 0; + // If everything fails to decrypt, it's a deeper issue and we shouldn't offer recovery here. + return this.undecryptableFolderIds.length > 0 && this.decryptableFolderIds.length > 0; } async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts index 9f6c8f6b194..9a6de3ad9af 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts @@ -7,10 +7,12 @@ import { BehaviorSubject } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { IconButtonModule, NavigationModule } from "@bitwarden/components"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component"; +import { GlobalStateProvider } from "@bitwarden/state"; import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service"; @@ -59,6 +61,8 @@ describe("NavigationProductSwitcherComponent", () => { productSwitcherService.shouldShowPremiumUpgradeButton$ = mockShouldShowPremiumUpgradeButton$; mockProducts$.next({ bento: [], other: [] }); + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + await TestBed.configureTestingModule({ imports: [RouterModule, NavigationModule, IconButtonModule, MockUpgradeNavButtonComponent], declarations: [NavigationProductSwitcherComponent, I18nPipe], @@ -72,6 +76,10 @@ describe("NavigationProductSwitcherComponent", () => { provide: ActivatedRoute, useValue: mock(), }, + { + provide: GlobalStateProvider, + useValue: fakeGlobalStateProvider, + }, ], }).compileComponents(); }); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index ea6e972e431..ba36063fb7b 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -16,10 +16,15 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; -import { LayoutComponent, NavigationModule } from "@bitwarden/components"; +import { + LayoutComponent, + NavigationModule, + StorybookGlobalStateProvider, +} from "@bitwarden/components"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { I18nPipe } from "@bitwarden/ui-common"; import { ProductSwitcherService } from "../shared/product-switcher.service"; @@ -82,7 +87,7 @@ class MockAccountService implements Partial { name: "Test User 1", email: "test@email.com", emailVerified: true, - creationDate: "2024-01-01T00:00:00.000Z", + creationDate: new Date("2024-01-01T00:00:00.000Z"), }); } @@ -183,6 +188,10 @@ export default { }, ]), ), + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index d412530a635..ad18b2b3490 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -82,7 +82,7 @@ class MockAccountService implements Partial { name: "Test User 1", email: "test@email.com", emailVerified: true, - creationDate: "2024-01-01T00:00:00.000Z", + creationDate: new Date("2024-01-01T00:00:00.000Z"), }); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index a71427cf475..9c56df0db59 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -39,7 +39,8 @@ import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -import { LayoutComponent } from "@bitwarden/components"; +import { LayoutComponent, StorybookGlobalStateProvider } from "@bitwarden/components"; +import { GlobalStateProvider } from "@bitwarden/state"; import { RoutedVaultFilterService } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -168,6 +169,10 @@ export default { providers: [ importProvidersFrom(RouterModule.forRoot([], { useHash: true })), importProvidersFrom(PreloadedEnglishI18nModule), + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/apps/web/src/index.html b/apps/web/src/index.html index 06f7587a123..5e56df553fc 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -122,7 +122,6 @@ - `;
} diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts index cf3d20762fe..b13920d9749 100644 --- a/libs/components/src/navigation/side-nav.component.ts +++ b/libs/components/src/navigation/side-nav.component.ts @@ -1,4 +1,5 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop"; import { CommonModule } from "@angular/common"; import { Component, ElementRef, inject, input, viewChild } from "@angular/core"; @@ -16,16 +17,26 @@ export type SideNavVariant = "primary" | "secondary"; @Component({ selector: "bit-side-nav", templateUrl: "side-nav.component.html", - imports: [CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe], + imports: [ + CommonModule, + CdkTrapFocus, + NavDividerComponent, + BitIconButtonComponent, + I18nPipe, + DragDropModule, + ], host: { class: "tw-block tw-h-full", }, }) export class SideNavComponent { + protected sideNavService = inject(SideNavService); + readonly variant = input("primary"); private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); - protected sideNavService = inject(SideNavService); + + private elementRef = inject>(ElementRef); protected handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -36,4 +47,21 @@ export class SideNavComponent { return true; }; + + protected onDragMoved(event: CdkDragMove) { + const rectX = this.elementRef.nativeElement.getBoundingClientRect().x; + const eventXPointer = event.pointerPosition.x; + + this.sideNavService.setWidthFromDrag(eventXPointer, rectX); + + // Fix for CDK applying a transform that can cause visual drifting + const element = event.source.element.nativeElement; + element.style.transform = "none"; + } + + protected onKeydown(event: KeyboardEvent) { + if (event.key === "ArrowRight" || event.key === "ArrowLeft") { + this.sideNavService.setWidthFromKeys(event.key); + } + } } diff --git a/libs/components/src/navigation/side-nav.service.ts b/libs/components/src/navigation/side-nav.service.ts index ce44811c7e0..63e54c81fe5 100644 --- a/libs/components/src/navigation/side-nav.service.ts +++ b/libs/components/src/navigation/side-nav.service.ts @@ -1,15 +1,37 @@ -import { Injectable } from "@angular/core"; +import { inject, Injectable } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { BehaviorSubject, Observable, combineLatest, fromEvent, map, startWith } from "rxjs"; +import { + BehaviorSubject, + Observable, + combineLatest, + fromEvent, + map, + startWith, + debounceTime, + first, +} from "rxjs"; + +import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state"; import { BREAKPOINTS, isAtOrLargerThanBreakpoint } from "../utils/responsive-utils"; type CollapsePreference = "open" | "closed" | null; +const BIT_SIDE_NAV_WIDTH_KEY_DEF = new KeyDefinition(BIT_SIDE_NAV_DISK, "side-nav-width", { + deserializer: (s) => s, +}); + @Injectable({ providedIn: "root", }) export class SideNavService { + // Units in rem + readonly DEFAULT_OPEN_WIDTH = 18; + readonly MIN_OPEN_WIDTH = 15; + readonly MAX_OPEN_WIDTH = 24; + + private rootFontSizePx: number; + private _open$ = new BehaviorSubject(isAtOrLargerThanBreakpoint("md")); open$ = this._open$.asObservable(); @@ -21,7 +43,30 @@ export class SideNavService { map(([open, isLargeScreen]) => open && !isLargeScreen), ); + /** + * Local component state width + * + * This observable has immediate pixel-perfect updates for the sidebar display width to use + */ + private readonly _width$ = new BehaviorSubject(this.DEFAULT_OPEN_WIDTH); + readonly width$ = this._width$.asObservable(); + + /** + * State provider width + * + * This observable is used to initialize the component state and will be periodically synced + * to the local _width$ state to avoid excessive writes + */ + private readonly widthState = inject(GlobalStateProvider).get(BIT_SIDE_NAV_WIDTH_KEY_DEF); + readonly widthState$ = this.widthState.state$.pipe( + map((width) => width ?? this.DEFAULT_OPEN_WIDTH), + ); + constructor() { + // Get computed root font size to support user-defined a11y font increases + this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16"); + + // Handle open/close state combineLatest([this.isLargeScreen$, this.userCollapsePreference$]) .pipe(takeUntilDestroyed()) .subscribe(([isLargeScreen, userCollapsePreference]) => { @@ -32,6 +77,16 @@ export class SideNavService { this.setOpen(); } }); + + // Initialize the resizable width from state provider + this.widthState$.pipe(first()).subscribe((width: number) => { + this._width$.next(width); + }); + + // Periodically sync to state provider when component state changes + this.width$.pipe(debounceTime(200), takeUntilDestroyed()).subscribe((width) => { + void this.widthState.update(() => width); + }); } get open() { @@ -46,6 +101,9 @@ export class SideNavService { this._open$.next(false); } + /** + * Toggle the open/close state of the side nav + */ toggle() { const curr = this._open$.getValue(); // Store user's preference based on what state they're toggling TO @@ -57,8 +115,51 @@ export class SideNavService { this.setOpen(); } } + + /** + * Set new side nav width from drag event coordinates + * + * @param eventXCoordinate x coordinate of the pointer's bounding client rect + * @param dragElementXCoordinate x coordinate of the drag element's bounding client rect + */ + setWidthFromDrag(eventXPointer: number, dragElementXCoordinate: number) { + const newWidthInPixels = eventXPointer - dragElementXCoordinate; + + const newWidthInRem = newWidthInPixels / this.rootFontSizePx; + + this._setWidthWithinMinMax(newWidthInRem); + } + + /** + * Set new side nav width from arrow key events + * + * @param key event key, must be either ArrowRight or ArrowLeft + */ + setWidthFromKeys(key: "ArrowRight" | "ArrowLeft") { + const currentWidth = this._width$.getValue(); + + const delta = key === "ArrowLeft" ? -1 : 1; + const newWidth = currentWidth + delta; + + this._setWidthWithinMinMax(newWidth); + } + + /** + * Calculate and set the new width, not going out of the min/max bounds + * @param newWidth desired new width: number + */ + private _setWidthWithinMinMax(newWidth: number) { + const width = Math.min(Math.max(newWidth, this.MIN_OPEN_WIDTH), this.MAX_OPEN_WIDTH); + + this._width$.next(width); + } } +/** + * Helper function for subscribing to media query events + * @param query media query to validate against + * @returns Observable + */ export const media = (query: string): Observable => { const mediaQuery = window.matchMedia(query); return fromEvent(mediaQuery, "change").pipe( diff --git a/libs/components/src/stories/colors.mdx b/libs/components/src/stories/colors.mdx index ca9a97b9071..3cf3b46215c 100644 --- a/libs/components/src/stories/colors.mdx +++ b/libs/components/src/stories/colors.mdx @@ -2,127 +2,772 @@ import { Meta } from "@storybook/addon-docs/blocks"; -export const Row = (name) => ( - - {name} - - -); +# Color System -export const Table = (args) => ( - - - - - - - - - {Row("background")} - {Row("background-alt")} - {Row("background-alt2")} - {Row("background-alt3")} - {Row("background-alt4")} - - - {Row("primary-100")} - {Row("primary-300")} - {Row("primary-600")} - {Row("primary-700")} - - - {Row("secondary-100")} - {Row("secondary-300")} - {Row("secondary-500")} - {Row("secondary-600")} - {Row("secondary-700")} - - - {Row("success-100")} - {Row("success-600")} - {Row("success-700")} - - - {Row("danger-100")} - {Row("danger-600")} - {Row("danger-700")} - - - {Row("warning-100")} - {Row("warning-600")} - {Row("warning-700")} - - - {Row("info-100")} - {Row("info-600")} - {Row("info-700")} - - - {Row("notification-100")} - {Row("notification-600")} - - - {Row("illustration-outline")} - {Row("illustration-bg-primary")} - {Row("illustration-bg-secondary")} - {Row("illustration-bg-tertiary")} - {Row("illustration-tertiary")} - {Row("illustration-logo")} - +Bitwarden uses a three-tier color token architecture: - - - - - - - - {Row("text-main")} - {Row("text-muted")} - {Row("text-contrast")} - {Row("text-alt2")} - {Row("text-code")} - +- **Primitive Colors** - Raw color values from the Figma design system +- **Semantic Tokens** - Meaningful names that reference primitives +- **Tailwind Utilities** - CSS classes for components -
General usage
Text
-); +## Color Token Structure - +### Semantic Foreground Tokens -# Colors +- **Neutral**: `fg-white`, `fg-dark`, `fg-contrast`, `fg-heading`, `fg-body`, `fg-body-subtle`, + `fg-disabled` +- **Brand**: `fg-brand-soft`, `fg-brand`, `fg-brand-strong` +- **Status**: `fg-success`, `fg-success-strong`, `fg-danger`, `fg-danger-strong`, `fg-warning`, + `fg-warning-strong`, `fg-sensitive` +- **Accent**: `fg-accent-primary`, `fg-accent-secondary`, `fg-accent-tertiary` (with `-soft` and + `-strong` variants) +- Format: `--color-fg-{name}` -Tailwind traditionally has a very large color palette. Bitwarden has their own more limited color -palette instead. +### Semantic Background Tokens -This has a couple of advantages: +- **Neutral**: `bg-white`, `bg-dark`, `bg-contrast`, `bg-contrast-strong`, `bg-primary`, + `bg-secondary`, `bg-tertiary`, `bg-quaternary`, `bg-gray`, `bg-disabled` +- **Brand**: `bg-brand-softer`, `bg-brand-soft`, `bg-brand-medium`, `bg-brand`, `bg-brand-strong` +- **Status**: `bg-success-soft`, `bg-success-medium`, `bg-success`, `bg-success-strong`, + `bg-danger-soft`, `bg-danger-medium`, `bg-danger`, `bg-danger-strong`, `bg-warning-soft`, + `bg-warning-medium`, `bg-warning`, `bg-warning-strong` +- **Accent**: `bg-accent-primary-soft`, `bg-accent-primary-medium`, `bg-accent-primary`, + `bg-accent-secondary-soft`, `bg-accent-secondary-medium`, `bg-accent-secondary`, + `bg-accent-tertiary-soft`, `bg-accent-tertiary-medium`, `bg-accent-tertiary` +- **Special**: `bg-hover`, `bg-overlay` +- Format: `--color-bg-{name}` -- Promotes consistency across the application. -- Easier to maintain and make adjustments. -- Allows us to support more than two themes light & dark, should it be needed. +### Semantic Border Tokens -Below are all the permited colors. Please consult design before considering adding a new color. +- **Neutral**: `border-muted`, `border-light`, `border-base`, `border-strong`, `border-buffer` +- **Brand**: `border-brand-soft`, `border-brand`, `border-brand-strong` +- **Status**: `border-success-soft`, `border-success`, `border-success-strong`, + `border-danger-soft`, `border-danger`, `border-danger-strong`, `border-warning-soft`, + `border-warning`, `border-warning-strong` +- **Accent**: `border-accent-primary-soft`, `border-accent-primary`, `border-accent-secondary-soft`, + `border-accent-secondary`, `border-accent-tertiary-soft`, `border-accent-tertiary` +- **Focus**: `border-focus` +- Format: `--color-border-{name}` -
- -
+## Semantic Color Tokens + +> **Note:** Due to Tailwind's utility naming and our semantic token structure, class names will +> appear repetitive (e.g., `tw-bg-bg-primary`). This repetition is intentional: +> +> - `tw-` = Tailwind prefix +> - `bg-` = Tailwind utility type (background) +> - `bg-primary` = Our semantic token name + +### Background Colors + +Use `tw-bg-bg-*` for background colors. These tokens automatically adapt to dark mode. + +export const Swatch = ({ name }) => { + const swatchClass = `tw-h-10 tw-w-10 tw-shrink-0 tw-rounded-lg tw-border tw-border-border-base tw-bg-${name}`; + return
; +}; + +export const BackgroundCard = ({ name, primitiveColor }) => { + const bgClass = `tw-flex tw-items-center tw-gap-3 tw-rounded-xl tw-p-4 tw-border tw-border-border-base tw-bg-bg-primary`; + const swatchClass = `tw-h-10 tw-w-10 tw-shrink-0 tw-rounded-lg tw-border tw-border-base tw-bg-bg-${name}`; + return ( +
+
+
bg-{name}
+
({primitiveColor})
+
+ +
+ ); +}; + +
+
+

Light mode

+ +
+

Neutral

+
+ + + + + + + + + + +
+
+ +
+

Brand

+
+ + + + + +
+
+ +
+

Status

+
+ + + + + + + + + + + + +
+
+ +
+

Accent

+
+ + + + + + + + + +
+
+ +
+

Hover

+
+ +
+
+ +
+

Overlay

+
+ +
+
+ +
+ +
+

Dark mode

+ +
+

Neutral

+
+ + + + + + + + + + +
+
+ +
+

Brand

+
+ + + + + +
+
+ +
+

Status

+
+ + + + + + + + + + + + +
+
+ +
+

Accent

+
+ + + + + + + + + +
+
+ +
+

Hover

+
+ +
+
+ +
+

Overlay

+
+ +
+
+ +
+
+ +--- + +### Foreground Colors + +Use `tw-text-fg-*` for text colors. These tokens automatically adapt to dark mode. + +export const ForegroundCard = ({ name, primitiveColor }) => { + const textClass = `tw-text-fg-${name} tw-text-2xl tw-font-bold tw-shrink-0`; + return ( +
+
+
fg-{name}
+
({primitiveColor})
+
+ +
+ ); +}; + +
+
+

Light mode

+ +
+

Neutral

+
+ + + + + + + +
+
+ +
+

Brand

+
+ + + +
+
+ +
+

Status

+
+ + + + + + + +
+
+ +
+

Accent

+
+ + + + + + + + + +
+
+ +
+ +
+

Dark mode

+ +
+

Neutral

+
+ + + + + + + +
+
+ +
+

Brand

+
+ + + +
+
+ +
+

Status

+
+ + + + + + + +
+
+ +
+

Accent

+
+ + + + + + + + + +
+
+ +
+
+ +--- + +### Border Colors + +Use `tw-border-border-*` for border colors. These tokens automatically adapt to dark mode. + +export const BorderCard = ({ name, primitiveColor }) => { + return ( +
+
+
+ border-{name} +
+
({primitiveColor})
+
+ +
+ ); +}; + +
+
+

Light mode

+ +
+

Neutral

+
+ + + + + +
+
+ +
+

Brand

+
+ + + +
+
+ +
+

Status

+
+ + + + + + + + + +
+
+ +
+

Accent

+
+ + + + + + +
+
+ +
+

Focus

+
+ +
+
+ +
+ +
+

Dark mode

+ +
+

Neutral

+
+ + + + + +
+
+ +
+

Brand

+
+ + + +
+
+ +
+

Status

+
+ + + + + + + + + +
+
+ +
+

Accent

+
+ + + + + + +
+
+ +
+

Focus

+
+ +
+
+ +
+
+ +--- + +## Usage Guidelines + +### ✅ DO - Use semantic tokens via Tailwind + +```html + +

Heading text

+

Body text

+ +Error message + + +
Primary background
+
Secondary background
+ +
Danger alert
+ + +
Base border
+ +
Brand border
+ + + +
+ Success alert with matching colors +
+ + +
Hover effect
+ + +
Modal overlay
+``` + +### ❌ DON'T - Use primitive colors directly + +```html + +

Text

+
Background
+ + +

Text

+
Background
+ + +Text +
Background
+``` + +**Why this is wrong:** Primitives aren't semantic and may change. Always use semantic tokens like +`tw-text-fg-brand`, `tw-bg-success`, etc. + +--- + +## Dark Mode + +- Semantic tokens automatically adapt to dark mode via `.theme_dark` class +- No component changes needed when theme switches +- The same semantic token name works in both light and dark themes +- All color values are automatically swapped based on the active theme + +--- + +## Migration Strategy + +- **New components:** Use semantic tokens (`fg-*`, `bg-*`, `border-*`) exclusively +- **Existing components:** Keep legacy tokens until refactoring +- **When refactoring:** Replace legacy tokens with semantic equivalents + +--- + +## Legacy Colors + +**Legacy colors (RGB format)** still exist for backwards compatibility: + +- `primary-*`, `secondary-*`, `success-*`, `danger-*`, `warning-*`, etc. +- Use these only when updating existing components +- Migrate to new semantic tokens when refactoring + +The following legacy colors are displayed below with both light and dark mode values: + +export const LegacyCard = ({ name }) => { + return ( +
+
+
{name}
+
(legacy RGB format)
+
+ +
+ ); +}; + +
+
+

Light mode

+ +
+

General

+
+ + + + + +
+
+ +
+

Primary

+
+ + + + +
+
+ +
+

+ Secondary +

+
+ + + + + +
+
+ +
+

Success

+
+ + + +
+
+ +
+

Danger

+
+ + + +
+
+ +
+

Warning

+
+ + + +
+
+ +
+

Info

+
+ + + +
+
+ +
+ +
+

Dark mode

+ +
+

General

+
+ + + + + +
+
+ +
+

Primary

+
+ + + + +
+
+ +
+

+ Secondary +

+
+ + + + + +
+
+ +
+

Success

+
+ + + +
+
+ +
+

Danger

+
+ + + +
+
+ +
+

Warning

+
+ + + +
+
+ +
+

Info

+
+ + + +
+
+ +
diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index fc6be00b0e0..08f4d875962 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -13,9 +13,11 @@ import { import { PasswordManagerLogo } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { LayoutComponent } from "../../layout"; import { I18nMockService } from "../../utils/i18n-mock.service"; +import { StorybookGlobalStateProvider } from "../../utils/state-mock"; import { positionFixedWrapperDecorator } from "../storybook-decorators"; import { DialogVirtualScrollBlockComponent } from "./components/dialog-virtual-scroll-block.component"; @@ -65,9 +67,14 @@ export default { yes: "Yes", no: "No", loading: "Loading", + resizeSideNavigation: "Resize side navigation", }); }, }, + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts index d696e6077dd..d20b5fd1cda 100644 --- a/libs/components/src/table/table.stories.ts +++ b/libs/components/src/table/table.stories.ts @@ -1,13 +1,14 @@ import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { countries } from "../form/countries"; import { LayoutComponent } from "../layout"; import { mockLayoutI18n } from "../layout/mocks"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; -import { I18nMockService } from "../utils"; +import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; import { TableDataSource } from "./table-data-source"; import { TableModule } from "./table.module"; @@ -27,6 +28,14 @@ export default { }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), ], argTypes: { alignRowContent: { diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index f0e55ddd9e1..757859985d6 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -13,6 +13,12 @@ @tailwind utilities; :root { + /* ======================================== + * LEGACY COLORS (RGB format) + * These are the original colors used throughout the app. + * Use these for existing components until migration is complete. + * ======================================== */ + --color-transparent-hover: rgb(0 0 0 / 0.02); --color-shadow: 168 179 200; @@ -74,6 +80,279 @@ --color-illustration-bg-tertiary: 255 255 255; --color-illustration-tertiary: 255 191 0; --color-illustration-logo: 23 93 220; + + /* ======================================== + * NEW COLOR PALETTE (Hex format) + * These colors are from the new Figma design system. + * Use these for new components and features. + * Format: --color-{family}-{shade} where shade ranges from 050 to 950 + * ======================================== */ + + /* Brand Colors */ + --color-brand-050: #eef6ff; + --color-brand-100: #dbeafe; + --color-brand-200: #bedbff; + --color-brand-300: #8ec5ff; + --color-brand-400: #6baefa; + --color-brand-500: #418bfb; + --color-brand-600: #2a70f4; + --color-brand-700: #175ddc; + --color-brand-800: #0d43af; + --color-brand-900: #0c3276; + --color-brand-950: #162455; + + /* Gray Colors */ + --color-gray-050: #f9fafb; + --color-gray-100: #f3f4f6; + --color-gray-200: #e5e7eb; + --color-gray-300: #d1d5dc; + --color-gray-400: #99a1af; + --color-gray-500: #6a7282; + --color-gray-600: #4a5565; + --color-gray-700: #333e4f; + --color-gray-800: #1e2939; + --color-gray-900: #101828; + --color-gray-950: #070b18; + --color-gray-950-rgb: 7, 11, 24; + + /* Red Colors */ + --color-red-050: #fef2f2; + --color-red-100: #ffe2e2; + --color-red-200: #ffc9c9; + --color-red-300: #ffa2a2; + --color-red-400: #ff6467; + --color-red-500: #fb2c36; + --color-red-600: #e7000b; + --color-red-700: #c10007; + --color-red-800: #9f0712; + --color-red-900: #791112; + --color-red-950: #460809; + + /* Orange Colors */ + --color-orange-050: #fff8f1; + --color-orange-100: #feecdc; + --color-orange-200: #fcd9bd; + --color-orange-300: #fdba8c; + --color-orange-400: #ff8a4c; + --color-orange-500: #ff5a1f; + --color-orange-600: #d03801; + --color-orange-700: #b43403; + --color-orange-800: #8a2c0d; + --color-orange-900: #70240b; + --color-orange-950: #441306; + + /* Yellow Colors */ + --color-yellow-050: #fefce8; + --color-yellow-100: #fef9c2; + --color-yellow-200: #fff085; + --color-yellow-300: #ffdf20; + --color-yellow-400: #fdc700; + --color-yellow-500: #f0b100; + --color-yellow-600: #d08700; + --color-yellow-700: #a65f00; + --color-yellow-800: #894b00; + --color-yellow-900: #733e0a; + --color-yellow-950: #432004; + + /* Green Colors */ + --color-green-050: #f0fdf4; + --color-green-100: #dcfce7; + --color-green-200: #b9f8cf; + --color-green-300: #7bf1a8; + --color-green-400: #18dc7a; + --color-green-500: #0abf52; + --color-green-600: #00a63e; + --color-green-700: #008236; + --color-green-800: #016630; + --color-green-900: #0d542b; + --color-green-950: #032e15; + + /* Pink Colors */ + --color-pink-050: #fdf2f8; + --color-pink-100: #fce7f3; + --color-pink-200: #fccee8; + --color-pink-300: #fda5d5; + --color-pink-400: #fb64b6; + --color-pink-500: #f6339a; + --color-pink-600: #e60076; + --color-pink-700: #c6005c; + --color-pink-800: #a3004c; + --color-pink-900: #861043; + --color-pink-950: #510424; + + /* Coral Colors */ + --color-coral-050: #fff2f0; + --color-coral-100: #ffe0dc; + --color-coral-200: #ffc1b9; + --color-coral-300: #ff9585; + --color-coral-400: #ff6550; + --color-coral-500: #ff4026; + --color-coral-600: #e11f05; + --color-coral-700: #c71800; + --color-coral-800: #a81400; + --color-coral-900: #7e0f00; + --color-coral-950: #4d0900; + + /* Teal Colors */ + --color-teal-050: #ecfeff; + --color-teal-100: #cefafe; + --color-teal-200: #a2f4fd; + --color-teal-300: #70ecf5; + --color-teal-400: #2cdde9; + --color-teal-500: #00c5db; + --color-teal-600: #009cb8; + --color-teal-700: #007c95; + --color-teal-800: #006278; + --color-teal-900: #0f495c; + --color-teal-950: #042e3e; + + /* Purple Colors */ + --color-purple-050: #faf5ff; + --color-purple-100: #f3e8ff; + --color-purple-200: #e9d4ff; + --color-purple-300: #dab2ff; + --color-purple-400: #c27aff; + --color-purple-500: #ad46ff; + --color-purple-600: #9810fa; + --color-purple-700: #8200db; + --color-purple-800: #6e11b0; + --color-purple-900: #59168b; + --color-purple-950: #3c0366; + + /* White and Black */ + --color-white: #ffffff; + --color-white-rgb: 255, 255, 255; + --color-black: #000000; + + /* ======================================== + * SEMANTIC FOREGROUND COLORS (Light Mode) + * These are the tokens that should be exposed to Tailwind + * They reference the primitive colors above + * ======================================== */ + + /* Neutral Foreground */ + --color-fg-white: var(--color-white); + --color-fg-dark: var(--color-gray-900); + --color-fg-contrast: var(--color-white); + --color-fg-heading: var(--color-gray-900); + --color-fg-body: var(--color-gray-600); + --color-fg-body-subtle: var(--color-gray-500); + --color-fg-disabled: var(--color-gray-400); + + /* Brand Foreground */ + --color-fg-brand-soft: var(--color-brand-200); + --color-fg-brand: var(--color-brand-700); + --color-fg-brand-strong: var(--color-brand-900); + + /* Status Foreground */ + --color-fg-success: var(--color-green-700); + --color-fg-success-strong: var(--color-green-900); + --color-fg-danger: var(--color-red-700); + --color-fg-danger-strong: var(--color-red-900); + --color-fg-warning: var(--color-orange-600); + --color-fg-warning-strong: var(--color-orange-900); + --color-fg-sensitive: var(--color-pink-600); + + /* Accent Foreground */ + --color-fg-accent-primary-soft: var(--color-teal-200); + --color-fg-accent-primary: var(--color-teal-400); + --color-fg-accent-primary-strong: var(--color-teal-800); + --color-fg-accent-secondary-soft: var(--color-coral-200); + --color-fg-accent-secondary: var(--color-coral-400); + --color-fg-accent-secondary-strong: var(--color-coral-900); + --color-fg-accent-tertiary-soft: var(--color-purple-200); + --color-fg-accent-tertiary: var(--color-purple-700); + --color-fg-accent-tertiary-strong: var(--color-purple-900); + + /* ======================================== + * SEMANTIC BACKGROUND COLORS (Light Mode) + * ======================================== */ + + /* Neutral Background */ + --color-bg-white: var(--color-white); + --color-bg-dark: var(--color-gray-800); + --color-bg-contrast: var(--color-gray-800); + --color-bg-contrast-strong: var(--color-gray-950); + --color-bg-primary: var(--color-white); + --color-bg-secondary: var(--color-gray-050); + --color-bg-tertiary: var(--color-gray-050); + --color-bg-quaternary: var(--color-gray-200); + --color-bg-gray: var(--color-gray-300); + --color-bg-disabled: var(--color-gray-100); + + /* Brand Background */ + --color-bg-brand-softer: var(--color-brand-050); + --color-bg-brand-soft: var(--color-brand-100); + --color-bg-brand-medium: var(--color-brand-200); + --color-bg-brand: var(--color-brand-700); + --color-bg-brand-strong: var(--color-brand-800); + + /* Status Background */ + --color-bg-success-soft: var(--color-green-050); + --color-bg-success-medium: var(--color-green-100); + --color-bg-success: var(--color-green-700); + --color-bg-success-strong: var(--color-green-800); + --color-bg-danger-soft: var(--color-red-050); + --color-bg-danger-medium: var(--color-red-100); + --color-bg-danger: var(--color-red-700); + --color-bg-danger-strong: var(--color-red-800); + --color-bg-warning-soft: var(--color-orange-050); + --color-bg-warning-medium: var(--color-orange-100); + --color-bg-warning: var(--color-orange-600); + --color-bg-warning-strong: var(--color-orange-700); + + /* Accent Background */ + --color-bg-accent-primary-soft: var(--color-teal-050); + --color-bg-accent-primary-medium: var(--color-teal-100); + --color-bg-accent-primary: var(--color-teal-400); + --color-bg-accent-secondary-soft: var(--color-coral-050); + --color-bg-accent-secondary-medium: var(--color-coral-100); + --color-bg-accent-secondary: var(--color-coral-400); + --color-bg-accent-tertiary-soft: var(--color-purple-050); + --color-bg-accent-tertiary-medium: var(--color-purple-100); + --color-bg-accent-tertiary: var(--color-purple-600); + + /* Hover & Overlay */ + --color-bg-hover: rgba(var(--color-gray-950-rgb), 0.05); + --color-bg-overlay: rgba(var(--color-gray-950-rgb), 0.3); + + /* ======================================== + * SEMANTIC BORDER COLORS (Light Mode) + * ======================================== */ + + /* Neutral Border */ + --color-border-muted: var(--color-gray-050); + --color-border-light: var(--color-gray-100); + --color-border-base: var(--color-gray-200); + --color-border-strong: var(--color-gray-800); + --color-border-buffer: var(--color-white); + + /* Brand Border */ + --color-border-brand-soft: var(--color-brand-200); + --color-border-brand: var(--color-brand-700); + --color-border-brand-strong: var(--color-brand-900); + + /* Status Border */ + --color-border-success-soft: var(--color-green-200); + --color-border-success: var(--color-green-700); + --color-border-success-strong: var(--color-green-900); + --color-border-danger-soft: var(--color-red-200); + --color-border-danger: var(--color-red-700); + --color-border-danger-strong: var(--color-red-900); + --color-border-warning-soft: var(--color-orange-200); + --color-border-warning: var(--color-orange-600); + --color-border-warning-strong: var(--color-orange-900); + + /* Accent Border */ + --color-border-accent-primary-soft: var(--color-teal-200); + --color-border-accent-primary: var(--color-teal-600); + --color-border-accent-secondary-soft: var(--color-coral-200); + --color-border-accent-secondary: var(--color-coral-600); + --color-border-accent-tertiary-soft: var(--color-purple-200); + --color-border-accent-tertiary: var(--color-purple-600); + + /* Focus Border */ + --color-border-focus: var(--color-black); } .theme_light { @@ -140,6 +419,129 @@ --color-illustration-bg-tertiary: 243 246 249; --color-illustration-tertiary: 255 191 0; --color-illustration-logo: 255 255 255; + + /* ======================================== + * SEMANTIC FOREGROUND COLORS (Dark Mode Overrides) + * ======================================== */ + + /* Neutral Foreground */ + --color-fg-contrast: var(--color-gray-900); + --color-fg-heading: var(--color-gray-050); + --color-fg-body: var(--color-gray-200); + --color-fg-body-subtle: var(--color-gray-400); + --color-fg-disabled: var(--color-gray-600); + + /* Brand Foreground */ + --color-fg-brand-soft: var(--color-brand-500); + --color-fg-brand: var(--color-brand-400); + --color-fg-brand-strong: var(--color-brand-200); + + /* Status Foreground */ + --color-fg-success: var(--color-green-400); + --color-fg-success-strong: var(--color-green-100); + --color-fg-danger: var(--color-red-400); + --color-fg-danger-strong: var(--color-red-100); + --color-fg-warning: var(--color-orange-400); + --color-fg-warning-strong: var(--color-orange-100); + --color-fg-sensitive: var(--color-pink-300); + + /* Accent Foreground */ + --color-fg-accent-primary-soft: var(--color-teal-400); + --color-fg-accent-primary: var(--color-teal-300); + --color-fg-accent-primary-strong: var(--color-teal-100); + --color-fg-accent-secondary-soft: var(--color-coral-500); + --color-fg-accent-secondary: var(--color-coral-400); + --color-fg-accent-secondary-strong: var(--color-coral-100); + --color-fg-accent-tertiary-soft: var(--color-purple-500); + --color-fg-accent-tertiary: var(--color-purple-400); + --color-fg-accent-tertiary-strong: var(--color-purple-100); + + /* ======================================== + * SEMANTIC BACKGROUND COLORS (Dark Mode Overrides) + * ======================================== */ + + /* Neutral Background */ + --color-bg-contrast: var(--color-gray-050); + --color-bg-contrast-strong: var(--color-gray-050); + --color-bg-primary: var(--color-gray-900); + --color-bg-secondary: var(--color-gray-800); + --color-bg-tertiary: var(--color-gray-950); + --color-bg-quaternary: var(--color-gray-700); + --color-bg-gray: var(--color-gray-600); + --color-bg-disabled: var(--color-gray-950); + + /* Brand Background */ + --color-bg-brand-softer: var(--color-brand-950); + --color-bg-brand-soft: var(--color-brand-900); + --color-bg-brand-medium: var(--color-brand-800); + --color-bg-brand: var(--color-brand-400); + --color-bg-brand-strong: var(--color-brand-300); + + /* Status Background */ + --color-bg-success-soft: var(--color-green-950); + --color-bg-success-medium: var(--color-green-900); + --color-bg-success: var(--color-green-400); + --color-bg-success-strong: var(--color-green-300); + --color-bg-danger-soft: var(--color-red-950); + --color-bg-danger-medium: var(--color-red-900); + --color-bg-danger: var(--color-red-400); + --color-bg-danger-strong: var(--color-red-300); + --color-bg-warning-soft: var(--color-orange-950); + --color-bg-warning-medium: var(--color-orange-900); + --color-bg-warning: var(--color-orange-400); + --color-bg-warning-strong: var(--color-orange-300); + + /* Accent Background */ + --color-bg-accent-primary-soft: var(--color-teal-950); + --color-bg-accent-primary-medium: var(--color-teal-900); + --color-bg-accent-primary: var(--color-teal-400); + --color-bg-accent-secondary-soft: var(--color-coral-950); + --color-bg-accent-secondary-medium: var(--color-coral-900); + --color-bg-accent-secondary: var(--color-coral-400); + --color-bg-accent-tertiary-soft: var(--color-purple-950); + --color-bg-accent-tertiary-medium: var(--color-purple-900); + --color-bg-accent-tertiary: var(--color-purple-600); + + /* Hover & Overlay */ + --color-bg-hover: rgba(var(--color-white-rgb), 0.05); + --color-bg-overlay: rgba(var(--color-gray-950-rgb), 0.85); + + /* ======================================== + * SEMANTIC BORDER COLORS (Dark Mode Overrides) + * ======================================== */ + + /* Neutral Border */ + --color-border-muted: var(--color-gray-900); + --color-border-light: var(--color-gray-800); + --color-border-base: var(--color-gray-700); + --color-border-strong: var(--color-gray-400); + --color-border-buffer: var(--color-gray-950); + + /* Brand Border */ + --color-border-brand-soft: var(--color-brand-800); + --color-border-brand: var(--color-brand-400); + --color-border-brand-strong: var(--color-brand-200); + + /* Status Border */ + --color-border-success-soft: var(--color-green-800); + --color-border-success: var(--color-green-400); + --color-border-success-strong: var(--color-green-200); + --color-border-danger-soft: var(--color-red-800); + --color-border-danger: var(--color-red-400); + --color-border-danger-strong: var(--color-red-200); + --color-border-warning-soft: var(--color-orange-800); + --color-border-warning: var(--color-orange-400); + --color-border-warning-strong: var(--color-orange-200); + + /* Accent Border */ + --color-border-accent-primary-soft: var(--color-teal-800); + --color-border-accent-secondary-soft: var(--color-coral-800); + --color-border-accent-secondary: var(--color-coral-500); + --color-border-accent-tertiary-soft: var(--color-purple-800); + --color-border-accent-tertiary: var(--color-purple-500); + + /* Focus Border */ + --color-border-focus: var(--color-white); } @layer components { diff --git a/libs/components/src/utils/index.ts b/libs/components/src/utils/index.ts index 5ac6c6c0da0..c66d7761862 100644 --- a/libs/components/src/utils/index.ts +++ b/libs/components/src/utils/index.ts @@ -2,3 +2,4 @@ export * from "./aria-disable-element"; export * from "./function-to-observable"; export * from "./has-scrollable-content"; export * from "./i18n-mock.service"; +export * from "./state-mock"; diff --git a/libs/components/src/utils/state-mock.ts b/libs/components/src/utils/state-mock.ts new file mode 100644 index 00000000000..d82705f4d3b --- /dev/null +++ b/libs/components/src/utils/state-mock.ts @@ -0,0 +1,48 @@ +import { BehaviorSubject, Observable } from "rxjs"; + +import { + GlobalState, + StateUpdateOptions, + GlobalStateProvider, + KeyDefinition, +} from "@bitwarden/state"; + +export class StorybookGlobalState implements GlobalState { + private _state$ = new BehaviorSubject(null); + + constructor(initialValue?: T | null) { + this._state$.next(initialValue ?? null); + } + + async update( + configureState: (state: T | null, dependency: TCombine) => T | null, + options?: Partial>, + ): Promise { + const currentState = this._state$.value; + const newState = configureState(currentState, null as TCombine); + this._state$.next(newState); + return newState; + } + + get state$(): Observable { + return this._state$.asObservable(); + } + + setValue(value: T | null): void { + this._state$.next(value); + } +} + +export class StorybookGlobalStateProvider implements GlobalStateProvider { + private states = new Map>(); + + get(keyDefinition: KeyDefinition): GlobalState { + const key = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`; + + if (!this.states.has(key)) { + this.states.set(key, new StorybookGlobalState()); + } + + return this.states.get(key)!; + } +} diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index e41cff16e48..bd88f5471ff 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -9,9 +9,9 @@ function rgba(color) { module.exports = { prefix: "tw-", content: [ - "./src/**/*.{html,ts}", + "./src/**/*.{html,ts,mdx}", "../../libs/assets/src/**/*.{html,ts}", - "../../libs/components/src/**/*.{html,ts}", + "../../libs/components/src/**/*.{html,ts,mdx}", "../../libs/key-management-ui/src/**/*.{html,ts}", "../../libs/auth/src/**/*.{html,ts}", ], @@ -78,6 +78,46 @@ module.exports = { alt3: rgba("--color-background-alt3"), alt4: rgba("--color-background-alt4"), }, + bg: { + white: "var(--color-bg-white)", + dark: "var(--color-bg-dark)", + contrast: "var(--color-bg-contrast)", + "contrast-strong": "var(--color-bg-contrast-strong)", + primary: "var(--color-bg-primary)", + secondary: "var(--color-bg-secondary)", + tertiary: "var(--color-bg-tertiary)", + quaternary: "var(--color-bg-quaternary)", + gray: "var(--color-bg-gray)", + disabled: "var(--color-bg-disabled)", + "brand-softer": "var(--color-bg-brand-softer)", + "brand-soft": "var(--color-bg-brand-soft)", + "brand-medium": "var(--color-bg-brand-medium)", + brand: "var(--color-bg-brand)", + "brand-strong": "var(--color-bg-brand-strong)", + "success-soft": "var(--color-bg-success-soft)", + "success-medium": "var(--color-bg-success-medium)", + success: "var(--color-bg-success)", + "success-strong": "var(--color-bg-success-strong)", + "danger-soft": "var(--color-bg-danger-soft)", + "danger-medium": "var(--color-bg-danger-medium)", + danger: "var(--color-bg-danger)", + "danger-strong": "var(--color-bg-danger-strong)", + "warning-soft": "var(--color-bg-warning-soft)", + "warning-medium": "var(--color-bg-warning-medium)", + warning: "var(--color-bg-warning)", + "warning-strong": "var(--color-bg-warning-strong)", + "accent-primary-soft": "var(--color-bg-accent-primary-soft)", + "accent-primary-medium": "var(--color-bg-accent-primary-medium)", + "accent-primary": "var(--color-bg-accent-primary)", + "accent-secondary-soft": "var(--color-bg-accent-secondary-soft)", + "accent-secondary-medium": "var(--color-bg-accent-secondary-medium)", + "accent-secondary": "var(--color-bg-accent-secondary)", + "accent-tertiary-soft": "var(--color-bg-accent-tertiary-soft)", + "accent-tertiary-medium": "var(--color-bg-accent-tertiary-medium)", + "accent-tertiary": "var(--color-bg-accent-tertiary)", + hover: "var(--color-bg-hover)", + overlay: "var(--color-bg-overlay)", + }, hover: { default: "var(--color-hover-default)", contrast: "var(--color-hover-contrast)", @@ -92,8 +132,62 @@ module.exports = { tertiary: rgba("--color-illustration-tertiary"), logo: rgba("--color-illustration-logo"), }, + fg: { + white: "var(--color-fg-white)", + dark: "var(--color-fg-dark)", + contrast: "var(--color-fg-contrast)", + heading: "var(--color-fg-heading)", + body: "var(--color-fg-body)", + "body-subtle": "var(--color-fg-body-subtle)", + disabled: "var(--color-fg-disabled)", + "brand-soft": "var(--color-fg-brand-soft)", + brand: "var(--color-fg-brand)", + "brand-strong": "var(--color-fg-brand-strong)", + success: "var(--color-fg-success)", + "success-strong": "var(--color-fg-success-strong)", + danger: "var(--color-fg-danger)", + "danger-strong": "var(--color-fg-danger-strong)", + warning: "var(--color-fg-warning)", + "warning-strong": "var(--color-fg-warning-strong)", + sensitive: "var(--color-fg-sensitive)", + "accent-primary-soft": "var(--color-fg-accent-primary-soft)", + "accent-primary": "var(--color-fg-accent-primary)", + "accent-primary-strong": "var(--color-fg-accent-primary-strong)", + "accent-secondary-soft": "var(--color-fg-accent-secondary-soft)", + "accent-secondary": "var(--color-fg-accent-secondary)", + "accent-secondary-strong": "var(--color-fg-accent-secondary-strong)", + "accent-tertiary-soft": "var(--color-fg-accent-tertiary-soft)", + "accent-tertiary": "var(--color-fg-accent-tertiary)", + "accent-tertiary-strong": "var(--color-fg-accent-tertiary-strong)", + }, + border: { + muted: "var(--color-border-muted)", + light: "var(--color-border-light)", + base: "var(--color-border-base)", + strong: "var(--color-border-strong)", + buffer: "var(--color-border-buffer)", + "brand-soft": "var(--color-border-brand-soft)", + brand: "var(--color-border-brand)", + "brand-strong": "var(--color-border-brand-strong)", + "success-soft": "var(--color-border-success-soft)", + success: "var(--color-border-success)", + "success-strong": "var(--color-border-success-strong)", + "danger-soft": "var(--color-border-danger-soft)", + danger: "var(--color-border-danger)", + "danger-strong": "var(--color-border-danger-strong)", + "warning-soft": "var(--color-border-warning-soft)", + warning: "var(--color-border-warning)", + "warning-strong": "var(--color-border-warning-strong)", + "accent-primary-soft": "var(--color-border-accent-primary-soft)", + "accent-primary": "var(--color-border-accent-primary)", + "accent-secondary-soft": "var(--color-border-accent-secondary-soft)", + "accent-secondary": "var(--color-border-accent-secondary)", + "accent-tertiary-soft": "var(--color-border-accent-tertiary-soft)", + "accent-tertiary": "var(--color-border-accent-tertiary)", + focus: "var(--color-border-focus)", + }, }, - textColor: { + textColor: () => ({ main: rgba("--color-text-main"), muted: rgba("--color-text-muted"), contrast: rgba("--color-text-contrast"), @@ -132,7 +226,62 @@ module.exports = { notification: { 600: rgba("--color-notification-600"), }, - }, + // New semantic fg tokens - manually flattened to generate tw-text-fg-* utilities + "fg-white": "var(--color-fg-white)", + "fg-dark": "var(--color-fg-dark)", + "fg-contrast": "var(--color-fg-contrast)", + "fg-heading": "var(--color-fg-heading)", + "fg-body": "var(--color-fg-body)", + "fg-body-subtle": "var(--color-fg-body-subtle)", + "fg-disabled": "var(--color-fg-disabled)", + "fg-brand-soft": "var(--color-fg-brand-soft)", + "fg-brand": "var(--color-fg-brand)", + "fg-brand-strong": "var(--color-fg-brand-strong)", + "fg-success": "var(--color-fg-success)", + "fg-success-strong": "var(--color-fg-success-strong)", + "fg-danger": "var(--color-fg-danger)", + "fg-danger-strong": "var(--color-fg-danger-strong)", + "fg-warning": "var(--color-fg-warning)", + "fg-warning-strong": "var(--color-fg-warning-strong)", + "fg-sensitive": "var(--color-fg-sensitive)", + "fg-accent-primary-soft": "var(--color-fg-accent-primary-soft)", + "fg-accent-primary": "var(--color-fg-accent-primary)", + "fg-accent-primary-strong": "var(--color-fg-accent-primary-strong)", + "fg-accent-secondary-soft": "var(--color-fg-accent-secondary-soft)", + "fg-accent-secondary": "var(--color-fg-accent-secondary)", + "fg-accent-secondary-strong": "var(--color-fg-accent-secondary-strong)", + "fg-accent-tertiary-soft": "var(--color-fg-accent-tertiary-soft)", + "fg-accent-tertiary": "var(--color-fg-accent-tertiary)", + "fg-accent-tertiary-strong": "var(--color-fg-accent-tertiary-strong)", + }), + borderColor: ({ theme }) => ({ + ...theme("colors"), + // New semantic border tokens - manually flattened to generate tw-border-border-* utilities + "border-muted": "var(--color-border-muted)", + "border-light": "var(--color-border-light)", + "border-base": "var(--color-border-base)", + "border-strong": "var(--color-border-strong)", + "border-buffer": "var(--color-border-buffer)", + "border-brand-soft": "var(--color-border-brand-soft)", + "border-brand": "var(--color-border-brand)", + "border-brand-strong": "var(--color-border-brand-strong)", + "border-success-soft": "var(--color-border-success-soft)", + "border-success": "var(--color-border-success)", + "border-success-strong": "var(--color-border-success-strong)", + "border-danger-soft": "var(--color-border-danger-soft)", + "border-danger": "var(--color-border-danger)", + "border-danger-strong": "var(--color-border-danger-strong)", + "border-warning-soft": "var(--color-border-warning-soft)", + "border-warning": "var(--color-border-warning)", + "border-warning-strong": "var(--color-border-warning-strong)", + "border-accent-primary-soft": "var(--color-border-accent-primary-soft)", + "border-accent-primary": "var(--color-border-accent-primary)", + "border-accent-secondary-soft": "var(--color-border-accent-secondary-soft)", + "border-accent-secondary": "var(--color-border-accent-secondary)", + "border-accent-tertiary-soft": "var(--color-border-accent-tertiary-soft)", + "border-accent-tertiary": "var(--color-border-accent-tertiary)", + "border-focus": "var(--color-border-focus)", + }), fontFamily: { sans: "var(--font-sans)", serif: "var(--font-serif)", diff --git a/libs/components/tailwind.config.js b/libs/components/tailwind.config.js index d8cef6596dc..0fa5b259bb6 100644 --- a/libs/components/tailwind.config.js +++ b/libs/components/tailwind.config.js @@ -11,11 +11,16 @@ config.content = [ "bitwarden_license/bit-web/src/**/*.{html,ts,mdx}", ".storybook/preview.tsx", ]; + +// Safelist is required for dynamic color classes in Storybook color documentation (colors.mdx). +// Tailwind's JIT compiler cannot detect dynamically constructed class names like `tw-bg-${name}`, +// so we must explicitly safelist these patterns to ensure all color utilities are generated. config.safelist = [ { pattern: /tw-bg-(.*)/, }, ]; + config.corePlugins.preflight = true; module.exports = config; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts new file mode 100644 index 00000000000..4771f47b4c9 --- /dev/null +++ b/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts @@ -0,0 +1,201 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { filter, firstValueFrom } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { Collection } from "@bitwarden/admin-console/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { + CipherWithIdExport, + CollectionWithIdExport, + FolderWithIdExport, +} from "@bitwarden/common/models/export"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrgKey, UserKey } from "@bitwarden/common/types/key"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; +import { + BitwardenEncryptedIndividualJsonExport, + BitwardenEncryptedJsonExport, + BitwardenEncryptedOrgJsonExport, + BitwardenJsonExport, + BitwardenPasswordProtectedFileFormat, + isOrgEncrypted, + isPasswordProtected, + isUnencrypted, +} from "@bitwarden/vault-export-core"; + +import { ImportResult } from "../../models/import-result"; +import { Importer } from "../importer"; + +import { BitwardenJsonImporter } from "./bitwarden-json-importer"; + +export class BitwardenEncryptedJsonImporter extends BitwardenJsonImporter implements Importer { + constructor( + protected keyService: KeyService, + protected encryptService: EncryptService, + protected i18nService: I18nService, + private cipherService: CipherService, + private accountService: AccountService, + ) { + super(); + } + + async parse(data: string): Promise { + const results: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport = JSON.parse(data); + + if (isPasswordProtected(results)) { + throw new Error( + "Data is password-protected. Use BitwardenPasswordProtectedImporter instead.", + ); + } + + if (results == null || results.items == null) { + const result = new ImportResult(); + result.success = false; + return result; + } + + if (isUnencrypted(results)) { + return super.parse(data); + } + + return await this.parseEncrypted(results); + } + + private async parseEncrypted(data: BitwardenEncryptedJsonExport): Promise { + const result = new ImportResult(); + const account = await firstValueFrom(this.accountService.activeAccount$); + + if (this.isNullOrWhitespace(data.encKeyValidation_DO_NOT_EDIT)) { + result.success = false; + result.errorMessage = this.i18nService.t("importEncKeyError"); + return result; + } + + const orgKeys = await firstValueFrom(this.keyService.orgKeys$(account.id)); + let keyForDecryption: OrgKey | UserKey | null | undefined = orgKeys?.[this.organizationId]; + if (!keyForDecryption) { + keyForDecryption = await firstValueFrom(this.keyService.userKey$(account.id)); + } + + if (!keyForDecryption) { + result.success = false; + result.errorMessage = this.i18nService.t("importEncKeyError"); + return result; + } + const encKeyValidation = new EncString(data.encKeyValidation_DO_NOT_EDIT); + try { + await this.encryptService.decryptString(encKeyValidation, keyForDecryption); + } catch { + result.success = false; + result.errorMessage = this.i18nService.t("importEncKeyError"); + return result; + } + + let groupingsMap: Map | null = null; + if (isOrgEncrypted(data)) { + groupingsMap = await this.parseEncryptedCollections(account.id, data, result); + } else { + groupingsMap = await this.parseEncryptedFolders(account.id, data, result); + } + + for (const c of data.items) { + const cipher = CipherWithIdExport.toDomain(c); + // reset ids in case they were set for some reason + cipher.id = null; + cipher.organizationId = this.organizationId; + cipher.collectionIds = null; + + // make sure password history is limited + if (cipher.passwordHistory != null && cipher.passwordHistory.length > 5) { + cipher.passwordHistory = cipher.passwordHistory.slice(0, 5); + } + + if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) { + result.folderRelationships.push([result.ciphers.length, groupingsMap.get(c.folderId)]); + } else if (this.organization && c.collectionIds != null) { + c.collectionIds.forEach((cId) => { + if (groupingsMap.has(cId)) { + result.collectionRelationships.push([result.ciphers.length, groupingsMap.get(cId)]); + } + }); + } + + const view = await this.cipherService.decrypt(cipher, account.id); + this.cleanupCipher(view); + result.ciphers.push(view); + } + + result.success = true; + return result; + } + + private async parseEncryptedFolders( + userId: UserId, + data: BitwardenEncryptedIndividualJsonExport, + importResult: ImportResult, + ): Promise> { + const groupingsMap = new Map(); + + if (data.folders == null) { + return groupingsMap; + } + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + + for (const f of data.folders) { + let folderView: FolderView; + const folder = FolderWithIdExport.toDomain(f); + if (folder != null) { + folderView = await folder.decrypt(userKey); + } + + if (folderView != null) { + groupingsMap.set(f.id, importResult.folders.length); + importResult.folders.push(folderView); + } + } + return groupingsMap; + } + + private async parseEncryptedCollections( + userId: UserId, + data: BitwardenEncryptedOrgJsonExport, + importResult: ImportResult, + ): Promise> { + const groupingsMap = new Map(); + if (data.collections == null) { + return groupingsMap; + } + + const orgKeys = await firstValueFrom( + this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)), + ); + + for (const c of data.collections) { + const collection = CollectionWithIdExport.toDomain( + c, + new Collection({ + id: c.id, + name: new EncString(c.name), + organizationId: this.organizationId, + }), + ); + + const orgKey = orgKeys[c.organizationId]; + const collectionView = await collection.decrypt(orgKey, this.encryptService); + + if (collectionView != null) { + groupingsMap.set(c.id, importResult.collections.length); + importResult.collections.push(collectionView); + } + } + return groupingsMap; + } +} diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index 1f5be7f18ab..ddeba885f88 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -1,30 +1,17 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { filter, firstValueFrom } from "rxjs"; - -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { Collection, CollectionView } from "@bitwarden/admin-console/common"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { CipherWithIdExport, CollectionWithIdExport, FolderWithIdExport, } from "@bitwarden/common/models/export"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { UserId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; -import { KeyService } from "@bitwarden/key-management"; import { - BitwardenEncryptedIndividualJsonExport, - BitwardenEncryptedOrgJsonExport, BitwardenJsonExport, BitwardenUnEncryptedIndividualJsonExport, + BitwardenUnEncryptedJsonExport, BitwardenUnEncryptedOrgJsonExport, + isOrgUnEncrypted, + isUnencrypted, } from "@bitwarden/vault-export-core"; import { ImportResult } from "../../models/import-result"; @@ -32,103 +19,30 @@ import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; export class BitwardenJsonImporter extends BaseImporter implements Importer { - private result: ImportResult; - - protected constructor( - protected keyService: KeyService, - protected encryptService: EncryptService, - protected i18nService: I18nService, - protected cipherService: CipherService, - protected accountService: AccountService, - ) { + protected constructor() { super(); } async parse(data: string): Promise { - const account = await firstValueFrom(this.accountService.activeAccount$); - this.result = new ImportResult(); const results: BitwardenJsonExport = JSON.parse(data); if (results == null || results.items == null) { - this.result.success = false; - return this.result; + const result = new ImportResult(); + result.success = false; + return result; } - if (results.encrypted) { - await this.parseEncrypted(results as any, account.id); - } else { - await this.parseDecrypted(results as any, account.id); + if (!isUnencrypted(results)) { + throw new Error("Data is encrypted. Use BitwardenEncryptedJsonImporter instead."); } - - return this.result; + return await this.parseDecrypted(results); } - private async parseEncrypted( - results: BitwardenEncryptedIndividualJsonExport | BitwardenEncryptedOrgJsonExport, - userId: UserId, - ) { - if (results.encKeyValidation_DO_NOT_EDIT != null) { - const orgKeys = await firstValueFrom(this.keyService.orgKeys$(userId)); - let keyForDecryption: SymmetricCryptoKey = orgKeys?.[this.organizationId]; - if (keyForDecryption == null) { - keyForDecryption = await firstValueFrom(this.keyService.userKey$(userId)); - } - const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT); - try { - await this.encryptService.decryptString(encKeyValidation, keyForDecryption); - } catch { - this.result.success = false; - this.result.errorMessage = this.i18nService.t("importEncKeyError"); - return; - } - } + private async parseDecrypted(results: BitwardenUnEncryptedJsonExport): Promise { + const importResult = new ImportResult(); - const groupingsMap = this.organization - ? await this.parseCollections(results as BitwardenEncryptedOrgJsonExport, userId) - : await this.parseFolders(results as BitwardenEncryptedIndividualJsonExport, userId); - - for (const c of results.items) { - const cipher = CipherWithIdExport.toDomain(c); - // reset ids in case they were set for some reason - cipher.id = null; - cipher.organizationId = this.organizationId; - cipher.collectionIds = null; - - // make sure password history is limited - if (cipher.passwordHistory != null && cipher.passwordHistory.length > 5) { - cipher.passwordHistory = cipher.passwordHistory.slice(0, 5); - } - - if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) { - this.result.folderRelationships.push([ - this.result.ciphers.length, - groupingsMap.get(c.folderId), - ]); - } else if (this.organization && c.collectionIds != null) { - c.collectionIds.forEach((cId) => { - if (groupingsMap.has(cId)) { - this.result.collectionRelationships.push([ - this.result.ciphers.length, - groupingsMap.get(cId), - ]); - } - }); - } - - const view = await this.cipherService.decrypt(cipher, userId); - this.cleanupCipher(view); - this.result.ciphers.push(view); - } - - this.result.success = true; - } - - private async parseDecrypted( - results: BitwardenUnEncryptedIndividualJsonExport | BitwardenUnEncryptedOrgJsonExport, - userId: UserId, - ) { - const groupingsMap = this.organization - ? await this.parseCollections(results as BitwardenUnEncryptedOrgJsonExport, userId) - : await this.parseFolders(results as BitwardenUnEncryptedIndividualJsonExport, userId); + const groupingsMap = isOrgUnEncrypted(results) + ? await this.parseCollections(results, importResult) + : await this.parseFolders(results, importResult); results.items.forEach((c) => { const cipher = CipherWithIdExport.toView(c); @@ -143,15 +57,15 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { } if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) { - this.result.folderRelationships.push([ - this.result.ciphers.length, + importResult.folderRelationships.push([ + importResult.ciphers.length, groupingsMap.get(c.folderId), ]); } else if (this.organization && c.collectionIds != null) { c.collectionIds.forEach((cId) => { if (groupingsMap.has(cId)) { - this.result.collectionRelationships.push([ - this.result.ciphers.length, + importResult.collectionRelationships.push([ + importResult.ciphers.length, groupingsMap.get(cId), ]); } @@ -159,79 +73,48 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { } this.cleanupCipher(cipher); - this.result.ciphers.push(cipher); + importResult.ciphers.push(cipher); }); - this.result.success = true; + importResult.success = true; + return importResult; } private async parseFolders( - data: BitwardenUnEncryptedIndividualJsonExport | BitwardenEncryptedIndividualJsonExport, - userId: UserId, - ): Promise> | null { + data: BitwardenUnEncryptedIndividualJsonExport, + importResult: ImportResult, + ): Promise> { + const groupingsMap = new Map(); if (data.folders == null) { - return null; + return groupingsMap; } - const userKey = await firstValueFrom(this.keyService.userKey$(userId)); - - const groupingsMap = new Map(); - for (const f of data.folders) { - let folderView: FolderView; - if (data.encrypted) { - const folder = FolderWithIdExport.toDomain(f); - if (folder != null) { - folderView = await folder.decrypt(userKey); - } - } else { - folderView = FolderWithIdExport.toView(f); - } - + const folderView = FolderWithIdExport.toView(f); if (folderView != null) { - groupingsMap.set(f.id, this.result.folders.length); - this.result.folders.push(folderView); + groupingsMap.set(f.id, importResult.folders.length); + importResult.folders.push(folderView); } } return groupingsMap; } private async parseCollections( - data: BitwardenUnEncryptedOrgJsonExport | BitwardenEncryptedOrgJsonExport, - userId: UserId, - ): Promise> | null { + data: BitwardenUnEncryptedOrgJsonExport, + importResult: ImportResult, + ): Promise> { + const groupingsMap = new Map(); if (data.collections == null) { - return null; + return groupingsMap; } - const orgKeys = await firstValueFrom( - this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)), - ); - - const groupingsMap = new Map(); - for (const c of data.collections) { - let collectionView: CollectionView; - if (data.encrypted) { - const collection = CollectionWithIdExport.toDomain( - c, - new Collection({ - id: c.id, - name: new EncString(c.name), - organizationId: this.organizationId, - }), - ); - - const orgKey = orgKeys[c.organizationId]; - collectionView = await collection.decrypt(orgKey, this.encryptService); - } else { - collectionView = CollectionWithIdExport.toView(c); - collectionView.organizationId = null; - } + const collectionView = CollectionWithIdExport.toView(c); + collectionView.organizationId = null; if (collectionView != null) { - groupingsMap.set(c.id, this.result.collections.length); - this.result.collections.push(collectionView); + groupingsMap.set(c.id, importResult.collections.length); + importResult.collections.push(collectionView); } } return groupingsMap; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index fdf92cac751..ff6e3692640 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -16,6 +16,7 @@ import { UserId } from "@bitwarden/user-core"; import { emptyAccountEncrypted } from "../spec-data/bitwarden-json/account-encrypted.json"; import { emptyUnencryptedExport } from "../spec-data/bitwarden-json/unencrypted.json"; +import { BitwardenEncryptedJsonImporter } from "./bitwarden-encrypted-json-importer"; import { BitwardenJsonImporter } from "./bitwarden-json-importer"; import { BitwardenPasswordProtectedImporter } from "./bitwarden-password-protected-importer"; @@ -92,7 +93,7 @@ describe("BitwardenPasswordProtectedImporter", () => { describe("Account encrypted", () => { beforeAll(() => { - jest.spyOn(BitwardenJsonImporter.prototype, "parse"); + jest.spyOn(BitwardenEncryptedJsonImporter.prototype, "parse"); }); beforeEach(() => { @@ -114,9 +115,11 @@ describe("BitwardenPasswordProtectedImporter", () => { ); }); - it("Should call BitwardenJsonImporter", async () => { - expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(true); - expect(BitwardenJsonImporter.prototype.parse).toHaveBeenCalledWith(emptyAccountEncrypted); + it("Should call BitwardenEncryptedJsonImporter", async () => { + expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(false); + expect(BitwardenEncryptedJsonImporter.prototype.parse).toHaveBeenCalledWith( + emptyAccountEncrypted, + ); }); }); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts index b685ddf0fb5..cc38c420d9b 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts @@ -14,14 +14,21 @@ import { KeyService, KdfType, } from "@bitwarden/key-management"; -import { BitwardenPasswordProtectedFileFormat } from "@bitwarden/vault-export-core"; +import { + BitwardenJsonExport, + BitwardenPasswordProtectedFileFormat, + isPasswordProtected, +} from "@bitwarden/vault-export-core"; import { ImportResult } from "../../models/import-result"; import { Importer } from "../importer"; -import { BitwardenJsonImporter } from "./bitwarden-json-importer"; +import { BitwardenEncryptedJsonImporter } from "./bitwarden-encrypted-json-importer"; -export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter implements Importer { +export class BitwardenPasswordProtectedImporter + extends BitwardenEncryptedJsonImporter + implements Importer +{ private key: SymmetricCryptoKey; constructor( @@ -38,20 +45,14 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im async parse(data: string): Promise { const result = new ImportResult(); - const parsedData: BitwardenPasswordProtectedFileFormat = JSON.parse(data); + const parsedData: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport = JSON.parse(data); if (!parsedData) { result.success = false; return result; } - // File is unencrypted - if (!parsedData?.encrypted) { - return await super.parse(data); - } - - // File is account-encrypted - if (!parsedData?.passwordProtected) { + if (!isPasswordProtected(parsedData)) { return await super.parse(data); } diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 156c03620b7..445e5fecde7 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -103,6 +103,7 @@ export const AUTOTYPE_SETTINGS_DISK = new StateDefinition("autotypeSettings", "d export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanner", "disk", { web: "disk-local", }); +export const BIT_SIDE_NAV_DISK = new StateDefinition("bitSideNav", "disk"); // DIRT diff --git a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-json-export-types.ts b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-json-export-types.ts index ab2bcbb9f1f..fd33bf96923 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-json-export-types.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-json-export-types.ts @@ -5,42 +5,48 @@ import { } from "@bitwarden/common/models/export"; // Base -export type BitwardenJsonExport = { - encrypted: boolean; - items: CipherWithIdExport[]; -}; +export type BitwardenJsonExport = BitwardenUnEncryptedJsonExport | BitwardenEncryptedJsonExport; // Decrypted -export type BitwardenUnEncryptedJsonExport = BitwardenJsonExport & { - encrypted: false; -}; +export type BitwardenUnEncryptedJsonExport = + | BitwardenUnEncryptedIndividualJsonExport + | BitwardenUnEncryptedOrgJsonExport; -export type BitwardenUnEncryptedIndividualJsonExport = BitwardenUnEncryptedJsonExport & { +export type BitwardenUnEncryptedIndividualJsonExport = { + encrypted: false; + items: CipherWithIdExport[]; folders: FolderWithIdExport[]; }; -export type BitwardenUnEncryptedOrgJsonExport = BitwardenUnEncryptedJsonExport & { +export type BitwardenUnEncryptedOrgJsonExport = { + encrypted: false; + items: CipherWithIdExport[]; collections: CollectionWithIdExport[]; }; // Account-encrypted -export type BitwardenEncryptedJsonExport = BitwardenJsonExport & { +export type BitwardenEncryptedJsonExport = + | BitwardenEncryptedIndividualJsonExport + | BitwardenEncryptedOrgJsonExport; + +export type BitwardenEncryptedIndividualJsonExport = { encrypted: true; encKeyValidation_DO_NOT_EDIT: string; -}; - -export type BitwardenEncryptedIndividualJsonExport = BitwardenEncryptedJsonExport & { + items: CipherWithIdExport[]; folders: FolderWithIdExport[]; }; -export type BitwardenEncryptedOrgJsonExport = BitwardenEncryptedJsonExport & { +export type BitwardenEncryptedOrgJsonExport = { + encrypted: true; + encKeyValidation_DO_NOT_EDIT: string; + items: CipherWithIdExport[]; collections: CollectionWithIdExport[]; }; // Password-protected export type BitwardenPasswordProtectedFileFormat = { - encrypted: boolean; - passwordProtected: boolean; + encrypted: true; + passwordProtected: true; salt: string; kdfIterations: number; kdfMemory?: number; @@ -49,3 +55,50 @@ export type BitwardenPasswordProtectedFileFormat = { encKeyValidation_DO_NOT_EDIT: string; data: string; }; + +// Unencrypted type guards +export function isUnencrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenUnEncryptedJsonExport { + return data != null && (data as { encrypted?: unknown }).encrypted !== true; +} + +export function isIndividualUnEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenUnEncryptedIndividualJsonExport { + return isUnencrypted(data) && (data as { folders?: unknown }).folders != null; +} + +export function isOrgUnEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenUnEncryptedOrgJsonExport { + return isUnencrypted(data) && (data as { collections?: unknown }).collections != null; +} + +// Encrypted type guards +export function isEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenEncryptedJsonExport { + return data != null && (data as { encrypted?: unknown }).encrypted === true; +} +export function isPasswordProtected( + data: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport | null | undefined, +): data is BitwardenPasswordProtectedFileFormat { + return ( + data != null && + (data as { encrypted?: unknown }).encrypted === true && + (data as { passwordProtected?: unknown }).passwordProtected === true + ); +} + +export function isIndividualEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenEncryptedIndividualJsonExport { + return isEncrypted(data) && (data as { folders?: unknown }).folders != null; +} + +export function isOrgEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenEncryptedOrgJsonExport { + return isEncrypted(data) && (data as { collections?: unknown }).collections != null; +} diff --git a/package-lock.json b/package-lock.json index 014c291c38c..c40b5361cc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -192,11 +192,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.12.0" + "version": "2025.12.1" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2025.12.0", + "version": "2025.12.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "4.0.0", @@ -491,7 +491,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2025.12.1" + "version": "2025.12.2" }, "libs/admin-console": { "name": "@bitwarden/admin-console",