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..f3cdf80f710 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,7 +173,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: fetch-depth: 1 ref: ${{ github.event.pull_request.head.sha }} @@ -343,7 +343,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 +491,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 +759,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 +1004,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 +1244,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 +1519,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 +1860,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/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/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/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/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index 16711fabbf4..828c1667c57 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -1,39 +1,37 @@ -
- - - - - - + + + + + + -
-
- -
-

- {{ "createdSendSuccessfully" | i18n }} -

-

- {{ formatExpirationDate() }} -

- +
+
+
- - - - - -
+

+ {{ "createdSendSuccessfully" | i18n }} +

+

+ {{ formatExpirationDate() }} +

+ + + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html index 88bff47191a..d8c12122120 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html @@ -15,8 +15,8 @@ } @if (savedUrls().length > 1) {
-

- {{ "savedWebsites" | i18n: savedUrls().length }} +

+ {{ "savedWebsites" | i18n: savedUrls().length.toString() }}

- - @if (!(autofillConfirmationFlagEnabled$ | async)) { - - } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index b9f48b7407b..bd9ce108522 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -13,7 +13,6 @@ import { UriMatchStrategy, UriMatchStrategySetting, } from "@bitwarden/common/models/domain/domain-service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -40,10 +39,6 @@ describe("ItemMoreOptionsComponent", () => { openSimpleDialog: jest.fn().mockResolvedValue(true), open: jest.fn(), }; - const featureFlag$ = new BehaviorSubject(false); - const configService = { - getFeatureFlag$: jest.fn().mockImplementation(() => featureFlag$.asObservable()), - }; const cipherService = { getFullCipherView: jest.fn(), encrypt: jest.fn(), @@ -93,7 +88,6 @@ describe("ItemMoreOptionsComponent", () => { TestBed.configureTestingModule({ imports: [ItemMoreOptionsComponent, NoopAnimationsModule], providers: [ - { provide: ConfigService, useValue: configService }, { provide: CipherService, useValue: cipherService }, { provide: VaultPopupAutofillService, useValue: autofillSvc }, @@ -152,22 +146,6 @@ describe("ItemMoreOptionsComponent", () => { expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); }); - it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled", async () => { - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - - await component.doAutofill(); - - expect(cipherService.getFullCipherView).toHaveBeenCalled(); - expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1); - expect(autofillSvc.doAutofill).toHaveBeenCalledWith( - expect.objectContaining({ id: "cipher-1" }), - true, - true, - ); - expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - it("does nothing if the user fails master password reprompt", async () => { baseCipher.reprompt = 2; // Master Password reprompt enabled autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); @@ -181,7 +159,6 @@ describe("ItemMoreOptionsComponent", () => { }); it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { - // autofill confirmation dialog is not shown when either the feature flag is disabled uriMatchStrategy$.next(UriMatchStrategy.Exact); autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); await component.doAutofill(); @@ -191,8 +168,6 @@ describe("ItemMoreOptionsComponent", () => { describe("autofill confirmation dialog", () => { beforeEach(() => { - // autofill confirmation dialog is shown when feature flag is enabled - featureFlag$.next(true); uriMatchStrategy$.next(UriMatchStrategy.Domain); passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); }); @@ -206,7 +181,7 @@ describe("ItemMoreOptionsComponent", () => { expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); }); - it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled", async () => { + it("opens the autofill confirmation dialog with filtered saved URLs", async () => { autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index b65acc6ca8e..c4353e17bef 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -11,9 +11,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; @@ -37,7 +35,6 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; -import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; import { AutofillConfirmationDialogComponent, @@ -98,10 +95,6 @@ export class ItemMoreOptionsComponent { protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; - protected autofillConfirmationFlagEnabled$ = this.configService - .getFeatureFlag$(FeatureFlag.AutofillConfirmation) - .pipe(map((isFeatureFlagEnabled) => isFeatureFlagEnabled)); - protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$; /** @@ -166,8 +159,6 @@ export class ItemMoreOptionsComponent { private collectionService: CollectionService, private restrictedItemTypesService: RestrictedItemTypesService, private cipherArchiveService: CipherArchiveService, - private configService: ConfigService, - private vaultPopupItemsService: VaultPopupItemsService, private domainSettingsService: DomainSettingsService, ) {} @@ -216,13 +207,9 @@ export class ItemMoreOptionsComponent { const cipherHasAllExactMatchLoginUris = uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); - const showAutofillConfirmation = await firstValueFrom(this.autofillConfirmationFlagEnabled$); const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); - if ( - showAutofillConfirmation && - (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) - ) { + if (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) { await this.dialogService.openSimpleDialog({ title: { key: "cannotAutofill" }, content: { key: "cannotAutofillExactMatch" }, @@ -233,11 +220,6 @@ export class ItemMoreOptionsComponent { return; } - if (!showAutofillConfirmation) { - await this.vaultPopupAutofillService.doAutofill(cipher, true, true); - return; - } - const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$); if (!currentTab?.url) { 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 0211ded753c..124057d3167 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -557,7 +557,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/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index f827dda9a9b..4adf3739845 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -587,6 +587,9 @@ export class VaultComponent implements OnInit, OnDestroy { queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText }, queryParamsHandling: "merge", replaceUrl: true, + state: { + focusMainAfterNav: false, + }, }), ); 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/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts index a5a99428b2d..bc9da5e1692 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts @@ -74,6 +74,9 @@ export class RoutedVaultFilterService implements OnDestroy { type: filter.type ?? null, }, queryParamsHandling: "merge", + state: { + focusMainAfterNav: false, + }, }; return [commands, extras]; } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index e791ca7a90b..a5121831304 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -424,6 +424,9 @@ export class VaultComponent implements OnInit, OnDestr queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText }, queryParamsHandling: "merge", replaceUrl: true, + state: { + focusMainAfterNav: false, + }, }), ); 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 @@ - `;
({ + asUuid: (x: any) => x, +})); + +import { DestroyRef } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { + LoginEmailServiceAbstraction, + LogoutService, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { SignedSecurityState } from "@bitwarden/common/key-management/types"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +// eslint-disable-next-line no-restricted-imports +import { AnonLayoutWrapperDataService, DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { LoginDecryptionOptionsComponent } from "./login-decryption-options.component"; +import { LoginDecryptionOptionsService } from "./login-decryption-options.service"; + +describe("LoginDecryptionOptionsComponent", () => { + let component: LoginDecryptionOptionsComponent; + let accountService: MockProxy; + let anonLayoutWrapperDataService: MockProxy; + let apiService: MockProxy; + let destroyRef: MockProxy; + let deviceTrustService: MockProxy; + let dialogService: MockProxy; + let formBuilder: FormBuilder; + let i18nService: MockProxy; + let keyService: MockProxy; + let loginDecryptionOptionsService: MockProxy; + let loginEmailService: MockProxy; + let messagingService: MockProxy; + let organizationApiService: MockProxy; + let passwordResetEnrollmentService: MockProxy; + let platformUtilsService: MockProxy; + let router: MockProxy; + let ssoLoginService: MockProxy; + let toastService: MockProxy; + let userDecryptionOptionsService: MockProxy; + let validationService: MockProxy; + let logoutService: MockProxy; + let registerSdkService: MockProxy; + let securityStateService: MockProxy; + let appIdService: MockProxy; + let configService: MockProxy; + let accountCryptographicStateService: MockProxy; + + const mockUserId = "user-id-123" as UserId; + const mockEmail = "test@example.com"; + const mockOrgId = "org-id-456"; + + beforeEach(() => { + accountService = mock(); + anonLayoutWrapperDataService = mock(); + apiService = mock(); + destroyRef = mock(); + deviceTrustService = mock(); + dialogService = mock(); + formBuilder = new FormBuilder(); + i18nService = mock(); + keyService = mock(); + loginDecryptionOptionsService = mock(); + loginEmailService = mock(); + messagingService = mock(); + organizationApiService = mock(); + passwordResetEnrollmentService = mock(); + platformUtilsService = mock(); + router = mock(); + ssoLoginService = mock(); + toastService = mock(); + userDecryptionOptionsService = mock(); + validationService = mock(); + logoutService = mock(); + registerSdkService = mock(); + securityStateService = mock(); + appIdService = mock(); + configService = mock(); + accountCryptographicStateService = mock(); + + // Setup default mocks + accountService.activeAccount$ = new BehaviorSubject({ + id: mockUserId, + email: mockEmail, + name: "Test User", + emailVerified: true, + creationDate: new Date(), + }); + platformUtilsService.getClientType.mockReturnValue(ClientType.Browser); + deviceTrustService.getShouldTrustDevice.mockResolvedValue(true); + i18nService.t.mockImplementation((key: string) => key); + + component = new LoginDecryptionOptionsComponent( + accountService, + anonLayoutWrapperDataService, + apiService, + destroyRef, + deviceTrustService, + dialogService, + formBuilder, + i18nService, + keyService, + loginDecryptionOptionsService, + loginEmailService, + messagingService, + organizationApiService, + passwordResetEnrollmentService, + platformUtilsService, + router, + ssoLoginService, + toastService, + userDecryptionOptionsService, + validationService, + logoutService, + registerSdkService, + securityStateService, + appIdService, + configService, + accountCryptographicStateService, + ); + }); + + describe("createUser with feature flag enabled", () => { + let mockPostKeysForTdeRegistration: jest.Mock; + let mockRegistration: any; + let mockAuth: any; + let mockSdkValue: any; + let mockSdkRef: any; + let mockSdk: any; + let mockDeviceKey: string; + let mockDeviceKeyObj: SymmetricCryptoKey; + let mockUserKeyBytes: Uint8Array; + let mockPrivateKey: string; + let mockSignedPublicKey: string; + let mockSigningKey: string; + let mockSecurityState: SignedSecurityState; + + beforeEach(async () => { + // Mock asUuid to return the input value for test consistency + jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk.service", () => ({ + asUuid: (x: any) => x, + })); + (Symbol as any).dispose = Symbol("dispose"); + + mockPrivateKey = "mock-private-key"; + mockSignedPublicKey = "mock-signed-public-key"; + mockSigningKey = "mock-signing-key"; + mockSecurityState = { + signature: "mock-signature", + payload: { + version: 2, + timestamp: Date.now(), + privateKeyHash: "mock-hash", + }, + } as any; + const deviceKeyBytes = new Uint8Array(32).fill(5); + mockDeviceKey = Buffer.from(deviceKeyBytes).toString("base64"); + mockDeviceKeyObj = SymmetricCryptoKey.fromString(mockDeviceKey); + mockUserKeyBytes = new Uint8Array(64); + + mockPostKeysForTdeRegistration = jest.fn().mockResolvedValue({ + account_cryptographic_state: { + V2: { + private_key: mockPrivateKey, + signed_public_key: mockSignedPublicKey, + signing_key: mockSigningKey, + security_state: mockSecurityState, + }, + }, + device_key: mockDeviceKey, + user_key: mockUserKeyBytes, + }); + + mockRegistration = { + post_keys_for_tde_registration: mockPostKeysForTdeRegistration, + }; + + mockAuth = { + registration: jest.fn().mockReturnValue(mockRegistration), + }; + + mockSdkValue = { + auth: jest.fn().mockReturnValue(mockAuth), + }; + + mockSdkRef = { + value: mockSdkValue, + [Symbol.dispose]: jest.fn(), + }; + + mockSdk = { + take: jest.fn().mockReturnValue(mockSdkRef), + }; + + registerSdkService.registerClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any; + + // Setup for new user state + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + trustedDeviceOption: { + hasAdminApproval: false, + hasLoginApprovingDevice: false, + hasManageResetPasswordPermission: false, + isTdeOffboarding: false, + }, + hasMasterPassword: false, + keyConnectorOption: undefined, + }), + ); + + ssoLoginService.getActiveUserOrganizationSsoIdentifier.mockResolvedValue("org-identifier"); + organizationApiService.getAutoEnrollStatus.mockResolvedValue({ + id: mockOrgId, + resetPasswordEnabled: true, + } as any); + + // Initialize component to set up new user state + await component.ngOnInit(); + }); + + it("should use SDK v2 registration when feature flag is enabled", async () => { + // Arrange + configService.getFeatureFlag.mockResolvedValue(true); + loginDecryptionOptionsService.handleCreateUserSuccess.mockResolvedValue(undefined); + router.navigate.mockResolvedValue(true); + appIdService.getAppId.mockResolvedValue("mock-app-id"); + organizationApiService.getKeys.mockResolvedValue({ + publicKey: "mock-org-public-key", + privateKey: "mock-org-private-key", + } as any); + + // Act + await component["createUser"](); + + // Assert + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM27279_V2RegistrationTdeJit, + ); + expect(appIdService.getAppId).toHaveBeenCalled(); + expect(organizationApiService.getKeys).toHaveBeenCalledWith(mockOrgId); + expect(registerSdkService.registerClient$).toHaveBeenCalledWith(mockUserId); + + // Verify SDK registration was called with correct parameters + expect(mockSdkValue.auth).toHaveBeenCalled(); + expect(mockAuth.registration).toHaveBeenCalled(); + expect(mockPostKeysForTdeRegistration).toHaveBeenCalledWith({ + org_id: mockOrgId, + org_public_key: "mock-org-public-key", + user_id: mockUserId, + device_identifier: "mock-app-id", + trust_device: true, + }); + + const expectedDeviceKey = mockDeviceKeyObj; + const expectedUserKey = new SymmetricCryptoKey(new Uint8Array(mockUserKeyBytes)); + + // Verify keys were set + expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId); + expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId); + expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId); + expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith( + mockSecurityState, + mockUserId, + ); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + expect.objectContaining({ + V2: { + private_key: mockPrivateKey, + signed_public_key: mockSignedPublicKey, + signing_key: mockSigningKey, + security_state: mockSecurityState, + }, + }), + mockUserId, + ); + + expect(validationService.showError).not.toHaveBeenCalled(); + + // Verify device and user keys were persisted + expect(deviceTrustService.setDeviceKey).toHaveBeenCalledWith( + mockUserId, + expect.any(SymmetricCryptoKey), + ); + expect(keyService.setUserKey).toHaveBeenCalledWith( + expect.any(SymmetricCryptoKey), + mockUserId, + ); + + const [, deviceKeyArg] = deviceTrustService.setDeviceKey.mock.calls[0]; + const [userKeyArg] = keyService.setUserKey.mock.calls[0]; + + expect((deviceKeyArg as SymmetricCryptoKey).keyB64).toBe(expectedDeviceKey.keyB64); + expect((userKeyArg as SymmetricCryptoKey).keyB64).toBe(expectedUserKey.keyB64); + + // Verify success toast and navigation + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "accountSuccessfullyCreated", + }); + expect(loginDecryptionOptionsService.handleCreateUserSuccess).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(["/tabs/vault"]); + }); + + it("should use legacy registration when feature flag is disabled", async () => { + // Arrange + configService.getFeatureFlag.mockResolvedValue(false); + + const mockPublicKey = "mock-public-key"; + const mockPrivateKey = { + encryptedString: "mock-encrypted-private-key", + } as any; + + keyService.initAccount.mockResolvedValue({ + publicKey: mockPublicKey, + privateKey: mockPrivateKey, + } as any); + + apiService.postAccountKeys.mockResolvedValue(undefined); + passwordResetEnrollmentService.enroll.mockResolvedValue(undefined); + deviceTrustService.trustDevice.mockResolvedValue(undefined); + loginDecryptionOptionsService.handleCreateUserSuccess.mockResolvedValue(undefined); + router.navigate.mockResolvedValue(true); + + // Act + await component["createUser"](); + + // Assert + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM27279_V2RegistrationTdeJit, + ); + expect(keyService.initAccount).toHaveBeenCalledWith(mockUserId); + expect(apiService.postAccountKeys).toHaveBeenCalledWith( + expect.objectContaining({ + publicKey: mockPublicKey, + encryptedPrivateKey: mockPrivateKey.encryptedString, + }), + ); + expect(passwordResetEnrollmentService.enroll).toHaveBeenCalledWith(mockOrgId); + expect(deviceTrustService.trustDevice).toHaveBeenCalledWith(mockUserId); + + // Verify success toast + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "accountSuccessfullyCreated", + }); + + // Verify navigation + expect(loginDecryptionOptionsService.handleCreateUserSuccess).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(["/tabs/vault"]); + }); + }); +}); diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts index fb07069998b..06263ef7371 100644 --- a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts @@ -5,7 +5,17 @@ import { Component, DestroyRef, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormControl, ReactiveFormsModule } from "@angular/forms"; import { Router } from "@angular/router"; -import { catchError, defer, firstValueFrom, from, map, of, switchMap, throwError } from "rxjs"; +import { + catchError, + concatMap, + defer, + firstValueFrom, + from, + map, + of, + switchMap, + throwError, +} from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -20,13 +30,27 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { + SignedPublicKey, + SignedSecurityState, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; +import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; +import { DeviceKey, UserKey } from "@bitwarden/common/types/key"; // 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 { @@ -40,6 +64,7 @@ import { TypographyModule, } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationId as SdkOrganizationId, UserId as SdkUserId } from "@bitwarden/sdk-internal"; import { LoginDecryptionOptionsService } from "./login-decryption-options.service"; @@ -112,6 +137,11 @@ export class LoginDecryptionOptionsComponent implements OnInit { private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private validationService: ValidationService, private logoutService: LogoutService, + private registerSdkService: RegisterSdkService, + private securityStateService: SecurityStateService, + private appIdService: AppIdService, + private configService: ConfigService, + private accountCryptographicStateService: AccountCryptographicStateService, ) { this.clientType = this.platformUtilsService.getClientType(); } @@ -251,9 +281,85 @@ export class LoginDecryptionOptionsComponent implements OnInit { } try { - const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId); - const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString); - await this.apiService.postAccountKeys(keysRequest); + const useSdkV2Creation = await this.configService.getFeatureFlag( + FeatureFlag.PM27279_V2RegistrationTdeJit, + ); + if (useSdkV2Creation) { + const deviceIdentifier = await this.appIdService.getAppId(); + const userId = this.activeAccountId; + const organizationId = this.newUserOrgId; + + const orgKeyResponse = await this.organizationApiService.getKeys(organizationId); + const register_result = await firstValueFrom( + this.registerSdkService.registerClient$(userId).pipe( + concatMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + return await ref.value + .auth() + .registration() + .post_keys_for_tde_registration({ + org_id: asUuid(organizationId), + org_public_key: orgKeyResponse.publicKey, + user_id: asUuid(userId), + device_identifier: deviceIdentifier, + trust_device: this.formGroup.value.rememberDevice, + }); + }), + ), + ); + // The keys returned here can only be v2 keys, since the SDK only implements returning V2 keys. + if ("V1" in register_result.account_cryptographic_state) { + throw new Error("Unexpected V1 account cryptographic state"); + } + + // Note: When SDK state management matures, these should be moved into post_keys_for_tde_registration + // Set account cryptography state + await this.accountCryptographicStateService.setAccountCryptographicState( + register_result.account_cryptographic_state, + userId, + ); + // Legacy individual states + await this.keyService.setPrivateKey( + register_result.account_cryptographic_state.V2.private_key, + userId, + ); + await this.keyService.setSignedPublicKey( + register_result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey, + userId, + ); + await this.keyService.setUserSigningKey( + register_result.account_cryptographic_state.V2.signing_key as WrappedSigningKey, + userId, + ); + await this.securityStateService.setAccountSecurityState( + register_result.account_cryptographic_state.V2.security_state as SignedSecurityState, + userId, + ); + + // TDE unlock + await this.deviceTrustService.setDeviceKey( + userId, + SymmetricCryptoKey.fromString(register_result.device_key) as DeviceKey, + ); + + // Set user key - user is now unlocked + await this.keyService.setUserKey( + SymmetricCryptoKey.fromString(register_result.user_key) as UserKey, + userId, + ); + } else { + const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId); + const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString); + await this.apiService.postAccountKeys(keysRequest); + await this.passwordResetEnrollmentService.enroll(this.newUserOrgId); + if (this.formGroup.value.rememberDevice) { + await this.deviceTrustService.trustDevice(this.activeAccountId); + } + } this.toastService.showToast({ variant: "success", @@ -261,12 +367,6 @@ export class LoginDecryptionOptionsComponent implements OnInit { message: this.i18nService.t("accountSuccessfullyCreated"), }); - await this.passwordResetEnrollmentService.enroll(this.newUserOrgId); - - if (this.formGroup.value.rememberDevice) { - await this.deviceTrustService.trustDevice(this.activeAccountId); - } - await this.loginDecryptionOptionsService.handleCreateUserSuccess(); if (this.clientType === ClientType.Desktop) { diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index ed8b7796966..db644e5e9d1 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -15,7 +15,7 @@ export function mockAccountInfoWith(info: Partial = {}): AccountInf name: "name", email: "email", emailVerified: true, - creationDate: "2024-01-01T00:00:00.000Z", + creationDate: new Date("2024-01-01T00:00:00.000Z"), ...info, }; } @@ -111,7 +111,7 @@ export class FakeAccountService implements AccountService { await this.mock.setAccountEmailVerified(userId, emailVerified); } - async setAccountCreationDate(userId: UserId, creationDate: string): Promise { + async setAccountCreationDate(userId: UserId, creationDate: Date): Promise { await this.mock.setAccountCreationDate(userId, creationDate); } diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index 78822f3ebd5..c80d6b0439c 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -2,33 +2,25 @@ import { Observable } from "rxjs"; import { UserId } from "../../types/guid"; +/** + * Holds state that represents a user's account with Bitwarden. + * Any additions here should be added to the equality check in the AccountService + * to ensure that emissions are done on every change. + * + * @property email - User's email address. + * @property emailVerified - Whether the email has been verified. + * @property name - User's display name (optional). + * @property creationDate - Date when the account was created. + * Will be undefined immediately after login until the first sync completes. + */ export type AccountInfo = { email: string; emailVerified: boolean; name: string | undefined; - creationDate: string | undefined; + creationDate: Date | undefined; }; export type Account = { id: UserId } & AccountInfo; - -export function accountInfoEqual(a: AccountInfo, b: AccountInfo) { - if (a == null && b == null) { - return true; - } - - if (a == null || b == null) { - return false; - } - - const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set; - for (const key of keys) { - if (a[key] !== b[key]) { - return false; - } - } - return true; -} - export abstract class AccountService { abstract accounts$: Observable>; @@ -77,7 +69,7 @@ export abstract class AccountService { * @param userId * @param creationDate */ - abstract setAccountCreationDate(userId: UserId, creationDate: string): Promise; + abstract setAccountCreationDate(userId: UserId, creationDate: Date): Promise; /** * updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account. * @param userId diff --git a/libs/common/src/auth/models/response/identity-token.response.ts b/libs/common/src/auth/models/response/identity-token.response.ts index ae208ef1a36..e43697f9ebe 100644 --- a/libs/common/src/auth/models/response/identity-token.response.ts +++ b/libs/common/src/auth/models/response/identity-token.response.ts @@ -19,9 +19,21 @@ export class IdentityTokenResponse extends BaseResponse { tokenType: string; // Decryption Information - privateKey: string; // userKeyEncryptedPrivateKey + + /** + * privateKey is actually userKeyEncryptedPrivateKey + * @deprecated Use {@link accountKeysResponseModel} instead + */ + privateKey: string; + + // TODO: https://bitwarden.atlassian.net/browse/PM-30124 - Rename to just accountKeys accountKeysResponseModel: PrivateKeysResponseModel | null = null; - key?: EncString; // masterKeyEncryptedUserKey + + /** + * key is actually masterKeyEncryptedUserKey + * @deprecated Use {@link userDecryptionOptions.masterPasswordUnlock.masterKeyWrappedUserKey} instead + */ + key?: EncString; twoFactorToken: string; kdfConfig: KdfConfig; forcePasswordReset: boolean; diff --git a/libs/common/src/auth/services/account.service.spec.ts b/libs/common/src/auth/services/account.service.spec.ts index f517b61ffb6..6668b9c39de 100644 --- a/libs/common/src/auth/services/account.service.spec.ts +++ b/libs/common/src/auth/services/account.service.spec.ts @@ -17,7 +17,7 @@ import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { Utils } from "../../platform/misc/utils"; import { UserId } from "../../types/guid"; -import { AccountInfo, accountInfoEqual } from "../abstractions/account.service"; +import { AccountInfo } from "../abstractions/account.service"; import { ACCOUNT_ACCOUNTS, @@ -27,63 +27,6 @@ import { AccountServiceImplementation, } from "./account.service"; -describe("accountInfoEqual", () => { - const accountInfo = mockAccountInfoWith(); - - it("compares nulls", () => { - expect(accountInfoEqual(null, null)).toBe(true); - expect(accountInfoEqual(null, accountInfo)).toBe(false); - expect(accountInfoEqual(accountInfo, null)).toBe(false); - }); - - it("compares all keys, not just those defined in AccountInfo", () => { - const different = { ...accountInfo, extra: "extra" }; - - expect(accountInfoEqual(accountInfo, different)).toBe(false); - }); - - it("compares name", () => { - const same = { ...accountInfo }; - const different = { ...accountInfo, name: "name2" }; - - expect(accountInfoEqual(accountInfo, same)).toBe(true); - expect(accountInfoEqual(accountInfo, different)).toBe(false); - }); - - it("compares email", () => { - const same = { ...accountInfo }; - const different = { ...accountInfo, email: "email2" }; - - expect(accountInfoEqual(accountInfo, same)).toBe(true); - expect(accountInfoEqual(accountInfo, different)).toBe(false); - }); - - it("compares emailVerified", () => { - const same = { ...accountInfo }; - const different = { ...accountInfo, emailVerified: false }; - - expect(accountInfoEqual(accountInfo, same)).toBe(true); - expect(accountInfoEqual(accountInfo, different)).toBe(false); - }); - - it("compares creationDate", () => { - const same = { ...accountInfo }; - const different = { ...accountInfo, creationDate: "2024-12-31T00:00:00.000Z" }; - - expect(accountInfoEqual(accountInfo, same)).toBe(true); - expect(accountInfoEqual(accountInfo, different)).toBe(false); - }); - - it("compares undefined creationDate", () => { - const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined }); - const same = { ...accountWithoutCreationDate }; - const different = { ...accountWithoutCreationDate, creationDate: "2024-01-01T00:00:00.000Z" }; - - expect(accountInfoEqual(accountWithoutCreationDate, same)).toBe(true); - expect(accountInfoEqual(accountWithoutCreationDate, different)).toBe(false); - }); -}); - describe("accountService", () => { let messagingService: MockProxy; let logService: MockProxy; @@ -121,6 +64,60 @@ describe("accountService", () => { jest.resetAllMocks(); }); + describe("accountInfoEqual", () => { + const accountInfo = mockAccountInfoWith(); + + it("compares nulls", () => { + expect((sut as any).accountInfoEqual(null, null)).toBe(true); + expect((sut as any).accountInfoEqual(null, accountInfo)).toBe(false); + expect((sut as any).accountInfoEqual(accountInfo, null)).toBe(false); + }); + + it("compares name", () => { + const same = { ...accountInfo }; + const different = { ...accountInfo, name: "name2" }; + + expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true); + expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false); + }); + + it("compares email", () => { + const same = { ...accountInfo }; + const different = { ...accountInfo, email: "email2" }; + + expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true); + expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false); + }); + + it("compares emailVerified", () => { + const same = { ...accountInfo }; + const different = { ...accountInfo, emailVerified: false }; + + expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true); + expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false); + }); + + it("compares creationDate", () => { + const same = { ...accountInfo }; + const different = { ...accountInfo, creationDate: new Date("2024-12-31T00:00:00.000Z") }; + + expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true); + expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false); + }); + + it("compares undefined creationDate", () => { + const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined }); + const same = { ...accountWithoutCreationDate }; + const different = { + ...accountWithoutCreationDate, + creationDate: new Date("2024-01-01T00:00:00.000Z"), + }; + + expect((sut as any).accountInfoEqual(accountWithoutCreationDate, same)).toBe(true); + expect((sut as any).accountInfoEqual(accountWithoutCreationDate, different)).toBe(false); + }); + }); + describe("activeAccount$", () => { it("should emit null if no account is active", () => { const emissions = trackEmissions(sut.activeAccount$); @@ -281,7 +278,7 @@ describe("accountService", () => { }); it("should update the account with a new creation date", async () => { - const newCreationDate = "2024-12-31T00:00:00.000Z"; + const newCreationDate = new Date("2024-12-31T00:00:00.000Z"); await sut.setAccountCreationDate(userId, newCreationDate); const currentState = await firstValueFrom(accountsState.state$); @@ -297,6 +294,24 @@ describe("accountService", () => { expect(currentState).toEqual(initialState); }); + it("should not update if the creation date has the same timestamp but different Date object", async () => { + const sameTimestamp = new Date(userInfo.creationDate.getTime()); + await sut.setAccountCreationDate(userId, sameTimestamp); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual(initialState); + }); + + it("should update if the creation date has a different timestamp", async () => { + const differentDate = new Date(userInfo.creationDate.getTime() + 1000); + await sut.setAccountCreationDate(userId, differentDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual({ + [userId]: { ...userInfo, creationDate: differentDate }, + }); + }); + it("should update from undefined to a defined creation date", async () => { const accountWithoutCreationDate = mockAccountInfoWith({ ...userInfo, @@ -304,7 +319,7 @@ describe("accountService", () => { }); accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate }); - const newCreationDate = "2024-06-15T12:30:00.000Z"; + const newCreationDate = new Date("2024-06-15T12:30:00.000Z"); await sut.setAccountCreationDate(userId, newCreationDate); const currentState = await firstValueFrom(accountsState.state$); @@ -313,14 +328,19 @@ describe("accountService", () => { }); }); - it("should update to a different creation date string format", async () => { - const newCreationDate = "2023-03-15T08:45:30.123Z"; - await sut.setAccountCreationDate(userId, newCreationDate); - const currentState = await firstValueFrom(accountsState.state$); - - expect(currentState).toEqual({ - [userId]: { ...userInfo, creationDate: newCreationDate }, + it("should not update when both creation dates are undefined", async () => { + const accountWithoutCreationDate = mockAccountInfoWith({ + ...userInfo, + creationDate: undefined, }); + accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate }); + + // Attempt to set to undefined (shouldn't trigger update) + const currentStateBefore = await firstValueFrom(accountsState.state$); + + // We can't directly call setAccountCreationDate with undefined, but we can verify + // the behavior through setAccountInfo which accountInfoEqual uses internally + expect(currentStateBefore[userId].creationDate).toBeUndefined(); }); }); diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index 1b028d1eba9..ea22bb9dd2c 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -18,7 +18,6 @@ import { Account, AccountInfo, InternalAccountService, - accountInfoEqual, } from "../../auth/abstractions/account.service"; import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; @@ -37,7 +36,10 @@ export const ACCOUNT_ACCOUNTS = KeyDefinition.record( ACCOUNT_DISK, "accounts", { - deserializer: (accountInfo) => accountInfo, + deserializer: (accountInfo) => ({ + ...accountInfo, + creationDate: accountInfo.creationDate ? new Date(accountInfo.creationDate) : undefined, + }), }, ); @@ -111,7 +113,7 @@ export class AccountServiceImplementation implements InternalAccountService { this.activeAccount$ = this.activeAccountIdState.state$.pipe( combineLatestWith(this.accounts$), map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)), - distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)), + distinctUntilChanged((a, b) => a?.id === b?.id && this.accountInfoEqual(a, b)), shareReplay({ bufferSize: 1, refCount: false }), ); this.accountActivity$ = this.globalStateProvider @@ -168,7 +170,7 @@ export class AccountServiceImplementation implements InternalAccountService { await this.setAccountInfo(userId, { emailVerified }); } - async setAccountCreationDate(userId: UserId, creationDate: string): Promise { + async setAccountCreationDate(userId: UserId, creationDate: Date): Promise { await this.setAccountInfo(userId, { creationDate }); } @@ -274,6 +276,23 @@ export class AccountServiceImplementation implements InternalAccountService { this._showHeader$.next(visible); } + private accountInfoEqual(a: AccountInfo, b: AccountInfo) { + if (a == null && b == null) { + return true; + } + + if (a == null || b == null) { + return false; + } + + return ( + a.email === b.email && + a.emailVerified === b.emailVerified && + a.name === b.name && + a.creationDate?.getTime() === b.creationDate?.getTime() + ); + } + private async setAccountInfo(userId: UserId, update: Partial): Promise { function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo { return { ...oldAccountInfo, ...update }; @@ -291,7 +310,7 @@ export class AccountServiceImplementation implements InternalAccountService { throw new Error("Account does not exist"); } - return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId])); + return !this.accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId])); }, }, ); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index f905c62288e..5a6eeebd001 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -44,6 +44,7 @@ export enum FeatureFlag { NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", DataRecoveryTool = "pm-28813-data-recovery-tool", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", + PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit", /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", @@ -62,7 +63,6 @@ export enum FeatureFlag { PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", - AutofillConfirmation = "pm-25083-autofill-confirm-from-search", RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", @@ -70,8 +70,6 @@ export enum FeatureFlag { /* Platform */ IpcChannelFramework = "ipc-channel-framework", - InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users", - PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", @@ -125,7 +123,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, - [FeatureFlag.AutofillConfirmation]: FALSE, [FeatureFlag.RiskInsightsForPremium]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, @@ -154,11 +151,10 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NoLogoutOnKdfChange]: FALSE, [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, + [FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE, /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, - [FeatureFlag.InactiveUserServerNotification]: FALSE, - [FeatureFlag.PushNotificationsWhenLocked]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, diff --git a/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts b/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts index 2bc99e5e5c2..ceff220fe42 100644 --- a/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts +++ b/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts @@ -39,6 +39,7 @@ export abstract class DeviceTrustServiceAbstraction { /** Retrieves the device key if it exists from state or secure storage if supported for the active user. */ abstract getDeviceKey(userId: UserId): Promise; + abstract setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise; abstract decryptUserKeyWithDeviceKey( userId: UserId, encryptedDevicePrivateKey: EncString, diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts index 59bd7bc11f2..518d16781ab 100644 --- a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts +++ b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts @@ -356,7 +356,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction { } } - private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise { + async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise { if (!userId) { throw new Error("UserId is required. Cannot set device key."); } diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts index 8c5493cf38a..2795e4c3003 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -5,7 +5,6 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf import { LogoutReason } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { mockAccountInfoWith } from "../../../../spec"; import { AccountService } from "../../../auth/abstractions/account.service"; @@ -130,15 +129,6 @@ describe("DefaultServerNotificationsService (multi-user)", () => { authRequestAnsweringService = mock(); - configService = mock(); - configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { - const flagValueByFlag: Partial> = { - [FeatureFlag.InactiveUserServerNotification]: true, - [FeatureFlag.PushNotificationsWhenLocked]: true, - }; - return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; - }); - policyService = mock(); defaultServerNotificationsService = new DefaultServerNotificationsService( diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index f373ce5d3a3..83ea12bf154 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -71,48 +71,20 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer private readonly configService: ConfigService, private readonly policyService: InternalPolicyService, ) { - this.notifications$ = this.configService - .getFeatureFlag$(FeatureFlag.InactiveUserServerNotification) - .pipe( - distinctUntilChanged(), - switchMap((inactiveUserServerNotificationEnabled) => { - if (inactiveUserServerNotificationEnabled) { - return this.accountService.accounts$.pipe( - map((accounts: Record): Set => { - const validUserIds = Object.entries(accounts) - .filter( - ([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified, - ) - .map(([userId, _]) => userId as UserId); - return new Set(validUserIds); - }), - trackedMerge((id: UserId) => { - return this.userNotifications$(id as UserId).pipe( - map( - (notification: NotificationResponse) => [notification, id as UserId] as const, - ), - ); - }), - ); - } - - return this.accountService.activeAccount$.pipe( - map((account) => account?.id), - distinctUntilChanged(), - switchMap((activeAccountId) => { - if (activeAccountId == null) { - // We don't emit server-notifications for inactive accounts currently - return EMPTY; - } - - return this.userNotifications$(activeAccountId).pipe( - map((notification) => [notification, activeAccountId] as const), - ); - }), - ); - }), - share(), // Multiple subscribers should only create a single connection to the server - ); + this.notifications$ = this.accountService.accounts$.pipe( + map((accounts: Record): Set => { + const validUserIds = Object.entries(accounts) + .filter(([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified) + .map(([userId, _]) => userId as UserId); + return new Set(validUserIds); + }), + trackedMerge((id: UserId) => { + return this.userNotifications$(id as UserId).pipe( + map((notification: NotificationResponse) => [notification, id as UserId] as const), + ); + }), + share(), // Multiple subscribers should only create a single connection to the server + ); } /** @@ -175,25 +147,13 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer } private hasAccessToken$(userId: UserId) { - return this.configService.getFeatureFlag$(FeatureFlag.PushNotificationsWhenLocked).pipe( + return this.authService.authStatusFor$(userId).pipe( + map( + (authStatus) => + authStatus === AuthenticationStatus.Locked || + authStatus === AuthenticationStatus.Unlocked, + ), distinctUntilChanged(), - switchMap((featureFlagEnabled) => { - if (featureFlagEnabled) { - return this.authService.authStatusFor$(userId).pipe( - map( - (authStatus) => - authStatus === AuthenticationStatus.Locked || - authStatus === AuthenticationStatus.Unlocked, - ), - distinctUntilChanged(), - ); - } else { - return this.authService.authStatusFor$(userId).pipe( - map((authStatus) => authStatus === AuthenticationStatus.Unlocked), - distinctUntilChanged(), - ); - } - }), ); } @@ -208,19 +168,13 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer return; } - if ( - await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.InactiveUserServerNotification), - ) - ) { - const activeAccountId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeAccountId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); - const isActiveUser = activeAccountId === userId; - if (!isActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) { - return; - } + const notificationIsForActiveUser = activeAccountId === userId; + if (!notificationIsForActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) { + return; } switch (notification.type) { diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 49fd33b8035..fdd05927b50 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -279,8 +279,8 @@ export class DefaultSyncService extends CoreSyncService { await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor); await this.tokenService.setSecurityStamp(response.securityStamp, response.id); await this.accountService.setAccountEmailVerified(response.id, response.emailVerified); + await this.accountService.setAccountCreationDate(response.id, new Date(response.creationDate)); await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices); - await this.accountService.setAccountCreationDate(response.id, response.creationDate); await this.billingAccountProfileStateService.setHasPremium( response.premiumPersonally, diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 8472a359c51..0d3a0b99fcb 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -207,9 +207,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider>; + abstract upsert( + cipher: CipherData | CipherData[], + userId?: UserId, + ): Promise>; abstract replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise; abstract clear(userId?: string): Promise; abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 402b8ed1030..3c44b854de7 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1196,12 +1196,15 @@ export class CipherService implements CipherServiceAbstraction { await this.encryptedCiphersState(userId).update(() => ciphers); } - async upsert(cipher: CipherData | CipherData[]): Promise> { + async upsert( + cipher: CipherData | CipherData[], + userId?: UserId, + ): Promise> { const ciphers = cipher instanceof CipherData ? [cipher] : cipher; const res = await this.updateEncryptedCipherState((current) => { ciphers.forEach((c) => (current[c.id as CipherId] = c)); return current; - }); + }, userId); // Some state storage providers (e.g. Electron) don't update the state immediately, wait for next tick // Otherwise, subscribers to cipherViews$ can get stale data await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts index 807311ca851..2f5e69d65ed 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts @@ -219,7 +219,7 @@ describe("DefaultCipherArchiveService", () => { } as any, }), ); - mockCipherService.replace.mockResolvedValue(undefined); + mockCipherService.upsert.mockResolvedValue(undefined); }); it("should archive single cipher", async () => { @@ -233,13 +233,13 @@ describe("DefaultCipherArchiveService", () => { true, ); expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId); - expect(mockCipherService.replace).toHaveBeenCalledWith( - expect.objectContaining({ - [cipherId]: expect.objectContaining({ + expect(mockCipherService.upsert).toHaveBeenCalledWith( + [ + expect.objectContaining({ archivedDate: "2024-01-15T10:30:00.000Z", revisionDate: "2024-01-15T10:31:00.000Z", }), - }), + ], userId, ); }); @@ -282,7 +282,7 @@ describe("DefaultCipherArchiveService", () => { } as any, }), ); - mockCipherService.replace.mockResolvedValue(undefined); + mockCipherService.upsert.mockResolvedValue(undefined); }); it("should unarchive single cipher", async () => { @@ -296,12 +296,12 @@ describe("DefaultCipherArchiveService", () => { true, ); expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId); - expect(mockCipherService.replace).toHaveBeenCalledWith( - expect.objectContaining({ - [cipherId]: expect.objectContaining({ + expect(mockCipherService.upsert).toHaveBeenCalledWith( + [ + expect.objectContaining({ revisionDate: "2024-01-15T10:31:00.000Z", }), - }), + ], userId, ); }); diff --git a/libs/common/src/vault/services/default-cipher-archive.service.ts b/libs/common/src/vault/services/default-cipher-archive.service.ts index 8076735c9e2..c1daade0dad 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -95,7 +95,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService { localCipher.revisionDate = cipher.revisionDate; } - await this.cipherService.replace(currentCiphers, userId); + await this.cipherService.upsert(Object.values(currentCiphers), userId); } async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { @@ -116,6 +116,6 @@ export class DefaultCipherArchiveService implements CipherArchiveService { localCipher.revisionDate = cipher.revisionDate; } - await this.cipherService.replace(currentCiphers, userId); + await this.cipherService.upsert(Object.values(currentCiphers), userId); } } diff --git a/libs/components/src/a11y/router-focus-manager.service.ts b/libs/components/src/a11y/router-focus-manager.service.ts index 27c4e0f9b1e..f7371e02a17 100644 --- a/libs/components/src/a11y/router-focus-manager.service.ts +++ b/libs/components/src/a11y/router-focus-manager.service.ts @@ -1,7 +1,7 @@ import { inject, Injectable } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { skip, filter, map, combineLatestWith, tap } from "rxjs"; +import { skip, filter, combineLatestWith, tap } from "rxjs"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -19,8 +19,10 @@ export class RouterFocusManagerService { * * By default, we focus the `main` after an internal route navigation. * - * Consumers can opt out of the passing the following to the `info` input: - * `` + * Consumers can opt out of the passing the following to the `state` input. Using `state` + * allows us to access the value between browser back/forward arrows. + * In template: `` + * In typescript: `this.router.navigate([], { state: { focusMainAfterNav: false }})` * * Or, consumers can use the autofocus directive on an applicable interactive element. * The autofocus directive will take precedence over this route focus pipeline. @@ -44,15 +46,12 @@ export class RouterFocusManagerService { skip(1), combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.RouterFocusManagement)), filter(([_navEvent, flagEnabled]) => flagEnabled), - map(() => { - const currentNavData = this.router.getCurrentNavigation()?.extras; + filter(() => { + const currentNavExtras = this.router.currentNavigation()?.extras; - const info = currentNavData?.info as { focusMainAfterNav?: boolean } | undefined; + const focusMainAfterNav: boolean | undefined = currentNavExtras?.state?.focusMainAfterNav; - return info; - }), - filter((currentNavInfo) => { - return currentNavInfo === undefined ? true : currentNavInfo?.focusMainAfterNav !== false; + return focusMainAfterNav !== false; }), tap(() => { const mainEl = document.querySelector("main"); diff --git a/libs/components/src/dialog/dialog.service.stories.ts b/libs/components/src/dialog/dialog.service.stories.ts index 3b5bdc4d4e9..4e5c718e494 100644 --- a/libs/components/src/dialog/dialog.service.stories.ts +++ b/libs/components/src/dialog/dialog.service.stories.ts @@ -6,13 +6,14 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an import { getAllByRole, userEvent } from "storybook/test"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { ButtonModule } from "../button"; import { IconButtonModule } from "../icon-button"; import { LayoutComponent } from "../layout"; import { SharedModule } from "../shared"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; -import { I18nMockService } from "../utils/i18n-mock.service"; +import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; import { DialogModule } from "./dialog.module"; import { DialogService } from "./dialog.service"; @@ -161,6 +162,10 @@ export default { }); }, }, + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/libs/components/src/drawer/drawer.stories.ts b/libs/components/src/drawer/drawer.stories.ts index 727d16b5481..9904b77ee9f 100644 --- a/libs/components/src/drawer/drawer.stories.ts +++ b/libs/components/src/drawer/drawer.stories.ts @@ -1,7 +1,8 @@ import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { ButtonModule } from "../button"; import { CalloutModule } from "../callout"; @@ -9,7 +10,7 @@ import { LayoutComponent } from "../layout"; import { mockLayoutI18n } from "../layout/mocks"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { TypographyModule } from "../typography"; -import { I18nMockService } from "../utils"; +import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; import { DrawerBodyComponent } from "./drawer-body.component"; import { DrawerHeaderComponent } from "./drawer-header.component"; @@ -47,6 +48,14 @@ export default { }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), ], } as Meta; diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts index d2c197d0088..9498c163da7 100644 --- a/libs/components/src/item/item.stories.ts +++ b/libs/components/src/item/item.stories.ts @@ -1,16 +1,23 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular"; +import { + Meta, + StoryObj, + applicationConfig, + componentWrapperDecorator, + moduleMetadata, +} from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { AvatarModule } from "../avatar"; import { BadgeModule } from "../badge"; import { IconButtonModule } from "../icon-button"; import { LayoutComponent } from "../layout"; import { TypographyModule } from "../typography"; -import { I18nMockService } from "../utils/i18n-mock.service"; +import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; import { ItemActionComponent } from "./item-action.component"; import { ItemContentComponent } from "./item-content.component"; @@ -50,6 +57,14 @@ export default { }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), componentWrapperDecorator((story) => `
${story}
`), ], parameters: { diff --git a/libs/components/src/layout/layout.stories.ts b/libs/components/src/layout/layout.stories.ts index a059fd61b92..59770c21d2e 100644 --- a/libs/components/src/layout/layout.stories.ts +++ b/libs/components/src/layout/layout.stories.ts @@ -1,13 +1,15 @@ import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; import { userEvent } from "storybook/test"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { CalloutModule } from "../callout"; import { NavigationModule } from "../navigation"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; +import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { LayoutComponent } from "./layout.component"; import { mockLayoutI18n } from "./mocks"; @@ -28,6 +30,14 @@ export default { }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), ], parameters: { chromatic: { viewports: [640, 1280] }, diff --git a/libs/components/src/layout/mocks.ts b/libs/components/src/layout/mocks.ts index 8b001eb8fd1..15b126ca718 100644 --- a/libs/components/src/layout/mocks.ts +++ b/libs/components/src/layout/mocks.ts @@ -5,4 +5,5 @@ export const mockLayoutI18n = { submenu: "submenu", toggleCollapse: "toggle collapse", loading: "Loading", + resizeSideNavigation: "Resize side navigation", }; diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index c0111c23fc1..fa1cb06dbfe 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -3,11 +3,13 @@ import { RouterModule } from "@angular/router"; import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { LayoutComponent } from "../layout"; import { SharedModule } from "../shared/shared.module"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; +import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { NavGroupComponent } from "./nav-group.component"; import { NavigationModule } from "./navigation.module"; @@ -42,6 +44,7 @@ export default { toggleSideNavigation: "Toggle side navigation", skipToContent: "Skip to content", loading: "Loading", + resizeSideNavigation: "Resize side navigation", }); }, }, @@ -58,6 +61,10 @@ export default { { useHash: true }, ), ), + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 131dacc8142..3036ab26348 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -1,12 +1,14 @@ import { RouterTestingModule } from "@angular/router/testing"; -import { StoryObj, Meta, moduleMetadata } from "@storybook/angular"; +import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { IconButtonModule } from "../icon-button"; import { LayoutComponent } from "../layout"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; +import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { NavItemComponent } from "./nav-item.component"; import { NavigationModule } from "./navigation.module"; @@ -31,11 +33,20 @@ export default { toggleSideNavigation: "Toggle side navigation", skipToContent: "Skip to content", loading: "Loading", + resizeSideNavigation: "Resize side navigation", }); }, }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), ], parameters: { design: { diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index c8b20ecba77..84c7e3e7298 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -5,47 +5,64 @@ }; as data ) { - + +
} 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/tabs/tab-nav-bar/tab-link.component.html b/libs/components/src/tabs/tab-nav-bar/tab-link.component.html index f05ed31547b..aa36eb37f99 100644 --- a/libs/components/src/tabs/tab-nav-bar/tab-link.component.html +++ b/libs/components/src/tabs/tab-nav-bar/tab-link.component.html @@ -5,7 +5,7 @@ [routerLinkActiveOptions]="routerLinkMatchOptions" #rla="routerLinkActive" [active]="rla.isActive" - [info]="{ focusMainAfterNav: false }" + [state]="{ focusMainAfterNav: false }" [disabled]="disabled" [attr.aria-disabled]="disabled" ariaCurrentWhenActive="page" 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/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/package-lock.json b/package-lock.json index deb3a9f261c..c40b5361cc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@angular/platform-browser": "20.3.15", "@angular/platform-browser-dynamic": "20.3.15", "@angular/router": "20.3.15", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.433", - "@bitwarden/sdk-internal": "0.2.0-main.433", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.439", + "@bitwarden/sdk-internal": "0.2.0-main.439", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -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", @@ -4973,9 +4973,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.433", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.433.tgz", - "integrity": "sha512-/eFzw+BUHxAmT75kKUn1r9MFsJH/GZpc3ljkjNjAqtvb3L+fz8VTHTe7FoloSoZEnAnp8OWOZy7n4DavT/XDiw==", + "version": "0.2.0-main.439", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.439.tgz", + "integrity": "sha512-Wujtym00U7XMEsf9zJ3/0Ggw9WmMcIpE9hMtcLryloX182118vnzkEQbEldqtywpMHiDsD9VmP6RiZ725nnUIQ==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^4.41.0" @@ -5078,9 +5078,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.433", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.433.tgz", - "integrity": "sha512-m2PnYR0ifF0BgZ63aAt8eag0v7LeEGTJ0sa7UMbTWLwmsNnHug4u7jxIJl0WaVILNeWWK8iD/WSiw3EJeb7Fmw==", + "version": "0.2.0-main.439", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.439.tgz", + "integrity": "sha512-uvIS8erGmzgWCZom7Kt78C4n4tbjfZuTCn7+y2+E8BTtLBqIZNtl4kC0tNh8c4GUWsmoIYlbQyz+HymWQ7J+QA==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index f4c484f1c61..29ee9683464 100644 --- a/package.json +++ b/package.json @@ -162,8 +162,8 @@ "@angular/platform-browser": "20.3.15", "@angular/platform-browser-dynamic": "20.3.15", "@angular/router": "20.3.15", - "@bitwarden/sdk-internal": "0.2.0-main.433", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.433", + "@bitwarden/sdk-internal": "0.2.0-main.439", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.439", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0",