Compare commits

...

76 Commits

Author SHA1 Message Date
Daniel James Smith
3c46db399e Merge branch 'main' into tools/pm-xxxx/introduce-bitwarden-encrypted-json-importer 2025-12-05 11:25:22 +01:00
Daniel James Smith
d12c098933 Only create one return object instead of multiple 2025-12-05 11:23:51 +01:00
Daniel James Smith
09f68e3a17 Add guard to prevent passing password-protected exports to the wrong importer. 2025-12-05 11:22:31 +01:00
bw-ghapp[bot]
2976c9c654 Autosync the updated translations (#17826)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-05 11:00:29 +01:00
Tom
d32365fbba [PM-29164] Access Intelligence display for only enterprise (#17807)
* Access Intelligence display for only enterprise

* modifying the access intelligence routing to properly match. Added documentation.

* tasks remove useriskinsights flag

* fixing tasks test cases

* tasks should only check for enterprise

* fixing uncommitted changes

* reverting unecessary change from all activites

* adding back missing test case
2025-12-04 19:04:26 -05:00
Shane Melton
2bf9e3f6df [PM-29106] Add null check for login Uris that may come from SDK login list view (#17791) 2025-12-04 13:39:12 -08:00
Jonathan Prusik
cf806dcac4 do not trigger an update notification if the entered password matches a stored cipher with the same value and matching username (#17811) 2025-12-04 15:16:48 -05:00
Jordan Aasen
474ffa2ce1 [PM-25360] - allow item details name to be selectable (#17693)
* allow item details name to be selectable

* use tw class
2025-12-04 09:13:21 -08:00
Bryan Cunningham
ad12704c21 [CL-871] responsive sidebar product switcher (#17780)
* only make switcher sticky when height is larger than 850

* use rem based value for media query

* add comment about why 53rem was chosen
2025-12-04 11:50:19 -05:00
Vicki League
4155e26c28 [PM-18839] Use mono font for color password component (#17785) 2025-12-04 10:44:04 -05:00
Andreas Coroiu
5386b58f23 Revert "Desktop Native compile debug builds with debug log level (#17357)" (#17815)
This reverts commit a2abbd09bf.
2025-12-04 16:06:13 +01:00
adudek-bw
b9cb19a98e [PM-27081] Fix direct importers for linux (#17480)
* Fix direct importers for linux
2025-12-04 09:45:46 -05:00
Bernd Schoolmann
2ef84ca460 [PM-27230] Resolve sdk breaking changes; update account init and save signed public key (#17488)
* Update account init and save signed public key

* Update sdk

* Fix build

* Fix types

* Fix test

* Fix test
2025-12-04 09:14:09 +01:00
renovate[bot]
433c0ced32 [deps] Vault: Update koa to v3 (#17565)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 15:27:49 -08:00
renovate[bot]
77fe04f8c7 [deps] Vault: Update open to v11 (#17625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 14:32:13 -08:00
Jared Snider
d581f06b32 refactor(IdentityTokenResponse): [Auth/PM-3537] Remove deprecated KeyConnectorUrl from of IdentityTokenResponse + misc TDE cleanup (#17593)
* PM-3537 - Remove KeyConnectorUrl from IdentityTokenResponse and clean up other flagged behavior

* PM-3537 - SSO Login Strategy tests - remove key connector url

* PM-3537 - Update LoginStrategyService tests to pass
2025-12-03 17:23:17 -05:00
Nick Krantz
dab1a37bfe PM-24535 Web premium upgrade path for archive (#16854)
* add premium badge to web filter when the user does not have access to premium

* remove feature flag pass through in favor of showing/hiding archive vault observable

* refactor archive observable to be more generic

* add archive premium badge for the web

* show premium badge inline for archive filter

* show premium subscription ended message when user has archived ciphers

* fix missing refactor

* remove unneeded can archive check

* reference observable directly

* reduce the number of firstValueFroms by combining observables into a single stream

* fix failing tests

* add import to storybook

* update variable naming for premium filters

* pass event to `promptForPremium`

* remove check for organization

* fix footer variable reference

* refactor back to `hasArchiveFlagEnabled$` - more straight forward to the underlying logic

* update archive service test with new feature flag format
2025-12-03 14:19:26 -06:00
Jonathan Prusik
04d7744747 normalize lowercasing for cipher compared against lowercased input value (#17803) 2025-12-03 15:12:12 -05:00
Anders Åberg
28fbddb63f fix(passkeys): [PM-28324] Add a guard that conditionally forces a popout depending on platform
* Add a guard that conditionally forces a popout depending on platform

* Test the routeguard

* Use mockImplementation instead.

* autoclose popout
2025-12-03 14:40:55 -05:00
Alex
d64da69fa7 [PM-6979] Remove HIBP 404 handling (#17769) 2025-12-03 14:21:58 -05:00
Bernd Schoolmann
1bfff49ef5 [PM-29122] Fix debug build causing slow unlock (#17798)
* Fix debug build causing slow unlock

* Cleanup

* Fix release mode build actually building debug
2025-12-03 19:10:10 +00:00
Bernd Schoolmann
6e2203d6d4 [PM-18026] Implement forced, automatic KDF upgrades (#15937)
* Implement automatic kdf upgrades

* Fix kdf config not being updated

* Update legacy kdf state on master password unlock sync

* Fix cli build

* Fix

* Deduplicate prompts

* Fix dismiss time

* Fix default kdf setting

* Fix build

* Undo changes

* Fix test

* Fix prettier

* Fix test

* Update libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>

* Update libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>

* Update libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>

* Only sync when there is at least one migration

* Relative imports

* Add tech debt comment

* Resolve inconsistent prefix

* Clean up

* Update docs

* Use default PBKDF2 iteratinos instead of custom threshold

* Undo type check

* Fix build

* Add comment

* Cleanup

* Cleanup

* Address component feedback

* Use isnullorwhitespace

* Fix tests

* Allow migration only on vault

* Fix tests

* Run prettier

* Fix tests

* Prevent await race condition

* Fix min and default values in kdf migration

* Run sync only when a migration was run

* Update libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.ts

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>

* Fix link not being blue

* Fix later button on browser

---------

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
2025-12-03 19:04:18 +01:00
Michael L.
6ae096485a Add support for Helium browser integration on mac (#17293)
Co-authored-by: Addison Beck <github@addisonbeck.com>
2025-12-03 12:14:07 -05:00
Vince Grassia
5f9759fde1 Update Linux build job in Build Desktop workflow to free up space on disk (#17784) 2025-12-03 12:10:42 -05:00
Jonathan Prusik
422e527516 [PM-28289] Address false-positives of new login save prompts (#17783)
* add values to TotpFieldNames constant

* add totp field check to username field qualification

* handle checking empty string cases

* update tests

* require stored username for new cipher notification prompt

* drop ambiguous token keyword from authoritative TOTP field names constant

* adjust shouldAttemptNotification logic for add and change cases
2025-12-03 11:46:48 -05:00
cyprain-okeke
17ebae11d7 Fix the bug by hiding the add button (#17744) 2025-12-03 16:08:48 +01:00
Bernd Schoolmann
a6100d8a0e Replace webcrypto RSA with PureCrypto RSA (#17742) 2025-12-03 13:11:03 +01:00
Jeffrey Holland
cf416388d7 Fix stale data issue in new login popout (#17307)
* Fix stale data issue in new login popout

* Update the comments

* Address critical claude code bot suggestions

* Clean out all stale data from pop up

* Fix cached cipher issue

* Fix caching issue between tab and overlay flow

* Address claude comments
2025-12-03 09:46:40 +01:00
SmithThe4th
6f9b25e98e Prevented double decryption (#17768) 2025-12-02 16:13:34 -05:00
Bryan Cunningham
dc953b3945 Revert using tooltip in appA11yTitle directive (#17787)
* revert using tooltip in title directive

* add back tooltip delay from revert

* add back label to carousel buttons

* fix documentation that does not need reverted

* remove unnecessary label attr
2025-12-02 16:03:06 -05:00
Isaac Ivins
365af52e33 [PM-27794] create send component desktop migration (#17786)
* wip

* updated tests to work, and linter
2025-12-02 21:27:41 +01:00
Todd Martin
dd623b136b chore(logs): Update "SSO login email not found" log level to debug
* Update log level to debug

* Fixed test.
2025-12-02 15:16:09 -05:00
Bryan Cunningham
44e3320a67 add back the aria-label when using the a11y title directive (#17776)
* add back the aria-label when using the a11y title directive

* add comment about why aria-label is being added back

* fix storybook a11y tests

* pass undefined to util function
2025-12-02 14:35:02 -05:00
renovate[bot]
30f615767c [deps] Autofill: Update prettier-plugin-tailwindcss to v0.7.1 (#17033)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 13:57:26 -05:00
Leslie Xiong
d373eefc9d [PM-27793] Create new v3 vault component (#17684)
Created Vault component for desktop vault-v3
- copied content from vault-v2.component.ts/html
- removed vault filters from html
2025-12-02 10:26:28 -08:00
Todd Martin
57b6d8ba58 chore: [PM-28640] revert script injection change
* chore: revert script injection change

* Removed async

* Adjust tests.

* Revert fido2.background.ts changes.

---------

Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
2025-12-02 13:24:22 -05:00
Nick Krantz
dd99190ca2 allow for archived ciphers to be shared into an organization (#17592) 2025-12-02 11:43:02 -06:00
Todd Martin
92709e63af chore(workflows): Updated branch for checkout 2025-12-02 12:20:22 -05:00
cyprain-okeke
12222e39b4 [PM-28258]Fix [Defect] New Organization creation without payment method succeeds without organization creation (#17719)
* Resolve the  payment validation issue

* remove the null error
2025-12-02 18:05:39 +01:00
Todd Martin
aa309e4e56 Revert "Bumped client version(s)". (#17765)
This reverts commit 406dbc8066.
2025-12-02 12:02:39 -05:00
Jonathan Prusik
f17890a26b [PM-27798] Prevent inline menu from opening on the page outside of the viewport (#17664)
* cleanup

* prevent inline menu from opening on the page outside of the viewport

* update inline menu viewport check to include checks on all sides of the viewport

* use VisualViewport when available

* update tests
2025-12-02 11:31:35 -05:00
Alex Morask
ebd5793568 [PM-24558] Remove FF: pm-21821-provider-portal-takeover (#17521)
* Remove FF: pm-21821-provider-portal-takeover

* Fix failing tests
2025-12-02 10:16:44 -06:00
gitclonebrian
bf461879e3 added perms to both token generation steps (#17398) 2025-12-02 11:06:26 -05:00
renovate[bot]
bbb5acba50 [deps] Autofill: Update tldts to v7.0.19 (#17676)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 08:03:46 -08:00
Stephon Brown
a9bf66e689 [PM-27600] Replace Hard-Coded Storage amount (#17393)
* feat(billing): add provided as a required property to premium response

* fix(billing): replace hard coded storage variables with retrieved plan

* tests(billing): add tests to pricing-summary service

* feat(billing): add optional property.

* fix(billing): update storage logic

* fix(billing): remove optional check

* fix(billing): remove optionality

* fix(billing): remove optionality

* fix(billing): refactored storage calculation logic

* feat(billing): add provided amounts to subscription-pricing-service

* fix(billing): update cloud premium component

* fix(billing): update desktop premium component

* fix(billing): update org plans component

* fix(billing) update stories and tests

* fix(billing): update messages

* fix(billing): replace storage sizes

* fix(billing): update messages

* fix(billing): update components

* fix(billing): update components for pricing and storage retrieval

* fix(billing): revert self-hosted change
2025-12-02 10:49:55 -05:00
Bernd Schoolmann
049acf1e12 Update sdk to build 403 and move webcrypto rsa to use sdk rsa extract public key (#17771) 2025-12-02 14:39:32 +01:00
Bernd Schoolmann
2e8faa9994 [PM-12628] Fix cli showing locked status when using session and check (#17515)
* Fix cli showing locked status when using session and check

* Cleanup
2025-12-02 14:39:18 +01:00
Alex Morask
2510844293 Clear premium interest on upgrade dialog open (#17518) 2025-12-02 07:19:41 -06:00
renovate[bot]
4a4ce8312c [deps]: Update Swatinem/rust-cache action to v2.8.2 (#17716)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
2025-12-02 10:29:11 +00:00
Github Actions
406dbc8066 Bumped client version(s) 2025-12-01 22:21:58 +00:00
Vicki League
37b233aad9 [CL-717] Fix autofill storybook config (#17757) 2025-12-01 17:20:40 -05:00
Vicki League
d4c62495b3 [CL-717] Bump to higher patch version for security fix (#17759) 2025-12-01 17:17:00 -05:00
Jordan Aasen
99186e3651 [PM-28514] - fix item copy actions for totp. add specs (#17709)
* fix item copy actions for totp. add specs

* add test to satisfy claude
2025-12-01 13:52:59 -08:00
Nik Gilmore
e694ab490c [PM-23562] Prevent closing dialog and window when uploading an attachment (#17287)
* Prevent users from cancelling an in-flight upload, and attempt to block them from closing the window.

* Add comment for deprecated event.returnValue
2025-12-01 12:50:13 -08:00
Vicki League
aac7ca172b [CL-717] Skip failing test affected by Angular 20 upgrade (#17761) 2025-12-01 20:38:09 +00:00
Leslie Tilton
399a5147a9 Remove additional flag from organization layout html component (#17755) 2025-12-01 13:52:03 -06:00
Vicki League
10424e227b [CL-717][PM-27966] Update to Angular 20 and Storybook 9 (#17638) 2025-12-01 14:15:58 -05:00
renovate[bot]
79d518fcf7 [deps]: Update Rust crate mockall to v0.14.0 (#17747)
* [deps]: Update Rust crate mockall to v0.14.0

* fix test cases global expectations

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: neuronull <9162534+neuronull@users.noreply.github.com>
2025-12-01 11:47:32 -07:00
Vijay Oommen
ee03c8a36a [PM-28616] Add UsePhishingBlocker in the client against organization (#17681)
* PM-28616 Add UsePhishingBlocker in the client against organization

* PM-28616 fixed failing unit test
2025-12-01 13:30:51 -05:00
Isaac Ivins
d05356dbeb [PM-27792] Scaffold layout desktop migration (#17658)
Introduces foundational scaffolding for the Bitwarden Desktop application UI migration
2025-12-01 10:04:07 -08:00
renovate[bot]
be00be8fd8 [deps] Autofill: Update lit to v3.3.1 (#17541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 12:59:40 -05:00
Bryan Cunningham
963a9156fb [CL-910] Use tooltip in title directive (#17084)
* use tooltip in a11y directive

* remove commented code

* add deprecation warning to appA11yTitle directive

* use label for tooltip in carousel nav

* wait for timeout before assertion

* remove unnecessary title directive use

* fix private variable lint errors

* increase tooltip show delay

* fix spec delay and export as constant

* use delay constant

---------

Co-authored-by: Vicki League <vleague@bitwarden.com>
2025-12-01 11:59:20 -05:00
SmithThe4th
4a2858132d [PM-28950] Add enum normalizers to protect against corrupted user data in SDK mapping (#17723)
* Added normalizers to protect against corrpupted user data when mapping between client and SDK

* Added comments

* simplified secure note normalization
2025-12-01 11:11:25 -05:00
neuronull
30b89d1fc2 Bump Rust version to 1.87.0 (#17641)
* Bump Rust version to 1.87.0

* clippy

* clippy

* clippy
2025-12-01 08:42:12 -07:00
Brandon Treston
b9d5724312 [PM-24011] Add handler for new policy sync push notification (#17465)
* add handler for new policy sync push notification

* fix story book build failure

* move logic into policy service, fix tests

* add account service

* add missing service to clie
2025-12-01 10:21:48 -05:00
Brandon Treston
e1d14ca7bd [PM-28350] Refactor policies component (#17636)
* refactor policies component

* add tests

* cleanup

* clean up

* change trackBy to

* change detetction
2025-12-01 10:20:28 -05:00
bw-ghapp[bot]
d0690ebc52 Autosync the updated translations (#17750)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-01 12:07:45 +00:00
bw-ghapp[bot]
9936330971 Autosync the updated translations (#17748)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-01 12:07:13 +00:00
bw-ghapp[bot]
39a2d80b10 Autosync the updated translations (#17749)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2025-12-01 12:06:45 +00:00
Github Actions
fc63c0c2cf Bumped client version(s) 2025-12-01 11:46:54 +00:00
Bernd Schoolmann
b7287d4614 [PM-26570] Remove biometrics v1 (#17629)
* Remove biometrics v1

* Cargo fmt

* Fix windows build

* Apply prettier

* Remove proxy code

* Fix build

* Fix

* Fix tests

* Remove v2 flag
2025-12-01 10:16:36 +01:00
renovate[bot]
b248341d0e [deps] Autofill: Update wait-on to v9.0.3 (#17542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 10:09:24 +01:00
Andreas Coroiu
2fd4a92cc5 [PM-28640] Fix passkeys not working on MV2 (#17701)
* fix: inject script contents directly

* fix: tests

* fix: tests

* fix: injection tests
2025-12-01 08:48:16 +01:00
Vince Grassia
39a22113df BRE-1355 - Fix lite naming and remove PAT (#17743) 2025-11-30 22:25:38 -05:00
Bernd Schoolmann
95def44097 Catch error initializing provider keys (#17672) 2025-11-29 20:23:32 +01:00
renovate[bot]
a8d6ad4db6 [deps] Platform: Update node-forge to v1.3.2 [SECURITY] (#17690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 08:50:24 +01:00
451 changed files with 12524 additions and 6079 deletions

View File

@@ -119,7 +119,7 @@
"rimraf",
"ssh-encoding",
"ssh-key",
"@storybook/web-components-webpack5",
"@storybook/web-components-vite",
"tabbable",
"tldts",
"wait-on",
@@ -311,26 +311,24 @@
"@compodoc/compodoc",
"@ng-select/ng-select",
"@storybook/addon-a11y",
"@storybook/addon-actions",
"@storybook/addon-designs",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-docs",
"@storybook/addon-links",
"@storybook/test-runner",
"@storybook/addon-themes",
"@storybook/angular",
"@storybook/manager-api",
"@storybook/theming",
"@types/react",
"autoprefixer",
"bootstrap",
"chromatic",
"ngx-toastr",
"path-browserify",
"react",
"react-dom",
"remark-gfm",
"storybook",
"tailwindcss",
"vite-tsconfig-paths",
"zone.js",
"@tailwindcss/container-queries",
],

View File

@@ -175,9 +175,23 @@ jobs:
- name: Check out repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Free disk space for build
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/.ghcup
sudo rm -rf /usr/share/miniconda
sudo rm -rf /usr/share/az_*
sudo rm -rf /usr/local/julia*
sudo rm -rf /usr/lib/mono
sudo rm -rf /usr/lib/heroku
sudo rm -rf /usr/local/aws-cli
sudo rm -rf /usr/local/aws-sam-cli
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
@@ -186,7 +200,7 @@ jobs:
node-version: ${{ env._NODE_VERSION }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
workspaces: |
apps/desktop/desktop_native -> target
@@ -249,9 +263,11 @@ jobs:
PKG_CONFIG_ALLOW_CROSS: true
PKG_CONFIG_ALL_STATIC: true
TARGET: musl
# Note: It is important that we use the release build because some compute heavy
# operations such as key derivation for oo7 on linux are too slow in debug mode
run: |
rustup target add x86_64-unknown-linux-musl
node build.js --target=x86_64-unknown-linux-musl
node build.js --target=x86_64-unknown-linux-musl --release
- name: Build application
run: npm run dist:lin
@@ -342,7 +358,7 @@ jobs:
node-version: ${{ env._NODE_VERSION }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
workspaces: |
apps/desktop/desktop_native -> target
@@ -412,9 +428,11 @@ jobs:
PKG_CONFIG_ALLOW_CROSS: true
PKG_CONFIG_ALL_STATIC: true
TARGET: musl
# Note: It is important that we use the release build because some compute heavy
# operations such as key derivation for oo7 on linux are too slow in debug mode
run: |
rustup target add aarch64-unknown-linux-musl
node build.js --target=aarch64-unknown-linux-musl
node build.js --target=aarch64-unknown-linux-musl --release
- name: Check index.d.ts generated
if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true'
@@ -490,7 +508,7 @@ jobs:
node-version: ${{ env._NODE_VERSION }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
workspaces: |
apps/desktop/desktop_native -> target
@@ -756,7 +774,7 @@ jobs:
node-version: ${{ env._NODE_VERSION }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
workspaces: |
apps/desktop/desktop_native -> target
@@ -1007,7 +1025,7 @@ jobs:
run: python3 -m pip install setuptools
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
workspaces: |
apps/desktop/desktop_native -> target
@@ -1244,7 +1262,7 @@ jobs:
run: python3 -m pip install setuptools
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
workspaces: |
apps/desktop/desktop_native -> target
@@ -1516,7 +1534,7 @@ jobs:
run: python3 -m pip install setuptools
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
workspaces: |
apps/desktop/desktop_native -> target

View File

@@ -158,7 +158,7 @@ jobs:
run: docker logout
bitwarden-lite-build:
name: Trigger Bitwarden Lite build
name: Trigger Bitwarden lite build
runs-on: ubuntu-22.04
needs: setup
permissions:
@@ -171,20 +171,27 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger Bitwarden Lite build
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Trigger Bitwarden lite build
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
@@ -192,6 +199,7 @@ jobs:
workflow_id: 'build-bitwarden-lite.yml',
ref: 'main',
inputs: {
use_latest_core_version: true
use_latest_core_version: true,
web_branch: process.env.GITHUB_REF
}
});

View File

@@ -29,7 +29,7 @@ on:
default: false
target_ref:
default: "main"
description: "Branch/Tag to target for cut"
description: "Branch/Tag to target for cut (ignored if not cutting rc)"
required: true
type: string
version_number_override:
@@ -102,11 +102,12 @@ jobs:
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write # for committing and pushing to current branch
- name: Check out branch
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: main
ref: ${{ github.ref }}
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
@@ -467,6 +468,7 @@ jobs:
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write # for creating and pushing new branch
- name: Check out target ref
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

View File

@@ -148,7 +148,7 @@ jobs:
components: llvm-tools
- name: Cache cargo registry
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
workspaces: "apps/desktop/desktop_native -> target"

View File

@@ -28,15 +28,13 @@ const config: StorybookConfig = {
],
addons: [
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-designs"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-themes"),
{
// @storybook/addon-docs is part of @storybook/addon-essentials
// eslint-disable-next-line storybook/no-uninstalled-addons
name: "@storybook/addon-docs",
name: getAbsolutePath("@storybook/addon-docs"),
options: {
mdxPluginOptions: {
mdxCompileOptions: {
@@ -60,6 +58,10 @@ const config: StorybookConfig = {
webpackFinal: async (config, { configType }) => {
if (config.resolve) {
config.resolve.plugins = [new TsconfigPathsPlugin()] as any;
config.resolve.fallback = {
...config.resolve.fallback,
path: require.resolve("path-browserify"),
};
}
return config;
},

View File

@@ -1,5 +1,5 @@
import { addons } from "@storybook/manager-api";
import { create } from "@storybook/theming/create";
import { addons } from "storybook/manager-api";
import { create } from "storybook/theming";
const lightTheme = create({
base: "light",

View File

@@ -28,7 +28,7 @@ const preview: Preview = {
],
parameters: {
a11y: {
element: "#storybook-root",
context: "#storybook-root",
},
controls: {
matchers: {
@@ -49,7 +49,7 @@ const preview: Preview = {
},
},
backgrounds: {
disable: true,
disabled: true,
},
},
tags: ["autodocs"],

View File

@@ -220,5 +220,31 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2025.11.2",
"version": "2025.12.0",
"scripts": {
"build": "npm run build:chrome",
"build:bit": "npm run build:bit:chrome",

View File

@@ -562,7 +562,7 @@
"description": "Verb"
},
"unArchive": {
"message": "Nicht mehr archivieren"
"message": "Wiederherstellen"
},
"itemsInArchive": {
"message": "Einträge im Archiv"
@@ -574,10 +574,10 @@
"message": "Archivierte Einträge werden hier angezeigt und von allgemeinen Suchergebnissen sowie Vorschlägen zum automatischen Ausfüllen ausgeschlossen."
},
"itemWasSentToArchive": {
"message": "Eintrag wurde ins Archiv verschoben"
"message": "Eintrag wurde archiviert"
},
"itemUnarchived": {
"message": "Eintrag wird nicht mehr archiviert"
"message": "Eintrag wurde wiederhergestellt"
},
"archiveItem": {
"message": "Eintrag archivieren"
@@ -1050,7 +1050,7 @@
"message": "Eintrag gespeichert"
},
"savedWebsite": {
"message": "Website gespeichert"
"message": "Gespeicherte Website"
},
"savedWebsites": {
"message": "Gespeicherte Websites ($COUNT$)",
@@ -1710,7 +1710,7 @@
"message": "Auto-Ausfüllen bestätigen"
},
"confirmAutofillDesc": {
"message": "Diese Website stimmt nicht mit deinen gespeicherten Zugangsdaten überein. Bevor du deine Zugangsdaten eingibst, stelle sicher, dass es sich um eine vertrauenswürdige Website handelt."
"message": "Diese Website stimmt nicht mit deinen gespeicherten Zugangsdaten überein. Stelle sicher, dass dies eine vertrauenswürdige Website ist, bevor du deine Zugangsdaten eingibst."
},
"showInlineMenuLabel": {
"message": "Vorschläge zum Auto-Ausfüllen in Formularfeldern anzeigen"
@@ -1874,7 +1874,7 @@
"message": "Ablaufjahr"
},
"monthly": {
"message": "month"
"message": "Monatlich"
},
"expiration": {
"message": "Gültig bis"
@@ -2446,7 +2446,7 @@
}
},
"topLayerHijackWarning": {
"message": "Diese Seite beeinträchtigt die Nutzung von Bitwarden. Das Bitwarden Inline-Menü wurde aus Sicherheitsgründen vorübergehend deaktiviert."
"message": "Diese Seite stört die Bitwarden-Nutzung. Das Bitwarden Inline-Menü wurde aus Sicherheitsgründen vorübergehend deaktiviert."
},
"setMasterPassword": {
"message": "Master-Passwort festlegen"
@@ -4075,7 +4075,7 @@
"message": "Kein Auto-Ausfüllen möglich"
},
"cannotAutofillExactMatch": {
"message": "Die Standard-Übereinstimmungserkennung steht auf \"Exakte Übereinstimmung\". Die aktuelle Website stimmt nicht genau mit den gespeicherten Zugangsdaten für diesen Eintrag überein."
"message": "Die Standard-Übereinstimmungserkennung ist auf Exakte Übereinstimmung“ eingestellt. Die aktuelle Website stimmt nicht genau mit den gespeicherten Zugangsdaten für diesen Eintrag überein."
},
"okay": {
"message": "Okay"
@@ -5665,7 +5665,7 @@
"message": "Phishing-Versuch erkannt"
},
"phishingPageSummary": {
"message": "Die Website, die du versuchst zu öffnen, ist eine bekannte böswillige Website und ein Sicherheitsrisiko."
"message": "Die Website, die du öffnen möchtest, ist als böswillige Website bekannt und stellt ein Sicherheitsrisiko dar."
},
"phishingPageCloseTabV2": {
"message": "Diesen Tab schließen"
@@ -5813,7 +5813,7 @@
"message": "Notfallzugriff"
},
"breachMonitoring": {
"message": "Datendiebstahl-Überwachung"
"message": "Datenleck-Überwachung"
},
"andMoreFeatures": {
"message": "Und mehr!"

View File

@@ -1406,6 +1406,27 @@
"learnMore": {
"message": "Learn more"
},
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"later": {
"message": "Later"
},
"authenticatorKeyTotp": {
"message": "Authenticator key (TOTP)"
},
@@ -1475,6 +1496,15 @@
"ppremiumSignUpStorage": {
"message": "1 GB encrypted storage for file attachments."
},
"premiumSignUpStorageV2": {
"message": "$SIZE$ encrypted storage for file attachments.",
"placeholders": {
"size": {
"content": "$1",
"example": "1 GB"
}
}
},
"premiumSignUpEmergency": {
"message": "Emergency access."
},

View File

@@ -861,7 +861,7 @@
"message": "A confirmação da senha principal não corresponde."
},
"newAccountCreated": {
"message": "A sua nova conta foi criada! Agora você pode conectar-se."
"message": "A sua conta nova foi criada! Agora você pode se conectar."
},
"newAccountCreated2": {
"message": "Sua nova conta foi criada!"
@@ -870,7 +870,7 @@
"message": "Você foi conectado!"
},
"youSuccessfullyLoggedIn": {
"message": "Você conectou-se à sua conta com sucesso"
"message": "Você se conectou com sucesso"
},
"youMayCloseThisWindow": {
"message": "Você pode fechar esta janela"
@@ -2482,16 +2482,16 @@
}
},
"policyInEffectUppercase": {
"message": "Contém um ou mais caracteres em maiúsculo"
"message": "Conter um ou mais caracteres em maiúsculo"
},
"policyInEffectLowercase": {
"message": "Contém um ou mais caracteres em minúsculo"
"message": "Conter um ou mais caracteres em minúsculo"
},
"policyInEffectNumbers": {
"message": "Contém um ou mais números"
"message": "Conter um ou mais números"
},
"policyInEffectSpecial": {
"message": "Contém um ou mais dos seguintes caracteres especiais $CHARS$",
"message": "Conter um ou mais dos seguintes caracteres especiais $CHARS$",
"placeholders": {
"chars": {
"content": "$1",
@@ -3308,7 +3308,7 @@
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
},
"contactCSToAvoidDataLossPart2": {
"message": "para evitar a perca adicional dos dados.",
"message": "para evitar a perca de dados adicionais.",
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
},
"generateUsername": {

View File

@@ -586,7 +586,7 @@
"message": "Arkiverade objekt är exkluderade från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?"
},
"upgradeToUseArchive": {
"message": "A premium membership is required to use Archive."
"message": "Ett premium-medlemskap krävs för att använda Arkiv."
},
"edit": {
"message": "Redigera"
@@ -598,7 +598,7 @@
"message": "Visa alla"
},
"showAll": {
"message": "Show all"
"message": "Visa alla"
},
"viewLess": {
"message": "Visa mindre"
@@ -1874,7 +1874,7 @@
"message": "Utgångsår"
},
"monthly": {
"message": "month"
"message": "månad"
},
"expiration": {
"message": "Utgång"
@@ -5825,10 +5825,10 @@
"message": "Uppgradera till Premium"
},
"unlockAdvancedSecurity": {
"message": "Unlock advanced security features"
"message": "Lås upp avancerade säkerhetsfunktioner"
},
"unlockAdvancedSecurityDesc": {
"message": "A Premium subscription gives you more tools to stay secure and in control"
"message": "En Premium-prenumeration ger dig fler verktyg för att hålla dig säker och ha kontroll"
},
"explorePremium": {
"message": "Utforska Premium"

View File

@@ -1874,7 +1874,7 @@
"message": "过期年份"
},
"monthly": {
"message": "month"
"message": ""
},
"expiration": {
"message": "有效期"
@@ -4894,7 +4894,7 @@
"message": "获取桌面 App"
},
"getTheDesktopAppDesc": {
"message": "无需使用浏览器访问您的密码库,然后在桌面 App 和浏览器扩展中同时设置生物识别解锁,即可实现快速解锁。"
"message": "无需使用浏览器访问您的密码库在桌面 App 和浏览器扩展中同时设置生物识别解锁,即可实现快速解锁。"
},
"downloadFromBitwardenNow": {
"message": "立即从 bitwarden.com 下载"
@@ -5772,7 +5772,7 @@
"message": "关于此设置"
},
"permitCipherDetailsDescription": {
"message": "Bitwarden 将使用已保存的登录 URI 来识别应使用哪个图标或更改密码的 URL 来改善您的体验。当您使用此服务时不会收集或保存任何信息。"
"message": "Bitwarden 将使用已保存的登录 URI 来确定应使用图标或更改密码的 URL,以提升您的使用体验。使用此服务时不会收集或保存任何信息。"
},
"noPermissionsViewPage": {
"message": "您没有查看此页面的权限。请尝试使用其他账户登录。"

View File

@@ -0,0 +1,193 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { platformPopoutGuard } from "./platform-popout.guard";
describe("platformPopoutGuard", () => {
let getPlatformInfoSpy: jest.SpyInstance;
let inPopoutSpy: jest.SpyInstance;
let inSidebarSpy: jest.SpyInstance;
let openPopoutSpy: jest.SpyInstance;
let closePopupSpy: jest.SpyInstance;
const mockRoute = {} as ActivatedRouteSnapshot;
const mockState: RouterStateSnapshot = {
url: "/login-with-passkey?param=value",
} as RouterStateSnapshot;
beforeEach(() => {
getPlatformInfoSpy = jest.spyOn(BrowserApi, "getPlatformInfo");
inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout");
inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar");
openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation();
TestBed.configureTestingModule({});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("when platform matches", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should open popout and block navigation when not already in popout or sidebar", async () => {
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(getPlatformInfoSpy).toHaveBeenCalled();
expect(inPopoutSpy).toHaveBeenCalledWith(window);
expect(inSidebarSpy).toHaveBeenCalledWith(window);
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
});
it("should allow navigation when already in popout", async () => {
inPopoutSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(closePopupSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it("should allow navigation when already in sidebar", async () => {
inSidebarSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(closePopupSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("when platform does not match", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should allow navigation without opening popout", async () => {
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(getPlatformInfoSpy).toHaveBeenCalled();
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("when forcePopout is true", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should open popout regardless of platform", async () => {
const guard = platformPopoutGuard(["linux"], true);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
});
it("should not open popout when already in popout", async () => {
inPopoutSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"], true);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("with multiple platforms", () => {
beforeEach(() => {
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it.each(["linux", "mac", "win"])(
"should open popout when platform is %s and included in platforms array",
async (platform) => {
getPlatformInfoSpy.mockResolvedValue({ os: platform });
const guard = platformPopoutGuard(["linux", "mac", "win"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
},
);
it("should not open popout when platform is not in the array", async () => {
getPlatformInfoSpy.mockResolvedValue({ os: "android" });
const guard = platformPopoutGuard(["linux", "mac"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("url handling", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should preserve query parameters in the popout url", async () => {
const stateWithQuery: RouterStateSnapshot = {
url: "/path?foo=bar&baz=qux",
} as RouterStateSnapshot;
const guard = platformPopoutGuard(["linux"]);
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/path?foo=bar&baz=qux&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
});
it("should handle urls without query parameters", async () => {
const stateWithoutQuery: RouterStateSnapshot = {
url: "/simple-path",
} as RouterStateSnapshot;
const guard = platformPopoutGuard(["linux"]);
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/simple-path?autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
});
});
});

View File

@@ -0,0 +1,46 @@
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
/**
* Guard that forces a popout window for specific platforms.
* Useful when popup context would close during operations (e.g., WebAuthn on Linux).
*
* @param platforms - Array of platform OS strings (e.g., ["linux", "mac", "win"])
* @param forcePopout - If true, always force popout regardless of platform (useful for testing)
* @returns CanActivateFn that opens popout and blocks navigation if conditions met
*/
export function platformPopoutGuard(
platforms: string[],
forcePopout: boolean = false,
): CanActivateFn {
return async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
// Check if current platform matches
const platformInfo = await BrowserApi.getPlatformInfo();
const isPlatformMatch = platforms.includes(platformInfo.os);
// Check if already in popout/sidebar
const inPopout = BrowserPopupUtils.inPopout(window);
const inSidebar = BrowserPopupUtils.inSidebar(window);
// Open popout if conditions met
if ((isPlatformMatch || forcePopout) && !inPopout && !inSidebar) {
// Add autoClosePopout query param to signal the popout should close after completion
const [path, existingQuery] = state.url.split("?");
const params = new URLSearchParams(existingQuery || "");
params.set("autoClosePopout", "true");
const urlWithAutoClose = `${path}?${params.toString()}`;
// Open the popout window
await BrowserPopupUtils.openPopout(`popup/index.html#${urlWithAutoClose}`);
// Close the original popup window
BrowserApi.closePopup(window);
return false; // Block navigation - popout will reload
}
return true; // Allow navigation
};
}

View File

@@ -69,8 +69,8 @@ export type FieldRect = {
};
export type InlineMenuPosition = {
button?: InlineMenuElementPosition;
list?: InlineMenuElementPosition;
button?: InlineMenuElementPosition | null;
list?: InlineMenuElementPosition | null;
};
export type NewLoginCipherData = {

View File

@@ -627,11 +627,11 @@ export default class NotificationBackground {
}
const username: string | null = data.username || null;
const currentPassword = data.password || null;
const newPassword = data.newPassword || null;
const currentPasswordFieldValue = data.password || null;
const newPasswordFieldValue = data.newPassword || null;
if (authStatus === AuthenticationStatus.Locked && newPassword !== null) {
await this.pushChangePasswordToQueue(null, loginDomain, newPassword, tab, true);
if (authStatus === AuthenticationStatus.Locked && newPasswordFieldValue !== null) {
await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true);
return true;
}
@@ -657,35 +657,49 @@ export default class NotificationBackground {
const [cipher] = ciphers;
if (
username !== null &&
newPassword === null &&
cipher.login.username === normalizedUsername &&
cipher.login.password === currentPassword
newPasswordFieldValue === null &&
cipher.login.username.toLowerCase() === normalizedUsername &&
cipher.login.password === currentPasswordFieldValue
) {
// Assumed to be a login
return false;
}
}
if (currentPassword && !newPassword) {
if (
ciphers.length > 0 &&
currentPasswordFieldValue?.length &&
// Only use current password for change if no new password present.
if (ciphers.length > 0) {
await this.pushChangePasswordToQueue(
ciphers.map((cipher) => cipher.id),
loginDomain,
currentPassword,
tab,
);
return true;
!newPasswordFieldValue
) {
const currentPasswordMatchesAnExistingValue = ciphers.some(
(cipher) =>
cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue,
);
// The password entered matched a stored cipher value with
// the same username (no change)
if (currentPasswordMatchesAnExistingValue) {
return false;
}
await this.pushChangePasswordToQueue(
ciphers.map((cipher) => cipher.id),
loginDomain,
currentPasswordFieldValue,
tab,
);
return true;
}
if (newPassword) {
if (newPasswordFieldValue) {
// Otherwise include all known ciphers.
if (ciphers.length > 0) {
await this.pushChangePasswordToQueue(
ciphers.map((cipher) => cipher.id),
loginDomain,
newPassword,
newPasswordFieldValue,
tab,
);

View File

@@ -262,11 +262,30 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*/
private notificationDataIncompleteOnBeforeRequest = (tabId: number) => {
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
return (
!modifyLoginData ||
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Add) ||
!this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Change)
if (!modifyLoginData) {
return true;
}
const shouldAttemptAddNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Add,
);
if (shouldAttemptAddNotification) {
return false;
}
const shouldAttemptChangeNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Change,
);
if (shouldAttemptChangeNotification) {
return false;
}
return false;
};
/**
@@ -454,15 +473,27 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
modifyLoginData: ModifyLoginCipherFormData,
notificationType: NotificationType,
): boolean => {
// Intentionally not stripping whitespace characters here as they
// represent user entry.
const usernameFieldHasValue = !!(modifyLoginData?.username || "").length;
const passwordFieldHasValue = !!(modifyLoginData?.password || "").length;
const newPasswordFieldHasValue = !!(modifyLoginData?.newPassword || "").length;
const canBeUserLogin = usernameFieldHasValue && passwordFieldHasValue;
const canBePasswordUpdate = passwordFieldHasValue && newPasswordFieldHasValue;
switch (notificationType) {
// `Add` case included because all forms with cached usernames (from previous
// visits) will appear to be "password only" and otherwise trigger the new login
// save notification.
case NotificationTypes.Add:
return (
modifyLoginData?.username && !!(modifyLoginData.password || modifyLoginData.newPassword)
);
// Can be values for nonstored login or account creation
return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue);
case NotificationTypes.Change:
return !!(modifyLoginData.password || modifyLoginData.newPassword);
// Can be login with nonstored login changes or account password update
return canBeUserLogin || canBePasswordUpdate;
case NotificationTypes.AtRiskPassword:
return !modifyLoginData.newPassword;
return !newPasswordFieldHasValue;
case NotificationTypes.Unlock:
// Unlock notifications are handled separately and do not require form data
return false;

View File

@@ -1424,11 +1424,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
}
/**
* calculates the postion and width for multi-input totp field inline menu
* @param totpFieldArray - the totp fields used to evaluate the position of the menu
* calculates the position and width for multi-input TOTP field inline menu
* @param totpFieldArray - the TOTP fields used to evaluate the position of the menu
*/
private calculateTotpMultiInputMenuBounds(totpFieldArray: AutofillField[]) {
// Filter the fields based on the provided totpfields
// Filter the fields based on the provided TOTP fields
const filteredObjects = this.allFieldData.filter((obj) =>
totpFieldArray.some((o) => o.opid === obj.opid),
);
@@ -1451,8 +1451,8 @@ export class OverlayBackground implements OverlayBackgroundInterface {
}
/**
* calculates the postion for multi-input totp field inline button
* @param totpFieldArray - the totp fields used to evaluate the position of the menu
* calculates the position for multi-input TOTP field inline button
* @param totpFieldArray - the TOTP fields used to evaluate the position of the menu
*/
private calculateTotpMultiInputButtonBounds(totpFieldArray: AutofillField[]) {
const filteredObjects = this.allFieldData.filter((obj) =>

View File

@@ -1,13 +1,9 @@
import { createRequire } from "module";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import type { StorybookConfig } from "@storybook/web-components-webpack5";
import type { StorybookConfig } from "@storybook/web-components-vite";
import remarkGfm from "remark-gfm";
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
const currentFile = fileURLToPath(import.meta.url);
const currentDirectory = dirname(currentFile);
import tsconfigPaths from "vite-tsconfig-paths";
const require = createRequire(import.meta.url);
@@ -18,10 +14,8 @@ const config: StorybookConfig = {
stories: ["../lit-stories/**/*.lit-stories.@(js|jsx|ts|tsx)", "../lit-stories/**/*.mdx"],
addons: [
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-designs"),
getAbsolutePath("@storybook/addon-interactions"),
{
name: "@storybook/addon-docs",
options: {
@@ -34,10 +28,8 @@ const config: StorybookConfig = {
},
],
framework: {
name: getAbsolutePath("@storybook/web-components-webpack5"),
options: {
legacyRootApi: true,
},
name: getAbsolutePath("@storybook/web-components-vite"),
options: {},
},
core: {
disableTelemetry: true,
@@ -46,33 +38,12 @@ const config: StorybookConfig = {
...existingConfig,
FLAGS: JSON.stringify({}),
}),
webpackFinal: async (config) => {
if (config.resolve) {
config.resolve.plugins = [
new TsconfigPathsPlugin({
configFile: resolve(currentDirectory, "../../../../../tsconfig.json"),
}),
] as any;
}
if (config.module && config.module.rules) {
config.module.rules.push({
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: [
{
loader: require.resolve("ts-loader"),
},
],
});
config.module.rules.push({
test: /\.scss$/,
use: [require.resolve("css-loader"), require.resolve("sass-loader")],
});
}
return config;
viteFinal: async (config) => {
return {
...config,
plugins: [...(config.plugins ?? []), tsconfigPaths()],
};
},
docs: {},
};
export default config;

View File

@@ -1,4 +1,4 @@
import { Meta, Controls, Primary } from "@storybook/addon-docs";
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
import * as stories from "./action-button.lit-stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Controls, Primary } from "@storybook/addon-docs";
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
import * as stories from "./badge-button.lit-stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Controls, Primary } from "@storybook/addon-docs";
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
import * as stories from "./body.lit-stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Controls, Primary } from "@storybook/addon-docs";
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
import * as stories from "./close-button.lit-stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Controls, Primary } from "@storybook/addon-docs";
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
import * as stories from "./edit-button.lit-stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Controls, Primary } from "@storybook/addon-docs";
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
import * as stories from "./footer.lit-stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Controls, Primary } from "@storybook/addon-docs";
import { Meta, Controls, Primary } from "@storybook/addon-docs/blocks";
import * as stories from "./header.lit-stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Controls } from "@storybook/addon-docs";
import { Meta, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./icons.lit-stories";

View File

@@ -13,7 +13,6 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails &
matches: string[];
excludeMatches: string[];
allFrames: true;
world?: "MAIN" | "ISOLATED";
};
type Fido2ExtensionMessage = {

View File

@@ -203,7 +203,6 @@ describe("Fido2Background", () => {
{ file: Fido2ContentScript.PageScriptDelayAppend },
{ file: Fido2ContentScript.ContentScript },
],
world: "MAIN",
...sharedRegistrationOptions,
});
});

View File

@@ -176,7 +176,6 @@ export class Fido2Background implements Fido2BackgroundInterface {
{ file: await this.getFido2PageScriptAppendFileName() },
{ file: Fido2ContentScript.ContentScript },
],
world: "MAIN",
...this.sharedRegistrationOptions,
});
}

View File

@@ -8,8 +8,7 @@
}
const script = globalContext.document.createElement("script");
// This script runs in world: MAIN, eliminating the risk associated with this lint error.
// DOM injection is still needed for the iframe timing hack.
// We're removing stack trace information in the page script instead
// eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
script.async = false;

View File

@@ -1,7 +1,7 @@
import { mock } from "jest-mock-extended";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
import { createPortSpyMock } from "../../../spec/autofill-mocks";
@@ -66,17 +66,38 @@ describe("AutofillInlineMenuIframeService", () => {
);
});
// TODO CG - This test is brittle and failing due to how we are calling the private method. This needs to be reworked
it.skip("creates an aria alert element if the ariaAlert param is passed", () => {
const ariaAlert = "aria alert";
it("creates an aria alert element if the ariaAlert param is passed to AutofillInlineMenuIframeService", () => {
jest.spyOn(autofillInlineMenuIframeService as any, "createAriaAlertElement");
autofillInlineMenuIframeService.initMenuIframe();
expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalledWith(
ariaAlert,
expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["ariaAlertElement"]).toBeDefined();
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("role")).toBe(
"alert",
);
expect(autofillInlineMenuIframeService["ariaAlertElement"]).toMatchSnapshot();
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-live")).toBe(
"polite",
);
expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-atomic")).toBe(
"true",
);
});
it("does not create an aria alert element if the ariaAlert param is not passed to AutofillInlineMenuIframeService", () => {
const shadowWithoutAlert = document.createElement("div").attachShadow({ mode: "open" });
const serviceWithoutAlert = new AutofillInlineMenuIframeService(
shadowWithoutAlert,
AutofillOverlayPort.Button,
{ height: "0px" },
"title",
);
jest.spyOn(serviceWithoutAlert as any, "createAriaAlertElement");
serviceWithoutAlert.initMenuIframe();
expect(serviceWithoutAlert["createAriaAlertElement"]).not.toHaveBeenCalled();
expect(serviceWithoutAlert["ariaAlertElement"]).toBeUndefined();
});
describe("on load of the iframe source", () => {
@@ -200,7 +221,7 @@ describe("AutofillInlineMenuIframeService", () => {
sendPortMessage(portSpy, { command: "updateAutofillInlineMenuPosition" });
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
).not.toHaveBeenCalled();
});
@@ -216,7 +237,7 @@ describe("AutofillInlineMenuIframeService", () => {
expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey);
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
});
});
@@ -234,14 +255,14 @@ describe("AutofillInlineMenuIframeService", () => {
it("passes the message on to the iframe element", () => {
const message = {
command: "initAutofillInlineMenuList",
theme: ThemeType.Light,
theme: ThemeTypes.Light,
};
sendPortMessage(portSpy, message);
expect(updateElementStylesSpy).not.toHaveBeenCalled();
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
});
@@ -249,18 +270,18 @@ describe("AutofillInlineMenuIframeService", () => {
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: false }));
const message = {
command: "initAutofillInlineMenuList",
theme: ThemeType.System,
theme: ThemeTypes.System,
};
sendPortMessage(portSpy, message);
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
).toHaveBeenCalledWith(
{
command: "initAutofillInlineMenuList",
theme: ThemeType.Light,
theme: ThemeTypes.Light,
},
autofillInlineMenuIframeService["extensionOrigin"],
);
@@ -270,18 +291,18 @@ describe("AutofillInlineMenuIframeService", () => {
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: true }));
const message = {
command: "initAutofillInlineMenuList",
theme: ThemeType.System,
theme: ThemeTypes.System,
};
sendPortMessage(portSpy, message);
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
).toHaveBeenCalledWith(
{
command: "initAutofillInlineMenuList",
theme: ThemeType.Dark,
theme: ThemeTypes.Dark,
},
autofillInlineMenuIframeService["extensionOrigin"],
);
@@ -290,7 +311,7 @@ describe("AutofillInlineMenuIframeService", () => {
it("updates the border to match the `dark` theme", () => {
const message = {
command: "initAutofillInlineMenuList",
theme: ThemeType.Dark,
theme: ThemeTypes.Dark,
};
sendPortMessage(portSpy, message);
@@ -364,6 +385,219 @@ describe("AutofillInlineMenuIframeService", () => {
autofillInlineMenuIframeService["handleFadeInInlineMenuIframe"],
).toHaveBeenCalled();
});
it("closes the inline menu when iframe is outside the viewport (bottom)", () => {
const viewportHeight = 800;
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
jest
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
.mockReturnValue({
top: 0,
left: 0,
right: 100,
bottom: viewportHeight + 1,
height: 98,
width: 262,
} as DOMRect);
Object.defineProperty(globalThis.window, "innerHeight", {
value: viewportHeight,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.window, "innerWidth", {
value: 1200,
writable: true,
configurable: true,
});
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuPosition",
styles: {},
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
});
it("closes the inline menu when iframe is outside the viewport (right)", () => {
const viewportWidth = 1200;
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
jest
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
.mockReturnValue({
top: 0,
left: 0,
right: viewportWidth + 1,
bottom: 100,
height: 98,
width: 262,
} as DOMRect);
Object.defineProperty(globalThis.window, "innerHeight", {
value: 800,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.window, "innerWidth", {
value: viewportWidth,
writable: true,
configurable: true,
});
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuPosition",
styles: {},
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
});
it("closes the inline menu when iframe is outside the viewport (left)", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
jest
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
.mockReturnValue({
top: 0,
left: -1,
right: 0,
bottom: 100,
height: 98,
width: 262,
} as DOMRect);
Object.defineProperty(globalThis.window, "innerHeight", {
value: 800,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.window, "innerWidth", {
value: 1200,
writable: true,
configurable: true,
});
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuPosition",
styles: {},
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
});
it("closes the inline menu when iframe is outside the viewport (top)", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
jest
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
.mockReturnValue({
top: -1,
left: 0,
right: 100,
bottom: 0,
height: 98,
width: 262,
} as DOMRect);
Object.defineProperty(globalThis.window, "innerHeight", {
value: 800,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.window, "innerWidth", {
value: 1200,
writable: true,
configurable: true,
});
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuPosition",
styles: {},
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
});
it("allows iframe (do not close) when it has no dimensions", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
jest
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
.mockReturnValue({
top: 0,
left: 0,
right: 0,
bottom: 0,
height: 0,
width: 0,
} as DOMRect);
Object.defineProperty(globalThis.window, "innerHeight", {
value: 800,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.window, "innerWidth", {
value: 1200,
writable: true,
configurable: true,
});
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuPosition",
styles: {},
});
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
});
it("uses visualViewport when available", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
jest
.spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect")
.mockReturnValue({
top: 0,
left: 0,
right: 100,
bottom: 700,
height: 98,
width: 262,
} as DOMRect);
Object.defineProperty(globalThis.window, "visualViewport", {
value: {
height: 600,
width: 1200,
} as VisualViewport,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.window, "innerHeight", {
value: 800,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.window, "innerWidth", {
value: 1200,
writable: true,
configurable: true,
});
sendPortMessage(portSpy, {
command: "updateAutofillInlineMenuPosition",
styles: {},
});
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
});
});
it("updates the visibility of the iframe", () => {
@@ -381,7 +615,7 @@ describe("AutofillInlineMenuIframeService", () => {
});
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage,
).toHaveBeenCalledWith(
{
command: "updateAutofillInlineMenuColorScheme",

View File

@@ -282,6 +282,15 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
const styles = this.fadeInTimeout ? Object.assign(position, { opacity: "0" }) : position;
this.updateElementStyles(this.iframe, styles);
const elementHeightCompletelyInViewport = this.isElementCompletelyWithinViewport(
this.iframe.getBoundingClientRect(),
);
if (!elementHeightCompletelyInViewport) {
this.forceCloseInlineMenu();
return;
}
if (this.fadeInTimeout) {
this.handleFadeInInlineMenuIframe();
}
@@ -289,6 +298,42 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.announceAriaAlert(this.ariaAlert, 2000);
}
/**
* Check if element is completely within the browser viewport.
*/
private isElementCompletelyWithinViewport(elementPosition: DOMRect) {
// An element that lacks size should be considered within the viewport
if (!elementPosition.height || !elementPosition.width) {
return true;
}
const [viewportHeight, viewportWidth] = this.getViewportSize();
const rightSideIsWithinViewport = (elementPosition.right || 0) <= viewportWidth;
const leftSideIsWithinViewport = (elementPosition.left || 0) >= 0;
const topSideIsWithinViewport = (elementPosition.top || 0) >= 0;
const bottomSideIsWithinViewport = (elementPosition.bottom || 0) <= viewportHeight;
return (
rightSideIsWithinViewport &&
leftSideIsWithinViewport &&
topSideIsWithinViewport &&
bottomSideIsWithinViewport
);
}
/** Use Visual Viewport API if available (better for mobile/zoom) */
private getViewportSize(): [
VisualViewport["height"] | Window["innerHeight"],
VisualViewport["width"] | Window["innerWidth"],
] {
if ("visualViewport" in globalThis.window && globalThis.window.visualViewport) {
return [globalThis.window.visualViewport.height, globalThis.window.visualViewport.width];
}
return [globalThis.window.innerHeight, globalThis.window.innerWidth];
}
/**
* Gets the page color scheme meta tag and sends a message to the iframe
* to update its color scheme. Will default to "normal" if the meta tag

View File

@@ -39,6 +39,7 @@ export class AutoFillConstants {
"otpcode",
"onetimepassword",
"security_code",
"second-factor",
"twofactor",
"twofa",
"twofactorcode",

View File

@@ -1603,14 +1603,14 @@ describe("AutofillOverlayContentService", () => {
it("skips triggering submission if a button is not found", async () => {
const submitButton = document.querySelector("button");
submitButton.remove();
submitButton?.remove();
await autofillOverlayContentService.setupOverlayListeners(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith(
"formFieldSubmitted",
@@ -1627,7 +1627,7 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock,
);
await flushPromises();
submitButton.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
submitButton?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted",
@@ -1641,7 +1641,7 @@ describe("AutofillOverlayContentService", () => {
<div id="shadow-root"></div>
<button id="button-el">Change Password</button>
</div>`;
const shadowRoot = document.getElementById("shadow-root").attachShadow({ mode: "open" });
const shadowRoot = document.getElementById("shadow-root")!.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<input type="password" id="password-field-1" placeholder="new password" />
`;
@@ -1668,7 +1668,7 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock,
);
await flushPromises();
buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
buttonElement?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
"formFieldSubmitted",
@@ -1716,6 +1716,85 @@ describe("AutofillOverlayContentService", () => {
});
});
describe("refreshMenuLayerPosition", () => {
it("calls refreshTopLayerPosition on the inline menu content service", () => {
autofillOverlayContentService.refreshMenuLayerPosition();
expect(inlineMenuContentService.refreshTopLayerPosition).toHaveBeenCalled();
});
it("does not throw if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
expect(() => serviceWithoutInlineMenu.refreshMenuLayerPosition()).not.toThrow();
});
});
describe("getOwnedInlineMenuTagNames", () => {
it("returns tag names from the inline menu content service", () => {
inlineMenuContentService.getOwnedTagNames.mockReturnValue(["div", "span"]);
const result = autofillOverlayContentService.getOwnedInlineMenuTagNames();
expect(result).toEqual(["div", "span"]);
});
it("returns an empty array if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const result = serviceWithoutInlineMenu.getOwnedInlineMenuTagNames();
expect(result).toEqual([]);
});
});
describe("getUnownedTopLayerItems", () => {
it("returns unowned top layer items from the inline menu content service", () => {
const mockElements = document.querySelectorAll("div");
inlineMenuContentService.getUnownedTopLayerItems.mockReturnValue(mockElements);
const result = autofillOverlayContentService.getUnownedTopLayerItems(true);
expect(result).toEqual(mockElements);
expect(inlineMenuContentService.getUnownedTopLayerItems).toHaveBeenCalledWith(true);
});
it("returns undefined if inline menu content service is not available", () => {
const serviceWithoutInlineMenu = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
);
const result = serviceWithoutInlineMenu.getUnownedTopLayerItems();
expect(result).toBeUndefined();
});
});
describe("clearUserFilledFields", () => {
it("deletes all user filled fields", () => {
const mockElement1 = document.createElement("input") as FillableFormFieldElement;
const mockElement2 = document.createElement("input") as FillableFormFieldElement;
autofillOverlayContentService["userFilledFields"] = {
username: mockElement1,
password: mockElement2,
};
autofillOverlayContentService.clearUserFilledFields();
expect(autofillOverlayContentService["userFilledFields"]).toEqual({});
});
});
describe("handleOverlayRepositionEvent", () => {
const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE];
repositionEvents.forEach((repositionEvent) => {
@@ -2049,7 +2128,7 @@ describe("AutofillOverlayContentService", () => {
});
it("skips focusing an element if no recently focused field exists", async () => {
autofillOverlayContentService["mostRecentlyFocusedField"] = undefined;
(autofillOverlayContentService as any)["mostRecentlyFocusedField"] = null;
sendMockExtensionMessage({
command: "redirectAutofillInlineMenuFocusOut",
@@ -2149,7 +2228,6 @@ describe("AutofillOverlayContentService", () => {
});
it("returns null if the sub frame URL cannot be parsed correctly", async () => {
delete globalThis.location;
globalThis.location = { href: "invalid-base" } as Location;
sendMockExtensionMessage(
{

View File

@@ -1400,7 +1400,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, {
root: null,
rootMargin: "0px",
threshold: 1.0,
threshold: 0.9999, // Safari doesn't seem to function properly with a threshold of 1,
});
}

View File

@@ -945,7 +945,8 @@ export class InlineMenuFieldQualificationService
!fieldType ||
!this.usernameFieldTypes.has(fieldType) ||
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) ||
this.fieldHasDisqualifyingAttributeValue(field)
this.fieldHasDisqualifyingAttributeValue(field) ||
this.isTotpField(field)
) {
return false;
}

View File

@@ -732,7 +732,11 @@ export default class MainBackground {
this.singleUserStateProvider,
);
this.organizationService = new DefaultOrganizationService(this.stateProvider);
this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService);
this.policyService = new DefaultPolicyService(
this.stateProvider,
this.organizationService,
this.accountService,
);
this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService(
this.accountService,
@@ -1196,6 +1200,7 @@ export default class MainBackground {
this.webPushConnectionService,
this.authRequestAnsweringService,
this.configService,
this.policyService,
);
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);

View File

@@ -12,7 +12,7 @@
<div class="tw-flex tw-flex-col tw-p-2">
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
<li>
{{ "ppremiumSignUpStorage" | i18n }}
{{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
</li>
<li>
{{ "premiumSignUpTwoStepOptions" | i18n }}

View File

@@ -1,13 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule, CurrencyPipe, Location } from "@angular/common";
import { Component } from "@angular/core";
import { Component, OnInit } from "@angular/core";
import { RouterModule } from "@angular/router";
import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -44,7 +45,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
SectionComponent,
],
})
export class PremiumV2Component extends BasePremiumComponent {
export class PremiumV2Component extends BasePremiumComponent implements OnInit {
priceString: string;
constructor(
@@ -59,6 +60,7 @@ export class PremiumV2Component extends BasePremiumComponent {
billingAccountProfileStateService: BillingAccountProfileStateService,
toastService: ToastService,
accountService: AccountService,
billingApiService: BillingApiServiceAbstraction,
) {
super(
i18nService,
@@ -70,15 +72,18 @@ export class PremiumV2Component extends BasePremiumComponent {
billingAccountProfileStateService,
toastService,
accountService,
billingApiService,
);
}
async ngOnInit() {
await super.ngOnInit();
// Support old price string. Can be removed in future once all translations are properly updated.
const thePrice = this.currencyPipe.transform(this.price, "$");
// Safari extension crashes due to $1 appearing in the price string ($10.00). Escape the $ to fix.
const formattedPrice = this.platformUtilsService.isSafari()
? thePrice.replace("$", "$$$")
: thePrice;
this.priceString = i18nService.t("premiumPriceV2", formattedPrice);
this.priceString = this.i18nService.t("premiumPriceV2", formattedPrice);
if (this.priceString.indexOf("%price%") > -1) {
this.priceString = this.priceString.replace("%price%", thePrice);
}

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.11.2",
"version": "2025.12.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.11.2",
"version": "2025.12.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Canvas } from "@storybook/addon-docs";
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
import * as stories from "./popup-layout.stories";

View File

@@ -48,6 +48,7 @@ import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/ke
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant";
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
import { platformPopoutGuard } from "../auth/popup/guards/platform-popout.guard";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
@@ -414,7 +415,7 @@ const routes: Routes = [
},
{
path: AuthRoute.LoginWithPasskey,
canActivate: [unauthGuardFn(unauthRouteOverrides)],
canActivate: [unauthGuardFn(unauthRouteOverrides), platformPopoutGuard(["linux"])],
data: {
pageIcon: TwoFactorAuthSecurityKeyIcon,
pageTitle: {

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from "@angular/common";
import { inject, Inject, Injectable } from "@angular/core";
import { inject, Inject, Injectable, DOCUMENT } from "@angular/core";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";

View File

@@ -381,4 +381,88 @@ describe("AddEditV2Component", () => {
expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]);
});
});
describe("reloadAddEditCipherData", () => {
beforeEach(fakeAsync(() => {
addEditCipherInfo$.next({
cipher: {
name: "InitialName",
type: CipherType.Login,
login: {
password: "initialPassword",
username: "initialUsername",
uris: [{ uri: "https://initial.com" }],
},
},
} as AddEditCipherInfo);
queryParams$.next({});
tick();
cipherServiceMock.setAddEditCipherInfo.mockClear();
}));
it("replaces all initialValues with new data, clearing stale fields", fakeAsync(() => {
const newCipherInfo = {
cipher: {
name: "UpdatedName",
type: CipherType.Login,
login: {
password: "updatedPassword",
uris: [{ uri: "https://updated.com" }],
},
},
} as AddEditCipherInfo;
addEditCipherInfo$.next(newCipherInfo);
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(component.config.initialValues).toEqual({
name: "UpdatedName",
password: "updatedPassword",
loginUri: "https://updated.com",
} as OptionalInitialValues);
expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledWith(null, "UserId");
}));
it("does not reload data if config is not set", fakeAsync(() => {
component.config = null;
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
}));
it("does not reload data if latestCipherInfo is null", fakeAsync(() => {
addEditCipherInfo$.next(null);
const messageListener = component["messageListener"];
messageListener({ command: "reloadAddEditCipherData" });
tick();
expect(component.config.initialValues).toEqual({
name: "InitialName",
password: "initialPassword",
username: "initialUsername",
loginUri: "https://initial.com",
} as OptionalInitialValues);
expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
}));
it("ignores messages with different commands", fakeAsync(() => {
const initialValues = component.config.initialValues;
const messageListener = component["messageListener"];
messageListener({ command: "someOtherCommand" });
tick();
expect(component.config.initialValues).toBe(initialValues);
}));
});
});

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Component, OnInit, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute, Params, Router } from "@angular/router";
@@ -158,7 +158,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
IconButtonModule,
],
})
export class AddEditV2Component implements OnInit {
export class AddEditV2Component implements OnInit, OnDestroy {
headerText: string;
config: CipherFormConfig;
canDeleteCipher$: Observable<boolean>;
@@ -200,12 +200,58 @@ export class AddEditV2Component implements OnInit {
this.subscribeToParams();
}
private messageListener: (message: any) => void;
async ngOnInit() {
this.fido2PopoutSessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (BrowserPopupUtils.inPopout(window)) {
this.popupCloseWarningService.enable();
}
// Listen for messages to reload cipher data when the pop up is already open
this.messageListener = async (message: any) => {
if (message?.command === "reloadAddEditCipherData") {
try {
await this.reloadCipherData();
} catch (error) {
this.logService.error("Failed to reload cipher data", error);
}
}
};
BrowserApi.addListener(chrome.runtime.onMessage, this.messageListener);
}
ngOnDestroy() {
if (this.messageListener) {
BrowserApi.removeListener(chrome.runtime.onMessage, this.messageListener);
}
}
/**
* Reloads the cipher data when the popup is already open and new form data is submitted.
* This completely replaces the initialValues to clear any stale data from the previous submission.
*/
private async reloadCipherData() {
if (!this.config) {
return;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const latestCipherInfo = await firstValueFrom(
this.cipherService.addEditCipherInfo$(activeUserId),
);
if (latestCipherInfo != null) {
this.config = {
...this.config,
initialValues: mapAddEditCipherInfoToInitialValues(latestCipherInfo),
};
// Be sure to clear the "cached" cipher info, so it doesn't get used again
await this.cipherService.setAddEditCipherInfo(null, activeUserId);
}
}
/**

View File

@@ -0,0 +1,425 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components";
import { CipherListView, CopyableCipherFields } from "@bitwarden/sdk-internal";
import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service";
import { ItemCopyActionsComponent } from "./item-copy-actions.component";
describe("ItemCopyActionsComponent", () => {
let fixture: ComponentFixture<ItemCopyActionsComponent>;
let component: ItemCopyActionsComponent;
let i18nService: jest.Mocked<I18nService>;
beforeEach(async () => {
i18nService = {
t: jest.fn((key: string) => `translated-${key}`),
} as unknown as jest.Mocked<I18nService>;
await TestBed.configureTestingModule({
imports: [
CommonModule,
JslibModule,
ItemModule,
IconButtonModule,
MenuModule,
ItemCopyActionsComponent, // standalone
],
providers: [
{ provide: I18nService, useValue: i18nService },
{
provide: VaultPopupCopyButtonsService,
useValue: {
showQuickCopyActions$: of(true),
} satisfies Partial<VaultPopupCopyButtonsService>,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ItemCopyActionsComponent);
component = fixture.componentInstance;
// Default cipher so tests can override as needed
component.cipher = {
name: "My cipher",
viewPassword: true,
login: { username: null, password: null, totp: null },
card: { code: null, number: null },
identity: {
fullAddressForCopy: null,
email: null,
username: null,
phone: null,
},
sshKey: {
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
notes: null,
copyableFields: [],
} as unknown as CipherViewLike;
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("findSingleCopyableItem", () => {
beforeEach(() => {
jest
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
.mockImplementation(
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
return Boolean(cipher.__copyable?.[field]);
},
);
});
it("returns the single item with value and translates its key", () => {
const items = [
{ key: "copyUsername", field: "username" as const },
{ key: "copyPassword", field: "password" as const },
];
(component.cipher as any).__copyable = {
username: true,
password: false,
};
const result = component.findSingleCopyableItem(items);
expect(result).toEqual({
key: "translated-copyUsername",
field: "username",
});
expect(i18nService.t).toHaveBeenCalledWith("copyUsername");
});
it("returns null when no items have a value", () => {
const items = [
{ key: "copyUsername", field: "username" as const },
{ key: "copyPassword", field: "password" as const },
];
(component.cipher as any).__copyable = {
username: false,
password: false,
};
const result = component.findSingleCopyableItem(items);
expect(result).toBeNull();
});
it("returns null when more than one item has a value", () => {
const items = [
{ key: "copyUsername", field: "username" as const },
{ key: "copyPassword", field: "password" as const },
];
(component.cipher as any).__copyable = {
username: true,
password: true,
};
const result = component.findSingleCopyableItem(items);
expect(result).toBeNull();
});
});
describe("singleCopyableLogin", () => {
beforeEach(() => {
jest
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
.mockImplementation(
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
return Boolean(cipher.__copyable?.[field]);
},
);
});
it("returns username with special-case logic when password is hidden and both username/password exist and no totp", () => {
(component.cipher as CipherView).viewPassword = false;
(component.cipher as any).__copyable = {
username: true,
password: true,
totp: false,
};
const result = component.singleCopyableLogin;
expect(result).toEqual({
key: "translated-copyUsername",
field: "username",
});
expect(i18nService.t).toHaveBeenCalledWith("copyUsername");
});
it("returns null when password is hidden but multiple fields exist, ensuring username and totp are shown in the menu UI ", () => {
(component.cipher as CipherView).viewPassword = false;
(component.cipher as any).__copyable = {
username: true,
password: true,
totp: true,
};
const result = component.singleCopyableLogin;
expect(result).toBeNull();
});
it("falls back to findSingleCopyableItem when password is visible", () => {
const findSingleCopyableItemSpy = jest.spyOn(component, "findSingleCopyableItem");
(component.cipher as CipherView).viewPassword = true;
void component.singleCopyableLogin;
expect(findSingleCopyableItemSpy).toHaveBeenCalled();
});
});
describe("singleCopyableCard", () => {
beforeEach(() => {
jest
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
.mockImplementation(
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
return Boolean(cipher.__copyable?.[field]);
},
);
});
it("returns security code when it is the only available card value", () => {
(component.cipher as any).__copyable = {
securityCode: true,
cardNumber: false,
};
const result = component.singleCopyableCard;
expect(result).toEqual({
key: "translated-securityCode",
field: "securityCode",
});
expect(i18nService.t).toHaveBeenCalledWith("securityCode");
});
it("returns null when both card number and security code are available", () => {
(component.cipher as any).__copyable = {
securityCode: true,
cardNumber: true,
};
const result = component.singleCopyableCard;
expect(result).toBeNull();
});
});
describe("singleCopyableIdentity", () => {
beforeEach(() => {
jest
.spyOn(CipherViewLikeUtils, "hasCopyableValue")
.mockImplementation(
(cipher: CipherViewLike & { __copyable?: Record<string, boolean> }, field) => {
return Boolean(cipher.__copyable?.[field]);
},
);
});
it("returns the only copyable identity field", () => {
(component.cipher as any).__copyable = {
address: false,
email: true,
username: false,
phone: false,
};
const result = component.singleCopyableIdentity;
expect(result).toEqual({
key: "translated-email",
field: "email",
});
expect(i18nService.t).toHaveBeenCalledWith("email");
});
it("returns null when multiple identity fields are available", () => {
(component.cipher as any).__copyable = {
address: true,
email: true,
username: false,
phone: false,
};
const result = component.singleCopyableIdentity;
expect(result).toBeNull();
});
});
describe("has*Values in non-list view", () => {
beforeEach(() => {
jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(false);
});
it("computes hasLoginValues from login fields", () => {
(component.cipher as CipherView).login = {
username: "user",
password: null,
totp: null,
} as any;
expect(component.hasLoginValues).toBe(true);
(component.cipher as CipherView).login = {
username: null,
password: null,
totp: null,
} as any;
expect(component.hasLoginValues).toBe(false);
});
it("computes hasCardValues from card fields", () => {
(component.cipher as CipherView).card = { code: "123", number: null } as any;
expect(component.hasCardValues).toBe(true);
(component.cipher as CipherView).card = { code: null, number: null } as any;
expect(component.hasCardValues).toBe(false);
});
it("computes hasIdentityValues from identity fields", () => {
(component.cipher as CipherView).identity = {
fullAddressForCopy: null,
email: "test@example.com",
username: null,
phone: null,
} as any;
expect(component.hasIdentityValues).toBe(true);
(component.cipher as CipherView).identity = {
fullAddressForCopy: null,
email: null,
username: null,
phone: null,
} as any;
expect(component.hasIdentityValues).toBe(false);
});
it("computes hasSecureNoteValue from notes", () => {
(component.cipher as CipherView).notes = "Some note" as any;
expect(component.hasSecureNoteValue).toBe(true);
(component.cipher as CipherView).notes = null as any;
expect(component.hasSecureNoteValue).toBe(false);
});
it("computes hasSshKeyValues from sshKey fields", () => {
(component.cipher as CipherView).sshKey = {
privateKey: "priv",
publicKey: null,
keyFingerprint: null,
} as any;
expect(component.hasSshKeyValues).toBe(true);
(component.cipher as CipherView).sshKey = {
privateKey: null,
publicKey: null,
keyFingerprint: null,
} as any;
expect(component.hasSshKeyValues).toBe(false);
});
});
describe("has*Values in list view", () => {
beforeEach(() => {
jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(true);
});
it("uses copyableFields for login values", () => {
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
"CardNumber",
] as CopyableCipherFields[];
expect(component.hasLoginValues).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"CardNumber",
] as CopyableCipherFields[];
expect(component.hasLoginValues).toBe(false);
});
it("uses copyableFields for card values", () => {
(component.cipher as CipherListView).copyableFields = [
"CardSecurityCode",
] as CopyableCipherFields[];
expect(component.hasCardValues).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
] as CopyableCipherFields[];
expect(component.hasCardValues).toBe(false);
});
it("uses copyableFields for identity values", () => {
(component.cipher as CipherListView).copyableFields = [
"IdentityEmail",
] as CopyableCipherFields[];
expect(component.hasIdentityValues).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
] as CopyableCipherFields[];
expect(component.hasIdentityValues).toBe(false);
});
it("uses copyableFields for secure note value", () => {
(component.cipher as CipherListView).copyableFields = [
"SecureNotes",
] as CopyableCipherFields[];
expect(component.hasSecureNoteValue).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
] as CopyableCipherFields[];
expect(component.hasSecureNoteValue).toBe(false);
});
it("uses copyableFields for ssh key values", () => {
(component.cipher as CipherListView).copyableFields = ["SshKey"] as CopyableCipherFields[];
expect(component.hasSshKeyValues).toBe(true);
(component.cipher as CipherListView).copyableFields = [
"LoginUsername",
] as CopyableCipherFields[];
expect(component.hasSshKeyValues).toBe(false);
});
});
});

View File

@@ -54,17 +54,20 @@ export class ItemCopyActionsComponent {
{ key: "copyPassword", field: "password" },
{ key: "copyVerificationCode", field: "totp" },
];
// If both the password and username are visible but the password is hidden, return the username
// If both the password and username are visible but the password is hidden and there's no
// totp code to copy return the username
if (
!this.cipher.viewPassword &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password") &&
!CipherViewLikeUtils.hasCopyableValue(this.cipher, "totp")
) {
return {
key: this.i18nService.t("copyUsername"),
field: "username" as const,
};
}
return this.findSingleCopyableItem(loginItems);
}

View File

@@ -108,7 +108,7 @@ describe("ItemMoreOptionsComponent", () => {
{ provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } },
{
provide: CipherArchiveService,
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) },
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: of(true) },
},
{ provide: ToastService, useValue: { showToast: () => {} } },
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },

View File

@@ -141,7 +141,7 @@ export class ItemMoreOptionsComponent {
}),
);
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$();
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$;
protected canArchive$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,

View File

@@ -49,7 +49,7 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy {
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
);
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$());
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$);
protected readonly userHasArchivedItems = toSignal(
this.userId$.pipe(

View File

@@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
import { CipherType } from "@bitwarden/common/vault/enums";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import {
@@ -23,6 +24,19 @@ describe("VaultPopoutWindow", () => {
.spyOn(BrowserPopupUtils, "closeSingleActionPopout")
.mockImplementation();
beforeEach(() => {
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]);
jest.spyOn(BrowserApi, "updateWindowProperties").mockResolvedValue();
global.chrome = {
...global.chrome,
runtime: {
...global.chrome?.runtime,
sendMessage: jest.fn().mockResolvedValue(undefined),
getURL: jest.fn((path) => `chrome-extension://extension-id/${path}`),
},
};
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -123,6 +137,32 @@ describe("VaultPopoutWindow", () => {
},
);
});
it("sends a message to refresh data when the popup is already open", async () => {
const existingPopupTab = {
id: 123,
windowId: 456,
url: `chrome-extension://extension-id/popup/index.html#/edit-cipher?singleActionPopout=${VaultPopoutType.addEditVaultItem}_${CipherType.Login}`,
} as chrome.tabs.Tab;
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([existingPopupTab]);
const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage");
const updateWindowSpy = jest.spyOn(BrowserApi, "updateWindowProperties");
await openAddEditVaultItemPopout(
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://jest-testing-website.com" }),
{
cipherType: CipherType.Login,
},
);
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith({
command: "reloadAddEditCipherData",
data: { cipherId: undefined, cipherType: CipherType.Login },
});
expect(updateWindowSpy).toHaveBeenCalledWith(456, { focused: true });
});
});
describe("closeAddEditVaultItemPopout", () => {

View File

@@ -115,10 +115,26 @@ async function openAddEditVaultItemPopout(
addEditCipherUrl += formatQueryString("uri", url);
}
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
singleActionKey,
senderWindowId: windowId,
});
const extensionUrl = chrome.runtime.getURL("popup/index.html");
const existingPopupTabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` });
const existingPopup = existingPopupTabs.find((tab) =>
tab.url?.includes(`singleActionPopout=${singleActionKey}`),
);
// Check if the an existing popup is already open
try {
await chrome.runtime.sendMessage({
command: "reloadAddEditCipherData",
data: { cipherId, cipherType },
});
await BrowserApi.updateWindowProperties(existingPopup.windowId, {
focused: true,
});
} catch {
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
singleActionKey,
senderWindowId: windowId,
});
}
}
/**

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/cli",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.11.0",
"version": "2025.12.0",
"keywords": [
"bitwarden",
"password",
@@ -75,20 +75,20 @@
"inquirer": "8.2.6",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"koa": "2.16.3",
"koa": "3.1.1",
"koa-bodyparser": "4.4.1",
"koa-json": "2.0.2",
"lowdb": "1.0.0",
"lunr": "2.3.9",
"multer": "2.0.2",
"node-fetch": "2.6.12",
"node-forge": "1.3.1",
"open": "10.1.2",
"node-forge": "1.3.2",
"open": "11.0.0",
"papaparse": "5.5.3",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
"semver": "7.7.3",
"tldts": "7.0.18",
"tldts": "7.0.19",
"zxcvbn": "4.4.2"
}
}

View File

@@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@@ -81,6 +82,7 @@ export class LoginCommand {
protected ssoUrlService: SsoUrlService,
protected i18nService: I18nService,
protected masterPasswordService: MasterPasswordServiceAbstraction,
protected encryptedMigrator: EncryptedMigrator,
) {}
async run(email: string, password: string, options: OptionValues) {
@@ -367,6 +369,8 @@ export class LoginCommand {
}
}
await this.encryptedMigrator.runMigrations(response.userId, password);
return await this.handleSuccessResponse(response);
} catch (e) {
if (

View File

@@ -182,6 +182,7 @@ export abstract class BaseProgram {
this.serviceContainer.organizationApiService,
this.serviceContainer.logout,
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -40,6 +41,7 @@ describe("UnlockCommand", () => {
const organizationApiService = mock<OrganizationApiServiceAbstraction>();
const logout = jest.fn();
const i18nService = mock<I18nService>();
const encryptedMigrator = mock<EncryptedMigrator>();
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
const configService = mock<ConfigService>();
@@ -92,6 +94,7 @@ describe("UnlockCommand", () => {
organizationApiService,
logout,
i18nService,
encryptedMigrator,
masterPasswordUnlockService,
configService,
);

View File

@@ -9,6 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -38,6 +39,7 @@ export class UnlockCommand {
private organizationApiService: OrganizationApiServiceAbstraction,
private logout: () => Promise<void>,
private i18nService: I18nService,
private encryptedMigrator: EncryptedMigrator,
private masterPasswordUnlockService: MasterPasswordUnlockService,
private configService: ConfigService,
) {}
@@ -116,6 +118,8 @@ export class UnlockCommand {
}
}
await this.encryptedMigrator.runMigrations(userId, password);
return this.successResponse();
}

View File

@@ -176,6 +176,7 @@ export class OssServeConfigurator {
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@@ -195,6 +195,7 @@ export class Program extends BaseProgram {
this.serviceContainer.ssoUrlService,
this.serviceContainer.i18nService,
this.serviceContainer.masterPasswordService,
this.serviceContainer.encryptedMigrator,
);
const response = await command.run(email, password, options);
this.processResponse(response, true);
@@ -277,6 +278,11 @@ export class Program extends BaseProgram {
})
.option("--check", "Check lock status.", async () => {
await this.exitIfNotAuthed();
const userId = (await firstValueFrom(this.serviceContainer.accountService.activeAccount$))
?.id;
await this.serviceContainer.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(
userId,
);
const authStatus = await this.serviceContainer.authService.getAuthStatus();
if (authStatus === AuthenticationStatus.Unlocked) {
@@ -306,6 +312,7 @@ export class Program extends BaseProgram {
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);

View File

@@ -76,6 +76,10 @@ import {
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -324,6 +328,7 @@ export class ServiceContainer {
cipherEncryptionService: CipherEncryptionService;
restrictedItemTypesService: RestrictedItemTypesService;
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
encryptedMigrator: EncryptedMigrator;
securityStateService: SecurityStateService;
masterPasswordUnlockService: MasterPasswordUnlockService;
cipherArchiveService: CipherArchiveService;
@@ -518,7 +523,11 @@ export class ServiceContainer {
this.ssoUrlService = new SsoUrlService();
this.organizationService = new DefaultOrganizationService(this.stateProvider);
this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService);
this.policyService = new DefaultPolicyService(
this.stateProvider,
this.organizationService,
this.accountService,
);
this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService(
this.accountService,
@@ -971,6 +980,16 @@ export class ServiceContainer {
);
this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService);
const changeKdfApiService = new DefaultChangeKdfApiService(this.apiService);
const changeKdfService = new DefaultChangeKdfService(changeKdfApiService, this.sdkService);
this.encryptedMigrator = new DefaultEncryptedMigrator(
this.kdfConfigService,
changeKdfService,
this.logService,
this.configService,
this.masterPasswordService,
this.syncService,
);
}
async logout() {

View File

@@ -1863,9 +1863,9 @@ dependencies = [
[[package]]
name = "mockall"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2"
checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b"
dependencies = [
"cfg-if",
"downcast",
@@ -1877,9 +1877,9 @@ dependencies = [
[[package]]
name = "mockall_derive"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898"
checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf"
dependencies = [
"cfg-if",
"proc-macro2",

View File

@@ -9,7 +9,7 @@ publish.workspace = true
anyhow = { workspace = true }
[target.'cfg(windows)'.dependencies]
mockall = "=0.13.1"
mockall = "=0.14.0"
serial_test = "=3.2.0"
tracing.workspace = true
windows = { workspace = true, features = [

View File

@@ -272,6 +272,7 @@ mod tests {
#[serial]
fn send_input_succeeds() {
let ctxi = MockInputOperations::send_input_context();
ctxi.checkpoint();
ctxi.expect().returning(|_| 1);
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
@@ -279,6 +280,8 @@ mod tests {
0,
)])
.unwrap();
drop(ctxi);
}
#[test]
@@ -288,9 +291,11 @@ mod tests {
)]
fn send_input_fails_sent_zero() {
let ctxi = MockInputOperations::send_input_context();
ctxi.checkpoint();
ctxi.expect().returning(|_| 0);
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(1));
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
@@ -298,6 +303,9 @@ mod tests {
0,
)])
.unwrap();
drop(ctxge);
drop(ctxi);
}
#[test]
@@ -305,9 +313,11 @@ mod tests {
#[should_panic(expected = "SendInput does not match expected. sent: 2, expected: 1")]
fn send_input_fails_sent_mismatch() {
let ctxi = MockInputOperations::send_input_context();
ctxi.checkpoint();
ctxi.expect().returning(|_| 2);
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(1));
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
@@ -315,5 +325,8 @@ mod tests {
0,
)])
.unwrap();
drop(ctxge);
drop(ctxi);
}
}

View File

@@ -186,6 +186,7 @@ mod tests {
let mut mock_handle = MockWindowHandleOperations::new();
let ctxse = MockErrorOperations::set_last_error_context();
ctxse.checkpoint();
ctxse
.expect()
.once()
@@ -198,6 +199,7 @@ mod tests {
.returning(|| Ok(0));
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(0));
let len = get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(
@@ -206,6 +208,9 @@ mod tests {
.unwrap();
assert_eq!(len, 0);
drop(ctxge);
drop(ctxse);
}
#[test]
@@ -215,6 +220,7 @@ mod tests {
let mut mock_handle = MockWindowHandleOperations::new();
let ctxse = MockErrorOperations::set_last_error_context();
ctxse.checkpoint();
ctxse.expect().with(predicate::eq(0)).returning(|_| {});
mock_handle
@@ -223,13 +229,18 @@ mod tests {
.returning(|| Ok(0));
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(1));
get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle)
.unwrap();
drop(ctxge);
drop(ctxse);
}
#[test]
#[serial]
fn get_window_title_succeeds() {
let mut mock_handle = MockWindowHandleOperations::new();
@@ -246,11 +257,11 @@ mod tests {
.unwrap();
assert_eq!(title.len(), 43); // That extra slot in the buffer for null char
assert_eq!(title, "*******************************************");
}
#[test]
#[serial]
fn get_window_title_returns_empty_string() {
let mock_handle = MockWindowHandleOperations::new();
@@ -273,10 +284,13 @@ mod tests {
.returning(|_| Ok(0));
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(1));
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
.unwrap();
drop(ctxge);
}
#[test]
@@ -290,9 +304,12 @@ mod tests {
.returning(|_| Ok(0));
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(0));
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
.unwrap();
drop(ctxge);
}
}

View File

@@ -61,8 +61,8 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
for (browser, config) in SUPPORTED_BROWSER_MAP.iter() {
let data_dir = get_browser_data_dir(config)?;
if data_dir.exists() {
let data_dir = get_and_validate_data_dir(config);
if data_dir.is_ok() {
browsers.push((*browser).to_string());
}
}
@@ -114,7 +114,7 @@ pub async fn import_logins(
#[derive(Debug, Clone, Copy)]
pub(crate) struct BrowserConfig {
pub name: &'static str,
pub data_dir: &'static str,
pub data_dir: &'static [&'static str],
}
pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock<
@@ -126,11 +126,19 @@ pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock<
.collect::<std::collections::HashMap<_, _>>()
});
fn get_browser_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
let dir = dirs::home_dir()
.ok_or_else(|| anyhow!("Home directory not found"))?
.join(config.data_dir);
Ok(dir)
fn get_and_validate_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
for data_dir in config.data_dir.iter() {
let dir = dirs::home_dir()
.ok_or_else(|| anyhow!("Home directory not found"))?
.join(data_dir);
if dir.exists() {
return Ok(dir);
}
}
Err(anyhow!(
"Browser user data directory '{:?}' not found",
config.data_dir
))
}
//
@@ -174,13 +182,7 @@ fn load_local_state_for_browser(browser_name: &String) -> Result<(PathBuf, Local
.get(browser_name.as_str())
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
let data_dir = get_browser_data_dir(config)?;
if !data_dir.exists() {
return Err(anyhow!(
"Browser user data directory '{}' not found",
data_dir.display()
));
}
let data_dir = get_and_validate_data_dir(config)?;
let local_state = load_local_state(&data_dir)?;

View File

@@ -18,19 +18,22 @@ use crate::{
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Chrome",
data_dir: ".config/google-chrome",
data_dir: &[".config/google-chrome"],
},
BrowserConfig {
name: "Chromium",
data_dir: "snap/chromium/common/chromium",
data_dir: &["snap/chromium/common/chromium"],
},
BrowserConfig {
name: "Brave",
data_dir: "snap/brave/current/.config/BraveSoftware/Brave-Browser",
data_dir: &[
"snap/brave/current/.config/BraveSoftware/Brave-Browser",
".config/BraveSoftware/Brave-Browser",
],
},
BrowserConfig {
name: "Opera",
data_dir: "snap/opera/current/.config/opera",
data_dir: &["snap/opera/current/.config/opera", ".config/opera"],
},
];

View File

@@ -14,31 +14,31 @@ use crate::{
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Chrome",
data_dir: "Library/Application Support/Google/Chrome",
data_dir: &["Library/Application Support/Google/Chrome"],
},
BrowserConfig {
name: "Chromium",
data_dir: "Library/Application Support/Chromium",
data_dir: &["Library/Application Support/Chromium"],
},
BrowserConfig {
name: "Microsoft Edge",
data_dir: "Library/Application Support/Microsoft Edge",
data_dir: &["Library/Application Support/Microsoft Edge"],
},
BrowserConfig {
name: "Brave",
data_dir: "Library/Application Support/BraveSoftware/Brave-Browser",
data_dir: &["Library/Application Support/BraveSoftware/Brave-Browser"],
},
BrowserConfig {
name: "Arc",
data_dir: "Library/Application Support/Arc/User Data",
data_dir: &["Library/Application Support/Arc/User Data"],
},
BrowserConfig {
name: "Opera",
data_dir: "Library/Application Support/com.operasoftware.Opera",
data_dir: &["Library/Application Support/com.operasoftware.Opera"],
},
BrowserConfig {
name: "Vivaldi",
data_dir: "Library/Application Support/Vivaldi",
data_dir: &["Library/Application Support/Vivaldi"],
},
];

View File

@@ -25,27 +25,27 @@ pub use signature::*;
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Brave",
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
data_dir: &["AppData/Local/BraveSoftware/Brave-Browser/User Data"],
},
BrowserConfig {
name: "Chrome",
data_dir: "AppData/Local/Google/Chrome/User Data",
data_dir: &["AppData/Local/Google/Chrome/User Data"],
},
BrowserConfig {
name: "Chromium",
data_dir: "AppData/Local/Chromium/User Data",
data_dir: &["AppData/Local/Chromium/User Data"],
},
BrowserConfig {
name: "Microsoft Edge",
data_dir: "AppData/Local/Microsoft/Edge/User Data",
data_dir: &["AppData/Local/Microsoft/Edge/User Data"],
},
BrowserConfig {
name: "Opera",
data_dir: "AppData/Roaming/Opera Software/Opera Stable",
data_dir: &["AppData/Roaming/Opera Software/Opera Stable"],
},
BrowserConfig {
name: "Vivaldi",
data_dir: "AppData/Local/Vivaldi/User Data",
data_dir: &["AppData/Local/Vivaldi/User Data"],
},
];

View File

@@ -107,7 +107,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
assert!(meta.loaders.contains(&"file"));
}
}
@@ -147,7 +147,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
assert!(meta.loaders.contains(&"file"));
}
}
@@ -183,7 +183,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
assert!(meta.loaders.contains(&"file"));
}
}

View File

@@ -3,16 +3,12 @@ use anyhow::{anyhow, Result};
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
#[cfg_attr(target_os = "windows", path = "unimplemented.rs")]
mod biometric;
pub use biometric::Biometric;
#[cfg(target_os = "windows")]
pub mod windows_focus;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
pub use biometric::Biometric;
use sha2::{Digest, Sha256};
use crate::crypto::{self, CipherString};

View File

@@ -2,7 +2,7 @@ use anyhow::{bail, Result};
use crate::biometric::{KeyMaterial, OsDerivedKey};
/// The MacOS implementation of the biometric trait.
/// Unimplemented stub for unsupported platforms
pub struct Biometric {}
impl super::BiometricTrait for Biometric {

View File

@@ -1,240 +0,0 @@
use std::{ffi::c_void, str::FromStr};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use rand::RngCore;
use sha2::{Digest, Sha256};
use windows::{
core::{factory, HSTRING},
Security::Credentials::UI::{
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
},
Win32::{
Foundation::HWND, System::WinRT::IUserConsentVerifierInterop,
UI::WindowsAndMessaging::GetForegroundWindow,
},
};
use windows_future::IAsyncOperation;
use super::{decrypt, encrypt, windows_focus::set_focus};
use crate::{
biometric::{KeyMaterial, OsDerivedKey},
crypto::CipherString,
};
/// The Windows OS implementation of the biometric trait.
pub struct Biometric {}
impl super::BiometricTrait for Biometric {
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
async fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool> {
let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap());
let h = h as *mut c_void;
let window = HWND(h);
// The Windows Hello prompt is displayed inside the application window. For best result we
// should set the window to the foreground and focus it.
set_focus(window);
// Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint
// unlock will not work. We get the current foreground window, which will either be the
// Bitwarden desktop app or the browser extension.
let foreground_window = unsafe { GetForegroundWindow() };
let interop = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
let operation: IAsyncOperation<UserConsentVerificationResult> = unsafe {
interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
};
let result = operation.get()?;
match result {
UserConsentVerificationResult::Verified => Ok(true),
_ => Ok(false),
}
}
async fn available() -> Result<bool> {
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?;
match ucv_available {
UserConsentVerifierAvailability::Available => Ok(true),
// TODO: look into removing this and making the check more ad-hoc
UserConsentVerifierAvailability::DeviceBusy => Ok(true),
_ => Ok(false),
}
}
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
let challenge: [u8; 16] = match challenge_str {
Some(challenge_str) => base64_engine
.decode(challenge_str)?
.try_into()
.map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?,
None => random_challenge(),
};
// Uses a key derived from the iv. This key is not intended to add any security
// but only a place-holder
let key = Sha256::digest(challenge);
let key_b64 = base64_engine.encode(key);
let iv_b64 = base64_engine.encode(challenge);
Ok(OsDerivedKey { key_b64, iv_b64 })
}
async fn set_biometric_secret(
service: &str,
account: &str,
secret: &str,
key_material: Option<KeyMaterial>,
iv_b64: &str,
) -> Result<String> {
let key_material = key_material.ok_or(anyhow!(
"Key material is required for Windows Hello protected keys"
))?;
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
crate::password::set_password(service, account, &encrypted_secret).await?;
Ok(encrypted_secret)
}
async fn get_biometric_secret(
service: &str,
account: &str,
key_material: Option<KeyMaterial>,
) -> Result<String> {
let key_material = key_material.ok_or(anyhow!(
"Key material is required for Windows Hello protected keys"
))?;
let encrypted_secret = crate::password::get_password(service, account).await?;
match CipherString::from_str(&encrypted_secret) {
Ok(secret) => {
// If the secret is a CipherString, it is encrypted and we need to decrypt it.
let secret = decrypt(&secret, &key_material)?;
Ok(secret)
}
Err(_) => {
// If the secret is not a CipherString, it is not encrypted and we can return it
// directly.
Ok(encrypted_secret)
}
}
}
}
fn random_challenge() -> [u8; 16] {
let mut challenge = [0u8; 16];
rand::rng().fill_bytes(&mut challenge);
challenge
}
#[cfg(test)]
mod tests {
use super::*;
use crate::biometric::BiometricTrait;
#[test]
fn test_derive_key_material() {
let iv_input = "l9fhDUP/wDJcKwmEzcb/3w==";
let result = <Biometric as BiometricTrait>::derive_key_material(Some(iv_input)).unwrap();
let key = base64_engine.decode(result.key_b64).unwrap();
assert_eq!(key.len(), 32);
assert_eq!(result.iv_b64, iv_input)
}
#[test]
fn test_derive_key_material_no_iv() {
let result = <Biometric as BiometricTrait>::derive_key_material(None).unwrap();
let key = base64_engine.decode(result.key_b64).unwrap();
assert_eq!(key.len(), 32);
let iv = base64_engine.decode(result.iv_b64).unwrap();
assert_eq!(iv.len(), 16);
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn test_prompt() {
<Biometric as BiometricTrait>::prompt(
vec![0, 0, 0, 0, 0, 0, 0, 0],
String::from("Hello from Rust"),
)
.await
.unwrap();
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn test_available() {
assert!(<Biometric as BiometricTrait>::available().await.unwrap())
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_requires_key() {
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await;
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Key material is required for Windows Hello protected keys"
);
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_handles_unencrypted_secret() {
let test = "test";
let secret = "password";
let key_material = KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
crate::password::set_password(test, test, secret)
.await
.unwrap();
let result =
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
.await
.unwrap();
crate::password::delete_password("test", "test")
.await
.unwrap();
assert_eq!(result, secret);
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_handles_encrypted_secret() {
let test = "test";
let secret =
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
let key_material = KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
crate::password::set_password(test, test, &secret.to_string())
.await
.unwrap();
let result =
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
.await
.unwrap();
crate::password::delete_password("test", "test")
.await
.unwrap();
assert_eq!(result, "secret");
}
#[tokio::test]
async fn set_biometric_secret_requires_key() {
let result =
<Biometric as BiometricTrait>::set_biometric_secret("", "", "", None, "").await;
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Key material is required for Windows Hello protected keys"
);
}
}

View File

@@ -1,28 +0,0 @@
use windows::{
core::s,
Win32::{
Foundation::HWND,
UI::{
Input::KeyboardAndMouse::SetFocus,
WindowsAndMessaging::{FindWindowA, SetForegroundWindow},
},
},
};
/// Searches for a window that looks like a security prompt and set it as focused.
/// Only works when the process has permission to foreground, either by being in foreground
/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
pub fn focus_security_prompt() {
let class_name = s!("Credential Dialog Xaml Host");
let hwnd = unsafe { FindWindowA(class_name, None) };
if let Ok(hwnd) = hwnd {
set_focus(hwnd);
}
}
pub(crate) fn set_focus(window: HWND) {
unsafe {
let _ = SetForegroundWindow(window);
let _ = SetFocus(Some(window));
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "",
"scripts": {
"build": "node scripts/build.js",
"build": "napi build --platform --js false",
"test": "cargo test"
},
"author": "",

View File

@@ -1,14 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { execSync } = require('child_process');
const args = process.argv.slice(2);
const isRelease = args.includes('--release');
if (isRelease) {
console.log('Building release mode.');
} else {
console.log('Building debug mode.');
process.env.RUST_LOG = 'debug';
}
execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env });

View File

@@ -961,7 +961,7 @@ pub mod logging {
};
use tracing::Level;
use tracing_subscriber::{
filter::EnvFilter,
filter::{EnvFilter, LevelFilter},
fmt::format::{DefaultVisitor, Writer},
layer::SubscriberExt,
util::SubscriberInitExt,
@@ -1049,17 +1049,9 @@ pub mod logging {
pub fn init_napi_log(js_log_fn: ThreadsafeFunction<(LogLevel, String), CalleeHandled>) {
let _ = JS_LOGGER.0.set(js_log_fn);
// the log level hierarchy is determined by:
// - if RUST_LOG is detected at runtime
// - if RUST_LOG is provided at compile time
// - default to INFO
let filter = EnvFilter::builder()
.with_default_directive(
option_env!("RUST_LOG")
.unwrap_or("info")
.parse()
.expect("should provide valid log level at compile time."),
)
// set the default log level to INFO.
.with_default_directive(LevelFilter::INFO.into())
// parse directives from the RUST_LOG environment variable,
// overriding the default directive for matching targets.
.from_env_lossy();

View File

@@ -8,9 +8,6 @@ use tracing_subscriber::{
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
};
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "macos")]
embed_plist::embed_info_plist!("../../../resources/info.desktop_proxy.plist");
@@ -64,9 +61,6 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi
#[allow(clippy::unwrap_used)]
#[tokio::main(flavor = "current_thread")]
async fn main() {
#[cfg(target_os = "windows")]
let should_foreground = windows::allow_foreground();
let sock_path = desktop_core::ipc::path("bw");
let log_path = {
@@ -158,9 +152,6 @@ async fn main() {
// Listen to stdin and send messages to ipc processor.
msg = stdin.next() => {
#[cfg(target_os = "windows")]
should_foreground.store(true, std::sync::atomic::Ordering::Relaxed);
match msg {
Some(Ok(msg)) => {
let msg = String::from_utf8(msg.to_vec()).unwrap();

View File

@@ -1,23 +0,0 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
pub fn allow_foreground() -> Arc<AtomicBool> {
let should_foreground = Arc::new(AtomicBool::new(false));
let should_foreground_clone = should_foreground.clone();
let _ = std::thread::spawn(move || loop {
if !should_foreground_clone.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
should_foreground_clone.store(false, Ordering::Relaxed);
for _ in 0..60 {
desktop_core::biometric::windows_focus::focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(1000));
}
});
should_foreground
}

View File

@@ -1,4 +1,4 @@
[toolchain]
channel = "1.85.0"
channel = "1.87.0"
components = [ "rustfmt", "clippy" ]
profile = "minimal"

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.11.3",
"version": "2025.12.0",
"keywords": [
"bitwarden",
"password",

View File

@@ -32,8 +32,9 @@
<string>/Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
<string>/Library/Application Support/net.imput.helium</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true/>

View File

@@ -101,8 +101,7 @@
supportsBiometric &&
form.value.biometric &&
isWindows &&
(userHasMasterPassword || (form.value.pin && userHasPinSet)) &&
isWindowsV2BiometricsEnabled
(userHasMasterPassword || (form.value.pin && userHasPinSet))
"
>
<div class="checkbox form-group-child">

View File

@@ -302,7 +302,6 @@ describe("SettingsComponent", () => {
describe("windows desktop", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
desktopBiometricsService.isWindowsV2BiometricsEnabled.mockResolvedValue(true);
// Recreate component to apply the correct device
fixture = TestBed.createComponent(SettingsComponent);
@@ -449,7 +448,6 @@ describe("SettingsComponent", () => {
desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled);
await component.ngOnInit();
component.isWindowsV2BiometricsEnabled = true;
component.isWindows = true;
component.form.value.requireMasterPasswordOnAppRestart = true;
component.userHasMasterPassword = false;
@@ -558,7 +556,6 @@ describe("SettingsComponent", () => {
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
await component.ngOnInit();
component.isWindowsV2BiometricsEnabled = true;
component.isWindows = true;
component.form.value.requireMasterPasswordOnAppRestart =
requireMasterPasswordOnAppRestart;
@@ -659,6 +656,7 @@ describe("SettingsComponent", () => {
describe("windows test cases", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
component.isWindows = true;
component.isLinux = false;
@@ -683,8 +681,6 @@ describe("SettingsComponent", () => {
describe("when windows v2 biometrics is enabled", () => {
beforeEach(() => {
component.isWindowsV2BiometricsEnabled = true;
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
});

View File

@@ -148,7 +148,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
userHasPinSet: boolean;
pinEnabled$: Observable<boolean> = of(true);
isWindowsV2BiometricsEnabled: boolean = false;
consolidatedSessionTimeoutComponent$: Observable<boolean>;
@@ -297,8 +296,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
// Autotype is for Windows initially
@@ -621,7 +618,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
// On Windows if a user turned off PIN without having a MP and has biometrics + require MP/PIN on restart enabled.
if (
this.isWindows &&
this.isWindowsV2BiometricsEnabled &&
this.supportsBiometric &&
this.form.value.requireMasterPasswordOnAppRestart &&
this.form.value.biometric &&
@@ -682,14 +678,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.autoPromptBiometrics.setValue(false);
await this.biometricStateService.setPromptAutomatically(false);
if (this.isWindowsV2BiometricsEnabled) {
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
if (!this.userHasMasterPassword && !this.userHasPinSet) {
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
await this.enrollPersistentBiometricIfNeeded(activeUserId);
} else {
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
}
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
if (!this.userHasMasterPassword && !this.userHasPinSet) {
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
await this.enrollPersistentBiometricIfNeeded(activeUserId);
} else {
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
}
} else if (this.isLinux) {
// Similar to Windows

View File

@@ -14,6 +14,7 @@ import {
} from "@bitwarden/angular/auth/guards";
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import {
DevicesIcon,
RegistrationUserAddIcon,
@@ -39,15 +40,19 @@ import {
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
import { VaultComponent } from "../vault/app/vault-v3/vault.component";
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
import { DesktopLayoutComponent } from "./layout/desktop-layout.component";
import { SendComponent } from "./tools/send/send.component";
import { SendV2Component } from "./tools/send-v2/send-v2.component";
/**
* Data properties acceptable for use in route objects in the desktop
@@ -99,7 +104,10 @@ const routes: Routes = [
{
path: "vault",
component: VaultV2Component,
canActivate: [authGuard],
canActivate: [
authGuard,
canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false),
],
},
{
path: "send",
@@ -325,6 +333,21 @@ const routes: Routes = [
},
],
},
{
path: "",
component: DesktopLayoutComponent,
canActivate: [authGuard],
children: [
{
path: "new-vault",
component: VaultComponent,
},
{
path: "new-sends",
component: SendV2Component,
},
],
},
];
@NgModule({

View File

@@ -0,0 +1,10 @@
<bit-layout>
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
<bit-nav-item icon="bwi-vault" [text]="'vault' | i18n" route="new-vault"></bit-nav-item>
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="new-sends"></bit-nav-item>
</app-side-nav>
<router-outlet></router-outlet>
</bit-layout>

View File

@@ -0,0 +1,61 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterModule } from "@angular/router";
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { NavigationModule } from "@bitwarden/components";
import { DesktopLayoutComponent } from "./desktop-layout.component";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
describe("DesktopLayoutComponent", () => {
let component: DesktopLayoutComponent;
let fixture: ComponentFixture<DesktopLayoutComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule],
providers: [
{
provide: I18nService,
useValue: mock<I18nService>(),
},
],
}).compileComponents();
fixture = TestBed.createComponent(DesktopLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("creates component", () => {
expect(component).toBeTruthy();
});
it("renders bit-layout component", () => {
const compiled = fixture.nativeElement;
const layoutElement = compiled.querySelector("bit-layout");
expect(layoutElement).toBeTruthy();
});
it("supports content projection for side-nav", () => {
const compiled = fixture.nativeElement;
const ngContent = compiled.querySelectorAll("ng-content");
expect(ngContent).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-layout",
imports: [RouterModule, I18nPipe, LayoutComponent, NavigationModule, DesktopSideNavComponent],
templateUrl: "./desktop-layout.component.html",
})
export class DesktopLayoutComponent {
protected readonly logo = PasswordManagerLogo;
}

View File

@@ -0,0 +1,3 @@
<bit-side-nav [variant]="variant()">
<ng-content></ng-content>
</bit-side-nav>

View File

@@ -0,0 +1,74 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { NavigationModule } from "@bitwarden/components";
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
describe("DesktopSideNavComponent", () => {
let component: DesktopSideNavComponent;
let fixture: ComponentFixture<DesktopSideNavComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DesktopSideNavComponent, NavigationModule],
providers: [
{
provide: I18nService,
useValue: mock<I18nService>(),
},
],
}).compileComponents();
fixture = TestBed.createComponent(DesktopSideNavComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("creates component", () => {
expect(component).toBeTruthy();
});
it("renders bit-side-nav component", () => {
const compiled = fixture.nativeElement;
const sideNavElement = compiled.querySelector("bit-side-nav");
expect(sideNavElement).toBeTruthy();
});
it("uses primary variant by default", () => {
expect(component.variant()).toBe("primary");
});
it("accepts variant input", () => {
fixture.componentRef.setInput("variant", "secondary");
fixture.detectChanges();
expect(component.variant()).toBe("secondary");
});
it.skip("passes variant to bit-side-nav", () => {
fixture.componentRef.setInput("variant", "secondary");
fixture.detectChanges();
const compiled = fixture.nativeElement;
const sideNavElement = compiled.querySelector("bit-side-nav");
expect(sideNavElement.getAttribute("ng-reflect-variant")).toBe("secondary");
});
});

View File

@@ -0,0 +1,14 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { NavigationModule, SideNavVariant } from "@bitwarden/components";
@Component({
selector: "app-side-nav",
templateUrl: "desktop-side-nav.component.html",
imports: [CommonModule, NavigationModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DesktopSideNavComponent {
readonly variant = input<SideNavVariant>("primary");
}

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core";
import { Inject, Injectable, DOCUMENT } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";

View File

@@ -0,0 +1,110 @@
<div id="sends" class="vault">
<div id="items" class="items">
<div class="content">
<div class="list full-height" *ngIf="filteredSends && filteredSends.length">
<button
type="button"
*ngFor="let s of filteredSends"
appStopClick
(click)="selectSend(s.id)"
title="{{ 'viewItem' | i18n }}"
(contextmenu)="viewSendMenu(s)"
[ngClass]="{ active: s.id === sendId }"
[attr.aria-pressed]="s.id === sendId"
class="flex-list-item"
>
<span class="item-icon" aria-hidden="true">
<i class="bwi bwi-fw bwi-lg" [ngClass]="s.type == 0 ? 'bwi-file-text' : 'bwi-file'"></i>
</span>
<span class="item-content">
<span class="item-title">
{{ s.name }}
<span class="title-badges">
<ng-container *ngIf="s.disabled">
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'disabled' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "disabled" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.password">
<i
class="bwi bwi-key"
appStopProp
title="{{ 'password' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "password" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.maxAccessCountReached">
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "maxAccessCountReached" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.expired">
<i
class="bwi bwi-clock"
appStopProp
title="{{ 'expired' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "expired" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.pendingDelete">
<i
class="bwi bwi-trash"
appStopProp
title="{{ 'pendingDeletion' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "pendingDeletion" | i18n }}</span>
</ng-container>
</span>
</span>
<span class="item-details">{{ s.deletionDate | date }}</span>
</span>
</button>
</div>
<div class="no-items" *ngIf="!filteredSends || !filteredSends.length">
<i class="bwi bwi-spinner bwi-spin bwi-3x" *ngIf="!loaded" aria-hidden="true"></i>
<ng-container *ngIf="loaded">
<img class="no-items-image" aria-hidden="true" />
<p>{{ "noItemsInList" | i18n }}</p>
</ng-container>
</div>
<div class="footer">
<button
type="button"
(click)="addSend()"
class="block primary"
appA11yTitle="{{ 'addItem' | i18n }}"
>
<i class="bwi bwi-plus bwi-lg" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<app-send-add-edit
id="addEdit"
class="details"
*ngIf="action == 'add' || action == 'edit'"
[sendId]="sendId"
[type]="selectedSendType"
(onSavedSend)="savedSend($event)"
(onCancelled)="cancel($event)"
(onDeletedSend)="deletedSend($event)"
></app-send-add-edit>
<div class="logo" *ngIf="!action">
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,364 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
import * as utils from "../../../utils";
import { SearchBarService } from "../../layout/search/search-bar.service";
import { AddEditComponent } from "../send/add-edit.component";
import { SendV2Component } from "./send-v2.component";
// Mock the invokeMenu utility function
jest.mock("../../../utils", () => ({
invokeMenu: jest.fn(),
}));
describe("SendV2Component", () => {
let component: SendV2Component;
let fixture: ComponentFixture<SendV2Component>;
let sendService: MockProxy<SendService>;
let searchBarService: MockProxy<SearchBarService>;
let broadcasterService: MockProxy<BroadcasterService>;
let accountService: MockProxy<AccountService>;
let policyService: MockProxy<PolicyService>;
beforeEach(async () => {
sendService = mock<SendService>();
searchBarService = mock<SearchBarService>();
broadcasterService = mock<BroadcasterService>();
accountService = mock<AccountService>();
policyService = mock<PolicyService>();
// Mock sendViews$ observable
sendService.sendViews$ = of([]);
searchBarService.searchText$ = new BehaviorSubject<string>("");
// Mock activeAccount$ observable for parent class ngOnInit
accountService.activeAccount$ = of({ id: "test-user-id" } as any);
policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false));
await TestBed.configureTestingModule({
imports: [SendV2Component],
providers: [
{ provide: SendService, useValue: sendService },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: BroadcasterService, useValue: broadcasterService },
{ provide: SearchService, useValue: mock<SearchService>() },
{ provide: PolicyService, useValue: policyService },
{ provide: SearchBarService, useValue: searchBarService },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: SendApiService, useValue: mock<SendApiService>() },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: AccountService, useValue: accountService },
],
}).compileComponents();
fixture = TestBed.createComponent(SendV2Component);
component = fixture.componentInstance;
});
it("creates component", () => {
expect(component).toBeTruthy();
});
it("initializes with correct default action", () => {
expect(component.action).toBe("");
});
it("subscribes to broadcaster service on init", async () => {
await component.ngOnInit();
expect(broadcasterService.subscribe).toHaveBeenCalledWith(
"SendV2Component",
expect.any(Function),
);
});
it("unsubscribes from broadcaster service on destroy", () => {
component.ngOnDestroy();
expect(broadcasterService.unsubscribe).toHaveBeenCalledWith("SendV2Component");
});
it("enables search bar on init", async () => {
await component.ngOnInit();
expect(searchBarService.setEnabled).toHaveBeenCalledWith(true);
});
it("disables search bar on destroy", () => {
component.ngOnDestroy();
expect(searchBarService.setEnabled).toHaveBeenCalledWith(false);
});
describe("addSend", () => {
it("sets action to Add", async () => {
await component.addSend();
expect(component.action).toBe("add");
});
it("calls resetAndLoad on addEditComponent when component exists", async () => {
const mockAddEdit = mock<AddEditComponent>();
component.addEditComponent = mockAddEdit;
await component.addSend();
expect(mockAddEdit.resetAndLoad).toHaveBeenCalled();
});
it("does not throw when addEditComponent is null", async () => {
component.addEditComponent = null;
await expect(component.addSend()).resolves.not.toThrow();
});
});
describe("cancel", () => {
it("resets action to None", () => {
component.action = "edit";
component.sendId = "test-id";
component.cancel(new SendView());
expect(component.action).toBe("");
expect(component.sendId).toBeNull();
});
});
describe("deletedSend", () => {
it("refreshes the list and resets action and sendId", async () => {
component.action = "edit";
component.sendId = "test-id";
jest.spyOn(component, "refresh").mockResolvedValue();
const mockSend = new SendView();
await component.deletedSend(mockSend);
expect(component.refresh).toHaveBeenCalled();
expect(component.action).toBe("");
expect(component.sendId).toBeNull();
});
});
describe("savedSend", () => {
it("refreshes the list and selects the saved send", async () => {
jest.spyOn(component, "refresh").mockResolvedValue();
jest.spyOn(component, "selectSend").mockResolvedValue();
const mockSend = new SendView();
mockSend.id = "saved-send-id";
await component.savedSend(mockSend);
expect(component.refresh).toHaveBeenCalled();
expect(component.selectSend).toHaveBeenCalledWith("saved-send-id");
});
});
describe("selectSend", () => {
it("sets action to Edit and updates sendId", async () => {
await component.selectSend("new-send-id");
expect(component.action).toBe("edit");
expect(component.sendId).toBe("new-send-id");
});
it("updates addEditComponent when it exists", async () => {
const mockAddEdit = mock<AddEditComponent>();
component.addEditComponent = mockAddEdit;
await component.selectSend("test-send-id");
expect(mockAddEdit.sendId).toBe("test-send-id");
expect(mockAddEdit.refresh).toHaveBeenCalled();
});
it("does not reload if same send is already selected in edit mode", async () => {
const mockAddEdit = mock<AddEditComponent>();
component.addEditComponent = mockAddEdit;
component.sendId = "same-id";
component.action = "edit";
await component.selectSend("same-id");
expect(mockAddEdit.refresh).not.toHaveBeenCalled();
});
it("reloads if selecting different send", async () => {
const mockAddEdit = mock<AddEditComponent>();
component.addEditComponent = mockAddEdit;
component.sendId = "old-id";
component.action = "edit";
await component.selectSend("new-id");
expect(mockAddEdit.refresh).toHaveBeenCalled();
});
});
describe("selectedSendType", () => {
it("returns the type of the currently selected send", () => {
const mockSend1 = new SendView();
mockSend1.id = "send-1";
mockSend1.type = SendType.Text;
const mockSend2 = new SendView();
mockSend2.id = "send-2";
mockSend2.type = SendType.File;
component.sends = [mockSend1, mockSend2];
component.sendId = "send-2";
expect(component.selectedSendType).toBe(SendType.File);
});
it("returns undefined when no send is selected", () => {
component.sends = [];
component.sendId = "non-existent";
expect(component.selectedSendType).toBeUndefined();
});
it("returns undefined when sendId is null", () => {
const mockSend = new SendView();
mockSend.id = "send-1";
mockSend.type = SendType.Text;
component.sends = [mockSend];
component.sendId = null;
expect(component.selectedSendType).toBeUndefined();
});
});
describe("viewSendMenu", () => {
let mockSend: SendView;
beforeEach(() => {
mockSend = new SendView();
mockSend.id = "test-send";
mockSend.name = "Test Send";
jest.clearAllMocks();
});
it("creates menu with copy link option", () => {
jest.spyOn(component, "copy").mockResolvedValue();
component.viewSendMenu(mockSend);
expect(utils.invokeMenu).toHaveBeenCalled();
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
expect(menuItems.length).toBeGreaterThanOrEqual(2); // At minimum: copy link + delete
});
it("includes remove password option when send has password and is not disabled", () => {
mockSend.password = "test-password";
mockSend.disabled = false;
jest.spyOn(component, "removePassword").mockResolvedValue(true);
component.viewSendMenu(mockSend);
expect(utils.invokeMenu).toHaveBeenCalled();
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
expect(menuItems.length).toBe(3); // copy link + remove password + delete
});
it("excludes remove password option when send has no password", () => {
mockSend.password = null;
mockSend.disabled = false;
component.viewSendMenu(mockSend);
expect(utils.invokeMenu).toHaveBeenCalled();
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
expect(menuItems.length).toBe(2); // copy link + delete (no remove password)
});
it("excludes remove password option when send is disabled", () => {
mockSend.password = "test-password";
mockSend.disabled = true;
component.viewSendMenu(mockSend);
expect(utils.invokeMenu).toHaveBeenCalled();
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
expect(menuItems.length).toBe(2); // copy link + delete (no remove password)
});
it("always includes delete option", () => {
jest.spyOn(component, "delete").mockResolvedValue(true);
jest.spyOn(component, "deletedSend").mockResolvedValue();
component.viewSendMenu(mockSend);
expect(utils.invokeMenu).toHaveBeenCalled();
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
// Delete is always the last item in the menu
expect(menuItems.length).toBeGreaterThan(0);
expect(menuItems[menuItems.length - 1]).toHaveProperty("label");
expect(menuItems[menuItems.length - 1]).toHaveProperty("click");
});
});
describe("search bar subscription", () => {
it("updates searchText when search bar text changes", () => {
const searchSubject = new BehaviorSubject<string>("initial");
searchBarService.searchText$ = searchSubject;
// Create new component to trigger constructor subscription
fixture = TestBed.createComponent(SendV2Component);
component = fixture.componentInstance;
searchSubject.next("new search text");
expect(component.searchText).toBe("new search text");
});
});
describe("load", () => {
it("sets loading states correctly", async () => {
jest.spyOn(component, "search").mockResolvedValue();
jest.spyOn(component, "selectAll");
expect(component.loaded).toBeFalsy();
await component.load();
expect(component.loading).toBe(false);
expect(component.loaded).toBe(true);
});
it("calls selectAll when onSuccessfulLoad is not set", async () => {
jest.spyOn(component, "search").mockResolvedValue();
jest.spyOn(component, "selectAll");
component.onSuccessfulLoad = null;
await component.load();
expect(component.selectAll).toHaveBeenCalled();
});
it("calls onSuccessfulLoad when it is set", async () => {
jest.spyOn(component, "search").mockResolvedValue();
const mockCallback = jest.fn().mockResolvedValue(undefined);
component.onSuccessfulLoad = mockCallback;
await component.load();
expect(mockCallback).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,233 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { mergeMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { invokeMenu, RendererMenuItem } from "../../../utils";
import { SearchBarService } from "../../layout/search/search-bar.service";
import { AddEditComponent } from "../send/add-edit.component";
const Action = Object.freeze({
/** No action is currently active. */
None: "",
/** The user is adding a new Send. */
Add: "add",
/** The user is editing an existing Send. */
Edit: "edit",
} as const);
type Action = (typeof Action)[keyof typeof Action];
const BroadcasterSubscriptionId = "SendV2Component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-send-v2",
imports: [CommonModule, JslibModule, FormsModule, AddEditComponent],
templateUrl: "./send-v2.component.html",
})
export class SendV2Component extends BaseSendComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(AddEditComponent) addEditComponent: AddEditComponent;
// The ID of the currently selected Send item being viewed or edited
sendId: string;
// Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit)
action: Action = Action.None;
constructor(
sendService: SendService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
environmentService: EnvironmentService,
private broadcasterService: BroadcasterService,
ngZone: NgZone,
searchService: SearchService,
policyService: PolicyService,
private searchBarService: SearchBarService,
logService: LogService,
sendApiService: SendApiService,
dialogService: DialogService,
toastService: ToastService,
accountService: AccountService,
private cdr: ChangeDetectorRef,
) {
super(
sendService,
i18nService,
platformUtilsService,
environmentService,
ngZone,
searchService,
policyService,
logService,
sendApiService,
dialogService,
toastService,
accountService,
);
// Listen to search bar changes and update the Send list filter
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.searchBarService.searchText$.subscribe((searchText) => {
this.searchText = searchText;
this.searchTextChanged();
setTimeout(() => this.cdr.detectChanges(), 250);
});
}
// Initialize the component: enable search bar, subscribe to sync events, and load Send items
async ngOnInit() {
this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchSends"));
await super.ngOnInit();
// Listen for sync completion events to refresh the Send list
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.ngZone.run(async () => {
switch (message.command) {
case "syncCompleted":
await this.load();
break;
}
});
});
await this.load();
}
// Clean up subscriptions and disable search bar when component is destroyed
ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.searchBarService.setEnabled(false);
}
// Load Send items from the service and display them in the list.
// Subscribes to sendViews$ observable to get updates when Sends change.
// Manually triggers change detection to ensure UI updates immediately.
// Note: The filter parameter is ignored in this implementation for desktop-specific behavior.
async load(filter: (send: SendView) => boolean = null) {
this.loading = true;
this.sendService.sendViews$
.pipe(
mergeMap(async (sends) => {
this.sends = sends;
await this.search(null);
// Trigger change detection after data updates
this.cdr.detectChanges();
}),
)
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
.subscribe();
if (this.onSuccessfulLoad != null) {
await this.onSuccessfulLoad();
} else {
// Default action
this.selectAll();
}
this.loading = false;
this.loaded = true;
}
// Open the add Send form to create a new Send item
async addSend() {
this.action = Action.Add;
if (this.addEditComponent != null) {
await this.addEditComponent.resetAndLoad();
}
}
// Close the add/edit form and return to the list view
cancel(s: SendView) {
this.action = Action.None;
this.sendId = null;
}
// Handle when a Send is deleted: refresh the list and close the edit form
async deletedSend(s: SendView) {
await this.refresh();
this.action = Action.None;
this.sendId = null;
}
// Handle when a Send is saved: refresh the list and re-select the saved Send
async savedSend(s: SendView) {
await this.refresh();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.selectSend(s.id);
}
// Select a Send from the list and open it in the edit form.
// If the same Send is already selected and in edit mode, do nothing to avoid unnecessary reloads.
async selectSend(sendId: string) {
if (sendId === this.sendId && this.action === Action.Edit) {
return;
}
this.action = Action.Edit;
this.sendId = sendId;
if (this.addEditComponent != null) {
this.addEditComponent.sendId = sendId;
await this.addEditComponent.refresh();
}
}
// Get the type (text or file) of the currently selected Send for the edit form
get selectedSendType() {
return this.sends.find((s) => s.id === this.sendId)?.type;
}
// Show the right-click context menu for a Send with options to copy link, remove password, or delete
viewSendMenu(send: SendView) {
const menu: RendererMenuItem[] = [];
menu.push({
label: this.i18nService.t("copyLink"),
click: () => this.copy(send),
});
if (send.password && !send.disabled) {
menu.push({
label: this.i18nService.t("removePassword"),
click: async () => {
await this.removePassword(send);
if (this.sendId === send.id) {
this.sendId = null;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.selectSend(send.id);
}
},
});
}
menu.push({
label: this.i18nService.t("delete"),
click: async () => {
await this.delete(send);
await this.deletedSend(send);
},
});
invokeMenu(menu);
}
}

Some files were not shown because too many files have changed in this diff Show More