mirror of
https://github.com/bitwarden/browser
synced 2026-01-30 16:23:53 +00:00
Merge branch 'main' into PM-7853-Clients-Hide-Send-from-navigation-when-user-is-subject-to-the-disable-Send-policy
This commit is contained in:
1
.github/renovate.json5
vendored
1
.github/renovate.json5
vendored
@@ -157,6 +157,7 @@
|
||||
"html-webpack-injector",
|
||||
"html-webpack-plugin",
|
||||
"interprocess",
|
||||
"itertools",
|
||||
"json5",
|
||||
"keytar",
|
||||
"libc",
|
||||
|
||||
@@ -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
|
||||
|
||||
2
.github/workflows/auto-branch-updater.yml
vendored
2
.github/workflows/auto-branch-updater.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/build-browser.yml
vendored
12
.github/workflows/build-browser.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/build-cli.yml
vendored
8
.github/workflows/build-cli.yml
vendored
@@ -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
|
||||
|
||||
20
.github/workflows/build-desktop.yml
vendored
20
.github/workflows/build-desktop.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/build-web.yml
vendored
8
.github/workflows/build-web.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/crowdin-pull.yml
vendored
2
.github/workflows/crowdin-pull.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/lint-crowdin-config.yml
vendored
2
.github/workflows/lint-crowdin-config.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/locales-lint.yml
vendored
4
.github/workflows/locales-lint.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/nx.yml
vendored
2
.github/workflows/nx.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/publish-cli.yml
vendored
6
.github/workflows/publish-cli.yml
vendored
@@ -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
|
||||
|
||||
|
||||
6
.github/workflows/publish-desktop.yml
vendored
6
.github/workflows/publish-desktop.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/publish-web.yml
vendored
4
.github/workflows/publish-web.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/release-browser.yml
vendored
4
.github/workflows/release-browser.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/release-cli.yml
vendored
2
.github/workflows/release-cli.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/release-web.yml
vendored
2
.github/workflows/release-web.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/repository-management.yml
vendored
4
.github/workflows/repository-management.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/version-auto-bump.yml
vendored
2
.github/workflows/version-auto-bump.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6039,5 +6039,8 @@
|
||||
},
|
||||
"whyAmISeeingThis": {
|
||||
"message": "Why am I seeing this?"
|
||||
},
|
||||
"resizeSideNavigation": {
|
||||
"message": "Resize side navigation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2015-2025 Bitwarden Inc. All rights reserved.</string>
|
||||
<string>Copyright © 2015-2026 Bitwarden Inc. All rights reserved.</string>
|
||||
<key>NSMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
|
||||
</dict>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2015-2025 Bitwarden Inc. All rights reserved.</string>
|
||||
<string>Copyright © 2015-2026 Bitwarden Inc. All rights reserved.</string>
|
||||
<key>NSHumanReadableDescription</key>
|
||||
<string>A secure and free password manager for all of your devices.</string>
|
||||
<key>SFSafariAppExtensionBundleIdentifiersToReplace</key>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<authors>Bitwarden Inc.</authors>
|
||||
<projectUrl>https://bitwarden.com/</projectUrl>
|
||||
<iconUrl>https://raw.githubusercontent.com/bitwarden/brand/master/icons/256x256.png</iconUrl>
|
||||
<copyright>Copyright © 2015-2025 Bitwarden Inc.</copyright>
|
||||
<copyright>Copyright © 2015-2026 Bitwarden Inc.</copyright>
|
||||
<projectSourceUrl>https://github.com/bitwarden/clients/</projectSourceUrl>
|
||||
<docsUrl>https://help.bitwarden.com/article/cli/</docsUrl>
|
||||
<bugTrackerUrl>https://github.com/bitwarden/clients/issues</bugTrackerUrl>
|
||||
|
||||
16
apps/desktop/desktop_native/Cargo.lock
generated
16
apps/desktop/desktop_native/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,6 @@ pub fn get_foreground_window_title() -> Result<String> {
|
||||
/// 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<u16>, keyboard_shortcut: Vec<String>) -> Result<()> {
|
||||
pub fn type_input(input: &[u16], keyboard_shortcut: &[String]) -> Result<()> {
|
||||
windowing::type_input(input, keyboard_shortcut)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ pub fn get_foreground_window_title() -> anyhow::Result<String> {
|
||||
todo!("Bitwarden does not yet support Linux autotype");
|
||||
}
|
||||
|
||||
pub fn type_input(_input: Vec<u16>, _keyboard_shortcut: Vec<String>) -> anyhow::Result<()> {
|
||||
pub fn type_input(_input: &[u16], _keyboard_shortcut: &[String]) -> anyhow::Result<()> {
|
||||
todo!("Bitwarden does not yet support Linux autotype");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ pub fn get_foreground_window_title() -> anyhow::Result<String> {
|
||||
todo!("Bitwarden does not yet support macOS autotype");
|
||||
}
|
||||
|
||||
pub fn type_input(_input: Vec<u16>, _keyboard_shortcut: Vec<String>) -> anyhow::Result<()> {
|
||||
pub fn type_input(_input: &[u16], _keyboard_shortcut: &[String]) -> anyhow::Result<()> {
|
||||
todo!("Bitwarden does not yet support macOS autotype");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <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
|
||||
/// <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<String> {
|
||||
window_title::get_foreground_window_title()
|
||||
}
|
||||
|
||||
pub fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> 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<KeyboardShortcutInput> = keyboard_shortcut
|
||||
.iter()
|
||||
.map(|s| KeyboardShortcutInput::try_from(s.as_str()))
|
||||
.try_collect()?;
|
||||
|
||||
type_input::type_input(input, &keyboard_shortcut)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <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::<INPUT>() as i32;
|
||||
let insert_count = unsafe { SendInput(inputs, INPUT_STRUCT_SIZE) };
|
||||
const INPUT_STRUCT_SIZE: usize = std::mem::size_of::<INPUT>();
|
||||
|
||||
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<u16>, keyboard_shortcut: Vec<String>) -> Result<()> {
|
||||
/// <https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput>
|
||||
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<INPUT> =
|
||||
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::<Win32InputOperations, Win32ErrorOperations>(keyboard_inputs)
|
||||
send_input::<Win32InputOperations, Win32ErrorOperations>(&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<INPUT>) {
|
||||
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<INPUT>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<INPUT> {
|
||||
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<Self, Self::Error> {
|
||||
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<INPUT> {
|
||||
/// 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<u16> {
|
||||
fn get_alphabetic_hotkey(letter: &str) -> Result<u16> {
|
||||
if letter.len() != 1 {
|
||||
error!(
|
||||
len = letter.len(),
|
||||
@@ -135,23 +144,28 @@ fn get_alphabetic_hotkey(letter: String) -> Result<u16> {
|
||||
}
|
||||
|
||||
/// 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:
|
||||
/// <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_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<I, E>(inputs: Vec<INPUT>) -> 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<I, E>(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: <https://docs.rs/mockall/latest/mockall/#static-methods>
|
||||
|
||||
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::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
send_input::<MockInputOperations, MockErrorOperations>(&[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<KeyboardShortcutInput> = 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<KeyboardShortcutInput> = 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::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
send_input::<MockInputOperations, MockErrorOperations>(&[build_unicode_input(
|
||||
InputKeyPress::Up,
|
||||
0,
|
||||
)])
|
||||
@@ -320,7 +334,7 @@ mod tests {
|
||||
ctxge.checkpoint();
|
||||
ctxge.expect().returning(|| WIN32_ERROR(1));
|
||||
|
||||
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
|
||||
send_input::<MockInputOperations, MockErrorOperations>(&[build_unicode_input(
|
||||
InputKeyPress::Up,
|
||||
0,
|
||||
)])
|
||||
|
||||
@@ -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
|
||||
// <https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw>
|
||||
fn get_window_text_length_w(&self) -> Result<i32>;
|
||||
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw
|
||||
// <https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw>
|
||||
fn get_window_text_w(&self, buffer: &mut Vec<u16>) -> Result<i32>;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ pub(super) fn get_foreground_window_title() -> Result<String> {
|
||||
|
||||
/// Retrieves the foreground window handle and validates it.
|
||||
fn get_foreground_window_handle() -> Result<WindowHandle> {
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow
|
||||
// <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<WindowHandle> {
|
||||
///
|
||||
/// # 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<H, E>(window_handle: &H) -> Result<usize>
|
||||
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<H, E>(window_handle: &H, expected_title_length: usize) -> Result<String>
|
||||
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<u16> = 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: <https://docs.rs/mockall/latest/mockall/#static-methods>
|
||||
|
||||
use mockall::predicate;
|
||||
use serial_test::serial;
|
||||
|
||||
@@ -1241,8 +1241,7 @@ pub mod autotype {
|
||||
input: Vec<u16>,
|
||||
keyboard_shortcut: Vec<String>,
|
||||
) -> 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}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<DesktopLayoutComponent>;
|
||||
|
||||
const fakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule],
|
||||
@@ -44,6 +48,10 @@ describe("DesktopLayoutComponent", () => {
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useValue: fakeGlobalStateProvider,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(DesktopLayoutComponent, {
|
||||
|
||||
@@ -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<DesktopSideNavComponent>;
|
||||
|
||||
const fakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopSideNavComponent, NavigationModule],
|
||||
@@ -32,6 +36,10 @@ describe("DesktopSideNavComponent", () => {
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useValue: fakeGlobalStateProvider,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -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<SendListFiltersService>;
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -4388,6 +4388,9 @@
|
||||
"sessionTimeoutHeader": {
|
||||
"message": "Session timeout"
|
||||
},
|
||||
"resizeSideNavigation": {
|
||||
"message": "Resize side navigation"
|
||||
},
|
||||
"sessionTimeoutSettingsManagedByOrganization": {
|
||||
"message": "This setting is managed by your organization."
|
||||
},
|
||||
|
||||
@@ -565,7 +565,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
}
|
||||
}
|
||||
|
||||
if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) {
|
||||
if (userCanArchive && !cipher.isDeleted && !cipher.isArchived) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("archiveVerb"),
|
||||
click: async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<authors>Bitwarden Inc.</authors>
|
||||
<projectUrl>https://bitwarden.com/</projectUrl>
|
||||
<iconUrl>https://raw.githubusercontent.com/bitwarden/brand/master/icons/256x256.png</iconUrl>
|
||||
<copyright>Copyright © 2015-2025 Bitwarden Inc.</copyright>
|
||||
<copyright>Copyright © 2015-2026 Bitwarden Inc.</copyright>
|
||||
<projectSourceUrl>https://github.com/bitwarden/clients/</projectSourceUrl>
|
||||
<docsUrl>https://bitwarden.com/help/</docsUrl>
|
||||
<bugTrackerUrl>https://github.com/bitwarden/clients/issues</bugTrackerUrl>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16,27 +16,26 @@
|
||||
<img class="tw-float-right tw-ml-5 mfaType7" alt="FIDO2 WebAuthn logo" />
|
||||
<ul class="bwi-ul">
|
||||
<li *ngFor="let k of keys; let i = index" #removeKeyBtn [appApiAction]="k.removePromise">
|
||||
<i class="bwi bwi-li bwi-key"></i>
|
||||
<span *ngIf="!k.configured || !k.name" bitTypography="body1" class="tw-font-medium">
|
||||
{{ "webAuthnkeyX" | i18n: (i + 1).toString() }}
|
||||
</span>
|
||||
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-medium">
|
||||
{{ k.name }}
|
||||
</span>
|
||||
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">
|
||||
<ng-container *ngIf="k.migrated">
|
||||
<span>{{ "webAuthnMigrated" | i18n }}</span>
|
||||
<ng-container *ngIf="k.configured">
|
||||
<i class="bwi bwi-li bwi-key"></i>
|
||||
<span *ngIf="k.configured" bitTypography="body1" class="tw-font-medium">
|
||||
{{ k.name || ("unnamedKey" | i18n) }}
|
||||
</span>
|
||||
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">
|
||||
<ng-container *ngIf="k.migrated">
|
||||
<span>{{ "webAuthnMigrated" | i18n }}</span>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
|
||||
<i
|
||||
class="bwi bwi-spin bwi-spinner tw-text-muted bwi-fw"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
*ngIf="$any(removeKeyBtn).loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
-
|
||||
<a bitLink href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
|
||||
<i
|
||||
class="bwi bwi-spin bwi-spinner tw-text-muted bwi-fw"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
*ngIf="$any(removeKeyBtn).loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
-
|
||||
<a bitLink href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
|
||||
</ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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>): 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<ActivatedRoute>(),
|
||||
},
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useValue: fakeGlobalStateProvider,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -183,6 +188,10 @@ export default {
|
||||
},
|
||||
]),
|
||||
),
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useClass: StorybookGlobalStateProvider,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -2634,6 +2634,9 @@
|
||||
"key": {
|
||||
"message": "Key"
|
||||
},
|
||||
"unnamedKey": {
|
||||
"message": "Unnamed key"
|
||||
},
|
||||
"twoStepAuthenticatorEnterCodeV2": {
|
||||
"message": "Verification code"
|
||||
},
|
||||
@@ -12305,6 +12308,9 @@
|
||||
"userVerificationFailed": {
|
||||
"message": "User verification failed."
|
||||
},
|
||||
"resizeSideNavigation": {
|
||||
"message": "Resize side navigation"
|
||||
},
|
||||
"recoveryDeleteCiphersTitle": {
|
||||
"message": "Delete unrecoverable vault items"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -157,8 +155,6 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
[FeatureFlag.InactiveUserServerNotification]: FALSE,
|
||||
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
|
||||
|
||||
/* Innovation */
|
||||
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
|
||||
|
||||
@@ -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 { AuthRequestAnsweringServiceAbstraction } 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<AuthRequestAnsweringServiceAbstraction>();
|
||||
|
||||
configService = mock<ConfigService>();
|
||||
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
|
||||
const flagValueByFlag: Partial<Record<FeatureFlag, boolean>> = {
|
||||
[FeatureFlag.InactiveUserServerNotification]: true,
|
||||
[FeatureFlag.PushNotificationsWhenLocked]: true,
|
||||
};
|
||||
return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any;
|
||||
});
|
||||
|
||||
policyService = mock<InternalPolicyService>();
|
||||
|
||||
defaultServerNotificationsService = new DefaultServerNotificationsService(
|
||||
|
||||
@@ -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<UserId, AccountInfo>): Set<UserId> => {
|
||||
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<UserId, AccountInfo>): Set<UserId> => {
|
||||
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) {
|
||||
|
||||
@@ -207,9 +207,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
* Update the local store of CipherData with the provided data. Values are upserted into the existing store.
|
||||
*
|
||||
* @param cipher The cipher data to upsert. Can be a single CipherData object or an array of CipherData objects.
|
||||
* @param userId Optional user ID for whom the cipher data is being upserted.
|
||||
* @returns A promise that resolves to a record of updated cipher store, keyed by their cipher ID. Returns all ciphers, not just those updated
|
||||
*/
|
||||
abstract upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>>;
|
||||
abstract upsert(
|
||||
cipher: CipherData | CipherData[],
|
||||
userId?: UserId,
|
||||
): Promise<Record<CipherId, CipherData>>;
|
||||
abstract replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise<any>;
|
||||
abstract clear(userId?: string): Promise<void>;
|
||||
abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any>;
|
||||
|
||||
@@ -1196,12 +1196,15 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.encryptedCiphersState(userId).update(() => ciphers);
|
||||
}
|
||||
|
||||
async upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>> {
|
||||
async upsert(
|
||||
cipher: CipherData | CipherData[],
|
||||
userId?: UserId,
|
||||
): Promise<Record<CipherId, CipherData>> {
|
||||
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));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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<DrawerComponent>;
|
||||
|
||||
|
||||
@@ -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) => `<div class="tw-bg-background-alt tw-p-2">${story}</div>`),
|
||||
],
|
||||
parameters: {
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -5,4 +5,5 @@ export const mockLayoutI18n = {
|
||||
submenu: "submenu",
|
||||
toggleCollapse: "toggle collapse",
|
||||
loading: "Loading",
|
||||
resizeSideNavigation: "Resize side navigation",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -5,47 +5,64 @@
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
|
||||
[ngClass]="{ 'tw-w-60': data.open }"
|
||||
[ngStyle]="
|
||||
variant() === 'secondary' && {
|
||||
'--color-text-alt2': 'var(--color-text-main)',
|
||||
'--color-background-alt3': 'var(--color-secondary-100)',
|
||||
'--color-background-alt4': 'var(--color-secondary-300)',
|
||||
'--color-hover-contrast': 'var(--color-hover-default)',
|
||||
}
|
||||
"
|
||||
[cdkTrapFocus]="data.isOverlay"
|
||||
[attr.role]="data.isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="data.isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3"
|
||||
<div class="tw-relative tw-h-full">
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
|
||||
[style.width.rem]="data.open ? (sideNavService.width$ | async) : undefined"
|
||||
[ngStyle]="
|
||||
variant() === 'secondary' && {
|
||||
'--color-text-alt2': 'var(--color-text-main)',
|
||||
'--color-background-alt3': 'var(--color-secondary-100)',
|
||||
'--color-background-alt4': 'var(--color-secondary-300)',
|
||||
'--color-hover-contrast': 'var(--color-hover-default)',
|
||||
}
|
||||
"
|
||||
[cdkTrapFocus]="data.isOverlay"
|
||||
[attr.role]="data.isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="data.isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (data.open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
|
||||
<button
|
||||
#toggleButton
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="data.open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (data.open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
|
||||
<button
|
||||
#toggleButton
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="data.open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
<div
|
||||
cdkDrag
|
||||
(cdkDragMoved)="onDragMoved($event)"
|
||||
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-200 hover:tw-ease-in-out hover:tw-delay-500 hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
|
||||
[class.tw-hidden]="!data.open"
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="separator"
|
||||
[attr.aria-valuenow]="sideNavService.width$ | async"
|
||||
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
|
||||
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
|
||||
aria-orientation="vertical"
|
||||
aria-controls="bit-side-nav"
|
||||
[attr.aria-label]="'resizeSideNavigation' | i18n"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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<SideNavVariant>("primary");
|
||||
|
||||
private readonly toggleButton = viewChild("toggleButton", { read: ElementRef });
|
||||
protected sideNavService = inject(SideNavService);
|
||||
|
||||
private elementRef = inject<ElementRef<HTMLElement>>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number>(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<boolean>(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<number>(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<boolean>
|
||||
*/
|
||||
export const media = (query: string): Observable<boolean> => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
return fromEvent<MediaQueryList>(mediaQuery, "change").pipe(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
48
libs/components/src/utils/state-mock.ts
Normal file
48
libs/components/src/utils/state-mock.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
GlobalState,
|
||||
StateUpdateOptions,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
} from "@bitwarden/state";
|
||||
|
||||
export class StorybookGlobalState<T> implements GlobalState<T> {
|
||||
private _state$ = new BehaviorSubject<T | null>(null);
|
||||
|
||||
constructor(initialValue?: T | null) {
|
||||
this._state$.next(initialValue ?? null);
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: Partial<StateUpdateOptions<T, TCombine>>,
|
||||
): Promise<T | null> {
|
||||
const currentState = this._state$.value;
|
||||
const newState = configureState(currentState, null as TCombine);
|
||||
this._state$.next(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
get state$(): Observable<T | null> {
|
||||
return this._state$.asObservable();
|
||||
}
|
||||
|
||||
setValue(value: T | null): void {
|
||||
this._state$.next(value);
|
||||
}
|
||||
}
|
||||
|
||||
export class StorybookGlobalStateProvider implements GlobalStateProvider {
|
||||
private states = new Map<string, StorybookGlobalState<any>>();
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
const key = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
|
||||
if (!this.states.has(key)) {
|
||||
this.states.set(key, new StorybookGlobalState<T>());
|
||||
}
|
||||
|
||||
return this.states.get(key)!;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user